From c40faecb954ddc696a0264941519c51ca4ff0a7c Mon Sep 17 00:00:00 2001 From: opressorMk2 <72047913+opressorMk2@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:15:40 +0300 Subject: [PATCH 001/109] Move indicator fx for warships. (#2871) Been playing this game for months, and after i feel myself enough experienced about the game, decided to start contributing to the project. This is my first one! ## Description: Move indicator fx for warships. Listening MoveWarshipIntentEvent to draw the fx. Code is basic, respectly layered and written regarding project's structure. ![warship](https://github.com/user-attachments/assets/7e286e2b-2331-40a3-b62b-ad03dceef676) ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: 420coder --------- Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com> --- src/client/graphics/GameRenderer.ts | 2 +- src/client/graphics/layers/DynamicUILayer.ts | 13 ++++ src/client/graphics/ui/MoveIndicatorUI.ts | 81 ++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 src/client/graphics/ui/MoveIndicatorUI.ts diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 41bfe13b3..b4cd3eb38 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -258,7 +258,7 @@ export function createRenderer( new UILayer(game, eventBus, transformHandler), new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState), new StructureIconsLayer(game, eventBus, uiState, transformHandler), - new DynamicUILayer(game, transformHandler), + new DynamicUILayer(game, transformHandler, eventBus), new NameLayer(game, transformHandler, eventBus), eventsDisplay, chatDisplay, diff --git a/src/client/graphics/layers/DynamicUILayer.ts b/src/client/graphics/layers/DynamicUILayer.ts index 66810b08a..affd9f0f4 100644 --- a/src/client/graphics/layers/DynamicUILayer.ts +++ b/src/client/graphics/layers/DynamicUILayer.ts @@ -1,4 +1,5 @@ import { renderNumber } from "src/client/Utils"; +import { EventBus } from "src/core/EventBus"; import { UnitType } from "src/core/game/Game"; import { BonusEventUpdate, @@ -6,7 +7,9 @@ import { GameUpdateType, } from "src/core/game/GameUpdates"; import type { GameView, UnitView } from "../../../core/game/GameView"; +import { MoveWarshipIntentEvent } from "../../Transport"; import { TransformHandler } from "../TransformHandler"; +import { MoveIndicatorUI } from "../ui/MoveIndicatorUI"; import { NavalTarget } from "../ui/NavalTarget"; import { NukeTelegraph } from "../ui/NukeTelegraph"; import { TextIndicator } from "../ui/TextIndicator"; @@ -24,8 +27,18 @@ export class DynamicUILayer implements Layer { constructor( private readonly game: GameView, private transformHandler: TransformHandler, + private eventBus: EventBus, ) {} + init() { + // Listen for warship move clicks for MoveIndicatorUI + this.eventBus.on(MoveWarshipIntentEvent, (e) => { + const x = this.game.x(e.tile); + const y = this.game.y(e.tile); + this.uiElements.push(new MoveIndicatorUI(this.transformHandler, x, y)); + }); + } + shouldTransform(): boolean { return false; } diff --git a/src/client/graphics/ui/MoveIndicatorUI.ts b/src/client/graphics/ui/MoveIndicatorUI.ts new file mode 100644 index 000000000..21f012546 --- /dev/null +++ b/src/client/graphics/ui/MoveIndicatorUI.ts @@ -0,0 +1,81 @@ +import { Cell } from "src/core/game/Game"; +import { TransformHandler } from "../TransformHandler"; +import { UIElement } from "./UIElement"; + +/** + * move indicator fx for warship, similar to moba games. + */ +export class MoveIndicatorUI implements UIElement { + private lifeTime = 0; + private readonly duration = 800; // ms + private readonly startRadius = 13; // starting distance from center (screen pixels) + private readonly chevronSize = 5; // size in screen pixels + private readonly cell: Cell; + + constructor( + private transformHandler: TransformHandler, + public x: number, + public y: number, + ) { + this.cell = new Cell(this.x + 0.5, this.y + 0.5); + } + + render(ctx: CanvasRenderingContext2D, delta: number): boolean { + this.lifeTime += delta; + if (this.lifeTime >= this.duration) return false; + + const t = this.lifeTime / this.duration; + const alpha = 1 - t; // fade out + + // Scale with zoom level (same pattern as NavalTarget) + const transformScale = this.transformHandler.scale; + const scale = transformScale > 10 ? 1 + (transformScale - 10) / 10 : 1; + + const radius = this.startRadius * scale * (1 - t * 0.7); // converge inward + const chevronSize = this.chevronSize * scale; + + // Get screen coordinates + const screenPos = this.transformHandler.worldToScreenCoordinates(this.cell); + const centerX = screenPos.x; + const centerY = screenPos.y; + + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = "#ff0000"; + ctx.lineWidth = 2 * scale; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + // pre calculation of offsets + const tipOffset = chevronSize * 0.4; + const wingOffset = chevronSize * 0.6; + const width = chevronSize; + + ctx.beginPath(); + + // Top (pointing down) + ctx.moveTo(centerX - width, centerY - radius - wingOffset); + ctx.lineTo(centerX, centerY - radius + tipOffset); + ctx.lineTo(centerX + width, centerY - radius - wingOffset); + + // Bottom (pointing up) + ctx.moveTo(centerX - width, centerY + radius + wingOffset); + ctx.lineTo(centerX, centerY + radius - tipOffset); + ctx.lineTo(centerX + width, centerY + radius + wingOffset); + + // Left (pointing right) + ctx.moveTo(centerX - radius - wingOffset, centerY - width); + ctx.lineTo(centerX - radius + tipOffset, centerY); + ctx.lineTo(centerX - radius - wingOffset, centerY + width); + + // Right (pointing left) + ctx.moveTo(centerX + radius + wingOffset, centerY - width); + ctx.lineTo(centerX + radius - tipOffset, centerY); + ctx.lineTo(centerX + radius + wingOffset, centerY + width); + + ctx.stroke(); + + ctx.restore(); + return true; + } +} From a28a7ef6fde1f66b46f426f43b56f80389b0234a Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:44:18 +0100 Subject: [PATCH 002/109] Fix: Bots NEVER attacked someone if they had water access (#2894) ## Description: Bots always attacked Terra Nullius if they shared a border with Terra Nullius. But water is Terra Nullius... So I changed that condition to `this.bot.neighbors().some((n) => !n.isPlayer())`. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/core/execution/BotExecution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts index 08bcda4ba..4f9f178f9 100644 --- a/src/core/execution/BotExecution.ts +++ b/src/core/execution/BotExecution.ts @@ -101,7 +101,7 @@ export class BotExecution implements Execution { } if (this.neighborsTerraNullius) { - if (this.bot.sharesBorderWith(this.mg.terraNullius())) { + if (this.bot.neighbors().some((n) => !n.isPlayer())) { this.attackBehavior.sendAttack(this.mg.terraNullius()); return; } From c8c97abe756594cc85e32da7938ab0716d6a7dad Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Wed, 14 Jan 2026 18:46:08 +0100 Subject: [PATCH 003/109] Fix transportship target tile on construction (#2896) ## Description: The recent pathfinding rework broke the naval invasion target. This change reverts the code to the previous working one. image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: IngloriousTom --- src/core/execution/TransportShipExecution.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 73c3b1028..6020d0489 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -143,14 +143,9 @@ export class TransportShipExecution implements Execution { this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.src, { troops: this.startTroops, + targetTile: this.dst ?? undefined, }); - if (this.dst !== null) { - this.boat.setTargetTile(this.dst); - } else { - this.boat.setTargetTile(undefined); - } - // Notify the target player about the incoming naval invasion if (this.targetID && this.targetID !== mg.terraNullius().id()) { mg.displayIncomingUnit( From 0421c4e958ad08f01fcffbc37696410b881bc0cc Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:47:44 +0100 Subject: [PATCH 004/109] =?UTF-8?q?Refreshed=20images=20for=20the=20help?= =?UTF-8?q?=20modal=20and=20other=20little=20optimizations=20=E2=9C=A8=20(?= =?UTF-8?q?#2897)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: 1. Changed default difficulty in singleplayer / host lobby to Easy (to synchronize the settings with the public lobby settings) 2. Switch bot count in singleplayer / host lobby to 100 after selecting "compact map" (to synchronize the settings with the public lobby settings) (and back to 400 after deselcting) 3. Some little padding optimizations, for example for the modal title: Screenshot 2026-01-14 163837 4. Refreshed images for the help page: ![infoMenu2](https://github.com/user-attachments/assets/dc0c49c1-b970-47e5-a188-56fefc2e1c90) ![infoMenu2Ally](https://github.com/user-attachments/assets/c6c49a2c-eec6-44ae-877e-b8bdd2ab8caf) ![playerInfoOverlay](https://github.com/user-attachments/assets/1c6c2fc0-ecc5-4946-a7a7-35b90c13790a) ![controlPanel](https://github.com/user-attachments/assets/3d10fbf7-fbff-46af-b02a-9bb390dd9955) ![eventsPanelAttack](https://github.com/user-attachments/assets/04af2c91-6be1-458f-bf13-f4ddaf247d8a) ![eventsPanel](https://github.com/user-attachments/assets/517ad982-b001-4a36-9dfd-84a7ca1e0162) ![leaderboard2](https://github.com/user-attachments/assets/8956d053-682f-4055-9fe9-a36b066b1ce3) ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- resources/images/helpModal/controlPanel.webp | Bin 15636 -> 3888 bytes resources/images/helpModal/eventsPanel.webp | Bin 66924 -> 10736 bytes .../images/helpModal/eventsPanelAttack.webp | Bin 15368 -> 7510 bytes resources/images/helpModal/infoMenu2.webp | Bin 73478 -> 8242 bytes resources/images/helpModal/infoMenu2Ally.webp | Bin 81586 -> 9572 bytes resources/images/helpModal/leaderboard2.webp | Bin 48572 -> 7628 bytes .../images/helpModal/playerInfoOverlay.webp | Bin 39490 -> 5496 bytes src/client/HelpModal.ts | 2 +- src/client/HostLobbyModal.ts | 9 +++++++-- src/client/NewsModal.ts | 2 +- src/client/SinglePlayerModal.ts | 13 ++++++++++--- src/client/StatsModal.ts | 2 +- src/client/UserSettingModal.ts | 2 +- src/client/components/baseComponents/Modal.ts | 4 ++-- 14 files changed, 23 insertions(+), 11 deletions(-) diff --git a/resources/images/helpModal/controlPanel.webp b/resources/images/helpModal/controlPanel.webp index c4bdd0faf4ce798e5f75de0da0da10ff7318ea4b..f0dd9168e2047d19d96a234f53624c82bd93bdef 100644 GIT binary patch literal 3888 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 literal 15636 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 diff --git a/resources/images/helpModal/eventsPanel.webp b/resources/images/helpModal/eventsPanel.webp index d7fb116dac83134b9564accf93b5c00a3c3b1be4..6479df76ca172fd94a0c6d5c94a625011ed285fc 100644 GIT binary patch literal 10736 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 literal 15368 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 literal 73478 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

x+6-fsqv(yP-nSoX>5BV{ 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 diff --git a/resources/images/helpModal/infoMenu2Ally.webp b/resources/images/helpModal/infoMenu2Ally.webp index 360f6314c45487b742c579f742008b9c88cc3567..5f892cf266739bcb9a1d9082f42843f8a50e6f1f 100644 GIT binary patch literal 9572 zcmV-qC7aq(Nk&FoB>(_dMM6+kP&gn^B>(`By8xX5DnkJy0zQ#Qo=c^qBB!GAsbKID z31@EnCV~Ou%97vxO7m(B|FHhG%~#qJQga)P`iT84>;cZN%m?ikt4I5yk{U&yceF5F#T{fF!aurI8?J^w@gH}|jn zucA-&UWERE{fGV+_y2$o@j9_iab>zN@;8HF9w0NIzGH{y9Y#NsaPLMhLCu>O_l*E=pUSFx(M4uQ!=H}(M+qp3uq_!U zf-f+$)TZXk7MU7_Ts3nI^N(QpvEJ2;KqXlke!{qVaT3M)7 z|AaBS3%?=I=pW}7dRGzp-Hh-*flNyUEe9sRJ#lnZlG}Q1_RR38lPm!)Dew%dW&+Bw zU0u4tyC24C>q%lqfvXu~GX~V&;A?YvqT9Q3P@20KXB9T1d9@>I5hYm?aM3{(>L}L9 z*Rcm3Fk~{>u)Q_I>J~uAzrTV&A-pf3`7Rlw_8Z0NFV_CTC`Qp!+dCh5+D-;*((EP{ zBZz)*YOeKRwkh%1xTa71*s&;Q!Jy8PQj>|HGKXikZVRMP4c@wD=MN0_xS~NUMWNuc z7{E(743F3+Z7XDaQN+iHQ#IEYgW?834K~+B6_g?hK$81Iklc!IE4}!$v81-WP_`-# zHBsrWnF;MJ_kr%Cwn5Zb{@T%&W+O1Z*3_;UU8vHR=VpiF3X z{=A&xw}aFh=(X94>L_#lo9icLgfWAyIQ!;4wZ4U$n?!))64LygUlv&%3Y$NQtD=2r zm%3mCriB1Ts1p4yBp|a(AlyDr^@*To%{c5ecXU|;oN!oKrJu@zNX(Tp9WXQ$QJ)%C zra!*fkG1x?)YfFDT{pJ!j55Wc!8;hzNC30Hsy$KqyDphnECBw26JKutoHHlcL*JV- z?z8A_CT*88R@EvXB`GSKD;Qt@$SGiFj>U#cVU$IvJK0HErd3$T?j9BW2;F%CrzoUV zlt$2*qk){E>HH+EPn-QmNI0j@!L)`zevOo61TJ{)TUVUpO1?%}W1EHSYS`K_(`p6y zR#lMG*_3Dgr`Cf=vvp;JCQK!SC8pya68FXt%1%#73g=hSy%lAd4gv4#C2S^q=}7xL zYJ25?0U;!U>VBqkDlMlMaS^`!uk(bj29#r?`$}1RIg7Re+w__pXR*G z=UfT*`|~=RKA34(;kcN5|FADD3D*m44Pbf0!rO&FB`yV>sLrrt|sWU6y>cBoY$Rsur*J9LQ3y`qR}Z z2$}9FC^if!0@rTd2+QyAoVi9so&+rhe$Az0kV=UyS#hh)6To(Q{9U8RKP{|i!by5$ z0bE)CBKS}IPxI=xY9syG+9BQ?!E3B0ufxF7JvoXB4QV&aMbxMPOF$0MIm4l6!=`Ja z6?u3;7ZY)SElgU#(8Lf{*nYysrE)F2KLOWo;a;&L9fB)kx!ERtb)^ouGV4g{r#=3S zITYl7!91%{dZb8#ABk_FH;OMRUCB zB3XM?aia0?E!B^#96@UTtJL|I(BNuCxdBx3!`7%?dFz0hpm7s7R#l}rM4*2<>H2PE zLc80RG#v!vkP4#u@ZjYGJ+p^;Wva)XAW3IbW`QbH;fiy6l

{ this.compactMap = val; + if (val && this.bots === 400) { + this.bots = 100; + } else if (!val && this.bots === 100) { + this.bots = 400; + } this.putGameConfig(); }; diff --git a/src/client/NewsModal.ts b/src/client/NewsModal.ts index 0a150144f..5a66d4c9e 100644 --- a/src/client/NewsModal.ts +++ b/src/client/NewsModal.ts @@ -28,7 +28,7 @@ export class NewsModal extends BaseModal { ariaLabel: translateText("common.back"), })}
(this.compactMap = val), + (val) => { + this.compactMap = val; + if (val && this.bots === 400) { + this.bots = 100; + } else if (!val && this.bots === 100) { + this.bots = 400; + } + }, )} @@ -693,7 +700,7 @@ export class SinglePlayerModal extends BaseModal { protected onClose(): void { // Reset all transient form state to ensure clean slate this.selectedMap = GameMapType.World; - this.selectedDifficulty = Difficulty.Medium; + this.selectedDifficulty = Difficulty.Easy; this.gameMode = GameMode.FFA; this.useRandomMap = false; this.disableNations = false; diff --git a/src/client/StatsModal.ts b/src/client/StatsModal.ts index 1346b0031..b2fc64257 100644 --- a/src/client/StatsModal.ts +++ b/src/client/StatsModal.ts @@ -196,7 +196,7 @@ export class StatsModal extends BaseModal { const maxGames = Math.max(...clans.map((c) => c.games), 1); return html` -
+
diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 49fefa462..70f4251f8 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -430,7 +430,7 @@ export class UserSettingModal extends BaseModal {
${activeContent}
diff --git a/src/client/components/baseComponents/Modal.ts b/src/client/components/baseComponents/Modal.ts index baa877b4c..7ef216ffc 100644 --- a/src/client/components/baseComponents/Modal.ts +++ b/src/client/components/baseComponents/Modal.ts @@ -82,14 +82,14 @@ export class OModal extends LitElement { ${this.inline || this.hideCloseButton ? html`` : html`
this.close()} > ✕
`} ${!this.hideHeader && this.title ? html`
${this.title}
` From e1d31ef1ee4c21943933a5b537575a45577a3d68 Mon Sep 17 00:00:00 2001 From: Himansu Rawal Date: Wed, 14 Jan 2026 23:35:43 +0545 Subject: [PATCH 005/109] =?UTF-8?q?fix:=20replace=20setInterval=20with=20r?= =?UTF-8?q?ecursive=20setTimeout=20in=20Master.ts=20to=20pr=E2=80=A6=20(#2?= =?UTF-8?q?869)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If this PR fixes an issue, link it below. If not, delete these two lines. Resolves #2868 ## Description: This PR addresses a critical memory leak in the Master server process (causing ~30GB RAM usage). The issue was caused by `setInterval` calling `fetchLobbies()` every 100ms. When `fetchLobbies` took longer than 100ms to complete (due to network latency or load), requests would pile up indefinitely, creating a massive queue of pending Promises and open sockets. I have refactored the polling logic into a generic `startPolling` utility (in `src/server/PollingLoop.ts`) that uses a recursive `setTimeout` pattern. This ensures that the next `fetchLobbies` call is only scheduled *after* the previous one has completed (successfully or failed), preventing any request pile-up. ## Please complete the following: - [x] I have added screenshots for all UI updates (N/A - backend only) - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file (N/A - no user facing text) - [x] I have added relevant tests to the test directory (`tests/PollingLoop.test.ts`) - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: codimo --- src/server/Master.ts | 16 +++-- src/server/PollingLoop.ts | 24 +++++++ src/server/PrivilegeRefresher.ts | 8 +-- src/server/Worker.ts | 105 +++++++++++++++---------------- tests/server/PollingLoop.test.ts | 77 +++++++++++++++++++++++ 5 files changed, 162 insertions(+), 68 deletions(-) create mode 100644 src/server/PollingLoop.ts create mode 100644 tests/server/PollingLoop.test.ts diff --git a/src/server/Master.ts b/src/server/Master.ts index d94fa3c65..fd3fdbb22 100644 --- a/src/server/Master.ts +++ b/src/server/Master.ts @@ -12,6 +12,7 @@ import { GameInfo } from "../core/Schemas"; import { generateID } from "../core/Util"; import { logger } from "./Logger"; import { MapPlaylist } from "./MapPlaylist"; +import { startPolling } from "./PollingLoop"; import { renderHtml } from "./RenderHtml"; const config = getServerConfigFromServer(); @@ -176,15 +177,12 @@ export async function startMaster() { }); }; - setInterval( - () => - fetchLobbies().then((lobbies) => { - if (lobbies === 0) { - scheduleLobbies(); - } - }), - 100, - ); + startPolling(async () => { + const lobbies = await fetchLobbies(); + if (lobbies === 0) { + scheduleLobbies(); + } + }, 100); } } }); diff --git a/src/server/PollingLoop.ts b/src/server/PollingLoop.ts new file mode 100644 index 000000000..1869a324f --- /dev/null +++ b/src/server/PollingLoop.ts @@ -0,0 +1,24 @@ +import { logger } from "./Logger"; + +const log = logger.child({ comp: "polling" }); + +/** + * Starts a polling loop that executes the given async task effectively recursively using setTimeout. + * This guarantees that the next execution only starts after the previous one has completed (or failed), + * preventing request pile-ups. + * + * @param task The async function to execute. + * @param intervalMs The delay in milliseconds before the next execution. + */ +export function startPolling(task: () => Promise, intervalMs: number) { + const runLoop = () => { + task() + .catch((error) => { + log.error("Error in polling loop:", error); + }) + .finally(() => { + setTimeout(runLoop, intervalMs); + }); + }; + runLoop(); +} diff --git a/src/server/PrivilegeRefresher.ts b/src/server/PrivilegeRefresher.ts index 89bdcb1ac..030da9621 100644 --- a/src/server/PrivilegeRefresher.ts +++ b/src/server/PrivilegeRefresher.ts @@ -1,6 +1,7 @@ import { base64url } from "jose"; import { Logger } from "winston"; import { CosmeticsSchema } from "../core/CosmeticSchemas"; +import { startPolling } from "./PollingLoop"; import { FailOpenPrivilegeChecker, PrivilegeChecker, @@ -28,12 +29,7 @@ export class PrivilegeRefresher { this.log.info( `Starting privilege refresher with interval ${this.refreshInterval}`, ); - // Add some jitter to the initial load and the interval. - setTimeout(() => this.loadPrivilegeChecker(), Math.random() * 1000); - setInterval( - () => this.loadPrivilegeChecker(), - this.refreshInterval + Math.random() * 1000, - ); + startPolling(() => this.loadPrivilegeChecker(), this.refreshInterval); } public get(): PrivilegeChecker { diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 97a9706c7..32b2eff6e 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -27,6 +27,7 @@ import { logger } from "./Logger"; import { GameEnv } from "../core/configuration/Config"; import { MapPlaylist } from "./MapPlaylist"; +import { startPolling } from "./PollingLoop"; import { PrivilegeRefresher } from "./PrivilegeRefresher"; import { verifyTurnstileToken } from "./Turnstile"; import { initWorkerMetrics } from "./WorkerMetrics"; @@ -43,7 +44,7 @@ export async function startWorker() { setTimeout( () => { - pollLobby(gm); + startMatchmakingPolling(gm); }, 1000 + Math.random() * 2000, ); @@ -483,63 +484,61 @@ export async function startWorker() { }); } -async function pollLobby(gm: GameManager) { - try { - const url = `${config.jwtIssuer() + "/matchmaking/checkin"}`; - const gameId = generateGameIdForWorker(); - if (gameId === null) { - log.warn(`Failed to generate game ID for worker ${workerId}`); - return; - } +async function startMatchmakingPolling(gm: GameManager) { + startPolling( + async () => { + try { + const url = `${config.jwtIssuer() + "/matchmaking/checkin"}`; + const gameId = generateGameIdForWorker(); + if (gameId === null) { + log.warn(`Failed to generate game ID for worker ${workerId}`); + return; + } - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 20000); - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": config.apiKey(), - }, - body: JSON.stringify({ - id: workerId, - gameId: gameId, - ccu: gm.activeClients(), - instanceId: process.env.INSTANCE_ID, - }), - signal: controller.signal, - }); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 20000); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": config.apiKey(), + }, + body: JSON.stringify({ + id: workerId, + gameId: gameId, + ccu: gm.activeClients(), + instanceId: process.env.INSTANCE_ID, + }), + signal: controller.signal, + }); - clearTimeout(timeoutId); + clearTimeout(timeoutId); - if (!response.ok) { - log.warn( - `Failed to poll lobby: ${response.status} ${response.statusText}`, - ); - return; - } + if (!response.ok) { + log.warn( + `Failed to poll lobby: ${response.status} ${response.statusText}`, + ); + return; + } - const data = await response.json(); - log.info(`Lobby poll successful:`, data); + const data = await response.json(); + log.info(`Lobby poll successful:`, data); - if (data.assignment) { - const gameConfig = playlist.get1v1Config(); - const game = gm.createGame(gameId, gameConfig); - setTimeout(() => { - // Wait a few seconds to allow clients to connect. - console.log(`Starting game ${gameId}`); - game.start(); - }, 5000); - } - } catch (error) { - log.error(`Error polling lobby:`, error); - } finally { - setTimeout( - () => { - pollLobby(gm); - }, - 5000 + Math.random() * 1000, - ); - } + if (data.assignment) { + const gameConfig = playlist.get1v1Config(); + const game = gm.createGame(gameId, gameConfig); + setTimeout(() => { + // Wait a few seconds to allow clients to connect. + console.log(`Starting game ${gameId}`); + game.start(); + }, 5000); + } + } catch (error) { + log.error(`Error polling lobby:`, error); + } + }, + 5000 + Math.random() * 1000, + ); } // TODO: This is a hack to generate a game ID for the worker. diff --git a/tests/server/PollingLoop.test.ts b/tests/server/PollingLoop.test.ts new file mode 100644 index 000000000..008a2f7d7 --- /dev/null +++ b/tests/server/PollingLoop.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { startPolling } from "../../src/server/PollingLoop"; + +vi.mock("../../src/server/Logger", () => ({ + logger: { + child: () => ({ + error: vi.fn(), + }), + }, +})); + +describe("PollingLoop", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should not start the next task until the previous one completes", async () => { + let taskCallCount = 0; + let resolveTask: ((value?: void) => void) | undefined; + + const task = vi.fn().mockImplementation(() => { + taskCallCount++; + return new Promise((resolve) => { + resolveTask = resolve; + }); + }); + + startPolling(task, 100); + + // Initial call + expect(taskCallCount).toBe(1); + + // Advance time past the interval - should NOT trigger next call yet + await vi.advanceTimersByTimeAsync(200); + expect(taskCallCount).toBe(1); + + // Resolve the first task + if (resolveTask) resolveTask(); + + // Wait for microtasks (promise callbacks, finally block) to run + await new Promise(process.nextTick); + + // NOW advance time to trigger the scheduled continuation + await vi.advanceTimersByTimeAsync(100); + + expect(taskCallCount).toBe(2); + }); + + it("should continue polling even if a task fails", async () => { + let taskCallCount = 0; + const task = vi.fn().mockImplementation(async () => { + taskCallCount++; + if (taskCallCount === 1) { + throw new Error("Task failed"); + } + }); + + startPolling(task, 100); + + // First call + expect(taskCallCount).toBe(1); + + // Wait for rejection and finally block + await new Promise(process.nextTick); + await new Promise(process.nextTick); + + // Advance time + await vi.advanceTimersByTimeAsync(100); + + // Second call + expect(taskCallCount).toBe(2); + }); +}); From 6719f4177bad14d793ff2a3e70c7764169d7353e Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:56:50 +0000 Subject: [PATCH 006/109] [Bugfix] Login Modal (#2903) If this PR fixes an issue, link it below. If not, delete these two lines. Resolves #2889 ## Description: fixes login in modal: image (theres also one more afterwards but that leaks my email and cba to edit it out) ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- src/client/LangSelector.ts | 1 + src/client/Main.ts | 4 +- src/client/TokenLoginModal.ts | 96 +++++++++++++++---- src/client/components/PlayPage.ts | 2 +- src/client/components/baseComponents/Modal.ts | 6 ++ 5 files changed, 86 insertions(+), 23 deletions(-) diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index 1ee3329c7..3097ea7a8 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -228,6 +228,7 @@ export class LangSelector extends LitElement { "stats-modal", "flag-input-modal", "flag-input", + "token-login", ]; document.title = this.translateText("main.title") ?? document.title; diff --git a/src/client/Main.ts b/src/client/Main.ts index f2cdae39d..88fe223f4 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -687,7 +687,7 @@ class Client { // in case it is unset during reload. this.userSettings.setSelectedPatternName(patternName); }); - this.tokenLoginModal.open(token); + this.tokenLoginModal.openWithToken(token); } else { alertAndStrip(`purchase succeeded: ${patternName}`); this.patternsModal.refresh(); @@ -706,7 +706,7 @@ class Client { } strip(); - this.tokenLoginModal.open(token); + this.tokenLoginModal.openWithToken(token); return; } diff --git a/src/client/TokenLoginModal.ts b/src/client/TokenLoginModal.ts index cb7ef143e..97f0b3043 100644 --- a/src/client/TokenLoginModal.ts +++ b/src/client/TokenLoginModal.ts @@ -1,17 +1,14 @@ -import { html, LitElement } from "lit"; -import { customElement, query } from "lit/decorators.js"; +import { html } from "lit"; +import { customElement } from "lit/decorators.js"; import { tempTokenLogin } from "./Auth"; +import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; import "./components/PatternButton"; +import { modalHeader } from "./components/ui/ModalHeader"; import { translateText } from "./Utils"; @customElement("token-login") -export class TokenLoginModal extends LitElement { - @query("o-modal") private modalEl!: HTMLElement & { - open: () => void; - close: () => void; - }; - +export class TokenLoginModal extends BaseModal { private isAttemptingLogin = false; private retryInterval: NodeJS.Timeout | undefined = undefined; @@ -27,39 +24,98 @@ export class TokenLoginModal extends LitElement { } render() { + const title = translateText("token_login_modal.title"); + const content = html` +
+ ${modalHeader({ + title, + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + })} +
+ ${this.email ? this.loginSuccess(this.email) : this.loggingIn()} +
+
+ `; + + if (this.inline) { + return content; + } + return html` - ${this.email ? this.loginSuccess(this.email) : this.loggingIn()} + ${content} `; } private loggingIn() { - return html`

${translateText("token_login_modal.logging_in")}

`; + const loggingText = translateText("token_login_modal.logging_in"); + return html` +
+
+
+
+
+

${loggingText}

+
+
+
+
+
+ `; } private loginSuccess(email: string) { - return html`

- ${translateText("token_login_modal.success", { - email, - })} -

`; + const successText = translateText("token_login_modal.success", { email }); + return html` +
+
+
+
+

${successText}

+
+ `; } - public async open(token: string) { - this.token = token; - this.modalEl?.open(); + public open(): void { + if (!this.token) { + return; + } + super.open(); + clearInterval(this.retryInterval); this.retryInterval = setInterval(() => this.tryLogin(), 3000); } + public openWithToken(token: string): void { + this.token = token; + this.email = null; + this.attemptCount = 0; + this.isAttemptingLogin = false; + this.open(); + } + public close() { this.token = null; clearInterval(this.retryInterval); this.attemptCount = 0; - this.modalEl?.close(); + super.close(); this.isAttemptingLogin = false; } diff --git a/src/client/components/PlayPage.ts b/src/client/components/PlayPage.ts index 15dd47d56..d785f1a62 100644 --- a/src/client/components/PlayPage.ts +++ b/src/client/components/PlayPage.ts @@ -13,7 +13,7 @@ export class PlayPage extends LitElement { id="page-play" class="flex flex-col gap-2 w-full max-w-6xl mx-auto px-0 sm:px-4 transition-all duration-300 my-auto min-h-0" > - +
diff --git a/src/client/components/baseComponents/Modal.ts b/src/client/components/baseComponents/Modal.ts index 7ef216ffc..3094faf99 100644 --- a/src/client/components/baseComponents/Modal.ts +++ b/src/client/components/baseComponents/Modal.ts @@ -25,6 +25,9 @@ export class OModal extends LitElement { @property({ type: Boolean }) public hideHeader = false; + @property({ type: String }) + public maxWidth = ""; + public onClose?: () => void; public open() { @@ -67,6 +70,8 @@ export class OModal extends LitElement { : `relative flex flex-col w-[90%] min-w-[400px] max-w-[900px] m-8 rounded-lg shadow-[0_20px_60px_rgba(0,0,0,0.8)] max-h-[calc(100vh-4rem)] ${ this.alwaysMaximized ? "h-auto" : "" }`; + const wrapperStyle = + !this.inline && this.maxWidth ? `max-width: ${this.maxWidth};` : ""; return html` ${this.isModalOpen @@ -78,6 +83,7 @@ export class OModal extends LitElement {
e.stopPropagation()} class="${wrapperClass}" + style="${wrapperStyle}" > ${this.inline || this.hideCloseButton ? html`` From 779ba2c518ad8fd18cbfc2da819600b4f9912532 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Fri, 16 Jan 2026 04:21:49 +0900 Subject: [PATCH 007/109] mls (v4.13) (#2907) ## Description: mls for v29 Version identifier within MLS: 4.13 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: aotumuri --- resources/lang/bg.json | 223 ++++++++++++++++++++++++++++++--------- resources/lang/ja.json | 231 +++++++++++++++++++++++++++++++---------- resources/lang/nl.json | 223 ++++++++++++++++++++++++++++++--------- 3 files changed, 529 insertions(+), 148 deletions(-) diff --git a/resources/lang/bg.json b/resources/lang/bg.json index 2fef4934e..ce630b90f 100644 --- a/resources/lang/bg.json +++ b/resources/lang/bg.json @@ -7,6 +7,7 @@ }, "common": { "close": "Затвори", + "back": "Назад", "available": "Наличен", "preset_max": "Макс", "summary_send": "Изпрати", @@ -17,7 +18,9 @@ "cap_tooltip": "Оставащ капацитет на получателя", "target_dead": "Целта бе елиминирана", "target_dead_note": "Не можеш да изпращаш ресурси на елиминиран играч.", - "none": "Няма" + "none": "Няма", + "copied": "Копирано!", + "click_to_copy": "Кликни, за да копираш" }, "main": { "title": "OpenFront (АЛФА)", @@ -26,17 +29,28 @@ "checking_login": "Проверяване на входа...", "logged_in": "Влезли сте!", "log_out": "Излез от профила си", - "create_lobby": "Създай частна игра", - "join_lobby": "Присъедини се към частна игра", - "single_player": "Самостоятелна игра", + "create": "Създай частна игра", + "join": "Присъедини се към частна игра", + "solo": "Самостоятелна игра", "instructions": "Инструкции", + "game_info": "Информация за играта", "wiki": "Wiki", "privacy_policy": "Поверителност", "terms_of_service": "Условия за ползване", - "reddit": "Reddit" + "copyright": "© OpenFront™ and Contributors", + "reddit": "Reddit", + "play": "Играй", + "news": "Новини", + "store": "Магазин", + "options": "Опции", + "keys": "Клавиши", + "stats": "Статистики", + "account": "Акаунт", + "help": "Помощ", + "menu": "Меню", + "pick_pattern": "Избери шаблон!" }, "news": { - "see_all_releases": "Виж всички издания", "github_link": "в GitHub", "title": "Бележки по изданието" }, @@ -64,7 +78,7 @@ "ui_gold": "Злато - Количеството злато, което притежаваш и скоростта, с която го получаваш.", "ui_attack_ratio": "Съотношение на атака - Количеството войници, които ще се използват при атака. Можеш да коригираш съотношението на атака, използвайки плъзгача. Притежаването на повече атакуващи войници от тези в защита ще доведе до по-малка загуба на войници при атака, докато разполагането с по-малко ще увеличи щетите, нанесени на атакуващите ти войници. Ефектът не надхвърля съотношения 2:1.", "ui_events": "Панел за събития", - "ui_events_desc": "Панелът за събития показва най-новите събития, заявки и съобщения от бърз чат. Някои примери са:", + "ui_events_desc": "Панелът за събития показва най-актуалните събития, заявки и съобщения от бърз чат. Някои примери са:", "ui_events_alliance": "Съюз - Заявките за съюз могат да бъдат приети или отхвърлени. Съюзниците могат да споделят ресурси и войници, но не могат да се атакуват взаимно. Кликането върху \"Фокусиране\" премества изгледа върху играча, изпратил заявката.", "ui_events_attack": "Атаки - Показани са атаките срещу теб, както и твоите собствени атаки. Кликни върху съобщението, за да центрираш изгледа върху атаката, ракетата или лодката (транспортен кораб). Можеш да оттеглиш войниците си, като кликнеш върху червения бутон X. Това ще струва живота на 25% от атакуващите ти войници. Ако оттеглиш атака с лодка, лодката се връща в началната си точка и ще атакува там, ако тази земя е била превзета от друг. Ракетите не могат да бъдат оттеглени, след като бъдат изстреляни.", "ui_events_quickchat": "Бърз чат - Тук можеш да видиш изпратените и получените съобщения в чата. Изпрати съобщение до играч, като кликнеш върху иконката за бърз чат в менюто му с информация.", @@ -83,6 +97,8 @@ "radial_attack": "Отвориш менюто за атака.", "radial_info": "Отвориш информационното меню.", "radial_boat": "Изпратиш лодка (транспортен кораб) за атака на избраното място. Възможно е само, ако имаш достъп до вода.", + "radial_donate_troops": "Дариш войници на съюзника, на когото си отворил радиалното меню, еквивалентни на процента на плъзгача за съотношение на атака.", + "radial_donate_gold": "Отваря плъзгащото меню за даряване на злато, за да можеш бързо да изпратиш злато на съюзниците.", "radial_close": "Затвориш менюто.", "info_title": "Информационно меню", "info_enemy_desc": "Съдържа информация като име на избрания играч, злато, войници, дали е спряна търговията с теб, изпратени ракети към теб и дали играчът е предател. Спряната търговия означава, че няма да получаваш злато от него и той няма да ти изпраща злато чрез търговски кораби. Ръчно (ако играчът кликне върху „Прекратяване на търговия“, което продължава, докато и двамата не кликнат върху „Започване на търговия“) или автоматично (ако си предал съюзника си, което продължава, докато не станете съюзници отново или след 5 минути). Показва се \"Да\" на \"Предател\" за 30 секунди, когато играчът е предал и нападнал играч, който е бил в съюз с него. Иконките по-долу представляват следните взаимодействия:", @@ -114,7 +130,7 @@ "build_silo": "Ракетен силоз", "build_silo_desc": "Позволява изстрелване на ракети.", "build_sam": "Противоракетна установка земя-въздух SAM", - "build_sam_desc": "Може да прихваща вражески ракети в своя обхват от 100 пиксела. Със 100% шанс да свали атомна бомба, 80% за водородна бомба и 50% за отделни бойни ракети на МИРВ. Противоракетната установка земя-въздух SAM има 7,5 секунди охлаждане.", + "build_sam_desc": "Може да прехваща вражески ракети в обхват от 100 пиксела. Противоракетната установка земя-въздух SAM има време за охлаждане от 7,5 секунди.", "build_atom": "Атомна бомба", "build_atom_desc": "Малка експлозивна бомба, която унищожава територия, сгради, кораби и лодки. Поражда се от най-близкия ракетен силоз и се приземява в областта, в която първо си щракнал, за да я построиш.", "build_hydrogen": "Водородна бомба", @@ -129,12 +145,15 @@ "icon_embargo": "Стоп знак за долар - Ембарго. Този играч е спрял да търгува с теб автоматично или ръчно.", "icon_request": "Пликче за писмо - молба за съюз. Този играч ти е изпратил молба за съюз.", "info_enemy_panel": "Информационно меню за врагове", - "exit_confirmation": "Сигурен ли си, че искаш да излезеш от играта?" + "exit_confirmation": "Сигурен ли си, че искаш да излезеш от играта?", + "bomb_direction": "Посока на дъгата на атомна/водородна бомба" }, "single_modal": { - "title": "Самостоятелна Игра", + "title": "Самостоятелно", "random_spawn": "Случайно появяване", "allow_alliances": "Позволяване на съюзничества", + "toggle_achievements": "Превключване на постижения", + "sign_in_for_achievements": "Впиши се, за да получаваш постижения", "options_title": "Опции", "bots": "Ботове: ", "bots_disabled": "Изключени", @@ -145,6 +164,8 @@ "infinite_troops": "Безкрайна популация", "compact_map": "Компактна карта", "max_timer": "Продължителност на играта (в минути)", + "max_timer_placeholder": "Минути", + "max_timer_invalid": "Моля, въведи валидна максимална стойност на таймера (1-120 минути)", "disable_nukes": "Изключване на ядрени оръжия", "enables_title": "Активиране на настройки", "start": "Започване на игра" @@ -156,10 +177,26 @@ }, "account_modal": { "title": "Акаунт", - "logged_in_as": "Вписан като {email}", + "connected_as": "Вписан като", + "stats_overview": "Преглед на статистики", + "save_progress_title": "Запази си напредъка", + "save_progress_desc": "Свържи си акаунта, за да запазиш статистиките, ранка и козметиките си в безопасност.", + "link_discord": "Свържи Discord акаунт", + "link_via_email_placeholder": "Свържи чрез имейл", + "link_button": "Свържи", + "log_out": "Изход от профила", + "welcome_back": "Добре дошъл отново!", + "sign_in_desc": "Впиши се, за да запазиш статистиките и напредъка си", + "or": "ИЛИ", + "email_placeholder": "Въведи имейл адреса си", + "get_magic_link": "Използвай магически линк", + "linked_account": "Вписан като {account_name}", "fetching_account": "Взима се информацията за профила...", - "logged_in_with_discord": "Вписал си се с Discord", - "recovery_email_sent": "Имейл за възстановяване бе изпратен на {email}" + "recovery_email_sent": "Имейл за възстановяване бе изпратен на {email}", + "not_found": "Не е намерен", + "clear_session": "Изчисти сесията", + "failed_to_send_recovery_email": "Грешка при изпращането на имейл за възстановяване", + "enter_email_address": "Моля, въведи имейл адрес" }, "stats_modal": { "title": "Статистики", @@ -167,11 +204,40 @@ "loading": "Зареждане...", "error": "Грешка при зареждането на клановите статистики", "no_stats": "Няма налични кланови статистики", + "no_data_yet": "Все още няма данни", "clan": "Клан", "games": "Игри", "win_score": "Резултат на победи", + "win_score_tooltip": "Претеглени победи въз основа на участието на клана и трудността на мача", "loss_score": "Резултат на загуби", - "win_loss_ratio": "П/З" + "loss_score_tooltip": "Претеглени загуби въз основа на участието на клана и трудността на мача", + "win_loss_ratio": "П/З", + "ratio": "Съотношение", + "rank": "Ранк", + "try_again": "Опитай отново" + }, + "game_info_modal": { + "title": "Информация за играта", + "players": "Играчи", + "atoms": "Атомни бомби", + "hydros": "Водородни бомби", + "mirv": "МИРВ", + "bombs": "Бомби", + "total_gold": "Общо", + "all_gold": "Всичко злато", + "trade": "Търговия", + "conquest_gold": "Завладяно злато от играч", + "stolen_gold": "Откраднато с военни кораби", + "num_of_conquests": "Брой завладяни играчи", + "duration": "Продължителност", + "survival_time": "Време на оцеляване", + "war": "Война", + "economy": "Икономика", + "conquests": "Завоевания", + "pirate": "Пират", + "conquered": "Завладяно", + "loading_game_info": "Зареждат се статистиките на играта", + "no_winner": "Играта е свършила без победител" }, "map": { "map": "Карта", @@ -186,6 +252,7 @@ "asia": "Азия", "mars": "Марс", "southamerica": "Южна Америка", + "britanniaclassic": "Британия (Класическа)", "britannia": "Британия", "gatewaytotheatlantic": "Порта към Атлантика", "australia": "Австралия", @@ -196,7 +263,7 @@ "betweentwoseas": "Между Две Морета", "faroeislands": "Фарьорски острови", "deglaciatedantarctica": "Обезледена Антарктида", - "europeclassic": "Европа (класическа)", + "europeclassic": "Европа (Класическа)", "falklandislands": "Фолкландски острови", "baikal": "Байкал", "halkidiki": "Халкидики", @@ -206,19 +273,33 @@ "yenisei": "Енисей", "pluto": "Плутон", "montreal": "Монтреал", + "newyorkcity": "Ню Йорк", "achiran": "Ахиран", "baikalnukewars": "Байкал (Ядрени войни)", "fourislands": "Четири острова", "gulfofstlawrence": "Залив Сейнт Лорънс", - "lisbon": "Лисабон" + "lisbon": "Лисабон", + "svalmel": "Свалмел", + "manicouagan": "Маникуаган", + "lemnos": "Лемнос", + "sierpinski": "Серпински", + "twolakes": "Две езера", + "straitofhormuz": "Ормузки проток", + "surrounded": "Обкражен", + "didier": "Дидиер", + "didierfrance": "Дидиер (Франция)", + "amazonriver": "Река Амазонка" }, "map_categories": { "continental": "Континентално", "regional": "Регионално", - "fantasy": "Друго" + "fantasy": "Друго", + "special": "Специално", + "arcade": "Аркада" }, "map_component": { - "loading": "Зареждане..." + "loading": "Зареждане...", + "error": "Грешка" }, "private_lobby": { "title": "Присъединяване към частна игра", @@ -229,42 +310,55 @@ "checking": "Проверяване на частна игра...", "not_found": "Не е намерена частната игра. Моля, провери ID-то и опитай отново.", "error": "Възникна грешка. Моля, опитай отново или се свържи с екипа за поддръжка.", - "joined_waiting": "Присъединяването е успешно! Чакане за започване на играта...", - "version_mismatch": "Тази игра е създадена на различна версия. Не можеш да бъдеш присъединен." + "joined_waiting": "Присъедини се към лобито! Чакаме хостът да започне...", + "version_mismatch": "Тази игра е създадена на различна версия. Не можеш да бъдеш присъединен.", + "disabled_units": "Изключване на военни единици" }, "public_lobby": { "join": "Присъединяване към следващата игра", "waiting": "чакащи играчи", - "teams_Duos": "по 2-ма (Дуос)", - "teams_Trios": "по 3-ма (Триос)", - "teams_Quads": "по 4-ма (Куадс)", + "teams_Duos": "{team_count} отбора по 2-ма (Дуос)", + "teams_Trios": "{team_count} отбора по 3-ма (Триос)", + "teams_Quads": "{team_count} отбора по 4-ма (Куадс)", + "waiting_for_players": "Изчакване на играчи", + "starting_game": "Играта се стартира…", "teams_hvn": "Хора срещу Нации", + "teams_hvn_detailed": "{num} Души vs {num} Нации", "teams": "{num} отбора", - "players_per_team": "по {num}" + "players_per_team": "по {num}", + "started": "Стартирана" }, "matchmaking_modal": { - "title": "Мачмейкинг", + "title": "1v1 Ранков мачмейкинг (АЛФА)", "connecting": "Свързване със сървъра за мачмейкинг...", "searching": "Търси се игра...", - "waiting_for_game": "Изчаква се да започне играта..." + "waiting_for_game": "Изчаква се да започне играта...", + "elo": "Твоето ЕЛО: {elo}" }, "username": { "enter_username": "Въведи потребителско име", "not_string": "Потребителското име трябва да е символен низ.", "too_short": "Потребителското име трябва да е дълго поне {min} символа.", "too_long": "Потребителското име не трябва да надвишава {max} символа.", - "invalid_chars": "Потребителското име може да съдържа само букви, цифри, интервали, долни черти и [квадратни скоби]." + "invalid_chars": "Потребителското име може да съдържа само букви, цифри, интервали и долни черти.", + "tag": "ТАГ", + "tag_too_short": "Клановият таг трябва да съдържа от 2 до 5 буквено-цифрови знака.", + "tag_invalid_chars": "Клановият таг може да съдържа само букви и цифри." }, "host_modal": { - "title": "Частна игра", + "title": "Създай частна игра", + "label": "Частна", "mode": "Начин на игра", "team_count": "Брой отбори", + "team_type": "Вид на отбора", "options_title": "Опции", "bots": "Ботове: ", "bots_disabled": "Изключени", + "player_immunity_duration": "Продължителност на военния имунитет (минути)", "nations": "Нации: ", "disable_nations": "Изключване на нации", "max_timer": "Продължителност на играта (в минути)", + "mins_placeholder": "Минути", "instant_build": "Незабавно построяване", "infinite_gold": "Безкрайно злато", "donate_gold": "Даряване на злато", @@ -283,7 +377,11 @@ "assigned_teams": "Назначени отбори", "empty_teams": "Празни отбори", "empty_team": "Празен", - "remove_player": "Премахване на {username}" + "remove_player": "Премахване на {username}", + "teams_Duos": "Дуос (отбори по 2-ма)", + "teams_Trios": "Триос (отбори по 3-ма)", + "teams_Quads": "Куадс (отбори по 4-ма)", + "teams_Humans Vs Nations": "Хора срещу Нации" }, "team_colors": { "red": "Червен", @@ -301,16 +399,20 @@ "code_license": "Кодът е лицензиран съгласно AGPL-3.0 (без гаранция)" }, "difficulty": { - "difficulty": "Трудност", - "Easy": "Релаксирано", - "Medium": "Балансирано", - "Hard": "Интензивно", - "Impossible": "Невъзможно" + "difficulty": "Трудност на нациите", + "easy": "Лесно", + "medium": "Средно", + "hard": "Трудно", + "impossible": "Невъзможно" }, "game_mode": { "ffa": "Всеки срещу всеки (FFA)", "teams": "Отбори" }, + "public_game_modifier": { + "random_spawn": "Случайно появяване", + "compact_map": "Компактна карта" + }, "select_lang": { "title": "Изберете език" }, @@ -340,16 +442,18 @@ "special_effects_desc": "Превключване на специалните ефекти. Деактивиране, за да се увеличи производителността", "structure_sprites_label": "Структурни спрайтове", "structure_sprites_desc": "Превключване на структурните спрайтове", + "cursor_cost_label_label": "Цена на изграждане под курсора", + "cursor_cost_label_desc": "Показване на цената за изграждане под иконката на курсора", "anonymous_names_label": "Скрити имена", "anonymous_names_desc": "Скриване на истинските имена на играчите с произволни такива на екрана ти.", "lobby_id_visibility_label": "Скрити ID-та на частните игри", "lobby_id_visibility_desc": "Скриване на ID-то на частната игра при нейното създаване", + "toggle_visibility": "Превключване на видимостта", "left_click_label": "Щтракване на ляв бутон, за да се отвори менюто", "left_click_desc": "Когато е ВКЛЮЧЕНО, щракването с ляв бутон отваря менюто и атаките се извършват чрез бутона на меч. Когато е ИЗКЛЮЧЕНО, щракването с ляв бутон атакува директно.", "left_click_menu": "Меню ляв клик", "attack_ratio_label": "⚔️ Съотношение на атака", "attack_ratio_desc": "Какъв процент от Вашите войници да се изпратят в атака (1–100%)", - "troop_ratio_desc": "Коригиране на баланса между войници (за битка) и работници (за производство на злато) (1–100%)", "territory_patterns_label": "🏳️ Териториални шаблони", "territory_patterns_desc": "Избиране дали да се показват дизайни на шаблони на територия в играта", "performance_overlay_label": "Горен слой за производителност", @@ -358,6 +462,7 @@ "easter_writing_speed_desc": "Регулиране на това колко бързо се преструвате, че кодирате(x1–x100)", "easter_bug_count_label": "Брой грешки", "easter_bug_count_desc": "С колко грешки сте ок (0–1000, емоционално)", + "press_a_key": "Натисни клавиш", "view_options": "Вижте настройките", "toggle_view": "Превключване на изгледа", "toggle_view_desc": "Алтернативен изглед (терен/държави)", @@ -416,7 +521,8 @@ "exit_game_label": "Напускане на играта", "exit_game_info": "Връщане в главното меню", "background_music_volume": "Сила на фоновата музика", - "sound_effects_volume": "Сила на звука на звуковите ефекти" + "sound_effects_volume": "Сила на звука на звуковите ефекти", + "keybind_conflict_error": "Клавишът {key} вече е вързан за друго действие." }, "chat": { "title": "Бърз чат", @@ -529,6 +635,7 @@ "other_team": "{team} отбор спечели!", "you_won": "Ти спечели!", "other_won": "{player} спечели!", + "nation_won": "Нацията {nation} спечели!", "exit": "Напускане на играта", "keep": "Продължаване на играта", "spectate": "Наблюдаване", @@ -537,7 +644,7 @@ "ofm_winter_description": "Присъедини се към състезателния турнир и се състезавай срещу най-добрите играчи", "join_tournament": "Присъедини се към турнира", "join_discord": "Присъедини се към общността ни в Discord!", - "discord_description": "Свържи се с други играчи, получавай актуална информация и споделяй стратегии", + "discord_description": "Свържи се с играчи, открий нови функции и спечели награди!", "join_server": "Влез в сървъра", "youtube_tutorial": "Нужда от помощ?" }, @@ -549,7 +656,7 @@ "team": "Отбор", "owned": "Притежавано", "gold": "Злато", - "troops": "Войници", + "maxtroops": "Максимални войници", "launchers": "Установки", "sams": "Противоракетни установки земя-въздух SAM", "warships": "Бойни кораби", @@ -565,6 +672,7 @@ "team": "Отбор", "alliance_timeout": "Съюзът изтича след", "troops": "Войници", + "maxtroops": "Максимални войници", "a_troops": "Атакуващи войници", "gold": "Злато", "ports": "Пристанища", @@ -575,7 +683,9 @@ "warships": "Бойни кораби", "health": "Живот", "attitude": "Становище", - "levels": "Нива" + "levels": "Нива", + "wilderness_title": "Пустош", + "irradiated_wilderness_title": "Облъчена пустош" }, "events_display": { "retreating": "отстъпване", @@ -653,7 +763,10 @@ "send_alliance": "Изпрати съюз", "send_troops": "Изпрати войници", "send_gold": "Изпрати злато", - "emotes": "Емоджита" + "emotes": "Емоджита", + "arc_up": "Възходяща дъга", + "arc_down": "Низходяща дъга", + "flip_rocket_trajectory": "Обърни траекторията на ракетата" }, "send_troops_modal": { "title_with_name": "Изпрати войници на {name}", @@ -702,25 +815,31 @@ }, "heads_up_message": { "choose_spawn": "Изберете начална локация", - "random_spawn": "Случайното появяване е активирано. Избиране на начална локация за теб..." + "random_spawn": "Случайното появяване е активирано. Избиране на начална локация за теб...", + "singleplayer_game_paused": "Играта е на пауза", + "multiplayer_game_paused": "Играта е паузирана от създателя на лобито" }, "territory_patterns": { "title": "Териториални шаблони", "colors": "Цветове", "purchase": "Купуване", "show_only_owned": "Моите шаблони", + "all_owned": "Имаш всички шаблони! Провери отново по-късно за нови артикули.", + "not_logged_in": "Не си се вписал в профил", "blocked": { "login": "Трябва да сте влезли в профила си, за да получите достъп до този шаблон.", "purchase": "Закупете този шаблон, за да го отключите." }, "pattern": { "default": "Стандартен" - } + }, + "select_skin": "Избери шаблон", + "selected": "е избран" }, "flag_input": { - "title": "Изберете знаме", - "button_title": "Изберете знаме!", - "search_flag": "Търсене..." + "title": "Избери знаме", + "button_title": "Избери знаме!", + "search_flag": "Търси..." }, "spawn_ad": { "loading": "Зарежда се реклама..." @@ -786,8 +905,9 @@ "mode": "Вид", "mode_ffa": "Всеки срещу всеки (FFA)", "mode_team": "Отбор", - "view": "Виж", + "replay": "Повторение", "details": "Детайли", + "ranking": "Класиране", "started": "Стартирана", "map": "Карта", "difficulty": "Трудност", @@ -796,13 +916,20 @@ "player_stats_tree": { "public": "Публична", "private": "Частна", - "singleplayer": "Самостоятелна Игра", + "singleplayer": "Самостоятелно", "mode": "Вид", "stats_wins": "Победи", "stats_losses": "Загуби", "stats_wlr": "Съотношение победи:загуби", "stats_games_played": "Изиграни игри", "mode_ffa": "Всеки срещу всеки (FFA)", - "mode_team": "Отбор" + "mode_team": "Отбор", + "no_stats": "Няма записани статистики за тази селекция." + }, + "matchmaking_button": { + "play_ranked": "1v1 Ранков мачмейкинг", + "description": "(АЛФА)", + "login_required": "Впиши се, за да играеш ранково!", + "must_login": "Трябва да си вписан в профила си, за да играеш ранков мачмейкинг." } } diff --git a/resources/lang/ja.json b/resources/lang/ja.json index 33bcbc2bf..381fd0390 100644 --- a/resources/lang/ja.json +++ b/resources/lang/ja.json @@ -7,6 +7,7 @@ }, "common": { "close": "閉じる", + "back": "戻る", "available": "利用可能", "preset_max": "最大", "summary_send": "送る", @@ -17,7 +18,9 @@ "cap_tooltip": "受取主が受け取れる量", "target_dead": "ターゲットは排除されました", "target_dead_note": "排除されたプレイヤーにはリソースを送ることができません。", - "none": "なし" + "none": "なし", + "copied": "コピーに成功しました!", + "click_to_copy": "クリックしてコピー" }, "main": { "title": "OpenFront (ALPHA)", @@ -26,17 +29,28 @@ "checking_login": "ログイン中...", "logged_in": "ログイン中!", "log_out": "ログアウト", - "create_lobby": "ロビーを作成", - "join_lobby": "ロビーに参加", - "single_player": "シングルプレイヤー", + "create": "ロビーを作成", + "join": "ロビーに参加", + "solo": "1人のロビー", "instructions": "説明書", + "game_info": "ゲームの情報", "wiki": "ウィキ", "privacy_policy": "プライバシーポリシー", "terms_of_service": "利用規約", - "reddit": "Reddit" + "copyright": "©️ OpenFront™ と貢献者", + "reddit": "Reddit", + "play": "プレイ", + "news": "お知らせ", + "store": "ストア", + "options": "設定", + "keys": "キー設定", + "stats": "統計", + "account": "アカウント", + "help": "ヘルプ", + "menu": "メニュー", + "pick_pattern": "模様を選択してください!" }, "news": { - "see_all_releases": "すべてのリリースを見る", "github_link": "GitHub上で", "title": "更新情報" }, @@ -67,7 +81,7 @@ "ui_events_desc": "イベントパネルには、最新のイベント、リクエスト、クイックチャットメッセージが表示されます。以下がその一例です:", "ui_events_alliance": "同盟 — 同盟リクエストは承認または拒否できます。同盟関係にあるプレイヤーは資源や軍隊を共有できますが、互いに攻撃することはできません。「Focus(注視)」をクリックすると、リクエストを送ったプレイヤーの位置に画面が移動します。", "ui_events_attack": "攻撃 — 敵からの攻撃や自分の攻撃が表示されます。メッセージをクリックすると、その攻撃・核・ボート(輸送船)に画面が移動します。赤い「X」ボタンをクリックすると軍隊を撤退させることができますが、その場合攻撃部隊の25%が犠牲になります。ボート攻撃を撤退させた場合、ボートは出発地点に戻り、その地点が占領されていれば再び攻撃します。核攻撃は発射後に撤退することはできません。", - "ui_events_quickchat": "クイックチャット – ここでは送信・受信したチャットメッセージを確認できます。プレイヤーにメッセージを送るには、そのプレイヤーの情報メニューにあるクイックチャットアイコンをクリックしてください。", + "ui_events_quickchat": "クイックチャット:ここでは、送信・受信されたメッセージを確認できます。プレイヤーにメッセージを送信するには、そのプレイヤーの情報メニューにあるクイックチャットアイコンをクリックしてください。", "ui_options": "オプション", "ui_options_desc": "以下の項目が含まれます:", "ui_playeroverlay": "プレイヤー情報オーバーレイ", @@ -77,12 +91,14 @@ "option_timer": "タイマー - ゲーム開始からの経過時間", "option_exit": "終了ボタン", "option_settings": "設定メニュー - 設定メニューを開きます。左クリックでオルタネート表示、ダークモード、絵文字、アクション、匿名モードを切り替えることができます。", - "radial_title": "ラジアルメニュー", - "radial_desc": "右クリック(またはモバイルでタッチ)するとラジアルメニューが開きます。右クリックすると、ラジアルメニューを閉じます。メニューから、次のようにできます:", + "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下のアイコンは、以下のプレイヤー間のやりとりを表しています:", @@ -110,11 +126,11 @@ "build_port": "港", "build_port_desc": "水辺にのみ建設でき、このアイコンから戦艦を建築することが可能です。自国と他国の間に貿易制限が為されていない限り、自動的に交易船を送り出し、交易が完了すると両国に資金がもたらされます。貿易は手動で「貿易停止」または「貿易開始」を切り替えることができます。また、あなたが相手を攻撃したり、攻撃された場合には交易は自動的に停止し、5分経過するか同盟を結ぶと再開されます。", "build_warship": "戦艦", - "build_warship_desc": "このユニットは、指定したエリアを巡回し、貿易船を拿捕したり、敵の軍艦やボートを撃破したりします。最寄りの港から出現し、最初にクリックした場所を巡回し始めます。軍艦は攻撃クリックで選択し、移動先を攻撃クリックすることで操作できます。", + "build_warship_desc": "このユニットは、指定したエリアを巡回し、貿易船を拿捕したり、敵の戦艦やボートを撃破したりします。最寄りの港から出現し、最初にクリックした場所を巡回し始めます。軍艦は攻撃クリックで選択し、移動先を攻撃クリックすることで操作できます。", "build_silo": "ミサイル格納庫", "build_silo_desc": "ミサイルの発射を可能にします。", "build_sam": "SAMランチャー", - "build_sam_desc": "100ピクセル以内に入った敵ミサイルを、クールダウン7.5秒で迎撃できます。命中率は、原子爆弾に対して100%、水素爆弾に対して80%、MIRVに対して50%です。", + "build_sam_desc": "半径100ピクセル内の敵ミサイルを迎撃できます。SAMのクールダウン時間は7.5秒です。", "build_atom": "原子爆弾", "build_atom_desc": "小型の爆弾で、領土・建物・船舶・ボートを破壊します。最寄りのミサイル格納庫から発射され、最初にクリックした場所に着弾します。", "build_hydrogen": "水素爆弾", @@ -129,12 +145,15 @@ "icon_embargo": "取引停止 - このプレイヤーがあなたを自動または手動で貿易制限をかけているときに表示されます。", "icon_request": "メール - このプレイヤーがあなたへ同盟の申込みをしているときに表示されます。", "info_enemy_panel": "敵の情報パネル", - "exit_confirmation": "本当にゲームを終了しますか?" + "exit_confirmation": "本当にゲームを終了しますか?", + "bomb_direction": "原子爆弾 / 水素爆弾の軌道の向き" }, "single_modal": { - "title": "シングルプレイヤー", + "title": "ソロ", "random_spawn": "ランダムスポーン", "allow_alliances": "同盟を許可", + "toggle_achievements": "実績の表示の切り替え", + "sign_in_for_achievements": "実績を確認するにはサインインしてください", "options_title": "オプション", "bots": "ボット数: ", "bots_disabled": "無効", @@ -145,6 +164,8 @@ "infinite_troops": "兵士無限", "compact_map": "小型マップ", "max_timer": "ゲーム時間 (分)", + "max_timer_placeholder": "分", + "max_timer_invalid": "適切な最大プレイ時間(1~120分)を入力してください", "disable_nukes": "核兵器使用禁止", "enables_title": "機能の有効化", "start": "ゲーム開始" @@ -156,10 +177,26 @@ }, "account_modal": { "title": "アカウント", - "logged_in_as": "{email} としてログインしました", + "connected_as": "接続されたアカウント", + "stats_overview": "統計の概要", + "save_progress_title": "進捗状況を保存する", + "save_progress_desc": "アカウントをリンクして、統計、ランク、コスメティックを安全に保ちます。", + "link_discord": "Discordアカウントを連携する", + "link_via_email_placeholder": "メールで連携する", + "link_button": "連携", + "log_out": "ログアウト", + "welcome_back": "おかえりなさい", + "sign_in_desc": "統計と進捗状況を保存するにはサインインしてください", + "or": "または", + "email_placeholder": "メールアドレスを入力してください", + "get_magic_link": "マジックリンクを入手", + "linked_account": "{account_name} としてログインしました", "fetching_account": "アカウント情報を取得中...", - "logged_in_with_discord": "Discordでログインしました", - "recovery_email_sent": "{email} に回復用のメールを送信しました" + "recovery_email_sent": "{email} に回復用のメールを送信しました", + "not_found": "見つかりません", + "clear_session": "セッションをクリア", + "failed_to_send_recovery_email": "再設定メールを送信できませんでした", + "enter_email_address": "メールアドレスを入力してください" }, "stats_modal": { "title": "ステータス", @@ -167,11 +204,40 @@ "loading": "ロード中…", "error": "クランステータスの読み込みに失敗しました", "no_stats": "クランステータスがありません", + "no_data_yet": "まだデータはありません", "clan": "クラン", "games": "ゲーム", "win_score": "勝利スコア", + "win_score_tooltip": "クランの参加と試合の難易度に基づいて重み付けされた勝利", "loss_score": "敗北スコア", - "win_loss_ratio": "勝利/敗北" + "loss_score_tooltip": "クランの参加と試合の難易度に基づいて重み付けされた敗北", + "win_loss_ratio": "勝利/敗北", + "ratio": "比率", + "rank": "ランク", + "try_again": "もう一度やり直してください" + }, + "game_info_modal": { + "title": "ゲームの詳細", + "players": "プレイヤー", + "atoms": "原子爆弾", + "hydros": "水素爆弾", + "mirv": "MIRV", + "bombs": "爆弾", + "total_gold": "合計", + "all_gold": "合計資金", + "trade": "貿易", + "conquest_gold": "征服したプレイヤーの資金数", + "stolen_gold": "戦艦で盗んだ資金", + "num_of_conquests": "征服したプレイヤーの数", + "duration": "間隔", + "survival_time": "生存時間", + "war": "戦争", + "economy": "経済", + "conquests": "征服", + "pirate": "海賊", + "conquered": "征服された", + "loading_game_info": "ゲームの統計を読み込んでいます", + "no_winner": "この試合の勝者はいなかった" }, "map": { "map": "地図", @@ -186,6 +252,7 @@ "asia": "アジア", "mars": "火星", "southamerica": "南アメリカ", + "britanniaclassic": "ブリタニア(クラシック)", "britannia": "ブリタニア", "gatewaytotheatlantic": "西ヨーロッパ", "australia": "オーストラリア", @@ -196,7 +263,7 @@ "betweentwoseas": "2つの海の間", "faroeislands": "フェロー諸島", "deglaciatedantarctica": "退氷した南極大陸", - "europeclassic": "ヨーロッパ (クラシック)", + "europeclassic": "ヨーロッパ(クラシック)", "falklandislands": "フォークランド諸島", "baikal": "バイカル湖付近", "halkidiki": "ハルキディキ半島", @@ -206,19 +273,33 @@ "yenisei": "エニセイ川", "pluto": "冥王星", "montreal": "モントリオール", + "newyorkcity": "ニューヨーク市", "achiran": "アチラン", "baikalnukewars": "バイカル(核戦争)", "fourislands": "4つの島", "gulfofstlawrence": "セントローレンス湾", - "lisbon": "リスボンの都市圏" + "lisbon": "リスボンの都市圏", + "svalmel": "スヴァルメル", + "manicouagan": "マニクアガン湖", + "lemnos": "レムノス島", + "sierpinski": "シェルピンスキー", + "twolakes": "二つの湖", + "straitofhormuz": "ホルムズ海峡", + "surrounded": "囲まれた島", + "didier": "ディディエ", + "didierfrance": "ディディエ(フランス)", + "amazonriver": "アマゾン川" }, "map_categories": { "continental": "大陸", "regional": "地域", - "fantasy": "その他" + "fantasy": "その他", + "special": "特殊マップ", + "arcade": "お楽しみマップ" }, "map_component": { - "loading": "読み込み中…" + "loading": "読み込み中…", + "error": "エラー" }, "private_lobby": { "title": "ランダム", @@ -229,42 +310,55 @@ "checking": "ロビーを確認中...", "not_found": "ロビーが見つかりません。IDを確認してもう一度お試しください。", "error": "エラーが発生しました。もう一度試すか、サポートにお問い合わせください。", - "joined_waiting": "参加に成功しました!ゲーム開始をお待ちください...", - "version_mismatch": "このゲームは別のバージョンで作成されました。参加できません。" + "joined_waiting": "ロビーに参加しました!ホストの開始を待っています…", + "version_mismatch": "このゲームは別のバージョンで作成されました。参加できません。", + "disabled_units": "無効ユニット" }, "public_lobby": { "join": "次のゲームに参加", "waiting": "人が参加しています...", - "teams_Duos": "2 人プレイヤー(ドゥオ)", - "teams_Trios": "3 人プレイヤー(トリオ)", - "teams_Quads": "4 人プレイヤー(クワッド)", - "teams_hvn": "プレイヤー対国家", + "teams_Duos": "{team_count}個の2人1組のチーム(デュオ)", + "teams_Trios": "{team_count}個の3人1組のチーム(トリオ)", + "teams_Quads": "{team_count}個の4人1組のチーム(クワッド)", + "waiting_for_players": "プレイヤーを待っています", + "starting_game": "ゲームを開始します...", + "teams_hvn": "人類 vs 国家", + "teams_hvn_detailed": "{num} 人類 vs {num} 国家", "teams": "{num}チーム", - "players_per_team": "{num}人プレイヤー" + "players_per_team": "{num}人プレイヤー", + "started": "開始しています" }, "matchmaking_modal": { - "title": "マッチングする", + "title": "1v1ランクマッチを作成 (アルファ版) ", "connecting": "サーバーに接続中…", "searching": "対戦相手を検索中…", - "waiting_for_game": "ゲーム開始を待っています…" + "waiting_for_game": "ゲーム開始を待っています…", + "elo": "あなたのELO: {elo}" }, "username": { "enter_username": "ユーザー名を入力", "not_string": "ユーザー名は文字列で入力してください。", "too_short": "ユーザー名は{min}文字より長い必要があります。", "too_long": "ユーザー名は{max}文字より短い必要があります。", - "invalid_chars": "ユーザー名には英字、数字、スペース、アンダースコア、および [角括弧] のみ使用できます。" + "invalid_chars": "ユーザー名には、文字、数字、スペース、アンダーバーのみを含めることができます。", + "tag": "タグ", + "tag_too_short": "クランタグは2〜5文字の英数字でなければなりません。", + "tag_invalid_chars": "クランタグには文字と数字のみを含めることができます。" }, "host_modal": { - "title": "プライベートロビー", + "title": "プライベートロビーを作成", + "label": "プライベート", "mode": "モード", "team_count": "チームの数", + "team_type": "チームタイプ", "options_title": "オプション", "bots": "ボット数: ", "bots_disabled": "無効", + "player_immunity_duration": "PVPの無敵時間(分)", "nations": "諸国: ", "disable_nations": "国家を無効化", "max_timer": "ゲーム時間 (分)", + "mins_placeholder": "分", "instant_build": "即時建築", "infinite_gold": "資金無限", "donate_gold": "資金援助", @@ -283,7 +377,11 @@ "assigned_teams": "チーム編成", "empty_teams": "空きのチーム", "empty_team": "空き", - "remove_player": "{username}を削除" + "remove_player": "{username}を削除", + "teams_Duos": "デュオ(2人1組)", + "teams_Trios": "トリオ(3人1組)", + "teams_Quads": "クワッド(4人1組)", + "teams_Humans Vs Nations": "人類 vs 国家" }, "team_colors": { "red": "赤", @@ -301,16 +399,20 @@ "code_license": "本ゲームのコードは AGPL-3.0 ライセンスに基づき公開されています(無保証)" }, "difficulty": { - "difficulty": "難易度", - "Easy": "簡単", - "Medium": "普通", - "Hard": "難しい", - "Impossible": "不可能" + "difficulty": "国家の難易度", + "easy": "簡単", + "medium": "普通", + "hard": "難しい", + "impossible": "不可能" }, "game_mode": { "ffa": "バトルロワイヤル", "teams": "チーム" }, + "public_game_modifier": { + "random_spawn": "ランダムスポーン", + "compact_map": "コンパクトマップ" + }, "select_lang": { "title": "言語を選択" }, @@ -335,21 +437,23 @@ "emojis_label": "絵文字を表示", "emojis_desc": "ゲーム中で絵文字を表示します", "alert_frame_label": "アラートフレーム", - "alert_frame_desc": "警告フレームの表示をを切り替えます。有効時、裏切られたときや陸上から攻撃を受けたときにフレームが表示されます。", + "alert_frame_desc": "警告フレームの表示を切り替えます。有効時、裏切られたときや陸上から攻撃を受けたときにフレームが表示されます。", "special_effects_label": "特殊効果", "special_effects_desc": "特殊効果を切り替えます。無効にするとパフォーマンスが向上します。", "structure_sprites_label": "建物アイコン", "structure_sprites_desc": "建物アイコンの表示切替", + "cursor_cost_label_label": "カーソルの下に表示される建設コスト", + "cursor_cost_label_desc": "建物を建てる際にカーソルの下に必要資金を表示する", "anonymous_names_label": "ユーザー名を匿名にする", "anonymous_names_desc": "自分の画面では他のプレイヤーのユーザー名を非表示にし、代わりに別の名前で表示します。", "lobby_id_visibility_label": "ロビーIDを非表示", "lobby_id_visibility_desc": "プライベートロビー作成時にロビーIDを隠す", + "toggle_visibility": "表示を切り替え", "left_click_label": "左クリックでメニューを開く", "left_click_desc": "オンにすると左クリックでメニューを開くことができ、剣ボタンで攻撃します。オフにすると左クリックでそのまま攻撃します。", "left_click_menu": "左クリックでメニューを開く", "attack_ratio_label": "⚔️ 出撃兵力の比率", "attack_ratio_desc": "初期時点で出撃する兵力の割合を設定します(1–100%)", - "troop_ratio_desc": "初期時点で兵士と金を生産する労働者の割合を設定します(1–100%)", "territory_patterns_label": "🏳️ 領土の模様", "territory_patterns_desc": "ゲーム内で領土の模様を表示するかどうか", "performance_overlay_label": "パフォーマンスオーバーレイ", @@ -358,6 +462,7 @@ "easter_writing_speed_desc": "コードを書く速さを調節する(x1-x100)", "easter_bug_count_label": "バグの個数", "easter_bug_count_desc": "どのぐらいの個数のバグを許容できるか(0–1000個)", + "press_a_key": "キーを押す", "view_options": "表示オプション", "toggle_view": "表示切り替え", "toggle_view_desc": "国境を非表示にし、地形だけが見れます", @@ -416,7 +521,8 @@ "exit_game_label": "ゲームから退出する", "exit_game_info": "メインメニューに戻ります", "background_music_volume": "BGM音量", - "sound_effects_volume": "効果音音量" + "sound_effects_volume": "効果音音量", + "keybind_conflict_error": "{key} キーはすでに他のアクションに使われています。" }, "chat": { "title": "クイックチャット", @@ -451,7 +557,7 @@ "mirv": "MIRVを[P1]に発射!", "focus": "[P1]に集中砲火だ!", "finish": "[P1]にとどめだ!", - "build_warships": "軍艦を建造せよ!" + "build_warships": "戦艦を建造せよ!" }, "defend": { "defend": "[P1]を守る!", @@ -513,7 +619,7 @@ "mirv": "指定したプレイヤーのみを狙う超大規模な爆発", "missile_silo": "核ミサイルの発射に使用される", "sam_launcher": "飛来する核ミサイルを迎撃する", - "warship": "貿易船を捕獲し、敵の船やボートを破壊する", + "warship": "貿易船を捕獲し、戦艦やボートを破壊する", "port": "貿易船を送って資金を獲得する", "defense_post": "近くの国境の防御を強化します", "city": "最大人口が増加します", @@ -529,6 +635,7 @@ "other_team": "{team}チームが勝利しました。", "you_won": "勝利!", "other_won": "{player}の勝利!", + "nation_won": "国家 {nation} が勝利しました!", "exit": "ゲームから退出", "keep": "観戦する", "spectate": "観戦する", @@ -537,7 +644,7 @@ "ofm_winter_description": "競技トーナメントにして、最強のプレイヤーたちに挑もう", "join_tournament": "トーナメントに参加", "join_discord": "Discordコミュニティに参加しよう!", - "discord_description": "他のプレイヤーと交流して、アップデート情報や戦略を共有しよう", + "discord_description": "プレイヤーとつながり、新しい機能を発見し、賞品を獲得しましょう!", "join_server": "サーバに入る", "youtube_tutorial": "ヘルプが必要ですか?" }, @@ -549,7 +656,7 @@ "team": "チーム", "owned": "領土", "gold": "ゴールド", - "troops": "兵士", + "maxtroops": "最大兵力", "launchers": "ランチャー", "sams": "SAM", "warships": "戦艦", @@ -565,6 +672,7 @@ "team": "チーム", "alliance_timeout": "同盟終了まで", "troops": "軍隊", + "maxtroops": "最大兵力", "a_troops": "攻撃兵士数", "gold": "資金", "ports": "港", @@ -575,7 +683,9 @@ "warships": "戦艦", "health": "体力", "attitude": "態度", - "levels": "レベル" + "levels": "レベル", + "wilderness_title": "荒野", + "irradiated_wilderness_title": "放射線に汚染された荒野" }, "events_display": { "retreating": "撤退中", @@ -653,7 +763,10 @@ "send_alliance": "同盟を要請", "send_troops": "軍隊を送信", "send_gold": "資金を送信", - "emotes": "絵文字" + "emotes": "絵文字", + "arc_up": "上向きの弧", + "arc_down": "下向きの弧", + "flip_rocket_trajectory": "ロケットの軌道を反転" }, "send_troops_modal": { "title_with_name": "{name}へ軍隊を送信", @@ -702,20 +815,26 @@ }, "heads_up_message": { "choose_spawn": "スタート地点を選んで下さい", - "random_spawn": "ランダムスポーンが有効です。開始地点を設定しています…" + "random_spawn": "ランダムスポーンが有効です。開始地点を設定しています…", + "singleplayer_game_paused": "ゲームを一時停止", + "multiplayer_game_paused": "ロビー作成者によってゲームが一時停止されました" }, "territory_patterns": { "title": "領土スキン", "colors": "色", "purchase": "購入", "show_only_owned": "自分の領地", + "all_owned": "すべてのスキンを手に入れました!新しいアイテムについては後ほどご確認ください。", + "not_logged_in": "ログインされていません", "blocked": { "login": "スキンを解放するにはログインしてください", "purchase": "スキンを解放するには購入してください" }, "pattern": { "default": "デフォルト" - } + }, + "select_skin": "スキンを選択", + "selected": "選択済" }, "flag_input": { "title": "旗を選択", @@ -786,8 +905,9 @@ "mode": "モード", "mode_ffa": "バトルロワイヤル", "mode_team": "チーム", - "view": "見る", + "replay": "リプレイ", "details": "詳細", + "ranking": "ランキング", "started": "既に開始", "map": "地図", "difficulty": "難易度", @@ -796,13 +916,20 @@ "player_stats_tree": { "public": "公開", "private": "非公開", - "singleplayer": "シングルプレイヤー", + "singleplayer": "ソロ", "mode": "モード", "stats_wins": "勝利数", "stats_losses": "敗北数", "stats_wlr": "勝敗比", "stats_games_played": "プレイ数", "mode_ffa": "デスマッチ", - "mode_team": "チーム" + "mode_team": "チーム", + "no_stats": "この選択に対する統計は記録されていません。" + }, + "matchmaking_button": { + "play_ranked": "1v1のランクマッチを作成", + "description": "(アルファ版)", + "login_required": "ランクマッチをプレイするにはログインしてください!", + "must_login": "ランクマッチをプレイするにはログインする必要があります。" } } diff --git a/resources/lang/nl.json b/resources/lang/nl.json index 746150408..d103626c0 100644 --- a/resources/lang/nl.json +++ b/resources/lang/nl.json @@ -7,6 +7,7 @@ }, "common": { "close": "Sluiten", + "back": "Terug", "available": "Beschikbaar", "preset_max": "Max", "summary_send": "Verstuur", @@ -17,7 +18,9 @@ "cap_tooltip": "Resterende capaciteit ontvanger", "target_dead": "Doelwit uitgeschakeld", "target_dead_note": "Je kunt geen middelen sturen naar een dode speler.", - "none": "Geen" + "none": "Geen", + "copied": "Gekopieerd!", + "click_to_copy": "Klik om te kopiëren" }, "main": { "title": "OpenFront (ALFA)", @@ -26,17 +29,28 @@ "checking_login": "Inlog controleren...", "logged_in": "Ingelogd!", "log_out": "Uitloggen", - "create_lobby": "Lobby aanmaken", - "join_lobby": "Lobby toetreden", - "single_player": "Eén Speler", + "create": "Lobby aanmaken", + "join": "Lobby toetreden", + "solo": "Solo-lobby", "instructions": "Instructies", + "game_info": "Spelinformatie", "wiki": "Wiki", "privacy_policy": "Privacybeleid", "terms_of_service": "Servicevoorwaarden", - "reddit": "Reddit" + "copyright": "© OpenFront™ en Bijdragers", + "reddit": "Reddit", + "play": "Spelen", + "news": "Nieuws", + "store": "Winkel", + "options": "Opties", + "keys": "Sneltoetsen", + "stats": "Statistieken", + "account": "Account", + "help": "Help", + "menu": "Menu", + "pick_pattern": "Kies een skin!" }, "news": { - "see_all_releases": "Bekijk alle releases", "github_link": "op GitHub", "title": "Release-opmerkingen" }, @@ -83,6 +97,8 @@ "radial_attack": "Het aanvalsmenu openen.", "radial_info": "Infomenu openen.", "radial_boat": "Stuur een Boot (transportschip) voor een aanval op de geselecteerde locatie. Alleen beschikbaar als je toegang hebt tot water.", + "radial_donate_troops": "Doneer troepen, gelijk aan het percentage van de ingestelde aanvalsverhouding, aan de bondgenoot waarop je het radiale menu hebt geopend.", + "radial_donate_gold": "Opent het gouddonatiemenu zodat je bondgenoten snel goud kan sturen.", "radial_close": "Het menu sluiten.", "info_title": "Infomenu", "info_enemy_desc": "Bevat informatie zoals de naam van de geselecteerde speler, goud, troepen, of ze de handel hebben stopgezet, hoeveel kernwapens ze op je hebben afgevuurd, en of de speler een verrader is. Een verrader is een speler die een bondgenoot heeft aangevallen. Handel gestopt betekent dat jullie geen goud meer van elkaar ontvangen via handelsschepen. Handmatig (als de speler op \"Stop handel\" heeft geklikt, wat duurt totdat jullie beide op \"Start handel\" hebben geklikt) of automatisch (als jij jullie bondgenootschap hebt verraden, wat 5 minuten duurt of korter als jullie weer bondgenoten worden). Verrader toont 30 seconden lang Ja als de speler een bondgenoot heeft aangevallen. De iconen hieronder staan voor de volgende interacties:", @@ -94,8 +110,8 @@ "info_ally_panel": "Infopaneel bondgenoot", "info_ally_desc": "Wanneer je een bondgenootschap sluit met een speler, worden de volgende nieuwe iconen beschikbaar:", "ally_betray": "Verraad je bondgenoot, beëindig het bondgenootschap, stop de handel, verzwak je verdediging. De handel tussen jullie wordt 5 minuten gepauzeerd (of totdat jullie weer bondgenoten worden) en anderen stoppen de handel mogelijk ook. En tenzij de andere speler zelf een verrader was, wordt je 30 seconden als verrader gemarkeerd. Gedurende deze tijd staat er een icoon boven je naam en is je verdediging 50% zwakker. Bots zullen minder snel een bondgenoten willen worden en spelers zullen zich wel tweemaal bedenken voor ze dat doen.", - "ally_donate": "Geef een deel van je troepen aan je bondgenoot. Gebruikt wanneer ze weinig troepen hebben en worden aangevallen, of wanneer ze die extra kracht nodig hebben om een ​​vijand te verpletteren.", - "ally_donate_gold": "Geef een deel van je goud aan je bondgenoot. Wanneer zij weinig goud hebben en het voor gebouwen nodig hebben, of wanneer je teamgenoot aan het sparen is voor die MIRV.", + "ally_donate": "Doneer een deel van je troepen aan je bondgenoot. Gebruikt wanneer ze weinig troepen hebben en worden aangevallen, of wanneer ze die extra kracht nodig hebben om een ​​vijand te verpletteren.", + "ally_donate_gold": "Doneer een deel van je goud aan je bondgenoot. Wanneer zij weinig goud hebben en het voor gebouwen nodig hebben, of wanneer je teamgenoot aan het sparen is voor die MIRV.", "build_menu_title": "Bouwmenu", "build_menu_desc": "Maak hier een van of bekijk hoeveel van elke je al hebt gemaakt:", "build_name": "Naam", @@ -114,7 +130,7 @@ "build_silo": "Raketsilo", "build_silo_desc": "Maakt het lanceren van raketten mogelijk.", "build_sam": "Luchtdoelraket (SAM)-lanceerder", - "build_sam_desc": "Kan vijandelijke raketten onderscheppen binnen een straal van 80 pixels of, voor MIRV-kernkoppen, 50 pixels. Raakt 100% van de atoombommen, 80% van de waterstofbommen en 50% van de individuele MIRV-kernkoppen. De SAM heeft een afkoeltijd van 7,5 seconden.", + "build_sam_desc": "Kan vijandelijke raketten onderscheppen binnen een bereik van 100 pixels. De SAM-lanceerder heeft een herstelperiode van 7,5 seconden.", "build_atom": "Atoombom", "build_atom_desc": "Kleine explosieve bom die gebied, gebouwen, schepen en boten vernietigt. Komt vanuit de dichtstbijzijnde Raketsilo en landt op de plek waar je hebt geklikt om het te bouwen.", "build_hydrogen": "Waterstofbom", @@ -129,12 +145,15 @@ "icon_embargo": "Dollar stopbord - Embargo. Deze speler heeft de handel met jou gestopt, automatisch of handmatig.", "icon_request": "Envelop - Alliantieverzoek. Deze speler stuurde je een verzoek om bondgenoten te worden.", "info_enemy_panel": "Infopaneel vijand", - "exit_confirmation": "Weet je zeker dat je dit spel wilt verlaten?" + "exit_confirmation": "Weet je zeker dat je dit spel wilt verlaten?", + "bomb_direction": "Atoom- / waterstofbom boogrichting" }, "single_modal": { - "title": "Eén speler", + "title": "Solo", "random_spawn": "Willekeurige startpositie", "allow_alliances": "Bondgenootschappen toestaan", + "toggle_achievements": "Prestaties in- of uitschakelen", + "sign_in_for_achievements": "Meld je aan voor prestaties", "options_title": "Opties", "bots": "Bots:", "bots_disabled": "Uitgeschakeld", @@ -145,6 +164,8 @@ "infinite_troops": "Oneindige troepen", "compact_map": "Compacte kaart", "max_timer": "Spellengte (minuten)", + "max_timer_placeholder": "Min.", + "max_timer_invalid": "Voer een geldige max. timertijd in (1-120 minuten)", "disable_nukes": "Kernwapens uitschakelen", "enables_title": "Onderdelen inschakelen", "start": "Start Spel" @@ -156,10 +177,26 @@ }, "account_modal": { "title": "Account", - "logged_in_as": "Ingelogd als {email}", + "connected_as": "Gekoppeld als", + "stats_overview": "Overzicht van statistieken", + "save_progress_title": "Sla je voortgang op", + "save_progress_desc": "Koppel je account om je statistieken, rang en cosmetica veilig te houden.", + "link_discord": "Discord-account koppelen", + "link_via_email_placeholder": "Koppel via e-mail", + "link_button": "Koppelen", + "log_out": "Uitloggen", + "welcome_back": "Welkom terug", + "sign_in_desc": "Log in om je statistieken en voortgang op te slaan", + "or": "OF", + "email_placeholder": "Voer je e-mailadres in", + "get_magic_link": "Krijg Magische Link", + "linked_account": "Ingelogd als {account_name}", "fetching_account": "Accountgegevens ophalen...", - "logged_in_with_discord": "Ingelogd met Discord", - "recovery_email_sent": "Herstelmail verzonden naar {email}" + "recovery_email_sent": "Herstelmail verzonden naar {email}", + "not_found": "Niet Gevonden", + "clear_session": "Sessie Wissen", + "failed_to_send_recovery_email": "Verzenden herstel e-mail mislukt", + "enter_email_address": "Voer een e-mailadres in alsjeblieft" }, "stats_modal": { "title": "Statistieken", @@ -167,11 +204,40 @@ "loading": "Laden...", "error": "Fout bij het laden van clan statistieken", "no_stats": "Er zijn geen clan statistieken beschikbaar", + "no_data_yet": "Nog geen gegevens", "clan": "Clan", "games": "Spellen", "win_score": "Win Score", + "win_score_tooltip": "Gewogen aantal overwinningen op basis van clandeelname en matchmoeilijkheidsgraad", "loss_score": "Verlies Score", - "win_loss_ratio": "Gewonnen/Verloren" + "loss_score_tooltip": "Gewogen aantal verliezen op basis van clandeelname en matchmoeilijkheidsgraad", + "win_loss_ratio": "Gewonnen/Verloren", + "ratio": "Verhouding", + "rank": "Rang", + "try_again": "Opnieuw Proberen" + }, + "game_info_modal": { + "title": "Spelinformatie", + "players": "Spelers", + "atoms": "Atoombom", + "hydros": "Waterstofbom", + "mirv": "MIRV", + "bombs": "Bommen", + "total_gold": "Totaal", + "all_gold": "Alle goud", + "trade": "Handel", + "conquest_gold": "Veroverd spelersgoud", + "stolen_gold": "Gestolen met oorlogsschepen", + "num_of_conquests": "Aantal veroverde spelers", + "duration": "Tijdsduur", + "survival_time": "Overlevingstijd", + "war": "Oorlog", + "economy": "Economie", + "conquests": "Veroveringen", + "pirate": "Kapen", + "conquered": "Veroverd", + "loading_game_info": "Spelstatistieken worden geladen", + "no_winner": "Dit spel eindigde zonder winnaar" }, "map": { "map": "Kaart", @@ -186,6 +252,7 @@ "asia": "Azië", "mars": "Mars", "southamerica": "Zuid-Amerika", + "britanniaclassic": "Britannia (Klassiek)", "britannia": "Groot-Brittanië", "gatewaytotheatlantic": "Poort van de Atlantische Oceaan", "australia": "Australië", @@ -196,7 +263,7 @@ "betweentwoseas": "Tussen twee zeeën", "faroeislands": "Faeröer eilanden", "deglaciatedantarctica": "Ontdooid Antarctica", - "europeclassic": "Europa (klassiek)", + "europeclassic": "Europa (Klassiek)", "falklandislands": "Falklandeilanden", "baikal": "Baikalmeer", "halkidiki": "Chalkidiki", @@ -206,19 +273,33 @@ "yenisei": "Jenisej", "pluto": "Pluto", "montreal": "Montreal", + "newyorkcity": "New York City", "achiran": "Achiran", "baikalnukewars": "Baikal (Kernoorlog)", "fourislands": "Vier Eilanden", "gulfofstlawrence": "Saint Lawrencebaai", - "lisbon": "Lissabon" + "lisbon": "Lissabon", + "svalmel": "Svalmel", + "manicouagan": "Manicouagan", + "lemnos": "Limnos", + "sierpinski": "Sierpinski", + "twolakes": "Twee Meren", + "straitofhormuz": "Straat van Hormuz", + "surrounded": "Omringd", + "didier": "Didier", + "didierfrance": "Didier (Frankrijk)", + "amazonriver": "Amazonerivier" }, "map_categories": { "continental": "Continent", "regional": "Regio", - "fantasy": "Overig" + "fantasy": "Overig", + "special": "Speciaal", + "arcade": "Arcade" }, "map_component": { - "loading": "Laden..." + "loading": "Laden...", + "error": "Fout" }, "private_lobby": { "title": "Privélobby toetreden", @@ -229,47 +310,60 @@ "checking": "Lobby controleren...", "not_found": "Lobby niet gevonden. Controleer het ID en probeer het opnieuw.", "error": "Er is een fout opgetreden. Probeer het opnieuw of neem contact op met support.", - "joined_waiting": "Succesvol toegetreden! Wachten tot het spel begint...", - "version_mismatch": "Dit spel is aangemaakt met een andere versie. Kan niet afspelen of deelnemen." + "joined_waiting": "Toegetreden tot lobby! Wachten op host om te starten...", + "version_mismatch": "Dit spel is aangemaakt met een andere versie. Kan niet afspelen of deelnemen.", + "disabled_units": "Uitgeschakelde Eenheden" }, "public_lobby": { "join": "Deelnemen aan volgende Spel", "waiting": "spelers wachten", - "teams_Duos": "van 2 (Duo's)", - "teams_Trios": "van 3 (Trio's)", - "teams_Quads": "van 4 (Viertallen)", + "teams_Duos": "{team_count} Teams van 2 (Duo's)", + "teams_Trios": "{team_count} Teams van 3 (Trio's)", + "teams_Quads": "{team_count} Teams van 4 (Viertallen)", + "waiting_for_players": "Wachten op spelers", + "starting_game": "Spel starten…", "teams_hvn": "Mensen vs Naties", + "teams_hvn_detailed": "{num} Mensen vs {num} Naties", "teams": "{num} Teams", - "players_per_team": "van {num}" + "players_per_team": "van {num}", + "started": "Begonnen" }, "matchmaking_modal": { - "title": "Matchmaking", + "title": "1v1 Competitieve Matchmaking (ALFA)", "connecting": "Verbinden met matchmakingserver...", "searching": "Zoeken naar een spel...", - "waiting_for_game": "Wachten tot het spel begint..." + "waiting_for_game": "Wachten tot het spel begint...", + "elo": "Jouw ELO: {elo}" }, "username": { "enter_username": "Voer je gebruikersnaam in", "not_string": "Gebruikersnaam moet een tekenreeks zijn.", "too_short": "Gebruikersnaam moet minstens {min} tekens lang zijn.", "too_long": "Gebruikersnaam mag niet langer zijn dan {max} tekens.", - "invalid_chars": "Gebruikersnaam mag alleen letters, cijfers, spaties, underscores en [vierkante haakjes] bevatten." + "invalid_chars": "Gebruikersnaam kan alleen letters, cijfers, spaties en underscores bevatten.", + "tag": "TAG", + "tag_too_short": "Clantag moet 2-5 alfanumerieke tekens zijn.", + "tag_invalid_chars": "Clantag kan alleen letters en cijfers bevatten." }, "host_modal": { - "title": "Privélobby", + "title": "Privélobby Aanmaken", + "label": "Privé", "mode": "Modus", "team_count": "Aantal teams", + "team_type": "Teamtype", "options_title": "Opties", "bots": "Bots:", "bots_disabled": "Uitgeschakeld", + "player_immunity_duration": "PVP-immuniteitsduur (minuten)", "nations": "Naties: ", "disable_nations": "Naties uitschakelen", "max_timer": "Spellengte (minuten)", + "mins_placeholder": "Min.", "instant_build": "Bouwwachttijd uitschakelen", "infinite_gold": "Oneindig goud", - "donate_gold": "Goud geven", + "donate_gold": "Goud doneren", "infinite_troops": "Oneindige troepen", - "donate_troops": "Troepen geven", + "donate_troops": "Troepen doneren", "compact_map": "Compacte kaart", "enables_title": "Onderdelen inschakelen", "player": "Speler", @@ -283,7 +377,11 @@ "assigned_teams": "Toegewezen Teams", "empty_teams": "Lege Teams", "empty_team": "Leeg", - "remove_player": "Verwijder {username}" + "remove_player": "Verwijder {username}", + "teams_Duos": "Duo's (teams van 2)", + "teams_Trios": "Trio's (teams van 3)", + "teams_Quads": "Viertallen (teams van 4)", + "teams_Humans Vs Nations": "Mensen vs Naties" }, "team_colors": { "red": "Rood", @@ -301,16 +399,20 @@ "code_license": "Code gelicenseerd onder AGPL-3.0 (geen garantie)" }, "difficulty": { - "difficulty": "Moeilijkheidsgraad", - "Easy": "Ontspannen", - "Medium": "Gebalanceerd", - "Hard": "Intens", - "Impossible": "Onmogelijk" + "difficulty": "Natie moeilijkheidsgraad", + "easy": "Makkelijk", + "medium": "Gemiddeld", + "hard": "Moeilijk", + "impossible": "Onmogelijk" }, "game_mode": { "ffa": "Iedereen tegen elkaar (FFA)", "teams": "Teams" }, + "public_game_modifier": { + "random_spawn": "Willekeurige Startpositie", + "compact_map": "Compacte Kaart" + }, "select_lang": { "title": "Kies taal" }, @@ -340,16 +442,18 @@ "special_effects_desc": "Visuele effecten aanzetten. Zet uit om de prestaties van het spel te verbeteren", "structure_sprites_label": "Gebouw afbeeldingen", "structure_sprites_desc": "3D-afbeeldingen gebouwen in-/uitschakelen", + "cursor_cost_label_label": "Cursor Bouwkosten", + "cursor_cost_label_desc": "Toon kosten onder de bouwcursor", "anonymous_names_label": "Verborgen Namen", "anonymous_names_desc": "Vervang echte spelersnamen door willekeurige namen op je scherm.", "lobby_id_visibility_label": "Verborgen Lobby-ID's", "lobby_id_visibility_desc": "Verberg Lobby-ID tijdens het maken van een privélobby", + "toggle_visibility": "Zichtbaar/onzichtbaar", "left_click_label": "Linkermuisknop voor openen menu", "left_click_desc": "Als AAN: linkermuisknop opent het Radiale menu met zwaard-aanvalsknop. Als UIT: linkermuisknop opent direct de aanval.", "left_click_menu": "Linkermuisknop Radiale Menu", "attack_ratio_label": "⚔️ Aanvalsverhouding", "attack_ratio_desc": "Welk percentage van je troepen je bij een aanval stuurt (1-100%)", - "troop_ratio_desc": "De balans tussen troepen (voor gevechten) en werkers (voor goudproductie) aanpassen (1-100%)", "territory_patterns_label": "🏳️ Skins voor gebieden", "territory_patterns_desc": "Kies of je skins op gebieden wilt weergeven in het spel", "performance_overlay_label": "Prestatie-overlay", @@ -358,6 +462,7 @@ "easter_writing_speed_desc": "Pas aan hoe snel je pretendeert te programmeren (x1-x100)", "easter_bug_count_label": "Aantal bugs", "easter_bug_count_desc": "Hoeveel bugs je oké vindt (0-1000, gevoelsmatig)", + "press_a_key": "Druk op een toets", "view_options": "Weergave-opties", "toggle_view": "Weergave wisselen", "toggle_view_desc": "Weergave wisselen (terrein/landen)", @@ -416,7 +521,8 @@ "exit_game_label": "Spel Verlaten", "exit_game_info": "Terug naar hoofdmenu", "background_music_volume": "Volume achtergrondmuziek", - "sound_effects_volume": "Volume geluidseffecten" + "sound_effects_volume": "Volume geluidseffecten", + "keybind_conflict_error": "De toets {key} is al verbonden aan een andere actie." }, "chat": { "title": "Snelchat", @@ -529,6 +635,7 @@ "other_team": "{team} team heeft gewonnen!", "you_won": "Je hebt gewonnen!", "other_won": "{player} heeft gewonnen!", + "nation_won": "Natie {nation} heeft gewonnen!", "exit": "Verlaat spel", "keep": "Blijf spelen", "spectate": "Toekijken", @@ -537,7 +644,7 @@ "ofm_winter_description": "Doe mee met het competitieve toernooi en concurreer met de beste spelers", "join_tournament": "Toernooi toetreden", "join_discord": "Word lid van onze Discord-gemeenschap!", - "discord_description": "Leg contact met andere spelers, krijg updates en deel strategieën", + "discord_description": "Maak contact met spelers, ontdek nieuwe functies en win prijzen!", "join_server": "Word lid van server", "youtube_tutorial": "Wat hulp nodig?" }, @@ -549,7 +656,7 @@ "team": "Team", "owned": "Bezit", "gold": "Goud", - "troops": "Troepen", + "maxtroops": "Max. troepen", "launchers": "Raketsilo's", "sams": "SAM-lanceerders", "warships": "Oorlogsschepen", @@ -565,6 +672,7 @@ "team": "Team", "alliance_timeout": "Alliantie eindigt over", "troops": "Troepen", + "maxtroops": "Max. troepen", "a_troops": "Aanvallende troepen", "gold": "Goud", "ports": "Havens", @@ -575,7 +683,9 @@ "warships": "Oorlogsschepen", "health": "Gezondheid", "attitude": "Houding", - "levels": "Levels" + "levels": "Levels", + "wilderness_title": "Wildernis", + "irradiated_wilderness_title": "Bestraalde Wildernis" }, "events_display": { "retreating": "trekken zich terug", @@ -653,7 +763,10 @@ "send_alliance": "Stuur Alliantieverzoek", "send_troops": "Geef Troepen", "send_gold": "Geef Goud", - "emotes": "Emoji's" + "emotes": "Emoji's", + "arc_up": "Opwaartse boog", + "arc_down": "Neerwaartse boog", + "flip_rocket_trajectory": "Rakettraject spiegelen" }, "send_troops_modal": { "title_with_name": "Stuur Troepen naar {name}", @@ -702,20 +815,26 @@ }, "heads_up_message": { "choose_spawn": "Kies een startlocatie", - "random_spawn": "Willekeurige startpositie is ingeschakeld. Positie wordt voor je gekozen..." + "random_spawn": "Willekeurige startpositie is ingeschakeld. Positie wordt voor je gekozen...", + "singleplayer_game_paused": "Spel gepauzeerd", + "multiplayer_game_paused": "Spel gepauzeerd door Lobby-maker" }, "territory_patterns": { "title": "Skins ", "colors": "Kleuren", "purchase": "Kopen", "show_only_owned": "Mijn Skins", + "all_owned": "Je bezit alle skins! Kom later terug voor nieuwe items.", + "not_logged_in": "Niet ingelogd", "blocked": { "login": "Je moet ingelogd zijn voor toegang tot deze skin.", "purchase": "Koop deze skin om te ontgrendelen." }, "pattern": { "default": "Standaard" - } + }, + "select_skin": "Kies Skin", + "selected": "geselecteerd" }, "flag_input": { "title": "Selecteer Vlag", @@ -786,8 +905,9 @@ "mode": "Modus", "mode_ffa": "Iedereen tegen elkaar", "mode_team": "Team", - "view": "Weergeven", + "replay": "Herhaling", "details": "Details", + "ranking": "Rang", "started": "Begonnen", "map": "Kaart", "difficulty": "Moeilijkheidsgraad", @@ -796,13 +916,20 @@ "player_stats_tree": { "public": "Openbaar", "private": "Privé", - "singleplayer": "Eén Speler", + "singleplayer": "Solo", "mode": "Modus", "stats_wins": "Overwinningen", "stats_losses": "Nederlagen", "stats_wlr": "Winst:verliesverhouding", "stats_games_played": "Gespeelde spellen", "mode_ffa": "Iedereen tegen elkaar", - "mode_team": "Team" + "mode_team": "Team", + "no_stats": "Geen statistieken vastgelegd voor deze selectie." + }, + "matchmaking_button": { + "play_ranked": "1v1 Competitieve Matchmaking", + "description": "(ALFA)", + "login_required": "Log in om competitief te spelen!", + "must_login": "Je moet ingelogd zijn om competitieve matchmaking te spelen." } } From 40a9e54ee7f4b971b2e36f4f839174159512c876 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Fri, 16 Jan 2026 04:21:49 +0900 Subject: [PATCH 008/109] mls (v4.13) (#2907) ## Description: mls for v29 Version identifier within MLS: 4.13 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: aotumuri --- resources/lang/bg.json | 223 ++++++++++++++++++++++++++++++--------- resources/lang/ja.json | 231 +++++++++++++++++++++++++++++++---------- resources/lang/nl.json | 223 ++++++++++++++++++++++++++++++--------- 3 files changed, 529 insertions(+), 148 deletions(-) diff --git a/resources/lang/bg.json b/resources/lang/bg.json index 2fef4934e..ce630b90f 100644 --- a/resources/lang/bg.json +++ b/resources/lang/bg.json @@ -7,6 +7,7 @@ }, "common": { "close": "Затвори", + "back": "Назад", "available": "Наличен", "preset_max": "Макс", "summary_send": "Изпрати", @@ -17,7 +18,9 @@ "cap_tooltip": "Оставащ капацитет на получателя", "target_dead": "Целта бе елиминирана", "target_dead_note": "Не можеш да изпращаш ресурси на елиминиран играч.", - "none": "Няма" + "none": "Няма", + "copied": "Копирано!", + "click_to_copy": "Кликни, за да копираш" }, "main": { "title": "OpenFront (АЛФА)", @@ -26,17 +29,28 @@ "checking_login": "Проверяване на входа...", "logged_in": "Влезли сте!", "log_out": "Излез от профила си", - "create_lobby": "Създай частна игра", - "join_lobby": "Присъедини се към частна игра", - "single_player": "Самостоятелна игра", + "create": "Създай частна игра", + "join": "Присъедини се към частна игра", + "solo": "Самостоятелна игра", "instructions": "Инструкции", + "game_info": "Информация за играта", "wiki": "Wiki", "privacy_policy": "Поверителност", "terms_of_service": "Условия за ползване", - "reddit": "Reddit" + "copyright": "© OpenFront™ and Contributors", + "reddit": "Reddit", + "play": "Играй", + "news": "Новини", + "store": "Магазин", + "options": "Опции", + "keys": "Клавиши", + "stats": "Статистики", + "account": "Акаунт", + "help": "Помощ", + "menu": "Меню", + "pick_pattern": "Избери шаблон!" }, "news": { - "see_all_releases": "Виж всички издания", "github_link": "в GitHub", "title": "Бележки по изданието" }, @@ -64,7 +78,7 @@ "ui_gold": "Злато - Количеството злато, което притежаваш и скоростта, с която го получаваш.", "ui_attack_ratio": "Съотношение на атака - Количеството войници, които ще се използват при атака. Можеш да коригираш съотношението на атака, използвайки плъзгача. Притежаването на повече атакуващи войници от тези в защита ще доведе до по-малка загуба на войници при атака, докато разполагането с по-малко ще увеличи щетите, нанесени на атакуващите ти войници. Ефектът не надхвърля съотношения 2:1.", "ui_events": "Панел за събития", - "ui_events_desc": "Панелът за събития показва най-новите събития, заявки и съобщения от бърз чат. Някои примери са:", + "ui_events_desc": "Панелът за събития показва най-актуалните събития, заявки и съобщения от бърз чат. Някои примери са:", "ui_events_alliance": "Съюз - Заявките за съюз могат да бъдат приети или отхвърлени. Съюзниците могат да споделят ресурси и войници, но не могат да се атакуват взаимно. Кликането върху \"Фокусиране\" премества изгледа върху играча, изпратил заявката.", "ui_events_attack": "Атаки - Показани са атаките срещу теб, както и твоите собствени атаки. Кликни върху съобщението, за да центрираш изгледа върху атаката, ракетата или лодката (транспортен кораб). Можеш да оттеглиш войниците си, като кликнеш върху червения бутон X. Това ще струва живота на 25% от атакуващите ти войници. Ако оттеглиш атака с лодка, лодката се връща в началната си точка и ще атакува там, ако тази земя е била превзета от друг. Ракетите не могат да бъдат оттеглени, след като бъдат изстреляни.", "ui_events_quickchat": "Бърз чат - Тук можеш да видиш изпратените и получените съобщения в чата. Изпрати съобщение до играч, като кликнеш върху иконката за бърз чат в менюто му с информация.", @@ -83,6 +97,8 @@ "radial_attack": "Отвориш менюто за атака.", "radial_info": "Отвориш информационното меню.", "radial_boat": "Изпратиш лодка (транспортен кораб) за атака на избраното място. Възможно е само, ако имаш достъп до вода.", + "radial_donate_troops": "Дариш войници на съюзника, на когото си отворил радиалното меню, еквивалентни на процента на плъзгача за съотношение на атака.", + "radial_donate_gold": "Отваря плъзгащото меню за даряване на злато, за да можеш бързо да изпратиш злато на съюзниците.", "radial_close": "Затвориш менюто.", "info_title": "Информационно меню", "info_enemy_desc": "Съдържа информация като име на избрания играч, злато, войници, дали е спряна търговията с теб, изпратени ракети към теб и дали играчът е предател. Спряната търговия означава, че няма да получаваш злато от него и той няма да ти изпраща злато чрез търговски кораби. Ръчно (ако играчът кликне върху „Прекратяване на търговия“, което продължава, докато и двамата не кликнат върху „Започване на търговия“) или автоматично (ако си предал съюзника си, което продължава, докато не станете съюзници отново или след 5 минути). Показва се \"Да\" на \"Предател\" за 30 секунди, когато играчът е предал и нападнал играч, който е бил в съюз с него. Иконките по-долу представляват следните взаимодействия:", @@ -114,7 +130,7 @@ "build_silo": "Ракетен силоз", "build_silo_desc": "Позволява изстрелване на ракети.", "build_sam": "Противоракетна установка земя-въздух SAM", - "build_sam_desc": "Може да прихваща вражески ракети в своя обхват от 100 пиксела. Със 100% шанс да свали атомна бомба, 80% за водородна бомба и 50% за отделни бойни ракети на МИРВ. Противоракетната установка земя-въздух SAM има 7,5 секунди охлаждане.", + "build_sam_desc": "Може да прехваща вражески ракети в обхват от 100 пиксела. Противоракетната установка земя-въздух SAM има време за охлаждане от 7,5 секунди.", "build_atom": "Атомна бомба", "build_atom_desc": "Малка експлозивна бомба, която унищожава територия, сгради, кораби и лодки. Поражда се от най-близкия ракетен силоз и се приземява в областта, в която първо си щракнал, за да я построиш.", "build_hydrogen": "Водородна бомба", @@ -129,12 +145,15 @@ "icon_embargo": "Стоп знак за долар - Ембарго. Този играч е спрял да търгува с теб автоматично или ръчно.", "icon_request": "Пликче за писмо - молба за съюз. Този играч ти е изпратил молба за съюз.", "info_enemy_panel": "Информационно меню за врагове", - "exit_confirmation": "Сигурен ли си, че искаш да излезеш от играта?" + "exit_confirmation": "Сигурен ли си, че искаш да излезеш от играта?", + "bomb_direction": "Посока на дъгата на атомна/водородна бомба" }, "single_modal": { - "title": "Самостоятелна Игра", + "title": "Самостоятелно", "random_spawn": "Случайно появяване", "allow_alliances": "Позволяване на съюзничества", + "toggle_achievements": "Превключване на постижения", + "sign_in_for_achievements": "Впиши се, за да получаваш постижения", "options_title": "Опции", "bots": "Ботове: ", "bots_disabled": "Изключени", @@ -145,6 +164,8 @@ "infinite_troops": "Безкрайна популация", "compact_map": "Компактна карта", "max_timer": "Продължителност на играта (в минути)", + "max_timer_placeholder": "Минути", + "max_timer_invalid": "Моля, въведи валидна максимална стойност на таймера (1-120 минути)", "disable_nukes": "Изключване на ядрени оръжия", "enables_title": "Активиране на настройки", "start": "Започване на игра" @@ -156,10 +177,26 @@ }, "account_modal": { "title": "Акаунт", - "logged_in_as": "Вписан като {email}", + "connected_as": "Вписан като", + "stats_overview": "Преглед на статистики", + "save_progress_title": "Запази си напредъка", + "save_progress_desc": "Свържи си акаунта, за да запазиш статистиките, ранка и козметиките си в безопасност.", + "link_discord": "Свържи Discord акаунт", + "link_via_email_placeholder": "Свържи чрез имейл", + "link_button": "Свържи", + "log_out": "Изход от профила", + "welcome_back": "Добре дошъл отново!", + "sign_in_desc": "Впиши се, за да запазиш статистиките и напредъка си", + "or": "ИЛИ", + "email_placeholder": "Въведи имейл адреса си", + "get_magic_link": "Използвай магически линк", + "linked_account": "Вписан като {account_name}", "fetching_account": "Взима се информацията за профила...", - "logged_in_with_discord": "Вписал си се с Discord", - "recovery_email_sent": "Имейл за възстановяване бе изпратен на {email}" + "recovery_email_sent": "Имейл за възстановяване бе изпратен на {email}", + "not_found": "Не е намерен", + "clear_session": "Изчисти сесията", + "failed_to_send_recovery_email": "Грешка при изпращането на имейл за възстановяване", + "enter_email_address": "Моля, въведи имейл адрес" }, "stats_modal": { "title": "Статистики", @@ -167,11 +204,40 @@ "loading": "Зареждане...", "error": "Грешка при зареждането на клановите статистики", "no_stats": "Няма налични кланови статистики", + "no_data_yet": "Все още няма данни", "clan": "Клан", "games": "Игри", "win_score": "Резултат на победи", + "win_score_tooltip": "Претеглени победи въз основа на участието на клана и трудността на мача", "loss_score": "Резултат на загуби", - "win_loss_ratio": "П/З" + "loss_score_tooltip": "Претеглени загуби въз основа на участието на клана и трудността на мача", + "win_loss_ratio": "П/З", + "ratio": "Съотношение", + "rank": "Ранк", + "try_again": "Опитай отново" + }, + "game_info_modal": { + "title": "Информация за играта", + "players": "Играчи", + "atoms": "Атомни бомби", + "hydros": "Водородни бомби", + "mirv": "МИРВ", + "bombs": "Бомби", + "total_gold": "Общо", + "all_gold": "Всичко злато", + "trade": "Търговия", + "conquest_gold": "Завладяно злато от играч", + "stolen_gold": "Откраднато с военни кораби", + "num_of_conquests": "Брой завладяни играчи", + "duration": "Продължителност", + "survival_time": "Време на оцеляване", + "war": "Война", + "economy": "Икономика", + "conquests": "Завоевания", + "pirate": "Пират", + "conquered": "Завладяно", + "loading_game_info": "Зареждат се статистиките на играта", + "no_winner": "Играта е свършила без победител" }, "map": { "map": "Карта", @@ -186,6 +252,7 @@ "asia": "Азия", "mars": "Марс", "southamerica": "Южна Америка", + "britanniaclassic": "Британия (Класическа)", "britannia": "Британия", "gatewaytotheatlantic": "Порта към Атлантика", "australia": "Австралия", @@ -196,7 +263,7 @@ "betweentwoseas": "Между Две Морета", "faroeislands": "Фарьорски острови", "deglaciatedantarctica": "Обезледена Антарктида", - "europeclassic": "Европа (класическа)", + "europeclassic": "Европа (Класическа)", "falklandislands": "Фолкландски острови", "baikal": "Байкал", "halkidiki": "Халкидики", @@ -206,19 +273,33 @@ "yenisei": "Енисей", "pluto": "Плутон", "montreal": "Монтреал", + "newyorkcity": "Ню Йорк", "achiran": "Ахиран", "baikalnukewars": "Байкал (Ядрени войни)", "fourislands": "Четири острова", "gulfofstlawrence": "Залив Сейнт Лорънс", - "lisbon": "Лисабон" + "lisbon": "Лисабон", + "svalmel": "Свалмел", + "manicouagan": "Маникуаган", + "lemnos": "Лемнос", + "sierpinski": "Серпински", + "twolakes": "Две езера", + "straitofhormuz": "Ормузки проток", + "surrounded": "Обкражен", + "didier": "Дидиер", + "didierfrance": "Дидиер (Франция)", + "amazonriver": "Река Амазонка" }, "map_categories": { "continental": "Континентално", "regional": "Регионално", - "fantasy": "Друго" + "fantasy": "Друго", + "special": "Специално", + "arcade": "Аркада" }, "map_component": { - "loading": "Зареждане..." + "loading": "Зареждане...", + "error": "Грешка" }, "private_lobby": { "title": "Присъединяване към частна игра", @@ -229,42 +310,55 @@ "checking": "Проверяване на частна игра...", "not_found": "Не е намерена частната игра. Моля, провери ID-то и опитай отново.", "error": "Възникна грешка. Моля, опитай отново или се свържи с екипа за поддръжка.", - "joined_waiting": "Присъединяването е успешно! Чакане за започване на играта...", - "version_mismatch": "Тази игра е създадена на различна версия. Не можеш да бъдеш присъединен." + "joined_waiting": "Присъедини се към лобито! Чакаме хостът да започне...", + "version_mismatch": "Тази игра е създадена на различна версия. Не можеш да бъдеш присъединен.", + "disabled_units": "Изключване на военни единици" }, "public_lobby": { "join": "Присъединяване към следващата игра", "waiting": "чакащи играчи", - "teams_Duos": "по 2-ма (Дуос)", - "teams_Trios": "по 3-ма (Триос)", - "teams_Quads": "по 4-ма (Куадс)", + "teams_Duos": "{team_count} отбора по 2-ма (Дуос)", + "teams_Trios": "{team_count} отбора по 3-ма (Триос)", + "teams_Quads": "{team_count} отбора по 4-ма (Куадс)", + "waiting_for_players": "Изчакване на играчи", + "starting_game": "Играта се стартира…", "teams_hvn": "Хора срещу Нации", + "teams_hvn_detailed": "{num} Души vs {num} Нации", "teams": "{num} отбора", - "players_per_team": "по {num}" + "players_per_team": "по {num}", + "started": "Стартирана" }, "matchmaking_modal": { - "title": "Мачмейкинг", + "title": "1v1 Ранков мачмейкинг (АЛФА)", "connecting": "Свързване със сървъра за мачмейкинг...", "searching": "Търси се игра...", - "waiting_for_game": "Изчаква се да започне играта..." + "waiting_for_game": "Изчаква се да започне играта...", + "elo": "Твоето ЕЛО: {elo}" }, "username": { "enter_username": "Въведи потребителско име", "not_string": "Потребителското име трябва да е символен низ.", "too_short": "Потребителското име трябва да е дълго поне {min} символа.", "too_long": "Потребителското име не трябва да надвишава {max} символа.", - "invalid_chars": "Потребителското име може да съдържа само букви, цифри, интервали, долни черти и [квадратни скоби]." + "invalid_chars": "Потребителското име може да съдържа само букви, цифри, интервали и долни черти.", + "tag": "ТАГ", + "tag_too_short": "Клановият таг трябва да съдържа от 2 до 5 буквено-цифрови знака.", + "tag_invalid_chars": "Клановият таг може да съдържа само букви и цифри." }, "host_modal": { - "title": "Частна игра", + "title": "Създай частна игра", + "label": "Частна", "mode": "Начин на игра", "team_count": "Брой отбори", + "team_type": "Вид на отбора", "options_title": "Опции", "bots": "Ботове: ", "bots_disabled": "Изключени", + "player_immunity_duration": "Продължителност на военния имунитет (минути)", "nations": "Нации: ", "disable_nations": "Изключване на нации", "max_timer": "Продължителност на играта (в минути)", + "mins_placeholder": "Минути", "instant_build": "Незабавно построяване", "infinite_gold": "Безкрайно злато", "donate_gold": "Даряване на злато", @@ -283,7 +377,11 @@ "assigned_teams": "Назначени отбори", "empty_teams": "Празни отбори", "empty_team": "Празен", - "remove_player": "Премахване на {username}" + "remove_player": "Премахване на {username}", + "teams_Duos": "Дуос (отбори по 2-ма)", + "teams_Trios": "Триос (отбори по 3-ма)", + "teams_Quads": "Куадс (отбори по 4-ма)", + "teams_Humans Vs Nations": "Хора срещу Нации" }, "team_colors": { "red": "Червен", @@ -301,16 +399,20 @@ "code_license": "Кодът е лицензиран съгласно AGPL-3.0 (без гаранция)" }, "difficulty": { - "difficulty": "Трудност", - "Easy": "Релаксирано", - "Medium": "Балансирано", - "Hard": "Интензивно", - "Impossible": "Невъзможно" + "difficulty": "Трудност на нациите", + "easy": "Лесно", + "medium": "Средно", + "hard": "Трудно", + "impossible": "Невъзможно" }, "game_mode": { "ffa": "Всеки срещу всеки (FFA)", "teams": "Отбори" }, + "public_game_modifier": { + "random_spawn": "Случайно появяване", + "compact_map": "Компактна карта" + }, "select_lang": { "title": "Изберете език" }, @@ -340,16 +442,18 @@ "special_effects_desc": "Превключване на специалните ефекти. Деактивиране, за да се увеличи производителността", "structure_sprites_label": "Структурни спрайтове", "structure_sprites_desc": "Превключване на структурните спрайтове", + "cursor_cost_label_label": "Цена на изграждане под курсора", + "cursor_cost_label_desc": "Показване на цената за изграждане под иконката на курсора", "anonymous_names_label": "Скрити имена", "anonymous_names_desc": "Скриване на истинските имена на играчите с произволни такива на екрана ти.", "lobby_id_visibility_label": "Скрити ID-та на частните игри", "lobby_id_visibility_desc": "Скриване на ID-то на частната игра при нейното създаване", + "toggle_visibility": "Превключване на видимостта", "left_click_label": "Щтракване на ляв бутон, за да се отвори менюто", "left_click_desc": "Когато е ВКЛЮЧЕНО, щракването с ляв бутон отваря менюто и атаките се извършват чрез бутона на меч. Когато е ИЗКЛЮЧЕНО, щракването с ляв бутон атакува директно.", "left_click_menu": "Меню ляв клик", "attack_ratio_label": "⚔️ Съотношение на атака", "attack_ratio_desc": "Какъв процент от Вашите войници да се изпратят в атака (1–100%)", - "troop_ratio_desc": "Коригиране на баланса между войници (за битка) и работници (за производство на злато) (1–100%)", "territory_patterns_label": "🏳️ Териториални шаблони", "territory_patterns_desc": "Избиране дали да се показват дизайни на шаблони на територия в играта", "performance_overlay_label": "Горен слой за производителност", @@ -358,6 +462,7 @@ "easter_writing_speed_desc": "Регулиране на това колко бързо се преструвате, че кодирате(x1–x100)", "easter_bug_count_label": "Брой грешки", "easter_bug_count_desc": "С колко грешки сте ок (0–1000, емоционално)", + "press_a_key": "Натисни клавиш", "view_options": "Вижте настройките", "toggle_view": "Превключване на изгледа", "toggle_view_desc": "Алтернативен изглед (терен/държави)", @@ -416,7 +521,8 @@ "exit_game_label": "Напускане на играта", "exit_game_info": "Връщане в главното меню", "background_music_volume": "Сила на фоновата музика", - "sound_effects_volume": "Сила на звука на звуковите ефекти" + "sound_effects_volume": "Сила на звука на звуковите ефекти", + "keybind_conflict_error": "Клавишът {key} вече е вързан за друго действие." }, "chat": { "title": "Бърз чат", @@ -529,6 +635,7 @@ "other_team": "{team} отбор спечели!", "you_won": "Ти спечели!", "other_won": "{player} спечели!", + "nation_won": "Нацията {nation} спечели!", "exit": "Напускане на играта", "keep": "Продължаване на играта", "spectate": "Наблюдаване", @@ -537,7 +644,7 @@ "ofm_winter_description": "Присъедини се към състезателния турнир и се състезавай срещу най-добрите играчи", "join_tournament": "Присъедини се към турнира", "join_discord": "Присъедини се към общността ни в Discord!", - "discord_description": "Свържи се с други играчи, получавай актуална информация и споделяй стратегии", + "discord_description": "Свържи се с играчи, открий нови функции и спечели награди!", "join_server": "Влез в сървъра", "youtube_tutorial": "Нужда от помощ?" }, @@ -549,7 +656,7 @@ "team": "Отбор", "owned": "Притежавано", "gold": "Злато", - "troops": "Войници", + "maxtroops": "Максимални войници", "launchers": "Установки", "sams": "Противоракетни установки земя-въздух SAM", "warships": "Бойни кораби", @@ -565,6 +672,7 @@ "team": "Отбор", "alliance_timeout": "Съюзът изтича след", "troops": "Войници", + "maxtroops": "Максимални войници", "a_troops": "Атакуващи войници", "gold": "Злато", "ports": "Пристанища", @@ -575,7 +683,9 @@ "warships": "Бойни кораби", "health": "Живот", "attitude": "Становище", - "levels": "Нива" + "levels": "Нива", + "wilderness_title": "Пустош", + "irradiated_wilderness_title": "Облъчена пустош" }, "events_display": { "retreating": "отстъпване", @@ -653,7 +763,10 @@ "send_alliance": "Изпрати съюз", "send_troops": "Изпрати войници", "send_gold": "Изпрати злато", - "emotes": "Емоджита" + "emotes": "Емоджита", + "arc_up": "Възходяща дъга", + "arc_down": "Низходяща дъга", + "flip_rocket_trajectory": "Обърни траекторията на ракетата" }, "send_troops_modal": { "title_with_name": "Изпрати войници на {name}", @@ -702,25 +815,31 @@ }, "heads_up_message": { "choose_spawn": "Изберете начална локация", - "random_spawn": "Случайното появяване е активирано. Избиране на начална локация за теб..." + "random_spawn": "Случайното появяване е активирано. Избиране на начална локация за теб...", + "singleplayer_game_paused": "Играта е на пауза", + "multiplayer_game_paused": "Играта е паузирана от създателя на лобито" }, "territory_patterns": { "title": "Териториални шаблони", "colors": "Цветове", "purchase": "Купуване", "show_only_owned": "Моите шаблони", + "all_owned": "Имаш всички шаблони! Провери отново по-късно за нови артикули.", + "not_logged_in": "Не си се вписал в профил", "blocked": { "login": "Трябва да сте влезли в профила си, за да получите достъп до този шаблон.", "purchase": "Закупете този шаблон, за да го отключите." }, "pattern": { "default": "Стандартен" - } + }, + "select_skin": "Избери шаблон", + "selected": "е избран" }, "flag_input": { - "title": "Изберете знаме", - "button_title": "Изберете знаме!", - "search_flag": "Търсене..." + "title": "Избери знаме", + "button_title": "Избери знаме!", + "search_flag": "Търси..." }, "spawn_ad": { "loading": "Зарежда се реклама..." @@ -786,8 +905,9 @@ "mode": "Вид", "mode_ffa": "Всеки срещу всеки (FFA)", "mode_team": "Отбор", - "view": "Виж", + "replay": "Повторение", "details": "Детайли", + "ranking": "Класиране", "started": "Стартирана", "map": "Карта", "difficulty": "Трудност", @@ -796,13 +916,20 @@ "player_stats_tree": { "public": "Публична", "private": "Частна", - "singleplayer": "Самостоятелна Игра", + "singleplayer": "Самостоятелно", "mode": "Вид", "stats_wins": "Победи", "stats_losses": "Загуби", "stats_wlr": "Съотношение победи:загуби", "stats_games_played": "Изиграни игри", "mode_ffa": "Всеки срещу всеки (FFA)", - "mode_team": "Отбор" + "mode_team": "Отбор", + "no_stats": "Няма записани статистики за тази селекция." + }, + "matchmaking_button": { + "play_ranked": "1v1 Ранков мачмейкинг", + "description": "(АЛФА)", + "login_required": "Впиши се, за да играеш ранково!", + "must_login": "Трябва да си вписан в профила си, за да играеш ранков мачмейкинг." } } diff --git a/resources/lang/ja.json b/resources/lang/ja.json index 33bcbc2bf..381fd0390 100644 --- a/resources/lang/ja.json +++ b/resources/lang/ja.json @@ -7,6 +7,7 @@ }, "common": { "close": "閉じる", + "back": "戻る", "available": "利用可能", "preset_max": "最大", "summary_send": "送る", @@ -17,7 +18,9 @@ "cap_tooltip": "受取主が受け取れる量", "target_dead": "ターゲットは排除されました", "target_dead_note": "排除されたプレイヤーにはリソースを送ることができません。", - "none": "なし" + "none": "なし", + "copied": "コピーに成功しました!", + "click_to_copy": "クリックしてコピー" }, "main": { "title": "OpenFront (ALPHA)", @@ -26,17 +29,28 @@ "checking_login": "ログイン中...", "logged_in": "ログイン中!", "log_out": "ログアウト", - "create_lobby": "ロビーを作成", - "join_lobby": "ロビーに参加", - "single_player": "シングルプレイヤー", + "create": "ロビーを作成", + "join": "ロビーに参加", + "solo": "1人のロビー", "instructions": "説明書", + "game_info": "ゲームの情報", "wiki": "ウィキ", "privacy_policy": "プライバシーポリシー", "terms_of_service": "利用規約", - "reddit": "Reddit" + "copyright": "©️ OpenFront™ と貢献者", + "reddit": "Reddit", + "play": "プレイ", + "news": "お知らせ", + "store": "ストア", + "options": "設定", + "keys": "キー設定", + "stats": "統計", + "account": "アカウント", + "help": "ヘルプ", + "menu": "メニュー", + "pick_pattern": "模様を選択してください!" }, "news": { - "see_all_releases": "すべてのリリースを見る", "github_link": "GitHub上で", "title": "更新情報" }, @@ -67,7 +81,7 @@ "ui_events_desc": "イベントパネルには、最新のイベント、リクエスト、クイックチャットメッセージが表示されます。以下がその一例です:", "ui_events_alliance": "同盟 — 同盟リクエストは承認または拒否できます。同盟関係にあるプレイヤーは資源や軍隊を共有できますが、互いに攻撃することはできません。「Focus(注視)」をクリックすると、リクエストを送ったプレイヤーの位置に画面が移動します。", "ui_events_attack": "攻撃 — 敵からの攻撃や自分の攻撃が表示されます。メッセージをクリックすると、その攻撃・核・ボート(輸送船)に画面が移動します。赤い「X」ボタンをクリックすると軍隊を撤退させることができますが、その場合攻撃部隊の25%が犠牲になります。ボート攻撃を撤退させた場合、ボートは出発地点に戻り、その地点が占領されていれば再び攻撃します。核攻撃は発射後に撤退することはできません。", - "ui_events_quickchat": "クイックチャット – ここでは送信・受信したチャットメッセージを確認できます。プレイヤーにメッセージを送るには、そのプレイヤーの情報メニューにあるクイックチャットアイコンをクリックしてください。", + "ui_events_quickchat": "クイックチャット:ここでは、送信・受信されたメッセージを確認できます。プレイヤーにメッセージを送信するには、そのプレイヤーの情報メニューにあるクイックチャットアイコンをクリックしてください。", "ui_options": "オプション", "ui_options_desc": "以下の項目が含まれます:", "ui_playeroverlay": "プレイヤー情報オーバーレイ", @@ -77,12 +91,14 @@ "option_timer": "タイマー - ゲーム開始からの経過時間", "option_exit": "終了ボタン", "option_settings": "設定メニュー - 設定メニューを開きます。左クリックでオルタネート表示、ダークモード、絵文字、アクション、匿名モードを切り替えることができます。", - "radial_title": "ラジアルメニュー", - "radial_desc": "右クリック(またはモバイルでタッチ)するとラジアルメニューが開きます。右クリックすると、ラジアルメニューを閉じます。メニューから、次のようにできます:", + "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下のアイコンは、以下のプレイヤー間のやりとりを表しています:", @@ -110,11 +126,11 @@ "build_port": "港", "build_port_desc": "水辺にのみ建設でき、このアイコンから戦艦を建築することが可能です。自国と他国の間に貿易制限が為されていない限り、自動的に交易船を送り出し、交易が完了すると両国に資金がもたらされます。貿易は手動で「貿易停止」または「貿易開始」を切り替えることができます。また、あなたが相手を攻撃したり、攻撃された場合には交易は自動的に停止し、5分経過するか同盟を結ぶと再開されます。", "build_warship": "戦艦", - "build_warship_desc": "このユニットは、指定したエリアを巡回し、貿易船を拿捕したり、敵の軍艦やボートを撃破したりします。最寄りの港から出現し、最初にクリックした場所を巡回し始めます。軍艦は攻撃クリックで選択し、移動先を攻撃クリックすることで操作できます。", + "build_warship_desc": "このユニットは、指定したエリアを巡回し、貿易船を拿捕したり、敵の戦艦やボートを撃破したりします。最寄りの港から出現し、最初にクリックした場所を巡回し始めます。軍艦は攻撃クリックで選択し、移動先を攻撃クリックすることで操作できます。", "build_silo": "ミサイル格納庫", "build_silo_desc": "ミサイルの発射を可能にします。", "build_sam": "SAMランチャー", - "build_sam_desc": "100ピクセル以内に入った敵ミサイルを、クールダウン7.5秒で迎撃できます。命中率は、原子爆弾に対して100%、水素爆弾に対して80%、MIRVに対して50%です。", + "build_sam_desc": "半径100ピクセル内の敵ミサイルを迎撃できます。SAMのクールダウン時間は7.5秒です。", "build_atom": "原子爆弾", "build_atom_desc": "小型の爆弾で、領土・建物・船舶・ボートを破壊します。最寄りのミサイル格納庫から発射され、最初にクリックした場所に着弾します。", "build_hydrogen": "水素爆弾", @@ -129,12 +145,15 @@ "icon_embargo": "取引停止 - このプレイヤーがあなたを自動または手動で貿易制限をかけているときに表示されます。", "icon_request": "メール - このプレイヤーがあなたへ同盟の申込みをしているときに表示されます。", "info_enemy_panel": "敵の情報パネル", - "exit_confirmation": "本当にゲームを終了しますか?" + "exit_confirmation": "本当にゲームを終了しますか?", + "bomb_direction": "原子爆弾 / 水素爆弾の軌道の向き" }, "single_modal": { - "title": "シングルプレイヤー", + "title": "ソロ", "random_spawn": "ランダムスポーン", "allow_alliances": "同盟を許可", + "toggle_achievements": "実績の表示の切り替え", + "sign_in_for_achievements": "実績を確認するにはサインインしてください", "options_title": "オプション", "bots": "ボット数: ", "bots_disabled": "無効", @@ -145,6 +164,8 @@ "infinite_troops": "兵士無限", "compact_map": "小型マップ", "max_timer": "ゲーム時間 (分)", + "max_timer_placeholder": "分", + "max_timer_invalid": "適切な最大プレイ時間(1~120分)を入力してください", "disable_nukes": "核兵器使用禁止", "enables_title": "機能の有効化", "start": "ゲーム開始" @@ -156,10 +177,26 @@ }, "account_modal": { "title": "アカウント", - "logged_in_as": "{email} としてログインしました", + "connected_as": "接続されたアカウント", + "stats_overview": "統計の概要", + "save_progress_title": "進捗状況を保存する", + "save_progress_desc": "アカウントをリンクして、統計、ランク、コスメティックを安全に保ちます。", + "link_discord": "Discordアカウントを連携する", + "link_via_email_placeholder": "メールで連携する", + "link_button": "連携", + "log_out": "ログアウト", + "welcome_back": "おかえりなさい", + "sign_in_desc": "統計と進捗状況を保存するにはサインインしてください", + "or": "または", + "email_placeholder": "メールアドレスを入力してください", + "get_magic_link": "マジックリンクを入手", + "linked_account": "{account_name} としてログインしました", "fetching_account": "アカウント情報を取得中...", - "logged_in_with_discord": "Discordでログインしました", - "recovery_email_sent": "{email} に回復用のメールを送信しました" + "recovery_email_sent": "{email} に回復用のメールを送信しました", + "not_found": "見つかりません", + "clear_session": "セッションをクリア", + "failed_to_send_recovery_email": "再設定メールを送信できませんでした", + "enter_email_address": "メールアドレスを入力してください" }, "stats_modal": { "title": "ステータス", @@ -167,11 +204,40 @@ "loading": "ロード中…", "error": "クランステータスの読み込みに失敗しました", "no_stats": "クランステータスがありません", + "no_data_yet": "まだデータはありません", "clan": "クラン", "games": "ゲーム", "win_score": "勝利スコア", + "win_score_tooltip": "クランの参加と試合の難易度に基づいて重み付けされた勝利", "loss_score": "敗北スコア", - "win_loss_ratio": "勝利/敗北" + "loss_score_tooltip": "クランの参加と試合の難易度に基づいて重み付けされた敗北", + "win_loss_ratio": "勝利/敗北", + "ratio": "比率", + "rank": "ランク", + "try_again": "もう一度やり直してください" + }, + "game_info_modal": { + "title": "ゲームの詳細", + "players": "プレイヤー", + "atoms": "原子爆弾", + "hydros": "水素爆弾", + "mirv": "MIRV", + "bombs": "爆弾", + "total_gold": "合計", + "all_gold": "合計資金", + "trade": "貿易", + "conquest_gold": "征服したプレイヤーの資金数", + "stolen_gold": "戦艦で盗んだ資金", + "num_of_conquests": "征服したプレイヤーの数", + "duration": "間隔", + "survival_time": "生存時間", + "war": "戦争", + "economy": "経済", + "conquests": "征服", + "pirate": "海賊", + "conquered": "征服された", + "loading_game_info": "ゲームの統計を読み込んでいます", + "no_winner": "この試合の勝者はいなかった" }, "map": { "map": "地図", @@ -186,6 +252,7 @@ "asia": "アジア", "mars": "火星", "southamerica": "南アメリカ", + "britanniaclassic": "ブリタニア(クラシック)", "britannia": "ブリタニア", "gatewaytotheatlantic": "西ヨーロッパ", "australia": "オーストラリア", @@ -196,7 +263,7 @@ "betweentwoseas": "2つの海の間", "faroeislands": "フェロー諸島", "deglaciatedantarctica": "退氷した南極大陸", - "europeclassic": "ヨーロッパ (クラシック)", + "europeclassic": "ヨーロッパ(クラシック)", "falklandislands": "フォークランド諸島", "baikal": "バイカル湖付近", "halkidiki": "ハルキディキ半島", @@ -206,19 +273,33 @@ "yenisei": "エニセイ川", "pluto": "冥王星", "montreal": "モントリオール", + "newyorkcity": "ニューヨーク市", "achiran": "アチラン", "baikalnukewars": "バイカル(核戦争)", "fourislands": "4つの島", "gulfofstlawrence": "セントローレンス湾", - "lisbon": "リスボンの都市圏" + "lisbon": "リスボンの都市圏", + "svalmel": "スヴァルメル", + "manicouagan": "マニクアガン湖", + "lemnos": "レムノス島", + "sierpinski": "シェルピンスキー", + "twolakes": "二つの湖", + "straitofhormuz": "ホルムズ海峡", + "surrounded": "囲まれた島", + "didier": "ディディエ", + "didierfrance": "ディディエ(フランス)", + "amazonriver": "アマゾン川" }, "map_categories": { "continental": "大陸", "regional": "地域", - "fantasy": "その他" + "fantasy": "その他", + "special": "特殊マップ", + "arcade": "お楽しみマップ" }, "map_component": { - "loading": "読み込み中…" + "loading": "読み込み中…", + "error": "エラー" }, "private_lobby": { "title": "ランダム", @@ -229,42 +310,55 @@ "checking": "ロビーを確認中...", "not_found": "ロビーが見つかりません。IDを確認してもう一度お試しください。", "error": "エラーが発生しました。もう一度試すか、サポートにお問い合わせください。", - "joined_waiting": "参加に成功しました!ゲーム開始をお待ちください...", - "version_mismatch": "このゲームは別のバージョンで作成されました。参加できません。" + "joined_waiting": "ロビーに参加しました!ホストの開始を待っています…", + "version_mismatch": "このゲームは別のバージョンで作成されました。参加できません。", + "disabled_units": "無効ユニット" }, "public_lobby": { "join": "次のゲームに参加", "waiting": "人が参加しています...", - "teams_Duos": "2 人プレイヤー(ドゥオ)", - "teams_Trios": "3 人プレイヤー(トリオ)", - "teams_Quads": "4 人プレイヤー(クワッド)", - "teams_hvn": "プレイヤー対国家", + "teams_Duos": "{team_count}個の2人1組のチーム(デュオ)", + "teams_Trios": "{team_count}個の3人1組のチーム(トリオ)", + "teams_Quads": "{team_count}個の4人1組のチーム(クワッド)", + "waiting_for_players": "プレイヤーを待っています", + "starting_game": "ゲームを開始します...", + "teams_hvn": "人類 vs 国家", + "teams_hvn_detailed": "{num} 人類 vs {num} 国家", "teams": "{num}チーム", - "players_per_team": "{num}人プレイヤー" + "players_per_team": "{num}人プレイヤー", + "started": "開始しています" }, "matchmaking_modal": { - "title": "マッチングする", + "title": "1v1ランクマッチを作成 (アルファ版) ", "connecting": "サーバーに接続中…", "searching": "対戦相手を検索中…", - "waiting_for_game": "ゲーム開始を待っています…" + "waiting_for_game": "ゲーム開始を待っています…", + "elo": "あなたのELO: {elo}" }, "username": { "enter_username": "ユーザー名を入力", "not_string": "ユーザー名は文字列で入力してください。", "too_short": "ユーザー名は{min}文字より長い必要があります。", "too_long": "ユーザー名は{max}文字より短い必要があります。", - "invalid_chars": "ユーザー名には英字、数字、スペース、アンダースコア、および [角括弧] のみ使用できます。" + "invalid_chars": "ユーザー名には、文字、数字、スペース、アンダーバーのみを含めることができます。", + "tag": "タグ", + "tag_too_short": "クランタグは2〜5文字の英数字でなければなりません。", + "tag_invalid_chars": "クランタグには文字と数字のみを含めることができます。" }, "host_modal": { - "title": "プライベートロビー", + "title": "プライベートロビーを作成", + "label": "プライベート", "mode": "モード", "team_count": "チームの数", + "team_type": "チームタイプ", "options_title": "オプション", "bots": "ボット数: ", "bots_disabled": "無効", + "player_immunity_duration": "PVPの無敵時間(分)", "nations": "諸国: ", "disable_nations": "国家を無効化", "max_timer": "ゲーム時間 (分)", + "mins_placeholder": "分", "instant_build": "即時建築", "infinite_gold": "資金無限", "donate_gold": "資金援助", @@ -283,7 +377,11 @@ "assigned_teams": "チーム編成", "empty_teams": "空きのチーム", "empty_team": "空き", - "remove_player": "{username}を削除" + "remove_player": "{username}を削除", + "teams_Duos": "デュオ(2人1組)", + "teams_Trios": "トリオ(3人1組)", + "teams_Quads": "クワッド(4人1組)", + "teams_Humans Vs Nations": "人類 vs 国家" }, "team_colors": { "red": "赤", @@ -301,16 +399,20 @@ "code_license": "本ゲームのコードは AGPL-3.0 ライセンスに基づき公開されています(無保証)" }, "difficulty": { - "difficulty": "難易度", - "Easy": "簡単", - "Medium": "普通", - "Hard": "難しい", - "Impossible": "不可能" + "difficulty": "国家の難易度", + "easy": "簡単", + "medium": "普通", + "hard": "難しい", + "impossible": "不可能" }, "game_mode": { "ffa": "バトルロワイヤル", "teams": "チーム" }, + "public_game_modifier": { + "random_spawn": "ランダムスポーン", + "compact_map": "コンパクトマップ" + }, "select_lang": { "title": "言語を選択" }, @@ -335,21 +437,23 @@ "emojis_label": "絵文字を表示", "emojis_desc": "ゲーム中で絵文字を表示します", "alert_frame_label": "アラートフレーム", - "alert_frame_desc": "警告フレームの表示をを切り替えます。有効時、裏切られたときや陸上から攻撃を受けたときにフレームが表示されます。", + "alert_frame_desc": "警告フレームの表示を切り替えます。有効時、裏切られたときや陸上から攻撃を受けたときにフレームが表示されます。", "special_effects_label": "特殊効果", "special_effects_desc": "特殊効果を切り替えます。無効にするとパフォーマンスが向上します。", "structure_sprites_label": "建物アイコン", "structure_sprites_desc": "建物アイコンの表示切替", + "cursor_cost_label_label": "カーソルの下に表示される建設コスト", + "cursor_cost_label_desc": "建物を建てる際にカーソルの下に必要資金を表示する", "anonymous_names_label": "ユーザー名を匿名にする", "anonymous_names_desc": "自分の画面では他のプレイヤーのユーザー名を非表示にし、代わりに別の名前で表示します。", "lobby_id_visibility_label": "ロビーIDを非表示", "lobby_id_visibility_desc": "プライベートロビー作成時にロビーIDを隠す", + "toggle_visibility": "表示を切り替え", "left_click_label": "左クリックでメニューを開く", "left_click_desc": "オンにすると左クリックでメニューを開くことができ、剣ボタンで攻撃します。オフにすると左クリックでそのまま攻撃します。", "left_click_menu": "左クリックでメニューを開く", "attack_ratio_label": "⚔️ 出撃兵力の比率", "attack_ratio_desc": "初期時点で出撃する兵力の割合を設定します(1–100%)", - "troop_ratio_desc": "初期時点で兵士と金を生産する労働者の割合を設定します(1–100%)", "territory_patterns_label": "🏳️ 領土の模様", "territory_patterns_desc": "ゲーム内で領土の模様を表示するかどうか", "performance_overlay_label": "パフォーマンスオーバーレイ", @@ -358,6 +462,7 @@ "easter_writing_speed_desc": "コードを書く速さを調節する(x1-x100)", "easter_bug_count_label": "バグの個数", "easter_bug_count_desc": "どのぐらいの個数のバグを許容できるか(0–1000個)", + "press_a_key": "キーを押す", "view_options": "表示オプション", "toggle_view": "表示切り替え", "toggle_view_desc": "国境を非表示にし、地形だけが見れます", @@ -416,7 +521,8 @@ "exit_game_label": "ゲームから退出する", "exit_game_info": "メインメニューに戻ります", "background_music_volume": "BGM音量", - "sound_effects_volume": "効果音音量" + "sound_effects_volume": "効果音音量", + "keybind_conflict_error": "{key} キーはすでに他のアクションに使われています。" }, "chat": { "title": "クイックチャット", @@ -451,7 +557,7 @@ "mirv": "MIRVを[P1]に発射!", "focus": "[P1]に集中砲火だ!", "finish": "[P1]にとどめだ!", - "build_warships": "軍艦を建造せよ!" + "build_warships": "戦艦を建造せよ!" }, "defend": { "defend": "[P1]を守る!", @@ -513,7 +619,7 @@ "mirv": "指定したプレイヤーのみを狙う超大規模な爆発", "missile_silo": "核ミサイルの発射に使用される", "sam_launcher": "飛来する核ミサイルを迎撃する", - "warship": "貿易船を捕獲し、敵の船やボートを破壊する", + "warship": "貿易船を捕獲し、戦艦やボートを破壊する", "port": "貿易船を送って資金を獲得する", "defense_post": "近くの国境の防御を強化します", "city": "最大人口が増加します", @@ -529,6 +635,7 @@ "other_team": "{team}チームが勝利しました。", "you_won": "勝利!", "other_won": "{player}の勝利!", + "nation_won": "国家 {nation} が勝利しました!", "exit": "ゲームから退出", "keep": "観戦する", "spectate": "観戦する", @@ -537,7 +644,7 @@ "ofm_winter_description": "競技トーナメントにして、最強のプレイヤーたちに挑もう", "join_tournament": "トーナメントに参加", "join_discord": "Discordコミュニティに参加しよう!", - "discord_description": "他のプレイヤーと交流して、アップデート情報や戦略を共有しよう", + "discord_description": "プレイヤーとつながり、新しい機能を発見し、賞品を獲得しましょう!", "join_server": "サーバに入る", "youtube_tutorial": "ヘルプが必要ですか?" }, @@ -549,7 +656,7 @@ "team": "チーム", "owned": "領土", "gold": "ゴールド", - "troops": "兵士", + "maxtroops": "最大兵力", "launchers": "ランチャー", "sams": "SAM", "warships": "戦艦", @@ -565,6 +672,7 @@ "team": "チーム", "alliance_timeout": "同盟終了まで", "troops": "軍隊", + "maxtroops": "最大兵力", "a_troops": "攻撃兵士数", "gold": "資金", "ports": "港", @@ -575,7 +683,9 @@ "warships": "戦艦", "health": "体力", "attitude": "態度", - "levels": "レベル" + "levels": "レベル", + "wilderness_title": "荒野", + "irradiated_wilderness_title": "放射線に汚染された荒野" }, "events_display": { "retreating": "撤退中", @@ -653,7 +763,10 @@ "send_alliance": "同盟を要請", "send_troops": "軍隊を送信", "send_gold": "資金を送信", - "emotes": "絵文字" + "emotes": "絵文字", + "arc_up": "上向きの弧", + "arc_down": "下向きの弧", + "flip_rocket_trajectory": "ロケットの軌道を反転" }, "send_troops_modal": { "title_with_name": "{name}へ軍隊を送信", @@ -702,20 +815,26 @@ }, "heads_up_message": { "choose_spawn": "スタート地点を選んで下さい", - "random_spawn": "ランダムスポーンが有効です。開始地点を設定しています…" + "random_spawn": "ランダムスポーンが有効です。開始地点を設定しています…", + "singleplayer_game_paused": "ゲームを一時停止", + "multiplayer_game_paused": "ロビー作成者によってゲームが一時停止されました" }, "territory_patterns": { "title": "領土スキン", "colors": "色", "purchase": "購入", "show_only_owned": "自分の領地", + "all_owned": "すべてのスキンを手に入れました!新しいアイテムについては後ほどご確認ください。", + "not_logged_in": "ログインされていません", "blocked": { "login": "スキンを解放するにはログインしてください", "purchase": "スキンを解放するには購入してください" }, "pattern": { "default": "デフォルト" - } + }, + "select_skin": "スキンを選択", + "selected": "選択済" }, "flag_input": { "title": "旗を選択", @@ -786,8 +905,9 @@ "mode": "モード", "mode_ffa": "バトルロワイヤル", "mode_team": "チーム", - "view": "見る", + "replay": "リプレイ", "details": "詳細", + "ranking": "ランキング", "started": "既に開始", "map": "地図", "difficulty": "難易度", @@ -796,13 +916,20 @@ "player_stats_tree": { "public": "公開", "private": "非公開", - "singleplayer": "シングルプレイヤー", + "singleplayer": "ソロ", "mode": "モード", "stats_wins": "勝利数", "stats_losses": "敗北数", "stats_wlr": "勝敗比", "stats_games_played": "プレイ数", "mode_ffa": "デスマッチ", - "mode_team": "チーム" + "mode_team": "チーム", + "no_stats": "この選択に対する統計は記録されていません。" + }, + "matchmaking_button": { + "play_ranked": "1v1のランクマッチを作成", + "description": "(アルファ版)", + "login_required": "ランクマッチをプレイするにはログインしてください!", + "must_login": "ランクマッチをプレイするにはログインする必要があります。" } } diff --git a/resources/lang/nl.json b/resources/lang/nl.json index 746150408..d103626c0 100644 --- a/resources/lang/nl.json +++ b/resources/lang/nl.json @@ -7,6 +7,7 @@ }, "common": { "close": "Sluiten", + "back": "Terug", "available": "Beschikbaar", "preset_max": "Max", "summary_send": "Verstuur", @@ -17,7 +18,9 @@ "cap_tooltip": "Resterende capaciteit ontvanger", "target_dead": "Doelwit uitgeschakeld", "target_dead_note": "Je kunt geen middelen sturen naar een dode speler.", - "none": "Geen" + "none": "Geen", + "copied": "Gekopieerd!", + "click_to_copy": "Klik om te kopiëren" }, "main": { "title": "OpenFront (ALFA)", @@ -26,17 +29,28 @@ "checking_login": "Inlog controleren...", "logged_in": "Ingelogd!", "log_out": "Uitloggen", - "create_lobby": "Lobby aanmaken", - "join_lobby": "Lobby toetreden", - "single_player": "Eén Speler", + "create": "Lobby aanmaken", + "join": "Lobby toetreden", + "solo": "Solo-lobby", "instructions": "Instructies", + "game_info": "Spelinformatie", "wiki": "Wiki", "privacy_policy": "Privacybeleid", "terms_of_service": "Servicevoorwaarden", - "reddit": "Reddit" + "copyright": "© OpenFront™ en Bijdragers", + "reddit": "Reddit", + "play": "Spelen", + "news": "Nieuws", + "store": "Winkel", + "options": "Opties", + "keys": "Sneltoetsen", + "stats": "Statistieken", + "account": "Account", + "help": "Help", + "menu": "Menu", + "pick_pattern": "Kies een skin!" }, "news": { - "see_all_releases": "Bekijk alle releases", "github_link": "op GitHub", "title": "Release-opmerkingen" }, @@ -83,6 +97,8 @@ "radial_attack": "Het aanvalsmenu openen.", "radial_info": "Infomenu openen.", "radial_boat": "Stuur een Boot (transportschip) voor een aanval op de geselecteerde locatie. Alleen beschikbaar als je toegang hebt tot water.", + "radial_donate_troops": "Doneer troepen, gelijk aan het percentage van de ingestelde aanvalsverhouding, aan de bondgenoot waarop je het radiale menu hebt geopend.", + "radial_donate_gold": "Opent het gouddonatiemenu zodat je bondgenoten snel goud kan sturen.", "radial_close": "Het menu sluiten.", "info_title": "Infomenu", "info_enemy_desc": "Bevat informatie zoals de naam van de geselecteerde speler, goud, troepen, of ze de handel hebben stopgezet, hoeveel kernwapens ze op je hebben afgevuurd, en of de speler een verrader is. Een verrader is een speler die een bondgenoot heeft aangevallen. Handel gestopt betekent dat jullie geen goud meer van elkaar ontvangen via handelsschepen. Handmatig (als de speler op \"Stop handel\" heeft geklikt, wat duurt totdat jullie beide op \"Start handel\" hebben geklikt) of automatisch (als jij jullie bondgenootschap hebt verraden, wat 5 minuten duurt of korter als jullie weer bondgenoten worden). Verrader toont 30 seconden lang Ja als de speler een bondgenoot heeft aangevallen. De iconen hieronder staan voor de volgende interacties:", @@ -94,8 +110,8 @@ "info_ally_panel": "Infopaneel bondgenoot", "info_ally_desc": "Wanneer je een bondgenootschap sluit met een speler, worden de volgende nieuwe iconen beschikbaar:", "ally_betray": "Verraad je bondgenoot, beëindig het bondgenootschap, stop de handel, verzwak je verdediging. De handel tussen jullie wordt 5 minuten gepauzeerd (of totdat jullie weer bondgenoten worden) en anderen stoppen de handel mogelijk ook. En tenzij de andere speler zelf een verrader was, wordt je 30 seconden als verrader gemarkeerd. Gedurende deze tijd staat er een icoon boven je naam en is je verdediging 50% zwakker. Bots zullen minder snel een bondgenoten willen worden en spelers zullen zich wel tweemaal bedenken voor ze dat doen.", - "ally_donate": "Geef een deel van je troepen aan je bondgenoot. Gebruikt wanneer ze weinig troepen hebben en worden aangevallen, of wanneer ze die extra kracht nodig hebben om een ​​vijand te verpletteren.", - "ally_donate_gold": "Geef een deel van je goud aan je bondgenoot. Wanneer zij weinig goud hebben en het voor gebouwen nodig hebben, of wanneer je teamgenoot aan het sparen is voor die MIRV.", + "ally_donate": "Doneer een deel van je troepen aan je bondgenoot. Gebruikt wanneer ze weinig troepen hebben en worden aangevallen, of wanneer ze die extra kracht nodig hebben om een ​​vijand te verpletteren.", + "ally_donate_gold": "Doneer een deel van je goud aan je bondgenoot. Wanneer zij weinig goud hebben en het voor gebouwen nodig hebben, of wanneer je teamgenoot aan het sparen is voor die MIRV.", "build_menu_title": "Bouwmenu", "build_menu_desc": "Maak hier een van of bekijk hoeveel van elke je al hebt gemaakt:", "build_name": "Naam", @@ -114,7 +130,7 @@ "build_silo": "Raketsilo", "build_silo_desc": "Maakt het lanceren van raketten mogelijk.", "build_sam": "Luchtdoelraket (SAM)-lanceerder", - "build_sam_desc": "Kan vijandelijke raketten onderscheppen binnen een straal van 80 pixels of, voor MIRV-kernkoppen, 50 pixels. Raakt 100% van de atoombommen, 80% van de waterstofbommen en 50% van de individuele MIRV-kernkoppen. De SAM heeft een afkoeltijd van 7,5 seconden.", + "build_sam_desc": "Kan vijandelijke raketten onderscheppen binnen een bereik van 100 pixels. De SAM-lanceerder heeft een herstelperiode van 7,5 seconden.", "build_atom": "Atoombom", "build_atom_desc": "Kleine explosieve bom die gebied, gebouwen, schepen en boten vernietigt. Komt vanuit de dichtstbijzijnde Raketsilo en landt op de plek waar je hebt geklikt om het te bouwen.", "build_hydrogen": "Waterstofbom", @@ -129,12 +145,15 @@ "icon_embargo": "Dollar stopbord - Embargo. Deze speler heeft de handel met jou gestopt, automatisch of handmatig.", "icon_request": "Envelop - Alliantieverzoek. Deze speler stuurde je een verzoek om bondgenoten te worden.", "info_enemy_panel": "Infopaneel vijand", - "exit_confirmation": "Weet je zeker dat je dit spel wilt verlaten?" + "exit_confirmation": "Weet je zeker dat je dit spel wilt verlaten?", + "bomb_direction": "Atoom- / waterstofbom boogrichting" }, "single_modal": { - "title": "Eén speler", + "title": "Solo", "random_spawn": "Willekeurige startpositie", "allow_alliances": "Bondgenootschappen toestaan", + "toggle_achievements": "Prestaties in- of uitschakelen", + "sign_in_for_achievements": "Meld je aan voor prestaties", "options_title": "Opties", "bots": "Bots:", "bots_disabled": "Uitgeschakeld", @@ -145,6 +164,8 @@ "infinite_troops": "Oneindige troepen", "compact_map": "Compacte kaart", "max_timer": "Spellengte (minuten)", + "max_timer_placeholder": "Min.", + "max_timer_invalid": "Voer een geldige max. timertijd in (1-120 minuten)", "disable_nukes": "Kernwapens uitschakelen", "enables_title": "Onderdelen inschakelen", "start": "Start Spel" @@ -156,10 +177,26 @@ }, "account_modal": { "title": "Account", - "logged_in_as": "Ingelogd als {email}", + "connected_as": "Gekoppeld als", + "stats_overview": "Overzicht van statistieken", + "save_progress_title": "Sla je voortgang op", + "save_progress_desc": "Koppel je account om je statistieken, rang en cosmetica veilig te houden.", + "link_discord": "Discord-account koppelen", + "link_via_email_placeholder": "Koppel via e-mail", + "link_button": "Koppelen", + "log_out": "Uitloggen", + "welcome_back": "Welkom terug", + "sign_in_desc": "Log in om je statistieken en voortgang op te slaan", + "or": "OF", + "email_placeholder": "Voer je e-mailadres in", + "get_magic_link": "Krijg Magische Link", + "linked_account": "Ingelogd als {account_name}", "fetching_account": "Accountgegevens ophalen...", - "logged_in_with_discord": "Ingelogd met Discord", - "recovery_email_sent": "Herstelmail verzonden naar {email}" + "recovery_email_sent": "Herstelmail verzonden naar {email}", + "not_found": "Niet Gevonden", + "clear_session": "Sessie Wissen", + "failed_to_send_recovery_email": "Verzenden herstel e-mail mislukt", + "enter_email_address": "Voer een e-mailadres in alsjeblieft" }, "stats_modal": { "title": "Statistieken", @@ -167,11 +204,40 @@ "loading": "Laden...", "error": "Fout bij het laden van clan statistieken", "no_stats": "Er zijn geen clan statistieken beschikbaar", + "no_data_yet": "Nog geen gegevens", "clan": "Clan", "games": "Spellen", "win_score": "Win Score", + "win_score_tooltip": "Gewogen aantal overwinningen op basis van clandeelname en matchmoeilijkheidsgraad", "loss_score": "Verlies Score", - "win_loss_ratio": "Gewonnen/Verloren" + "loss_score_tooltip": "Gewogen aantal verliezen op basis van clandeelname en matchmoeilijkheidsgraad", + "win_loss_ratio": "Gewonnen/Verloren", + "ratio": "Verhouding", + "rank": "Rang", + "try_again": "Opnieuw Proberen" + }, + "game_info_modal": { + "title": "Spelinformatie", + "players": "Spelers", + "atoms": "Atoombom", + "hydros": "Waterstofbom", + "mirv": "MIRV", + "bombs": "Bommen", + "total_gold": "Totaal", + "all_gold": "Alle goud", + "trade": "Handel", + "conquest_gold": "Veroverd spelersgoud", + "stolen_gold": "Gestolen met oorlogsschepen", + "num_of_conquests": "Aantal veroverde spelers", + "duration": "Tijdsduur", + "survival_time": "Overlevingstijd", + "war": "Oorlog", + "economy": "Economie", + "conquests": "Veroveringen", + "pirate": "Kapen", + "conquered": "Veroverd", + "loading_game_info": "Spelstatistieken worden geladen", + "no_winner": "Dit spel eindigde zonder winnaar" }, "map": { "map": "Kaart", @@ -186,6 +252,7 @@ "asia": "Azië", "mars": "Mars", "southamerica": "Zuid-Amerika", + "britanniaclassic": "Britannia (Klassiek)", "britannia": "Groot-Brittanië", "gatewaytotheatlantic": "Poort van de Atlantische Oceaan", "australia": "Australië", @@ -196,7 +263,7 @@ "betweentwoseas": "Tussen twee zeeën", "faroeislands": "Faeröer eilanden", "deglaciatedantarctica": "Ontdooid Antarctica", - "europeclassic": "Europa (klassiek)", + "europeclassic": "Europa (Klassiek)", "falklandislands": "Falklandeilanden", "baikal": "Baikalmeer", "halkidiki": "Chalkidiki", @@ -206,19 +273,33 @@ "yenisei": "Jenisej", "pluto": "Pluto", "montreal": "Montreal", + "newyorkcity": "New York City", "achiran": "Achiran", "baikalnukewars": "Baikal (Kernoorlog)", "fourislands": "Vier Eilanden", "gulfofstlawrence": "Saint Lawrencebaai", - "lisbon": "Lissabon" + "lisbon": "Lissabon", + "svalmel": "Svalmel", + "manicouagan": "Manicouagan", + "lemnos": "Limnos", + "sierpinski": "Sierpinski", + "twolakes": "Twee Meren", + "straitofhormuz": "Straat van Hormuz", + "surrounded": "Omringd", + "didier": "Didier", + "didierfrance": "Didier (Frankrijk)", + "amazonriver": "Amazonerivier" }, "map_categories": { "continental": "Continent", "regional": "Regio", - "fantasy": "Overig" + "fantasy": "Overig", + "special": "Speciaal", + "arcade": "Arcade" }, "map_component": { - "loading": "Laden..." + "loading": "Laden...", + "error": "Fout" }, "private_lobby": { "title": "Privélobby toetreden", @@ -229,47 +310,60 @@ "checking": "Lobby controleren...", "not_found": "Lobby niet gevonden. Controleer het ID en probeer het opnieuw.", "error": "Er is een fout opgetreden. Probeer het opnieuw of neem contact op met support.", - "joined_waiting": "Succesvol toegetreden! Wachten tot het spel begint...", - "version_mismatch": "Dit spel is aangemaakt met een andere versie. Kan niet afspelen of deelnemen." + "joined_waiting": "Toegetreden tot lobby! Wachten op host om te starten...", + "version_mismatch": "Dit spel is aangemaakt met een andere versie. Kan niet afspelen of deelnemen.", + "disabled_units": "Uitgeschakelde Eenheden" }, "public_lobby": { "join": "Deelnemen aan volgende Spel", "waiting": "spelers wachten", - "teams_Duos": "van 2 (Duo's)", - "teams_Trios": "van 3 (Trio's)", - "teams_Quads": "van 4 (Viertallen)", + "teams_Duos": "{team_count} Teams van 2 (Duo's)", + "teams_Trios": "{team_count} Teams van 3 (Trio's)", + "teams_Quads": "{team_count} Teams van 4 (Viertallen)", + "waiting_for_players": "Wachten op spelers", + "starting_game": "Spel starten…", "teams_hvn": "Mensen vs Naties", + "teams_hvn_detailed": "{num} Mensen vs {num} Naties", "teams": "{num} Teams", - "players_per_team": "van {num}" + "players_per_team": "van {num}", + "started": "Begonnen" }, "matchmaking_modal": { - "title": "Matchmaking", + "title": "1v1 Competitieve Matchmaking (ALFA)", "connecting": "Verbinden met matchmakingserver...", "searching": "Zoeken naar een spel...", - "waiting_for_game": "Wachten tot het spel begint..." + "waiting_for_game": "Wachten tot het spel begint...", + "elo": "Jouw ELO: {elo}" }, "username": { "enter_username": "Voer je gebruikersnaam in", "not_string": "Gebruikersnaam moet een tekenreeks zijn.", "too_short": "Gebruikersnaam moet minstens {min} tekens lang zijn.", "too_long": "Gebruikersnaam mag niet langer zijn dan {max} tekens.", - "invalid_chars": "Gebruikersnaam mag alleen letters, cijfers, spaties, underscores en [vierkante haakjes] bevatten." + "invalid_chars": "Gebruikersnaam kan alleen letters, cijfers, spaties en underscores bevatten.", + "tag": "TAG", + "tag_too_short": "Clantag moet 2-5 alfanumerieke tekens zijn.", + "tag_invalid_chars": "Clantag kan alleen letters en cijfers bevatten." }, "host_modal": { - "title": "Privélobby", + "title": "Privélobby Aanmaken", + "label": "Privé", "mode": "Modus", "team_count": "Aantal teams", + "team_type": "Teamtype", "options_title": "Opties", "bots": "Bots:", "bots_disabled": "Uitgeschakeld", + "player_immunity_duration": "PVP-immuniteitsduur (minuten)", "nations": "Naties: ", "disable_nations": "Naties uitschakelen", "max_timer": "Spellengte (minuten)", + "mins_placeholder": "Min.", "instant_build": "Bouwwachttijd uitschakelen", "infinite_gold": "Oneindig goud", - "donate_gold": "Goud geven", + "donate_gold": "Goud doneren", "infinite_troops": "Oneindige troepen", - "donate_troops": "Troepen geven", + "donate_troops": "Troepen doneren", "compact_map": "Compacte kaart", "enables_title": "Onderdelen inschakelen", "player": "Speler", @@ -283,7 +377,11 @@ "assigned_teams": "Toegewezen Teams", "empty_teams": "Lege Teams", "empty_team": "Leeg", - "remove_player": "Verwijder {username}" + "remove_player": "Verwijder {username}", + "teams_Duos": "Duo's (teams van 2)", + "teams_Trios": "Trio's (teams van 3)", + "teams_Quads": "Viertallen (teams van 4)", + "teams_Humans Vs Nations": "Mensen vs Naties" }, "team_colors": { "red": "Rood", @@ -301,16 +399,20 @@ "code_license": "Code gelicenseerd onder AGPL-3.0 (geen garantie)" }, "difficulty": { - "difficulty": "Moeilijkheidsgraad", - "Easy": "Ontspannen", - "Medium": "Gebalanceerd", - "Hard": "Intens", - "Impossible": "Onmogelijk" + "difficulty": "Natie moeilijkheidsgraad", + "easy": "Makkelijk", + "medium": "Gemiddeld", + "hard": "Moeilijk", + "impossible": "Onmogelijk" }, "game_mode": { "ffa": "Iedereen tegen elkaar (FFA)", "teams": "Teams" }, + "public_game_modifier": { + "random_spawn": "Willekeurige Startpositie", + "compact_map": "Compacte Kaart" + }, "select_lang": { "title": "Kies taal" }, @@ -340,16 +442,18 @@ "special_effects_desc": "Visuele effecten aanzetten. Zet uit om de prestaties van het spel te verbeteren", "structure_sprites_label": "Gebouw afbeeldingen", "structure_sprites_desc": "3D-afbeeldingen gebouwen in-/uitschakelen", + "cursor_cost_label_label": "Cursor Bouwkosten", + "cursor_cost_label_desc": "Toon kosten onder de bouwcursor", "anonymous_names_label": "Verborgen Namen", "anonymous_names_desc": "Vervang echte spelersnamen door willekeurige namen op je scherm.", "lobby_id_visibility_label": "Verborgen Lobby-ID's", "lobby_id_visibility_desc": "Verberg Lobby-ID tijdens het maken van een privélobby", + "toggle_visibility": "Zichtbaar/onzichtbaar", "left_click_label": "Linkermuisknop voor openen menu", "left_click_desc": "Als AAN: linkermuisknop opent het Radiale menu met zwaard-aanvalsknop. Als UIT: linkermuisknop opent direct de aanval.", "left_click_menu": "Linkermuisknop Radiale Menu", "attack_ratio_label": "⚔️ Aanvalsverhouding", "attack_ratio_desc": "Welk percentage van je troepen je bij een aanval stuurt (1-100%)", - "troop_ratio_desc": "De balans tussen troepen (voor gevechten) en werkers (voor goudproductie) aanpassen (1-100%)", "territory_patterns_label": "🏳️ Skins voor gebieden", "territory_patterns_desc": "Kies of je skins op gebieden wilt weergeven in het spel", "performance_overlay_label": "Prestatie-overlay", @@ -358,6 +462,7 @@ "easter_writing_speed_desc": "Pas aan hoe snel je pretendeert te programmeren (x1-x100)", "easter_bug_count_label": "Aantal bugs", "easter_bug_count_desc": "Hoeveel bugs je oké vindt (0-1000, gevoelsmatig)", + "press_a_key": "Druk op een toets", "view_options": "Weergave-opties", "toggle_view": "Weergave wisselen", "toggle_view_desc": "Weergave wisselen (terrein/landen)", @@ -416,7 +521,8 @@ "exit_game_label": "Spel Verlaten", "exit_game_info": "Terug naar hoofdmenu", "background_music_volume": "Volume achtergrondmuziek", - "sound_effects_volume": "Volume geluidseffecten" + "sound_effects_volume": "Volume geluidseffecten", + "keybind_conflict_error": "De toets {key} is al verbonden aan een andere actie." }, "chat": { "title": "Snelchat", @@ -529,6 +635,7 @@ "other_team": "{team} team heeft gewonnen!", "you_won": "Je hebt gewonnen!", "other_won": "{player} heeft gewonnen!", + "nation_won": "Natie {nation} heeft gewonnen!", "exit": "Verlaat spel", "keep": "Blijf spelen", "spectate": "Toekijken", @@ -537,7 +644,7 @@ "ofm_winter_description": "Doe mee met het competitieve toernooi en concurreer met de beste spelers", "join_tournament": "Toernooi toetreden", "join_discord": "Word lid van onze Discord-gemeenschap!", - "discord_description": "Leg contact met andere spelers, krijg updates en deel strategieën", + "discord_description": "Maak contact met spelers, ontdek nieuwe functies en win prijzen!", "join_server": "Word lid van server", "youtube_tutorial": "Wat hulp nodig?" }, @@ -549,7 +656,7 @@ "team": "Team", "owned": "Bezit", "gold": "Goud", - "troops": "Troepen", + "maxtroops": "Max. troepen", "launchers": "Raketsilo's", "sams": "SAM-lanceerders", "warships": "Oorlogsschepen", @@ -565,6 +672,7 @@ "team": "Team", "alliance_timeout": "Alliantie eindigt over", "troops": "Troepen", + "maxtroops": "Max. troepen", "a_troops": "Aanvallende troepen", "gold": "Goud", "ports": "Havens", @@ -575,7 +683,9 @@ "warships": "Oorlogsschepen", "health": "Gezondheid", "attitude": "Houding", - "levels": "Levels" + "levels": "Levels", + "wilderness_title": "Wildernis", + "irradiated_wilderness_title": "Bestraalde Wildernis" }, "events_display": { "retreating": "trekken zich terug", @@ -653,7 +763,10 @@ "send_alliance": "Stuur Alliantieverzoek", "send_troops": "Geef Troepen", "send_gold": "Geef Goud", - "emotes": "Emoji's" + "emotes": "Emoji's", + "arc_up": "Opwaartse boog", + "arc_down": "Neerwaartse boog", + "flip_rocket_trajectory": "Rakettraject spiegelen" }, "send_troops_modal": { "title_with_name": "Stuur Troepen naar {name}", @@ -702,20 +815,26 @@ }, "heads_up_message": { "choose_spawn": "Kies een startlocatie", - "random_spawn": "Willekeurige startpositie is ingeschakeld. Positie wordt voor je gekozen..." + "random_spawn": "Willekeurige startpositie is ingeschakeld. Positie wordt voor je gekozen...", + "singleplayer_game_paused": "Spel gepauzeerd", + "multiplayer_game_paused": "Spel gepauzeerd door Lobby-maker" }, "territory_patterns": { "title": "Skins ", "colors": "Kleuren", "purchase": "Kopen", "show_only_owned": "Mijn Skins", + "all_owned": "Je bezit alle skins! Kom later terug voor nieuwe items.", + "not_logged_in": "Niet ingelogd", "blocked": { "login": "Je moet ingelogd zijn voor toegang tot deze skin.", "purchase": "Koop deze skin om te ontgrendelen." }, "pattern": { "default": "Standaard" - } + }, + "select_skin": "Kies Skin", + "selected": "geselecteerd" }, "flag_input": { "title": "Selecteer Vlag", @@ -786,8 +905,9 @@ "mode": "Modus", "mode_ffa": "Iedereen tegen elkaar", "mode_team": "Team", - "view": "Weergeven", + "replay": "Herhaling", "details": "Details", + "ranking": "Rang", "started": "Begonnen", "map": "Kaart", "difficulty": "Moeilijkheidsgraad", @@ -796,13 +916,20 @@ "player_stats_tree": { "public": "Openbaar", "private": "Privé", - "singleplayer": "Eén Speler", + "singleplayer": "Solo", "mode": "Modus", "stats_wins": "Overwinningen", "stats_losses": "Nederlagen", "stats_wlr": "Winst:verliesverhouding", "stats_games_played": "Gespeelde spellen", "mode_ffa": "Iedereen tegen elkaar", - "mode_team": "Team" + "mode_team": "Team", + "no_stats": "Geen statistieken vastgelegd voor deze selectie." + }, + "matchmaking_button": { + "play_ranked": "1v1 Competitieve Matchmaking", + "description": "(ALFA)", + "login_required": "Log in om competitief te spelen!", + "must_login": "Je moet ingelogd zijn om competitieve matchmaking te spelen." } } From 920e029967f501b2478818f5523daeeeaabfce06 Mon Sep 17 00:00:00 2001 From: Alexander Hoare Date: Thu, 15 Jan 2026 20:22:50 +0000 Subject: [PATCH 009/109] Amended renderNumber to display numbers in the billions range (#2911) If this PR fixes an issue, link it below. If not, delete these two lines. Resolves [#2910](https://github.com/openfrontio/OpenFrontIO/issues/2910) ## Description: When any number is rendered using the Utils.ts renderNumber() function that is greater than 999,999,999, the display uses 1001M instead of 1B. Similar to existing logic, I expand the if statements to check if the number is greater than a threshold in the billions, and format it accordingly if so. Screenshots: image img1 ## Please complete the following: - [YES] I have added screenshots for all UI updates - [YES ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [YES] I have added relevant tests to the test directory - [YES] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: visneh --- src/client/Utils.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/client/Utils.ts b/src/client/Utils.ts index 2f4e0dafd..ab56464f1 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -42,7 +42,13 @@ export function renderNumber( num = Number(num); num = Math.max(num, 0); - if (num >= 10_000_000) { + if (num >= 10_000_000_000) { + const value = Math.floor(num / 100000000) / 10; + return value.toFixed(fixedPoints ?? 1) + "B"; + } else if (num >= 1_000_000_000) { + const value = Math.floor(num / 10000000) / 100; + return value.toFixed(fixedPoints ?? 2) + "B"; + } else if (num >= 10_000_000) { const value = Math.floor(num / 100000) / 10; return value.toFixed(fixedPoints ?? 1) + "M"; } else if (num >= 1_000_000) { From 0466eeac134c70a3dc4ef0ef28b4c991f7682d0b Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Thu, 15 Jan 2026 21:24:35 +0100 Subject: [PATCH 010/109] Add train gold to game info ranking (#2901) ## Description: The game info panel was missing the gold generated with trains, which was recently added into the recorded stats. This PR adds the gold train ranking, grouped with the naval trade. Visually the game info panel is not matching the new visual identity, but this PR only focuses on the missing data. image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: IngloriousTom --- .../baseComponents/ranking/GameInfoRanking.ts | 12 +++- .../baseComponents/ranking/PlayerRow.ts | 68 +++++++++++++++---- .../baseComponents/ranking/RankingControls.ts | 11 ++- .../baseComponents/ranking/RankingHeader.ts | 23 ++++--- tests/GameInfoRanking.test.ts | 15 ++-- 5 files changed, 100 insertions(+), 29 deletions(-) diff --git a/src/client/components/baseComponents/ranking/GameInfoRanking.ts b/src/client/components/baseComponents/ranking/GameInfoRanking.ts index 015d6ae40..fa78d36f0 100644 --- a/src/client/components/baseComponents/ranking/GameInfoRanking.ts +++ b/src/client/components/baseComponents/ranking/GameInfoRanking.ts @@ -2,6 +2,8 @@ import { AnalyticsRecord, PlayerRecord } from "../../../../core/Schemas"; import { GOLD_INDEX_STEAL, GOLD_INDEX_TRADE, + GOLD_INDEX_TRAIN_OTHER, + GOLD_INDEX_TRAIN_SELF, GOLD_INDEX_WAR, } from "../../../../core/StatsSchemas"; @@ -12,7 +14,8 @@ export enum RankType { MIRV = "MIRV", TotalGold = "TotalGold", StolenGold = "StolenGold", - TradedGold = "TradedGold", + NavalTrade = "NavalTrade", + TrainTrade = "TrainTrade", ConqueredGold = "ConqueredGold", Lifetime = "Lifetime", } @@ -134,10 +137,15 @@ export class Ranking { return Number(player.gold.reduce((sum, gold) => sum + gold, 0n)); case RankType.StolenGold: return Number(player.gold[GOLD_INDEX_STEAL] ?? 0n); - case RankType.TradedGold: + case RankType.NavalTrade: return Number(player.gold[GOLD_INDEX_TRADE] ?? 0n); case RankType.ConqueredGold: return Number(player.gold[GOLD_INDEX_WAR] ?? 0n); + case RankType.TrainTrade: { + const ownTrains = player.gold[GOLD_INDEX_TRAIN_SELF] ?? 0n; + const otherTrains = player.gold[GOLD_INDEX_TRAIN_OTHER] ?? 0n; + return Number(ownTrains + otherTrains); + } } } diff --git a/src/client/components/baseComponents/ranking/PlayerRow.ts b/src/client/components/baseComponents/ranking/PlayerRow.ts index 2ebe635ed..9989962a9 100644 --- a/src/client/components/baseComponents/ranking/PlayerRow.ts +++ b/src/client/components/baseComponents/ranking/PlayerRow.ts @@ -1,5 +1,10 @@ import { LitElement, html } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { + GOLD_INDEX_TRADE, + GOLD_INDEX_TRAIN_OTHER, + GOLD_INDEX_TRAIN_SELF, +} from "src/core/StatsSchemas"; import { renderNumber } from "../../../Utils"; import { PlayerInfo, RankType } from "./GameInfoRanking"; @@ -67,10 +72,12 @@ export class PlayerRow extends LitElement { case RankType.MIRV: return this.renderBombScore(); case RankType.TotalGold: - case RankType.TradedGold: case RankType.ConqueredGold: case RankType.StolenGold: return this.renderGoldScore(); + case RankType.NavalTrade: + case RankType.TrainTrade: + return this.renderTradeScore(); default: return html``; } @@ -109,14 +116,15 @@ export class PlayerRow extends LitElement {
`; } - private renderBombType(value: number, highlight: boolean) { + + private renderMultiScoreType(value: number, highlight: boolean) { return html`
- ${value} + ${renderNumber(value)}
`; } @@ -124,17 +132,17 @@ export class PlayerRow extends LitElement { private renderAllBombs() { return html`
- ${this.renderBombType( + ${this.renderMultiScoreType( this.player.atoms, this.rankType === RankType.Atoms, )} / - ${this.renderBombType( + ${this.renderMultiScoreType( this.player.hydros, this.rankType === RankType.Hydros, )} / - ${this.renderBombType( + ${this.renderMultiScoreType( this.player.mirv, this.rankType === RankType.MIRV, )} @@ -142,9 +150,28 @@ export class PlayerRow extends LitElement { `; } + private renderAllTrades() { + const navalTrade = this.player.gold[GOLD_INDEX_TRADE] ?? 0n; + const ownTrainTrade = this.player.gold[GOLD_INDEX_TRAIN_SELF] ?? 0n; + const otherTrainTrade = this.player.gold[GOLD_INDEX_TRAIN_OTHER] ?? 0n; + return html` +
+ ${this.renderMultiScoreType( + Number(navalTrade), + this.rankType === RankType.NavalTrade, + )} + / + ${this.renderMultiScoreType( + Number(ownTrainTrade + otherTrainTrade), + this.rankType === RankType.TrainTrade, + )} +
+ `; + } + private renderBombScore() { return html` -
+
${this.renderPlayerIcon()}
${this.renderPlayerName()} ${this.renderAllBombs()} @@ -157,13 +184,12 @@ export class PlayerRow extends LitElement { return html`
${this.renderPlayerIcon()} -
- ${this.renderPlayerName()} -
+
${this.renderPlayerName()}
+
${renderNumber(this.score)}
@@ -172,6 +198,24 @@ export class PlayerRow extends LitElement { `; } + private renderTradeScore() { + return html` +
+ ${this.renderPlayerIcon()} +
${this.renderPlayerName()}
+
+ +
+
+ ${this.renderAllTrades()} +
+ +
+ `; + } + private renderPlayerName() { return html`
diff --git a/src/client/components/baseComponents/ranking/RankingControls.ts b/src/client/components/baseComponents/ranking/RankingControls.ts index 59e3ea76c..25321d8aa 100644 --- a/src/client/components/baseComponents/ranking/RankingControls.ts +++ b/src/client/components/baseComponents/ranking/RankingControls.ts @@ -7,8 +7,10 @@ const economyRankings = new Set([ RankType.TotalGold, RankType.StolenGold, RankType.ConqueredGold, - RankType.TradedGold, + RankType.NavalTrade, + RankType.TrainTrade, ]); +const tradeRankings = new Set([RankType.NavalTrade, RankType.TrainTrade]); const bombRankings = new Set([RankType.Atoms, RankType.Hydros, RankType.MIRV]); const warRankings = new Set([ RankType.Conquests, @@ -18,6 +20,7 @@ const warRankings = new Set([ ]); const isEconomyRanking = (t: RankType) => economyRankings.has(t); +const isTradeRanking = (t: RankType) => tradeRankings.has(t); const isBombRanking = (t: RankType) => bombRankings.has(t); const isWarRanking = (t: RankType) => warRankings.has(t); @@ -87,7 +90,6 @@ export class RankingControls extends LitElement { if (!isEconomyRanking(this.rankType)) return ""; const econButtons = [ - [RankType.TradedGold, "game_info_modal.trade"], [RankType.StolenGold, "game_info_modal.pirate"], [RankType.ConqueredGold, "game_info_modal.conquered"], [RankType.TotalGold, "game_info_modal.total_gold"], @@ -95,6 +97,11 @@ export class RankingControls extends LitElement { return html`
+ ${this.renderSubButton( + RankType.NavalTrade, + isTradeRanking(this.rankType), + "game_info_modal.trade", + )} ${econButtons.map(([type, label]) => this.renderSubButton(type as RankType, this.rankType === type, label), )} diff --git a/src/client/components/baseComponents/ranking/RankingHeader.ts b/src/client/components/baseComponents/ranking/RankingHeader.ts index 881de1101..6869b9526 100644 --- a/src/client/components/baseComponents/ranking/RankingHeader.ts +++ b/src/client/components/baseComponents/ranking/RankingHeader.ts @@ -36,17 +36,17 @@ export class RankingHeader extends LitElement { case RankType.MIRV: return html`
- ${this.renderBombHeaderButton( + ${this.renderMultipleChoiceHeaderButton( translateText("game_info_modal.atoms"), RankType.Atoms, )} / - ${this.renderBombHeaderButton( + ${this.renderMultipleChoiceHeaderButton( translateText("game_info_modal.hydros"), RankType.Hydros, )} / - ${this.renderBombHeaderButton( + ${this.renderMultipleChoiceHeaderButton( translateText("game_info_modal.mirv"), RankType.MIRV, )} @@ -56,10 +56,15 @@ export class RankingHeader extends LitElement { return html`
${translateText("game_info_modal.all_gold")}
`; - case RankType.TradedGold: - return html`
- ${translateText("game_info_modal.trade")} -
`; + case RankType.NavalTrade: + case RankType.TrainTrade: + return html` +
+ ${this.renderMultipleChoiceHeaderButton("🚂", RankType.TrainTrade)} + / + ${this.renderMultipleChoiceHeaderButton("🚢", RankType.NavalTrade)} +
+ `; case RankType.ConqueredGold: return html`
${translateText("game_info_modal.conquest_gold")} @@ -74,13 +79,13 @@ export class RankingHeader extends LitElement { } } - private renderBombHeaderButton(label: string, type: RankType) { + private renderMultipleChoiceHeaderButton(label: string, type: RankType) { return html` diff --git a/tests/GameInfoRanking.test.ts b/tests/GameInfoRanking.test.ts index 7955fb807..1e523ae93 100644 --- a/tests/GameInfoRanking.test.ts +++ b/tests/GameInfoRanking.test.ts @@ -13,6 +13,8 @@ import { AnalyticsRecord } from "../src/core/Schemas"; import { GOLD_INDEX_STEAL, GOLD_INDEX_TRADE, + GOLD_INDEX_TRAIN_OTHER, + GOLD_INDEX_TRAIN_SELF, GOLD_INDEX_WAR, } from "../src/core/StatsSchemas"; @@ -55,7 +57,7 @@ describe("Ranking class", () => { stats: { units: { port: [2n, 0n, 0n, 2n] }, conquests: 5n, - gold: [0n, 100n, 20n, 0n], // total 120 + gold: [0n, 100n, 20n, 0n, 15n, 5n], // total 140 bombs: { abomb: [1n], hbomb: [1n], @@ -70,7 +72,7 @@ describe("Ranking class", () => { stats: { units: { city: [2n, 0n, 0n, 2n] }, conquests: 8n, - gold: [0n, 50n, 10n, 5n], // total 65 + gold: [0n, 50n, 10n, 5n], // total 65, no train trade bombs: { abomb: [0n], hbomb: [2n], @@ -86,7 +88,7 @@ describe("Ranking class", () => { // no units, but has conquests/killedAt to count as played conquests: 8n, killedAt: BigInt(600), - gold: [0n, 10n, 2n, 10n], // total 22 + gold: [0n, 10n, 2n, 10n, 0n, 5n], // total 27 bombs: {}, }, persistentID: null, @@ -178,9 +180,14 @@ describe("Ranking class", () => { expect(r.score(p1, RankType.StolenGold)).toBe( Number(p1.gold[GOLD_INDEX_STEAL] ?? 0n), ); - expect(r.score(p1, RankType.TradedGold)).toBe( + expect(r.score(p1, RankType.NavalTrade)).toBe( Number(p1.gold[GOLD_INDEX_TRADE] ?? 0n), ); + const ownTrain = p1.gold[GOLD_INDEX_TRAIN_SELF] ?? 0n; + const otherTrain = p1.gold[GOLD_INDEX_TRAIN_OTHER] ?? 0n; + expect(r.score(p1, RankType.TrainTrade)).toBe( + Number(ownTrain + otherTrain), + ); expect(r.score(p1, RankType.ConqueredGold)).toBe( Number(p1.gold[GOLD_INDEX_WAR] ?? 0n), ); From cfa40f2e5eda26e2009531ceea130d72ed5c6f62 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 15 Jan 2026 20:57:46 +0000 Subject: [PATCH 011/109] mergestats (#2904) If this PR fixes an issue, link it below. If not, delete these two lines. Resolves #2704 ## Description: Merges together easy + medium difficulties. Before: image After: (dont have one to show oop) (btw that win ratio in the first screenshot is not mine.. :skull:) ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- .../baseComponents/stats/PlayerStatsTree.ts | 210 +++++++++++++----- 1 file changed, 158 insertions(+), 52 deletions(-) diff --git a/src/client/components/baseComponents/stats/PlayerStatsTree.ts b/src/client/components/baseComponents/stats/PlayerStatsTree.ts index 4ec48f70a..e703ee2a2 100644 --- a/src/client/components/baseComponents/stats/PlayerStatsTree.ts +++ b/src/client/components/baseComponents/stats/PlayerStatsTree.ts @@ -1,4 +1,4 @@ -import { LitElement, html } from "lit"; +import { LitElement, PropertyValues, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { PlayerStatsLeaf, PlayerStatsTree } from "../../../../core/ApiSchemas"; import { @@ -21,22 +21,31 @@ export class PlayerStatsTreeView extends LitElement { @state() selectedMode: GameMode = GameMode.FFA; @state() selectedDifficulty: Difficulty = Difficulty.Medium; + private get typeNode() { + return this.statsTree?.[this.selectedType]; + } + + private get modeNode() { + return this.typeNode?.[this.selectedMode]; + } + + private get shouldMergeDifficulties() { + return this.selectedType === GameType.Public; + } + private get availableTypes(): GameType[] { if (!this.statsTree) return []; return Object.keys(this.statsTree).filter(isGameType); } private get availableModes(): GameMode[] { - const typeNode = this.statsTree?.[this.selectedType]; - if (!typeNode) return []; - return Object.keys(typeNode).filter(isGameMode); + if (!this.typeNode) return []; + return Object.keys(this.typeNode).filter(isGameMode); } private get availableDifficulties(): Difficulty[] { - const typeNode = this.statsTree?.[this.selectedType]; - const modeNode = typeNode?.[this.selectedMode]; - if (!modeNode) return []; - return Object.keys(modeNode).filter(isDifficulty); + if (!this.modeNode) return []; + return Object.keys(this.modeNode).filter(isDifficulty); } private labelForMode(m: GameMode) { @@ -50,52 +59,37 @@ export class PlayerStatsTreeView extends LitElement { } private getSelectedLeaf(): PlayerStatsLeaf | null { - const typeNode = this.statsTree?.[this.selectedType]; - if (!typeNode) return null; - const modeNode = typeNode[this.selectedMode]; + const modeNode = this.modeNode; if (!modeNode) return null; - const diffNode = modeNode[this.selectedDifficulty]; - if (!diffNode) return null; - return diffNode; - } - private getDisplayedStats(): PlayerStats | null { - const leaf = this.getSelectedLeaf(); - if (!leaf || !leaf.stats) return null; - return leaf.stats; - } - - private setGameType(t: GameType) { - if (this.selectedType === t) return; - this.selectedType = t; - const modes = this.availableModes; - if (!modes.includes(this.selectedMode)) { - this.selectedMode = modes[0] ?? this.selectedMode; + if (!this.shouldMergeDifficulties) { + return modeNode[this.selectedDifficulty] ?? null; } - const diffs = this.availableDifficulties; - if (!diffs.includes(this.selectedDifficulty)) { - this.selectedDifficulty = diffs[0] ?? this.selectedDifficulty; - } - this.requestUpdate(); + + const diffKeys = Object.keys(modeNode).filter(isDifficulty); + if (!diffKeys.length) return null; + + return diffKeys.reduce((merged, diffKey) => { + const leaf = modeNode[diffKey]; + if (!leaf) return merged; + if (!merged) { + return { + wins: leaf.wins, + losses: leaf.losses, + total: leaf.total, + stats: this.cloneStats(leaf.stats), + }; + } + return { + wins: merged.wins + leaf.wins, + losses: merged.losses + leaf.losses, + total: merged.total + leaf.total, + stats: this.mergeStats(merged.stats, leaf.stats), + }; + }, null); } - private setMode(m: GameMode) { - if (this.selectedMode === m) return; - this.selectedMode = m; - const diffs = this.availableDifficulties; - if (!diffs.includes(this.selectedDifficulty)) { - this.selectedDifficulty = diffs[0] ?? this.selectedDifficulty; - } - this.requestUpdate(); - } - - private setDifficulty(d: Difficulty) { - if (this.selectedDifficulty === d) return; - this.selectedDifficulty = d; - this.requestUpdate(); - } - - render() { + private syncSelection(): void { const types = this.availableTypes; if (types.length && !types.includes(this.selectedType)) { this.selectedType = types[0]; @@ -105,10 +99,122 @@ export class PlayerStatsTreeView extends LitElement { this.selectedMode = modes[0]; } const diffs = this.availableDifficulties; - if (diffs.length && !diffs.includes(this.selectedDifficulty)) { + if ( + !this.shouldMergeDifficulties && + diffs.length && + !diffs.includes(this.selectedDifficulty) + ) { this.selectedDifficulty = diffs[0]; } + } + protected willUpdate(changedProperties: PropertyValues) { + if ( + changedProperties.has("statsTree") || + changedProperties.has("selectedType") || + changedProperties.has("selectedMode") || + changedProperties.has("selectedDifficulty") + ) { + this.syncSelection(); + } + } + + private setGameType(t: GameType) { + if (this.selectedType === t) return; + this.selectedType = t; + this.requestUpdate(); + } + + private setMode(m: GameMode) { + if (this.selectedMode === m) return; + this.selectedMode = m; + this.requestUpdate(); + } + + private setDifficulty(d: Difficulty) { + if (this.selectedDifficulty === d) return; + this.selectedDifficulty = d; + this.requestUpdate(); + } + + private mergeStats( + base: PlayerStats | undefined, + next: PlayerStats | undefined, + ): PlayerStats | undefined { + if (!base && !next) return undefined; + if (!base) return this.cloneStats(next); + if (!next) return this.cloneStats(base); + + return { + attacks: this.mergeStatArrays(base.attacks, next.attacks), + betrayals: this.mergeStatValue(base.betrayals, next.betrayals), + killedAt: this.mergeStatValue(base.killedAt, next.killedAt), + conquests: this.mergeStatValue(base.conquests, next.conquests), + boats: this.mergeStatRecord(base.boats, next.boats), + bombs: this.mergeStatRecord(base.bombs, next.bombs), + gold: this.mergeStatArrays(base.gold, next.gold), + units: this.mergeStatRecord(base.units, next.units), + }; + } + + private mergeStatValue( + base: bigint | undefined, + next: bigint | undefined, + ): bigint | undefined { + if (base === undefined && next === undefined) return undefined; + return (base ?? 0n) + (next ?? 0n); + } + + private mergeStatArrays( + base: bigint[] | undefined, + next: bigint[] | undefined, + ): bigint[] | undefined { + if (!base && !next) return undefined; + const maxLen = Math.max(base?.length ?? 0, next?.length ?? 0); + const merged: bigint[] = []; + for (let i = 0; i < maxLen; i += 1) { + merged[i] = (base?.[i] ?? 0n) + (next?.[i] ?? 0n); + } + return merged; + } + + private mergeStatRecord( + base: Partial> | undefined, + next: Partial> | undefined, + ): Partial> | undefined { + if (!base && !next) return undefined; + const merged: Partial> = {}; + const keys = new Set([ + ...Object.keys(base ?? {}), + ...Object.keys(next ?? {}), + ]) as Set; + keys.forEach((key) => { + const mergedArray = this.mergeStatArrays(base?.[key], next?.[key]); + if (mergedArray) { + merged[key] = mergedArray; + } + }); + return Object.keys(merged).length ? merged : undefined; + } + + private cloneStats(stats: PlayerStats | undefined): PlayerStats | undefined { + if (!stats) return undefined; + return { + attacks: stats.attacks ? [...stats.attacks] : undefined, + betrayals: stats.betrayals, + killedAt: stats.killedAt, + conquests: stats.conquests, + boats: stats.boats ? { ...stats.boats } : undefined, + bombs: stats.bombs ? { ...stats.bombs } : undefined, + gold: stats.gold ? [...stats.gold] : undefined, + units: stats.units ? { ...stats.units } : undefined, + }; + } + + render() { + const types = this.availableTypes; + const modes = this.availableModes; + const diffs = this.availableDifficulties; const leaf = this.getSelectedLeaf(); const wlr = leaf ? leaf.losses === 0n @@ -167,7 +273,7 @@ export class PlayerStatsTreeView extends LitElement { : html``} - ${diffs.length + ${!this.shouldMergeDifficulties && diffs.length ? html`
@@ -209,7 +315,7 @@ export class PlayerStatsTreeView extends LitElement {
From 01e682b5765b1a4dbb0d73e366238050e190a091 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 15 Jan 2026 21:15:38 +0000 Subject: [PATCH 012/109] Revert "Amended renderNumber to display numbers in the billions range" (#2913) Reverts openfrontio/OpenFrontIO#2911 ## Description: Reverts confusing "billion" ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- src/client/Utils.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/client/Utils.ts b/src/client/Utils.ts index ab56464f1..2f4e0dafd 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -42,13 +42,7 @@ export function renderNumber( num = Number(num); num = Math.max(num, 0); - if (num >= 10_000_000_000) { - const value = Math.floor(num / 100000000) / 10; - return value.toFixed(fixedPoints ?? 1) + "B"; - } else if (num >= 1_000_000_000) { - const value = Math.floor(num / 10000000) / 100; - return value.toFixed(fixedPoints ?? 2) + "B"; - } else if (num >= 10_000_000) { + if (num >= 10_000_000) { const value = Math.floor(num / 100000) / 10; return value.toFixed(fixedPoints ?? 1) + "M"; } else if (num >= 1_000_000) { From 9cd87f8906f3e633111ed98786ee2787e2c4b865 Mon Sep 17 00:00:00 2001 From: Aaron Tidwell Date: Thu, 15 Jan 2026 17:40:45 -0500 Subject: [PATCH 013/109] Map generator -verbose and -performance flags (#2721) Resolves #2718 ## Description: Adds go-style error log levels, with an additional ALL log level. - WARN/ERROR - Only success output - INFO - Existing output - DEBUG - New output - ALL - New output (includes the logs from when removal/performance is enabled) In addition - Add `-verbose` (`-v`), `-log-level`, `-log-removal`, and `-log-performance` flags to map generator - No changes to default behavior of `go run .` without the new flags - excludes test maps from performance warnings (test maps already skip the removal steps) - updates readme with the different flags and how they impact the logger Default run (matches existing) `go run . >> output.txt 2>&1` [output.txt](https://github.com/user-attachments/files/24365745/output.txt) Default run w/ `-verbose` (log level DEBUG) `go run . -v >> output.txt 2>&1` [output.txt](https://github.com/user-attachments/files/24365812/output.txt) Default run w/ `-log-performance` `go run . -log-performance >> output.txt 2>&1` [output.txt](https://github.com/user-attachments/files/24365971/output.txt) Run of just africa w/ all new logging enabled `go run . -maps=africa -log-level=all >> output.txt 2>&1` [output.txt](https://github.com/user-attachments/files/24365724/output.txt) ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: tidwell --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- map-generator/README.md | 21 ++++ map-generator/logger.go | 213 +++++++++++++++++++++++++++++++++ map-generator/main.go | 36 +++++- map-generator/map_generator.go | 96 +++++++++------ 4 files changed, 326 insertions(+), 40 deletions(-) create mode 100644 map-generator/logger.go diff --git a/map-generator/README.md b/map-generator/README.md index 3d9a83eb1..7fa443205 100644 --- a/map-generator/README.md +++ b/map-generator/README.md @@ -41,6 +41,27 @@ To process a subset of maps, pass a comma-separated list: - `../resources/maps//map16x.bin` - 1/16 scale (quarter dimensions) binary map data used for mini-maps. - `../resources/maps//thumbnail.webp` - WebP image thumbnail of the map. +## Command Line Flags + +- `--maps`: Optional comma-separated list of maps to process. + - ex: `go run . --maps=world,eastasia,big_plains` + +### Logging + +- `--log-level`: Explicitly sets the log level. + - ex: `go run . --log-level=debug` + - values: `ALL`, `DEBUG`, `INFO` (default), `WARN`, `ERROR`. +- `--verbose` or `-v`: Adds additional logging and prefixes logs with the `[mapname]`. Alias of `--log-level=DEBUG`. +- `--debug-performance`: Adds additional logging for performance-based recommendations, sets `--log-level=DEBUG`. +- `--debug-removal`: Adds additional logging of removed island and lake position/size, sets `--log-level=DEBUG`. + +The Generator outputs logs using `slog` with standard log-levels, and an additional ALL level. + +The `--verbose`, `-v`, `--debug-performance`, and `--debug-removal` flags all set the log level to `DEBUG`. +`debug-performance` and `debug-removal` are opt-in on top of the debug log level, as they can produce wordy output. You must pass the specific flag to see the corresponding logs if the `log-level` is set to `DEBUG`. + +Setting `--log-level=ALL` will output all possible logs, including all `DEBUG` tiers, regardless of whether the specific flags are passed. + ## Create image.png The map-generator will process your input file at `assets/maps//image.png` to generate the map diff --git a/map-generator/logger.go b/map-generator/logger.go new file mode 100644 index 000000000..204158b11 --- /dev/null +++ b/map-generator/logger.go @@ -0,0 +1,213 @@ +// This is the custom logger providing the multi-level and flag-based logging for +// the map-generator. It uses slog. +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "log/slog" + "strings" + "sync" +) + +type LogFlags struct { + logLevel string // The log-level (most -> least wordy): ALL, DEBUG, INFO (default), WARN, ERROR + verbose bool // sets log-level=DEBUG + performance bool // opts-in to performance checks and sets log-level=DEBUG + removal bool // opts-in to island/lake removal logging and sets log-level=DEBUG +} + +// LevelAll is a custom log Level that outputs all messages, regardless of other passed flags +const LevelAll = slog.Level(-8) + +// PerformanceLogTag is a slog attribute used to tag performance-related log messages. +var PerformanceLogTag = slog.String("tag", "performance") + +// RemovalLogTag is a slog attribute used to tag land/water removal-related log messages. +var RemovalLogTag = slog.String("tag", "removal") + +// DetermineLogLevel determines the log level based on the LogFlags +// It prioritizes the log level flag over the default, and switches to debug if performance or removal flags are set. +func DetermineLogLevel( + logFlags LogFlags) slog.Level { + + var level = slog.LevelInfo + if logFlags.verbose { + level = slog.LevelDebug + } + + // switch to debug if any of the optional flags is enabled + if logFlags.performance || logFlags.removal { + level = slog.LevelDebug + } + + // parse the log-level input string to the slog.Level type + if logFlags.logLevel != "" { + switch strings.ToLower(logFlags.logLevel) { + case "all": + level = LevelAll + case "debug": + level = slog.LevelDebug + case "info": + level = slog.LevelInfo + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + fmt.Printf("invalid log level: %s, defaulting to info\n", logFlags.logLevel) + level = slog.LevelInfo + } + } + return level +} + +// GeneratorLogger is a custom slog.Handler that outputs logs based on log level and additional LogFlags. +type GeneratorLogger struct { + opts slog.HandlerOptions + w io.Writer + mu *sync.Mutex + attrs []slog.Attr + prefix string + flags LogFlags +} + +// NewGeneratorLogger creates a new GeneratorLogger. +// It initializes a handler with specific output, options, and flags +func NewGeneratorLogger( + out io.Writer, + opts *slog.HandlerOptions, + flags LogFlags) *GeneratorLogger { + + h := &GeneratorLogger{ + w: out, + mu: &sync.Mutex{}, + flags: flags, + } + if opts != nil { + h.opts = *opts + } + if h.opts.Level == nil { + h.opts.Level = slog.LevelInfo + } + return h +} + +// Enabled checks if a given log level is enabled for this handler. +func (h *GeneratorLogger) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.opts.Level.Level() +} + +// Handle processes a log record. +// It decides whether to output each record based on log level, flags, and if the map is a test map +// On output, it formats the log message with any extra formatting +func (h *GeneratorLogger) Handle(_ context.Context, r slog.Record) error { + isPerformanceLog := false + isRemovalLog := false + isTestMap := false + + var mapName string + + findAttrs := func(a slog.Attr) { + if a.Equal(PerformanceLogTag) { + isPerformanceLog = true + } + if a.Equal(RemovalLogTag) { + isRemovalLog = true + } + if a.Key == "map" { + mapName = a.Value.String() + } + if a.Key == "isTest" { + isTestMap = a.Value.Bool() + } + } + + // Check record attributes for performance tag and map name + r.Attrs(func(a slog.Attr) bool { + findAttrs(a) + return true + }) + + // Check handler's own attributes for performance tag and map name + for _, a := range h.attrs { + findAttrs(a) + } + + // Don't log messages if the flags are not set + // If the log level is set to LevelAll, disregard + if h.opts.Level != LevelAll && isPerformanceLog && !h.flags.performance { + return nil + } + if h.opts.Level != LevelAll && (isRemovalLog && !h.flags.removal) { + return nil + } + + // dont log performance messages for test maps + if isPerformanceLog && isTestMap { + return nil + } + + buf := &bytes.Buffer{} + + // Add map name as a prefix in log Level DEBUG and ALL + if (h.opts.Level == slog.LevelDebug || h.opts.Level == LevelAll) && mapName != "" { + mapName = strings.Trim(mapName, `"`) + fmt.Fprintf(buf, "[%s] ", mapName) + } + + // Add prefix for performance messages + if isPerformanceLog { + fmt.Fprintf(buf, "[PERF] ") + } + + if h.prefix != "" { + fmt.Fprintf(buf, "%s ", h.prefix) + } + + fmt.Fprintln(buf, r.Message) + + h.mu.Lock() + defer h.mu.Unlock() + _, err := h.w.Write(buf.Bytes()) + return err +} + +// WithAttrs returns a new handler with the given attributes added. +func (h *GeneratorLogger) WithAttrs(attrs []slog.Attr) slog.Handler { + newHandler := *h + newHandler.attrs = append(newHandler.attrs, attrs...) + return &newHandler +} + +// WithGroup returns a new handler with the given group name. +// The group name is added as a prefix to subsequent log messages. +func (h *GeneratorLogger) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + newHandler := *h + if newHandler.prefix != "" { + newHandler.prefix += "." + } + newHandler.prefix += name + return &newHandler +} + +type loggerKey struct{} + +// LoggerFromContext retrieves the logger from the context. +// If no logger is found, it returns the default logger. +func LoggerFromContext(ctx context.Context) *slog.Logger { + if logger, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok { + return logger + } + return slog.Default() +} + +// ContextWithLogger returns a new context with the provided logger. +func ContextWithLogger(ctx context.Context, logger *slog.Logger) context.Context { + return context.WithValue(ctx, loggerKey{}, logger) +} diff --git a/map-generator/main.go b/map-generator/main.go index b05070bfd..b6da02593 100644 --- a/map-generator/main.go +++ b/map-generator/main.go @@ -1,19 +1,18 @@ package main import ( + "context" "encoding/json" "flag" "fmt" "log" + "log/slog" "os" "path/filepath" "strings" "sync" ) -// mapsFlag holds the comma-separated list of map names passed via the --maps command-line argument. -var mapsFlag string - // maps defines the registry of available maps to be processed. // Each entry contains the folder name and a flag indicating if it's a test map. // @@ -75,6 +74,12 @@ var maps = []struct { {Name: "world", IsTest: true}, } +// mapsFlag holds the comma-separated list of map names passed via the --maps command-line argument. +var mapsFlag string + +// logFlags holds all the flags related to configuring the map-generator logging +var logFlags LogFlags + // outputMapDir returns the absolute path to the directory where generated map files should be written. // It distinguishes between test and production output locations. func outputMapDir(isTest bool) (string, error) { @@ -104,7 +109,7 @@ func inputMapDir(isTest bool) (string, error) { // processMap handles the end-to-end generation for a single map. // It reads the source image and JSON, generates the terrain data, and writes the binary outputs and updated manifest. -func processMap(name string, isTest bool) error { +func processMap(ctx context.Context, name string, isTest bool) error { outputMapBaseDir, err := outputMapDir(isTest) if err != nil { return fmt.Errorf("failed to get map directory: %w", err) @@ -135,7 +140,7 @@ func processMap(name string, isTest bool) error { } // Generate maps - result, err := GenerateMap(GeneratorArgs{ + result, err := GenerateMap(ctx, GeneratorArgs{ ImageBuffer: imageBuffer, RemoveSmall: !isTest, // Don't remove small islands for test maps Name: name, @@ -230,7 +235,11 @@ func loadTerrainMaps() error { mapItem := mapItem go func() { defer wg.Done() - if err := processMap(mapItem.Name, mapItem.IsTest); err != nil { + mapLogTag := slog.String("map", mapItem.Name) + testLogTag := slog.Bool("isTest", mapItem.IsTest) + logger := slog.Default().With(mapLogTag).With(testLogTag) + ctx := ContextWithLogger(context.Background(), logger) + if err := processMap(ctx, mapItem.Name, mapItem.IsTest); err != nil { errChan <- err } }() @@ -254,8 +263,23 @@ func loadTerrainMaps() error { // It parses flags and triggers the map generation process. func main() { flag.StringVar(&mapsFlag, "maps", "", "optional comma-separated list of maps to process. ex: --maps=world,eastasia,big_plains") + flag.StringVar(&logFlags.logLevel, "log-level", "", "Explicitly sets the log level to one of: ALL, DEBUG, INFO (default), WARN, ERROR.") + flag.BoolVar(&logFlags.verbose, "verbose", false, "Adds additional logging and prefixes logs with the [mapname]. Alias of log-level=DEBUG.") + flag.BoolVar(&logFlags.verbose, "v", false, "-verbose shorthand") + flag.BoolVar(&logFlags.performance, "log-performance", false, "Adds additional logging for performance-based recommendations, sets log-level=DEBUG") + flag.BoolVar(&logFlags.removal, "log-removal", false, "Adds additional logging of removed island and lake position/size, sets log-level=DEBUG") flag.Parse() + logger := slog.New(NewGeneratorLogger( + os.Stdout, + &slog.HandlerOptions{ + Level: DetermineLogLevel(logFlags), + }, + logFlags, + )) + + slog.SetDefault(logger) + if err := loadTerrainMaps(); err != nil { log.Fatalf("Error generating terrain maps: %v", err) } diff --git a/map-generator/map_generator.go b/map-generator/map_generator.go index f27ea0fda..1c0dffdd3 100644 --- a/map-generator/map_generator.go +++ b/map-generator/map_generator.go @@ -2,20 +2,25 @@ package main import ( "bytes" + "context" "fmt" "image" "image/color" "image/png" - "log" "math" "github.com/chai2010/webp" ) -// The smallest a body of land or lake can be, all smaller are removed const ( + // The smallest a body of land or lake can be, all smaller are removed minIslandSize = 30 minLakeSize = 200 + // the recommended max area pixel size for input images + minRecommendedPixelSize = 2000000 + maxRecommendedPixelSize = 3000000 + // the recommended max number of land tiles in the output bin at full size + maxRecommendedLandTileCount = 3000000 ) // Holds raw RGBA image data for the thumbnail @@ -94,7 +99,8 @@ type GeneratorArgs struct { // // Misc Notes // - It normalizes map width/height to multiples of 4 for the mini map downscaling. -func GenerateMap(args GeneratorArgs) (MapResult, error) { +func GenerateMap(ctx context.Context, args GeneratorArgs) (MapResult, error) { + logger := LoggerFromContext(ctx) img, err := png.Decode(bytes.NewReader(args.ImageBuffer)) if err != nil { return MapResult{}, fmt.Errorf("failed to decode PNG: %w", err) @@ -107,7 +113,12 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) { width = width - (width % 4) height = height - (height % 4) - log.Printf("Processing Map: %s, dimensions: %dx%d", args.Name, width, height) + logger.Info(fmt.Sprintf("Processing Map: %s, dimensions: %dx%d", args.Name, width, height)) + + area := width * height + if area < minRecommendedPixelSize || area > maxRecommendedPixelSize { + logger.Debug(fmt.Sprintf("Map area %d pixels is outside recommended range (%d - %d)", area, minRecommendedPixelSize, maxRecommendedPixelSize), PerformanceLogTag) + } // Initialize terrain grid terrain := make([][]Terrain, width) @@ -137,16 +148,16 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) { } } - removeSmallIslands(terrain, args.RemoveSmall) - processWater(terrain, args.RemoveSmall) + removeSmallIslands(ctx, terrain, args.RemoveSmall) + processWater(ctx, terrain, args.RemoveSmall) terrain4x := createMiniMap(terrain) - processWater(terrain4x, false) + processWater(ctx, terrain4x, false) terrain16x := createMiniMap(terrain4x) - processWater(terrain16x, false) + processWater(ctx, terrain16x, false) - thumb := createMapThumbnail(terrain4x, 0.5) + thumb := createMapThumbnail(ctx, terrain4x, 0.5) webp, err := convertToWebP(ThumbData{ Data: thumb.Pix, Width: thumb.Bounds().Dx(), @@ -156,9 +167,20 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) { return MapResult{}, fmt.Errorf("failed to save thumbnail: %w", err) } - mapData, mapNumLandTiles := packTerrain(terrain) - mapData4x, numLandTiles4x := packTerrain(terrain4x) - mapData16x, numLandTiles16x := packTerrain(terrain16x) + mapData, mapNumLandTiles := packTerrain(ctx, terrain) + mapData4x, numLandTiles4x := packTerrain(ctx, terrain4x) + mapData16x, numLandTiles16x := packTerrain(ctx, terrain16x) + + logger.Debug(fmt.Sprintf("Land Tile Count (1x): %d", mapNumLandTiles)) + logger.Debug(fmt.Sprintf("Land Tile Count (4x): %d", numLandTiles4x)) + logger.Debug(fmt.Sprintf("Land Tile Count (16x): %d", numLandTiles16x)) + + if mapNumLandTiles == 0 { + return MapResult{}, fmt.Errorf("Map has 0 land tiles") + } + if mapNumLandTiles > maxRecommendedLandTileCount { + logger.Debug(fmt.Sprintf("Map has more land tiles (%d) than recommended maximum (%d)", mapNumLandTiles, maxRecommendedLandTileCount), PerformanceLogTag) + } return MapResult{ Map: MapInfo{ @@ -242,8 +264,9 @@ func createMiniMap(tm [][]Terrain) [][]Terrain { // It marks Land tiles as shoreline if they neighbor Water, and Water tiles as // shoreline if they neighbor Land. // Returns a list of coordinates for all shoreline Water tiles found. -func processShore(terrain [][]Terrain) []Coord { - log.Println("Identifying shorelines") +func processShore(ctx context.Context, terrain [][]Terrain) []Coord { + logger := LoggerFromContext(ctx) + logger.Info("Identifying shorelines") var shorelineWaters []Coord width := len(terrain) height := len(terrain[0]) @@ -280,8 +303,9 @@ func processShore(terrain [][]Terrain) []Coord { // processDistToLand calculates the distance of water tiles from the nearest land. // It uses a Breadth-First Search (BFS) starting from the shoreline water tiles. // The distance is stored in the Magnitude field of the Water tiles. -func processDistToLand(shorelineWaters []Coord, terrain [][]Terrain) { - log.Println("Setting Water tiles magnitude = Manhattan distance from nearest land") +func processDistToLand(ctx context.Context, shorelineWaters []Coord, terrain [][]Terrain) { + logger := LoggerFromContext(ctx) + logger.Info("Setting Water tiles magnitude = Manhattan distance from nearest land") width := len(terrain) height := len(terrain[0]) @@ -362,8 +386,9 @@ func getNeighborCoords(x, y int, terrain [][]Terrain) []Coord { // It finds all connected water bodies and marks the largest one as Ocean. // If removeSmall is true, lakes smaller than minLakeSize are converted to Land. // Finally, it triggers shoreline identification and distance-to-land calculations. -func processWater(terrain [][]Terrain, removeSmall bool) { - log.Println("Processing water bodies") +func processWater(ctx context.Context, terrain [][]Terrain, removeSmall bool) { + logger := LoggerFromContext(ctx) + logger.Info("Processing water bodies") visited := make(map[string]bool) type waterBody struct { @@ -408,13 +433,14 @@ func processWater(terrain [][]Terrain, removeSmall bool) { for _, coord := range largestWaterBody.coords { terrain[coord.X][coord.Y].Ocean = true } - log.Printf("Identified ocean with %d water tiles", largestWaterBody.size) + logger.Info(fmt.Sprintf("Identified ocean with %d water tiles", largestWaterBody.size)) if removeSmall { // Remove small water bodies - log.Println("Searching for small water bodies for removal") + logger.Info("Searching for small water bodies for removal") for w := 1; w < len(waterBodies); w++ { if waterBodies[w].size < minLakeSize { + logger.Debug(fmt.Sprintf("Removing small lake at %d,%d (size %d)", waterBodies[w].coords[0].X, waterBodies[w].coords[0].Y, waterBodies[w].size), RemovalLogTag) smallLakes++ for _, coord := range waterBodies[w].coords { terrain[coord.X][coord.Y].Type = Land @@ -422,15 +448,14 @@ func processWater(terrain [][]Terrain, removeSmall bool) { } } } - log.Printf("Identified and removed %d bodies of water smaller than %d tiles", - smallLakes, minLakeSize) + logger.Info(fmt.Sprintf("Identified and removed %d bodies of water smaller than %d tiles", smallLakes, minLakeSize)) } // Process shorelines and distances - shorelineWaters := processShore(terrain) - processDistToLand(shorelineWaters, terrain) + shorelineWaters := processShore(ctx, terrain) + processDistToLand(ctx, shorelineWaters, terrain) } else { - log.Println("No water bodies found in the map") + logger.Info("No water bodies found in the map") } } @@ -465,7 +490,8 @@ func getArea(x, y int, terrain [][]Terrain, visited map[string]bool) []Coord { // removeSmallIslands identifies and removes small land masses from the terrain. // If removeSmall is true, any removed bodies are converted to Water. -func removeSmallIslands(terrain [][]Terrain, removeSmall bool) { +func removeSmallIslands(ctx context.Context, terrain [][]Terrain, removeSmall bool) { + logger := LoggerFromContext(ctx) if !removeSmall { return } @@ -501,6 +527,7 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) { for _, body := range landBodies { if body.size < minIslandSize { + logger.Debug(fmt.Sprintf("Removing small island at %d,%d (size %d)", body.coords[0].X, body.coords[0].Y, body.size), RemovalLogTag) smallIslands++ for _, coord := range body.coords { terrain[coord.X][coord.Y].Type = Water @@ -509,8 +536,7 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) { } } - log.Printf("Identified and removed %d islands smaller than %d tiles", - smallIslands, minIslandSize) + logger.Info(fmt.Sprintf("Identified and removed %d islands smaller than %d tiles", smallIslands, minIslandSize)) } // packTerrain serializes the terrain grid into a byte slice. @@ -521,7 +547,7 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) { // - Bits 0-4: Magnitude (0-31). For Water, this is (Distance / 2). // // Returns the packed data and the count of land tiles. -func packTerrain(terrain [][]Terrain) (data []byte, numLandTiles int) { +func packTerrain(ctx context.Context, terrain [][]Terrain) (data []byte, numLandTiles int) { width := len(terrain) height := len(terrain[0]) packedData := make([]byte, width*height) @@ -553,15 +579,16 @@ func packTerrain(terrain [][]Terrain) (data []byte, numLandTiles int) { } } - logBinaryAsBits(packedData, 8) + logBinaryAsBits(ctx, packedData, 8) return packedData, numLandTiles } // createMapThumbnail generates an RGBA image representation of the terrain. // It scales the map dimensions based on the provided quality factor. // Each pixel's color is determined by the terrain type and magnitude via getThumbnailColor. -func createMapThumbnail(terrain [][]Terrain, quality float64) *image.RGBA { - log.Println("Creating thumbnail") +func createMapThumbnail(ctx context.Context, terrain [][]Terrain, quality float64) *image.RGBA { + logger := LoggerFromContext(ctx) + logger.Info("Creating thumbnail") srcWidth := len(terrain) srcHeight := len(terrain[0]) @@ -664,7 +691,8 @@ func getThumbnailColor(t Terrain) RGBA { // logBinaryAsBits logs the binary representation of the first 'length' bytes of data. // It is a helper function for debugging packed terrain data. -func logBinaryAsBits(data []byte, length int) { +func logBinaryAsBits(ctx context.Context, data []byte, length int) { + logger := LoggerFromContext(ctx) if length > len(data) { length = len(data) } @@ -673,7 +701,7 @@ func logBinaryAsBits(data []byte, length int) { for i := 0; i < length; i++ { bits += fmt.Sprintf("%08b ", data[i]) } - log.Printf("Binary data (bits): %s", bits) + logger.Info(fmt.Sprintf("Binary data (bits): %s", bits)) } // createCombinedBinary combines the info JSON, map data, and mini-map data into a single binary buffer. From dbb5eb5993b31f18139e6574262fde87947b7005 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 15 Jan 2026 16:05:00 -0800 Subject: [PATCH 014/109] use GIT_COMMIT instead of version for manifest.json cache busting to prevent users from pulling stale manifest if the version is not updated --- src/client/TerrainMapFileLoader.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/client/TerrainMapFileLoader.ts b/src/client/TerrainMapFileLoader.ts index 771698902..c3b185ac9 100644 --- a/src/client/TerrainMapFileLoader.ts +++ b/src/client/TerrainMapFileLoader.ts @@ -1,4 +1,6 @@ -import version from "resources/version.txt?raw"; import { FetchGameMapLoader } from "../core/game/FetchGameMapLoader"; -export const terrainMapFileLoader = new FetchGameMapLoader(`/maps`, version); +export const terrainMapFileLoader = new FetchGameMapLoader( + `/maps`, + window.GIT_COMMIT, +); From e1d4b9a00e0ef09315b3138463de3771b5e4c377 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 15 Jan 2026 16:16:33 -0800 Subject: [PATCH 015/109] allow name to be placed on shore tiles, this prevents rivers from bisecting player names causing them to be too small --- src/client/graphics/NameBoxCalculator.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/graphics/NameBoxCalculator.ts b/src/client/graphics/NameBoxCalculator.ts index 67f21916c..fa2165512 100644 --- a/src/client/graphics/NameBoxCalculator.ts +++ b/src/client/graphics/NameBoxCalculator.ts @@ -92,6 +92,7 @@ export function createGrid( const tile = game.ref(cell.x, cell.y); grid[x - scaledBoundingBox.min.x][y - scaledBoundingBox.min.y] = game.isLake(tile) || + game.isShore(tile) || game.owner(tile) === player || game.hasFallout(tile); } From b2ba37e0abbab5ba70d3c839320087a570976e1e Mon Sep 17 00:00:00 2001 From: Achim Marius <67611764+plazmaezio@users.noreply.github.com> Date: Fri, 16 Jan 2026 06:09:01 +0200 Subject: [PATCH 016/109] Destroy incoming nukes when alliance is created (#2716) Resolves #2484 ## Description: - When an alliance is created between two players, any incoming nukes between them are destroyed mid-air. This prevents the traitor debuff from being applied on impact, even if the nukes were launched before the alliance was formed. - If a player has launched nukes at multiple nations, only the nukes targeting the newly allied nation are destroyed. This is what the players will see after the alliance is created (in case they have launched nukes at each other): Screenshot 2026-01-04 092907 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: assessin. --- resources/lang/en.json | 2 + .../alliance/AllianceRequestReplyExecution.ts | 66 ++++++- tests/AllianceAcceptNukes.test.ts | 163 ++++++++++++++++++ 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 tests/AllianceAcceptNukes.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index c18d7be1a..da453ae7b 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -699,6 +699,8 @@ "alliance_request_status": "{name} {status} your alliance request", "alliance_accepted": "accepted", "alliance_rejected": "rejected", + "alliance_nukes_destroyed_outgoing": "{count, plural, one {# nuke launched toward {name} was destroyed due to the alliance} other {# nukes launched toward {name} were destroyed due to the alliance}}", + "alliance_nukes_destroyed_incoming": "{count, plural, one {# nuke launched by {name} was destroyed due to the alliance} other {# nukes launched by {name} were destroyed due to the alliance}}", "duration_second": "1 second", "betrayal_description": "You broke your alliance with {name}, making you a TRAITOR ({malusPercent}% defense debuff for {durationText})", "duration_seconds_plural": "{seconds} seconds", diff --git a/src/core/execution/alliance/AllianceRequestReplyExecution.ts b/src/core/execution/alliance/AllianceRequestReplyExecution.ts index b1a79fc44..35ec2ef69 100644 --- a/src/core/execution/alliance/AllianceRequestReplyExecution.ts +++ b/src/core/execution/alliance/AllianceRequestReplyExecution.ts @@ -1,4 +1,11 @@ -import { Execution, Game, Player, PlayerID } from "../../game/Game"; +import { + Execution, + Game, + MessageType, + Player, + PlayerID, + UnitType, +} from "../../game/Game"; export class AllianceRequestReplyExecution implements Execution { private active = true; @@ -10,6 +17,57 @@ export class AllianceRequestReplyExecution implements Execution { private accept: boolean, ) {} + private cancelNukesBetweenAlliedPlayers( + mg: Game, + p1: Player, + p2: Player, + ): void { + const neutralized = new Map(); + + const players = [p1, p2]; + + for (const launcher of players) { + for (const unit of launcher.units( + UnitType.AtomBomb, + UnitType.HydrogenBomb, + )) { + if (!unit.isActive() || unit.reachedTarget()) continue; + + const targetTile = unit.targetTile(); + if (!targetTile) continue; + + const targetOwner = mg.owner(targetTile); + if (!targetOwner.isPlayer()) continue; + + const other = launcher === p1 ? p2 : p1; + if (targetOwner !== other) continue; + + unit.delete(false); + neutralized.set(launcher, (neutralized.get(launcher) ?? 0) + 1); + } + } + + for (const [launcher, count] of neutralized) { + const other = launcher === p1 ? p2 : p1; + + mg.displayMessage( + "events_display.alliance_nukes_destroyed_outgoing", + MessageType.ALLIANCE_ACCEPTED, + launcher.id(), + undefined, + { name: other.displayName(), count }, + ); + + mg.displayMessage( + "events_display.alliance_nukes_destroyed_incoming", + MessageType.ALLIANCE_ACCEPTED, + other.id(), + undefined, + { name: launcher.displayName(), count }, + ); + } + } + init(mg: Game, ticks: number): void { if (!mg.hasPlayer(this.requestorID)) { console.warn( @@ -33,6 +91,12 @@ export class AllianceRequestReplyExecution implements Execution { request.accept(); this.requestor.updateRelation(this.recipient, 100); this.recipient.updateRelation(this.requestor, 100); + + this.cancelNukesBetweenAlliedPlayers( + mg, + this.requestor, + this.recipient, + ); } else { request.reject(); } diff --git a/tests/AllianceAcceptNukes.test.ts b/tests/AllianceAcceptNukes.test.ts new file mode 100644 index 000000000..d3da89172 --- /dev/null +++ b/tests/AllianceAcceptNukes.test.ts @@ -0,0 +1,163 @@ +import { AllianceRequestReplyExecution } from "src/core/execution/alliance/AllianceRequestReplyExecution"; +import { GameUpdateType } from "src/core/game/GameUpdates"; +import { NukeExecution } from "../src/core/execution/NukeExecution"; +import { + Game, + Player, + PlayerInfo, + PlayerType, + UnitType, +} from "../src/core/game/Game"; +import { setup } from "./util/Setup"; +import { TestConfig } from "./util/TestConfig"; + +let game: Game; +let player1: Player; +let player2: Player; +let player3: Player; + +describe("Alliance acceptance immediately destroys in-flight nukes", () => { + beforeEach(async () => { + game = await setup( + "plains", + { + infiniteGold: true, + instantBuild: true, + infiniteTroops: true, + }, + [ + new PlayerInfo("player1", PlayerType.Human, "c1", "p1"), + new PlayerInfo("player2", PlayerType.Human, "c2", "p2"), + new PlayerInfo("player3", PlayerType.Human, "c3", "p3"), + ], + ); + + (game.config() as TestConfig).nukeAllianceBreakThreshold = () => 0; + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + player1 = game.player("p1"); + player2 = game.player("p2"); + player3 = game.player("p3"); + + player1.conquer(game.ref(0, 0)); + player2.conquer(game.ref(5, 5)); + player3.conquer(game.ref(10, 10)); + + player1.buildUnit(UnitType.MissileSilo, game.ref(0, 0), {}); + }); + + test("accepting alliance destroys in-flight nukes between the newly allied players", () => { + game.addExecution( + new NukeExecution( + UnitType.AtomBomb, + player1, + game.ref(5, 5), + game.ref(0, 0), + -1, + 5, + ), + ); + + game.executeNextTick(); // init + game.executeNextTick(); // spawn nuke + + expect(game.units(UnitType.AtomBomb)).toHaveLength(1); + + expect(player2.isAlliedWith(player1)).toBe(false); + expect(player1.isFriendly(player2)).toBe(false); + + player2.createAllianceRequest(player1); + game.addExecution( + new AllianceRequestReplyExecution(player2.id(), player1, true), + ); + + game.executeNextTick(); + + expect(player2.isAlliedWith(player1)).toBe(true); + expect(player1.isFriendly(player2)).toBe(true); + + expect(game.units(UnitType.AtomBomb)).toHaveLength(0); + }); + + test("accepting alliance destroys only nukes between allied players", () => { + player1.buildUnit(UnitType.MissileSilo, game.ref(0, 0), {}); + + game.addExecution( + new NukeExecution(UnitType.AtomBomb, player1, game.ref(5, 5), null), + ); + game.addExecution( + new NukeExecution(UnitType.AtomBomb, player1, game.ref(10, 10), null), + ); + + game.executeNextTick(); // init + game.executeNextTick(); // spawn nukes + + expect(game.units(UnitType.AtomBomb)).toHaveLength(2); + + expect(player2.isAlliedWith(player1)).toBe(false); + expect(player1.isFriendly(player2)).toBe(false); + + player1.createAllianceRequest(player2); + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, true), + ); + + game.executeNextTick(); + + expect(player2.isAlliedWith(player1)).toBe(true); + expect(player1.isFriendly(player2)).toBe(true); + + expect(game.units(UnitType.AtomBomb)).toHaveLength(1); + + // Ensure remaining nuke targets player3 + const remainingNuke = game.units(UnitType.AtomBomb)[0]; + expect(remainingNuke.targetTile()).toBe(game.ref(10, 10)); + }); + + test("accepting alliance displays a nuke-cancellation display message", () => { + game.addExecution( + new NukeExecution( + UnitType.AtomBomb, + player1, + game.ref(5, 5), + game.ref(0, 0), + -1, + 5, + ), + ); + + game.executeNextTick(); // init + game.executeNextTick(); // spawn nuke + + expect(game.units(UnitType.AtomBomb)).toHaveLength(1); + + expect(player2.isAlliedWith(player1)).toBe(false); + expect(player1.isFriendly(player2)).toBe(false); + + player2.createAllianceRequest(player1); + game.addExecution( + new AllianceRequestReplyExecution(player2.id(), player1, true), + ); + + const updates = game.executeNextTick(); + + expect(player2.isAlliedWith(player1)).toBe(true); + expect(player1.isFriendly(player2)).toBe(true); + + expect(game.units(UnitType.AtomBomb)).toHaveLength(0); + + const messages = + updates[GameUpdateType.DisplayEvent]?.map((e) => e.message) ?? []; + + expect( + messages.some( + (m) => + m === "events_display.alliance_nukes_destroyed_outgoing" || + m === "events_display.alliance_nukes_destroyed_incoming", + ), + ).toBe(true); + }); +}); From d758e213513990b5c9a1470ed195533c6ed89adf Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Fri, 16 Jan 2026 19:14:38 +0100 Subject: [PATCH 017/109] Restyle game rank modal (#2918) ## Description: The game rank modal was still using the old style, which clashes strongly with the new one. This PR changes changes the modal style to be consistent with the new one: ### Old image ### New ![redesign](https://github.com/user-attachments/assets/ecf4f0ae-88f0-433c-90be-f41447e17afe) Tagged as `v29` to have a consistent style in the same version. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: IngloriousTom --- resources/lang/en.json | 2 + src/client/GameInfoModal.ts | 8 ++- src/client/components/baseComponents/Modal.ts | 4 +- .../baseComponents/ranking/PlayerRow.ts | 60 +++++++++---------- .../baseComponents/ranking/RankingControls.ts | 10 ++-- .../baseComponents/ranking/RankingHeader.ts | 14 +++-- 6 files changed, 54 insertions(+), 44 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index c18d7be1a..d3c3d4e62 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -224,6 +224,8 @@ "total_gold": "Total", "all_gold": "All gold", "trade": "Trade", + "train_trade": "Train", + "naval_trade": "Tradeship", "conquest_gold": "Conquered player gold", "stolen_gold": "Stolen with warships", "num_of_conquests": "Number of conquered players", diff --git a/src/client/GameInfoModal.ts b/src/client/GameInfoModal.ts index a1f50e4b7..917665a93 100644 --- a/src/client/GameInfoModal.ts +++ b/src/client/GameInfoModal.ts @@ -49,7 +49,9 @@ export class GameInfoModal extends LitElement { title="${translateText("game_info_modal.title")}" translationKey="main.game_info" > -
+
${this.isLoadingGame ? this.renderLoadingAnimation() @@ -108,7 +110,7 @@ export class GameInfoModal extends LitElement { const isUnusualThumbnailSize = hasUnusualThumbnailSize(info.config.gameMap); return html`
${this.mapImage ? html` 0 ? this.score(this.rankedPlayers[0]) : 0; return html` -
    +
      `} ${!this.hideHeader && this.title ? html`
      ${this.title}
      ` diff --git a/src/client/components/baseComponents/ranking/PlayerRow.ts b/src/client/components/baseComponents/ranking/PlayerRow.ts index 9989962a9..773188c8b 100644 --- a/src/client/components/baseComponents/ranking/PlayerRow.ts +++ b/src/client/components/baseComponents/ranking/PlayerRow.ts @@ -27,15 +27,13 @@ export class PlayerRow extends LitElement { const visibleBorder = player.winner || this.currentPlayer; return html`
    • ${Number(this.score).toFixed(0)}
      @@ -106,10 +104,10 @@ export class PlayerRow extends LitElement { const width = Math.min(Math.max((this.score / bestScore) * 100, 0), 100); return html`
      -
      +
      @@ -121,8 +119,8 @@ export class PlayerRow extends LitElement { return html`
      ${renderNumber(value)}
      @@ -157,13 +155,13 @@ export class PlayerRow extends LitElement { return html`
      ${this.renderMultiScoreType( - Number(navalTrade), - this.rankType === RankType.NavalTrade, + Number(ownTrainTrade + otherTrainTrade), + this.rankType === RankType.TrainTrade, )} / ${this.renderMultiScoreType( - Number(ownTrainTrade + otherTrainTrade), - this.rankType === RankType.TrainTrade, + Number(navalTrade), + this.rankType === RankType.NavalTrade, )}
      `; @@ -189,7 +187,7 @@ export class PlayerRow extends LitElement {
      ${renderNumber(this.score)}
      @@ -200,18 +198,20 @@ export class PlayerRow extends LitElement { private renderTradeScore() { return html` -
      - ${this.renderPlayerIcon()} -
      ${this.renderPlayerName()}
      -
      - -
      -
      - ${this.renderAllTrades()} +
      +
      + ${this.renderPlayerIcon()} +
      + ${this.renderPlayerName()} +
      +
      + +
      +
      + ${this.renderAllTrades()} +
      +
      -
      `; } @@ -221,7 +221,7 @@ export class PlayerRow extends LitElement {
      ${this.player.tag ? this.renderTag(this.player.tag) : ""}
      ${this.player.username}
      @@ -232,7 +232,7 @@ export class PlayerRow extends LitElement { private renderTag(tag: string) { return html`
      ${tag}
      diff --git a/src/client/components/baseComponents/ranking/RankingControls.ts b/src/client/components/baseComponents/ranking/RankingControls.ts index 25321d8aa..e32933efe 100644 --- a/src/client/components/baseComponents/ranking/RankingControls.ts +++ b/src/client/components/baseComponents/ranking/RankingControls.ts @@ -57,9 +57,9 @@ export class RankingControls extends LitElement { private renderButton(type: RankType, active: boolean, label: string) { return html` From fb910cbff5f33485719e8e931e4baa902d70aa74 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Fri, 16 Jan 2026 19:19:41 +0100 Subject: [PATCH 018/109] =?UTF-8?q?Lobby=20Gold=20Options=20(Starting=20Go?= =?UTF-8?q?ld,=20Gold=20Multiplier)=20=F0=9F=92=B0=20(#2915)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: We might want to add this to v29 to have a third possible public game modifier from the beginning on 😄 Would be fun - Add starting gold option (0 to 1_000_000_000 allowed, also applies to nations) - Add gold multiplier option (0.1 to 1000 allowed, also applies to nations and bots) - Add third public game modifier (3% chance of starting with 5M gold) - Why 5M? It's enough gold to massively change the game start but not enough to insta-hydro someone (launcher + hydro is 6M) image ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- resources/lang/en.json | 15 +- src/client/HostLobbyModal.ts | 204 +++++++++++++++++++++ src/client/PublicLobby.ts | 3 + src/client/SinglePlayerModal.ts | 224 ++++++++++++++++++++++++ src/core/Schemas.ts | 3 + src/core/configuration/Config.ts | 2 + src/core/configuration/DefaultConfig.ts | 32 +++- src/core/game/Game.ts | 1 + src/core/game/PlayerImpl.ts | 2 +- src/server/GameServer.ts | 8 +- src/server/MapPlaylist.ts | 8 +- 11 files changed, 488 insertions(+), 14 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index da453ae7b..0422bd517 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -171,7 +171,11 @@ "max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)", "disable_nukes": "Disable Nukes", "enables_title": "Enable Settings", - "start": "Start Game" + "start": "Start Game", + "gold_multiplier": "Gold multiplier", + "gold_multiplier_placeholder": "2.0x", + "starting_gold": "Starting gold", + "starting_gold_placeholder": "5000000" }, "token_login_modal": { "title": "Logging in...", @@ -379,7 +383,11 @@ "teams_Duos": "Duos (teams of 2)", "teams_Trios": "Trios (teams of 3)", "teams_Quads": "Quads (teams of 4)", - "teams_Humans Vs Nations": "Humans vs Nations" + "teams_Humans Vs Nations": "Humans vs Nations", + "gold_multiplier": "Gold multiplier", + "gold_multiplier_placeholder": "2.0x", + "starting_gold": "Starting gold", + "starting_gold_placeholder": "5000000" }, "team_colors": { "red": "Red", @@ -409,7 +417,8 @@ }, "public_game_modifier": { "random_spawn": "Random Spawn", - "compact_map": "Compact Map" + "compact_map": "Compact Map", + "starting_gold": "5M Starting Gold" }, "select_lang": { "title": "Select Language" diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 5abdb60a9..e7d709c12 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -60,6 +60,10 @@ export class HostLobbyModal extends BaseModal { @state() private instantBuild: boolean = false; @state() private randomSpawn: boolean = false; @state() private compactMap: boolean = false; + @state() private goldMultiplier: boolean = false; + @state() private goldMultiplierValue: number | undefined = undefined; + @state() private startingGold: boolean = false; + @state() private startingGoldValue: number | undefined = undefined; @state() private lobbyId = ""; @state() private copySuccess = false; @state() private lobbyUrlSuffix = ""; @@ -739,6 +743,158 @@ export class HostLobbyModal extends BaseModal { ${translateText("host_modal.player_immunity_duration")}
      + + +
      this.goldMultiplier, + (val) => (this.goldMultiplier = val), + () => this.goldMultiplierValue, + (val) => (this.goldMultiplierValue = val), + 2, + ).click} + @keydown=${this.createToggleHandlers( + () => this.goldMultiplier, + (val) => (this.goldMultiplier = val), + () => this.goldMultiplierValue, + (val) => (this.goldMultiplierValue = val), + 2, + ).keydown} + class="relative p-3 rounded-xl border transition-all duration-200 flex flex-col items-center justify-between gap-2 h-full cursor-pointer min-h-[100px] ${this + .goldMultiplier + ? "bg-blue-500/20 border-blue-500/50" + : "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"}" + > +
      +
      + ${this.goldMultiplier + ? html` + + ` + : ""} +
      +
      + + ${this.goldMultiplier + ? html`` + : html`
      `} + +
      + ${translateText("single_modal.gold_multiplier")} +
      +
      + + +
      this.startingGold, + (val) => (this.startingGold = val), + () => this.startingGoldValue, + (val) => (this.startingGoldValue = val), + 5000000, + ).click} + @keydown=${this.createToggleHandlers( + () => this.startingGold, + (val) => (this.startingGold = val), + () => this.startingGoldValue, + (val) => (this.startingGoldValue = val), + 5000000, + ).keydown} + class="relative p-3 rounded-xl border transition-all duration-200 flex flex-col items-center justify-between gap-2 h-full cursor-pointer min-h-[100px] ${this + .startingGold + ? "bg-blue-500/20 border-blue-500/50" + : "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"}" + > +
      +
      + ${this.startingGold + ? html` + + ` + : ""} +
      +
      + + ${this.startingGold + ? html`` + : html`
      `} + +
      + ${translateText("single_modal.starting_gold")} +
      +
      @@ -968,6 +1124,10 @@ export class HostLobbyModal extends BaseModal { this.lobbyCreatorClientID = ""; this.lobbyIdVisible = true; this.nationCount = 0; + this.goldMultiplier = false; + this.goldMultiplierValue = undefined; + this.startingGold = false; + this.startingGoldValue = undefined; this.leaveLobbyOnClose = true; } @@ -1036,6 +1196,44 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); } + private handleGoldMultiplierValueKeyDown(e: KeyboardEvent) { + if (["+", "-", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleGoldMultiplierValueChanges(e: Event) { + const input = e.target as HTMLInputElement; + const value = parseFloat(input.value); + + if (isNaN(value) || value < 0.1 || value > 1000) { + this.goldMultiplierValue = undefined; + input.value = ""; + } else { + this.goldMultiplierValue = value; + } + this.putGameConfig(); + } + + private handleStartingGoldValueKeyDown(e: KeyboardEvent) { + if (["-", "+", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleStartingGoldValueChanges(e: Event) { + const input = e.target as HTMLInputElement; + input.value = input.value.replace(/[eE+-]/g, ""); + const value = parseInt(input.value); + + if (isNaN(value) || value < 0 || value > 1000000000) { + this.startingGoldValue = undefined; + } else { + this.startingGoldValue = value; + } + this.putGameConfig(); + } + private handleRandomSpawnChange = (val: boolean) => { this.randomSpawn = val; this.putGameConfig(); @@ -1151,6 +1349,12 @@ export class HostLobbyModal extends BaseModal { }), maxTimerValue: this.maxTimer === true ? this.maxTimerValue : undefined, + goldMultiplier: + this.goldMultiplier === true + ? this.goldMultiplierValue + : undefined, + startingGold: + this.startingGold === true ? this.startingGoldValue : undefined, } satisfies Partial, }, bubbles: true, diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index c7516804d..4c895ab8f 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -374,6 +374,9 @@ export class PublicLobby extends LitElement { if (publicGameModifiers.isCompact) { labels.push(translateText("public_game_modifier.compact_map")); } + if (publicGameModifiers.startingGold) { + labels.push(translateText("public_game_modifier.starting_gold")); + } return labels; } diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 12c805751..c34dfe268 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -52,6 +52,10 @@ export class SinglePlayerModal extends BaseModal { @state() private showAchievements: boolean = false; @state() private mapWins: Map> = new Map(); @state() private userMeResponse: UserMeResponse | false = false; + @state() private goldMultiplier: boolean = false; + @state() private goldMultiplierValue: number | undefined = undefined; + @state() private startingGold: boolean = false; + @state() private startingGoldValue: number | undefined = undefined; @state() private disabledUnits: UnitType[] = []; @@ -601,6 +605,180 @@ export class SinglePlayerModal extends BaseModal { ${translateText("single_modal.max_timer")}
      + + +
      { + if ( + (e.target as HTMLElement).tagName.toLowerCase() === + "input" + ) + return; + this.goldMultiplier = !this.goldMultiplier; + if (!this.goldMultiplier) { + this.goldMultiplierValue = undefined; + } else { + if ( + !this.goldMultiplierValue || + this.goldMultiplierValue <= 0 + ) { + this.goldMultiplierValue = 2; + } + setTimeout(() => { + const input = this.renderRoot.querySelector( + "#gold-multiplier-value", + ) as HTMLInputElement; + if (input) { + input.focus(); + input.select(); + } + }, 0); + } + }} + > +
      +
      + ${this.goldMultiplier + ? html` + + ` + : ""} +
      +
      + + ${this.goldMultiplier + ? html`` + : html`
      `} + +
      + ${translateText("single_modal.gold_multiplier")} +
      +
      + + +
      { + if ( + (e.target as HTMLElement).tagName.toLowerCase() === + "input" + ) + return; + this.startingGold = !this.startingGold; + if (!this.startingGold) { + this.startingGoldValue = undefined; + } else { + if ( + !this.startingGoldValue || + this.startingGoldValue < 0 + ) { + this.startingGoldValue = 5000000; + } + setTimeout(() => { + const input = this.renderRoot.querySelector( + "#starting-gold-value", + ) as HTMLInputElement; + if (input) { + input.focus(); + input.select(); + } + }, 0); + } + }} + > +
      +
      + ${this.startingGold + ? html` + + ` + : ""} +
      +
      + + ${this.startingGold + ? html`` + : html`
      `} + +
      + ${translateText("single_modal.starting_gold")} +
      +
      @@ -714,6 +892,10 @@ export class SinglePlayerModal extends BaseModal { this.randomSpawn = false; this.teamCount = 2; this.disabledUnits = []; + this.goldMultiplier = false; + this.goldMultiplierValue = undefined; + this.startingGold = false; + this.startingGoldValue = undefined; } private handleSelectRandomMap() { @@ -767,6 +949,42 @@ export class SinglePlayerModal extends BaseModal { } } + private handleGoldMultiplierValueKeyDown(e: KeyboardEvent) { + if (["+", "-", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleGoldMultiplierValueChanges(e: Event) { + const input = e.target as HTMLInputElement; + const value = parseFloat(input.value); + + if (isNaN(value) || value < 0.1 || value > 1000) { + this.goldMultiplierValue = undefined; + input.value = ""; + } else { + this.goldMultiplierValue = value; + } + } + + private handleStartingGoldValueKeyDown(e: KeyboardEvent) { + if (["-", "+", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleStartingGoldValueChanges(e: Event) { + const input = e.target as HTMLInputElement; + input.value = input.value.replace(/[eE+-]/g, ""); + const value = parseInt(input.value); + + if (isNaN(value) || value < 0 || value > 1000000000) { + this.startingGoldValue = undefined; + } else { + this.startingGoldValue = value; + } + } + private handleGameModeSelection(value: GameMode) { this.gameMode = value; } @@ -888,6 +1106,12 @@ export class SinglePlayerModal extends BaseModal { : { disableNations: this.disableNations, }), + ...(this.goldMultiplier && this.goldMultiplierValue + ? { goldMultiplier: this.goldMultiplierValue } + : {}), + ...(this.startingGold && this.startingGoldValue !== undefined + ? { startingGold: this.startingGoldValue } + : {}), }, lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP }, diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 10a1a84b2..28362063f 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -190,6 +190,7 @@ export const GameConfigSchema = z.object({ .object({ isCompact: z.boolean(), isRandomSpawn: z.boolean(), + startingGold: z.number().int().min(0).optional(), }) .optional(), disableNations: z.boolean(), @@ -204,6 +205,8 @@ export const GameConfigSchema = z.object({ spawnImmunityDuration: z.number().int().min(0).optional(), // In ticks disabledUnits: z.enum(UnitType).array().optional(), playerTeams: TeamCountConfigSchema.optional(), + goldMultiplier: z.number().min(0.1).max(1000).optional(), + startingGold: z.number().int().min(0).max(1000000000).optional(), }); export const TeamSchema = z.string(); diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 4c9fce54d..ac1d9ee4a 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -76,6 +76,8 @@ export interface Config { numSpawnPhaseTurns(): number; userSettings(): UserSettings; playerTeams(): TeamCountConfig; + goldMultiplier(): number; + startingGold(playerInfo: PlayerInfo): Gold; startManpower(playerInfo: PlayerInfo): number; troopIncreaseRate(player: Player | PlayerView): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 7311cb60c..36057bdad 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -245,6 +245,15 @@ export class DefaultConfig implements Config { donateTroops(): boolean { return this._gameConfig.donateTroops; } + goldMultiplier(): number { + return this._gameConfig.goldMultiplier ?? 1; + } + startingGold(playerInfo: PlayerInfo): Gold { + if (playerInfo.playerType === PlayerType.Bot) { + return 0n; + } + return BigInt(this._gameConfig.startingGold ?? 0); + } trainSpawnRate(numPlayerFactories: number): number { // hyperbolic decay, midpoint at 10 factories @@ -252,15 +261,21 @@ export class DefaultConfig implements Config { return (numPlayerFactories + 10) * 18; } trainGold(rel: "self" | "team" | "ally" | "other"): Gold { + const multiplier = this.goldMultiplier(); + let baseGold: bigint; switch (rel) { case "ally": - return 35_000n; + baseGold = 35_000n; + break; case "team": case "other": - return 25_000n; + baseGold = 25_000n; + break; case "self": - return 10_000n; + baseGold = 10_000n; + break; } + return BigInt(Math.floor(Number(baseGold) * multiplier)); } trainStationMinRange(): number { @@ -281,7 +296,8 @@ export class DefaultConfig implements Config { const numPortBonus = numPorts - 1; // Hyperbolic decay, midpoint at 5 ports, 3x bonus max. const bonus = 1 + 2 * (numPortBonus / (numPortBonus + 5)); - return BigInt(Math.floor(baseGold * bonus)); + const multiplier = this.goldMultiplier(); + return BigInt(Math.floor(baseGold * bonus * multiplier)); } // Probability of trade ship spawn = 1 / tradeShipSpawnRate @@ -791,10 +807,14 @@ export class DefaultConfig implements Config { } goldAdditionRate(player: Player): Gold { + const multiplier = this.goldMultiplier(); + let baseRate: bigint; if (player.type() === PlayerType.Bot) { - return 50n; + baseRate = 50n; + } else { + baseRate = 100n; } - return 100n; + return BigInt(Math.floor(Number(baseRate) * multiplier)); } nukeMagnitudes(unitType: UnitType): NukeMagnitude { diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 4ead6efe5..fdfff12d8 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -211,6 +211,7 @@ export enum GameMapSize { export interface PublicGameModifiers { isCompact: boolean; isRandomSpawn: boolean; + startingGold?: number; } export interface UnitInfo { diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 3b773576e..e09360acc 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -112,7 +112,7 @@ export class PlayerImpl implements Player { ) { this._name = playerInfo.name; this._troops = toInt(startTroops); - this._gold = 0n; + this._gold = mg.config().startingGold(playerInfo); this._displayName = this._name; this._pseudo_random = new PseudoRandom(simpleHash(this.playerInfo.id)); } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 068253920..1f685a72e 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -127,14 +127,18 @@ export class GameServer { if (gameConfig.gameMode !== undefined) { this.gameConfig.gameMode = gameConfig.gameMode; } - if (gameConfig.disabledUnits !== undefined) { this.gameConfig.disabledUnits = gameConfig.disabledUnits; } - if (gameConfig.playerTeams !== undefined) { this.gameConfig.playerTeams = gameConfig.playerTeams; } + if (gameConfig.goldMultiplier !== undefined) { + this.gameConfig.goldMultiplier = gameConfig.goldMultiplier; + } + if (gameConfig.startingGold !== undefined) { + this.gameConfig.startingGold = gameConfig.startingGold; + } } public joinClient(client: Client) { diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index a9ba0e78d..beadd0bec 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -94,7 +94,9 @@ export class MapPlaylist { const playerTeams = mode === GameMode.Team ? this.getTeamCount() : undefined; - let { isCompact, isRandomSpawn } = this.getRandomPublicGameModifiers(); + const modifiers = this.getRandomPublicGameModifiers(); + const { startingGold } = modifiers; + let { isCompact, isRandomSpawn } = modifiers; // Duos, Trios, and Quads should not get random spawn (as it defeats the purpose) if ( @@ -122,7 +124,8 @@ export class MapPlaylist { maxPlayers: await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact), gameType: GameType.Public, gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal, - publicGameModifiers: { isCompact, isRandomSpawn }, + publicGameModifiers: { isCompact, isRandomSpawn, startingGold }, + startingGold, difficulty: playerTeams === HumansVsNations ? Difficulty.Impossible @@ -198,6 +201,7 @@ export class MapPlaylist { return { isRandomSpawn: Math.random() < 0.1, // 10% chance isCompact: Math.random() < 0.05, // 5% chance + startingGold: Math.random() < 0.03 ? 5_000_000 : undefined, // 3% chance }; } From d0fda1d5358397a53fb95dc733a15299a01c5023 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 15 Jan 2026 20:57:46 +0000 Subject: [PATCH 019/109] mergestats (#2904) If this PR fixes an issue, link it below. If not, delete these two lines. Resolves #2704 ## Description: Merges together easy + medium difficulties. Before: image After: (dont have one to show oop) (btw that win ratio in the first screenshot is not mine.. :skull:) ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- .../baseComponents/stats/PlayerStatsTree.ts | 210 +++++++++++++----- 1 file changed, 158 insertions(+), 52 deletions(-) diff --git a/src/client/components/baseComponents/stats/PlayerStatsTree.ts b/src/client/components/baseComponents/stats/PlayerStatsTree.ts index 4ec48f70a..e703ee2a2 100644 --- a/src/client/components/baseComponents/stats/PlayerStatsTree.ts +++ b/src/client/components/baseComponents/stats/PlayerStatsTree.ts @@ -1,4 +1,4 @@ -import { LitElement, html } from "lit"; +import { LitElement, PropertyValues, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { PlayerStatsLeaf, PlayerStatsTree } from "../../../../core/ApiSchemas"; import { @@ -21,22 +21,31 @@ export class PlayerStatsTreeView extends LitElement { @state() selectedMode: GameMode = GameMode.FFA; @state() selectedDifficulty: Difficulty = Difficulty.Medium; + private get typeNode() { + return this.statsTree?.[this.selectedType]; + } + + private get modeNode() { + return this.typeNode?.[this.selectedMode]; + } + + private get shouldMergeDifficulties() { + return this.selectedType === GameType.Public; + } + private get availableTypes(): GameType[] { if (!this.statsTree) return []; return Object.keys(this.statsTree).filter(isGameType); } private get availableModes(): GameMode[] { - const typeNode = this.statsTree?.[this.selectedType]; - if (!typeNode) return []; - return Object.keys(typeNode).filter(isGameMode); + if (!this.typeNode) return []; + return Object.keys(this.typeNode).filter(isGameMode); } private get availableDifficulties(): Difficulty[] { - const typeNode = this.statsTree?.[this.selectedType]; - const modeNode = typeNode?.[this.selectedMode]; - if (!modeNode) return []; - return Object.keys(modeNode).filter(isDifficulty); + if (!this.modeNode) return []; + return Object.keys(this.modeNode).filter(isDifficulty); } private labelForMode(m: GameMode) { @@ -50,52 +59,37 @@ export class PlayerStatsTreeView extends LitElement { } private getSelectedLeaf(): PlayerStatsLeaf | null { - const typeNode = this.statsTree?.[this.selectedType]; - if (!typeNode) return null; - const modeNode = typeNode[this.selectedMode]; + const modeNode = this.modeNode; if (!modeNode) return null; - const diffNode = modeNode[this.selectedDifficulty]; - if (!diffNode) return null; - return diffNode; - } - private getDisplayedStats(): PlayerStats | null { - const leaf = this.getSelectedLeaf(); - if (!leaf || !leaf.stats) return null; - return leaf.stats; - } - - private setGameType(t: GameType) { - if (this.selectedType === t) return; - this.selectedType = t; - const modes = this.availableModes; - if (!modes.includes(this.selectedMode)) { - this.selectedMode = modes[0] ?? this.selectedMode; + if (!this.shouldMergeDifficulties) { + return modeNode[this.selectedDifficulty] ?? null; } - const diffs = this.availableDifficulties; - if (!diffs.includes(this.selectedDifficulty)) { - this.selectedDifficulty = diffs[0] ?? this.selectedDifficulty; - } - this.requestUpdate(); + + const diffKeys = Object.keys(modeNode).filter(isDifficulty); + if (!diffKeys.length) return null; + + return diffKeys.reduce((merged, diffKey) => { + const leaf = modeNode[diffKey]; + if (!leaf) return merged; + if (!merged) { + return { + wins: leaf.wins, + losses: leaf.losses, + total: leaf.total, + stats: this.cloneStats(leaf.stats), + }; + } + return { + wins: merged.wins + leaf.wins, + losses: merged.losses + leaf.losses, + total: merged.total + leaf.total, + stats: this.mergeStats(merged.stats, leaf.stats), + }; + }, null); } - private setMode(m: GameMode) { - if (this.selectedMode === m) return; - this.selectedMode = m; - const diffs = this.availableDifficulties; - if (!diffs.includes(this.selectedDifficulty)) { - this.selectedDifficulty = diffs[0] ?? this.selectedDifficulty; - } - this.requestUpdate(); - } - - private setDifficulty(d: Difficulty) { - if (this.selectedDifficulty === d) return; - this.selectedDifficulty = d; - this.requestUpdate(); - } - - render() { + private syncSelection(): void { const types = this.availableTypes; if (types.length && !types.includes(this.selectedType)) { this.selectedType = types[0]; @@ -105,10 +99,122 @@ export class PlayerStatsTreeView extends LitElement { this.selectedMode = modes[0]; } const diffs = this.availableDifficulties; - if (diffs.length && !diffs.includes(this.selectedDifficulty)) { + if ( + !this.shouldMergeDifficulties && + diffs.length && + !diffs.includes(this.selectedDifficulty) + ) { this.selectedDifficulty = diffs[0]; } + } + protected willUpdate(changedProperties: PropertyValues) { + if ( + changedProperties.has("statsTree") || + changedProperties.has("selectedType") || + changedProperties.has("selectedMode") || + changedProperties.has("selectedDifficulty") + ) { + this.syncSelection(); + } + } + + private setGameType(t: GameType) { + if (this.selectedType === t) return; + this.selectedType = t; + this.requestUpdate(); + } + + private setMode(m: GameMode) { + if (this.selectedMode === m) return; + this.selectedMode = m; + this.requestUpdate(); + } + + private setDifficulty(d: Difficulty) { + if (this.selectedDifficulty === d) return; + this.selectedDifficulty = d; + this.requestUpdate(); + } + + private mergeStats( + base: PlayerStats | undefined, + next: PlayerStats | undefined, + ): PlayerStats | undefined { + if (!base && !next) return undefined; + if (!base) return this.cloneStats(next); + if (!next) return this.cloneStats(base); + + return { + attacks: this.mergeStatArrays(base.attacks, next.attacks), + betrayals: this.mergeStatValue(base.betrayals, next.betrayals), + killedAt: this.mergeStatValue(base.killedAt, next.killedAt), + conquests: this.mergeStatValue(base.conquests, next.conquests), + boats: this.mergeStatRecord(base.boats, next.boats), + bombs: this.mergeStatRecord(base.bombs, next.bombs), + gold: this.mergeStatArrays(base.gold, next.gold), + units: this.mergeStatRecord(base.units, next.units), + }; + } + + private mergeStatValue( + base: bigint | undefined, + next: bigint | undefined, + ): bigint | undefined { + if (base === undefined && next === undefined) return undefined; + return (base ?? 0n) + (next ?? 0n); + } + + private mergeStatArrays( + base: bigint[] | undefined, + next: bigint[] | undefined, + ): bigint[] | undefined { + if (!base && !next) return undefined; + const maxLen = Math.max(base?.length ?? 0, next?.length ?? 0); + const merged: bigint[] = []; + for (let i = 0; i < maxLen; i += 1) { + merged[i] = (base?.[i] ?? 0n) + (next?.[i] ?? 0n); + } + return merged; + } + + private mergeStatRecord( + base: Partial> | undefined, + next: Partial> | undefined, + ): Partial> | undefined { + if (!base && !next) return undefined; + const merged: Partial> = {}; + const keys = new Set([ + ...Object.keys(base ?? {}), + ...Object.keys(next ?? {}), + ]) as Set; + keys.forEach((key) => { + const mergedArray = this.mergeStatArrays(base?.[key], next?.[key]); + if (mergedArray) { + merged[key] = mergedArray; + } + }); + return Object.keys(merged).length ? merged : undefined; + } + + private cloneStats(stats: PlayerStats | undefined): PlayerStats | undefined { + if (!stats) return undefined; + return { + attacks: stats.attacks ? [...stats.attacks] : undefined, + betrayals: stats.betrayals, + killedAt: stats.killedAt, + conquests: stats.conquests, + boats: stats.boats ? { ...stats.boats } : undefined, + bombs: stats.bombs ? { ...stats.bombs } : undefined, + gold: stats.gold ? [...stats.gold] : undefined, + units: stats.units ? { ...stats.units } : undefined, + }; + } + + render() { + const types = this.availableTypes; + const modes = this.availableModes; + const diffs = this.availableDifficulties; const leaf = this.getSelectedLeaf(); const wlr = leaf ? leaf.losses === 0n @@ -167,7 +273,7 @@ export class PlayerStatsTreeView extends LitElement { : html``} - ${diffs.length + ${!this.shouldMergeDifficulties && diffs.length ? html`
      @@ -209,7 +315,7 @@ export class PlayerStatsTreeView extends LitElement {
      From 4e8454f3ccdc49d70e0bb300127c6b305780916d Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Fri, 16 Jan 2026 19:19:41 +0100 Subject: [PATCH 020/109] =?UTF-8?q?Lobby=20Gold=20Options=20(Starting=20Go?= =?UTF-8?q?ld,=20Gold=20Multiplier)=20=F0=9F=92=B0=20(#2915)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: We might want to add this to v29 to have a third possible public game modifier from the beginning on 😄 Would be fun - Add starting gold option (0 to 1_000_000_000 allowed, also applies to nations) - Add gold multiplier option (0.1 to 1000 allowed, also applies to nations and bots) - Add third public game modifier (3% chance of starting with 5M gold) - Why 5M? It's enough gold to massively change the game start but not enough to insta-hydro someone (launcher + hydro is 6M) image ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- resources/lang/en.json | 15 +- src/client/HostLobbyModal.ts | 204 +++++++++++++++++++++ src/client/PublicLobby.ts | 3 + src/client/SinglePlayerModal.ts | 224 ++++++++++++++++++++++++ src/core/Schemas.ts | 3 + src/core/configuration/Config.ts | 2 + src/core/configuration/DefaultConfig.ts | 32 +++- src/core/game/Game.ts | 1 + src/core/game/PlayerImpl.ts | 2 +- src/server/GameServer.ts | 8 +- src/server/MapPlaylist.ts | 8 +- 11 files changed, 488 insertions(+), 14 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index d3c3d4e62..6027fc282 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -171,7 +171,11 @@ "max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)", "disable_nukes": "Disable Nukes", "enables_title": "Enable Settings", - "start": "Start Game" + "start": "Start Game", + "gold_multiplier": "Gold multiplier", + "gold_multiplier_placeholder": "2.0x", + "starting_gold": "Starting gold", + "starting_gold_placeholder": "5000000" }, "token_login_modal": { "title": "Logging in...", @@ -381,7 +385,11 @@ "teams_Duos": "Duos (teams of 2)", "teams_Trios": "Trios (teams of 3)", "teams_Quads": "Quads (teams of 4)", - "teams_Humans Vs Nations": "Humans vs Nations" + "teams_Humans Vs Nations": "Humans vs Nations", + "gold_multiplier": "Gold multiplier", + "gold_multiplier_placeholder": "2.0x", + "starting_gold": "Starting gold", + "starting_gold_placeholder": "5000000" }, "team_colors": { "red": "Red", @@ -411,7 +419,8 @@ }, "public_game_modifier": { "random_spawn": "Random Spawn", - "compact_map": "Compact Map" + "compact_map": "Compact Map", + "starting_gold": "5M Starting Gold" }, "select_lang": { "title": "Select Language" diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 5abdb60a9..e7d709c12 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -60,6 +60,10 @@ export class HostLobbyModal extends BaseModal { @state() private instantBuild: boolean = false; @state() private randomSpawn: boolean = false; @state() private compactMap: boolean = false; + @state() private goldMultiplier: boolean = false; + @state() private goldMultiplierValue: number | undefined = undefined; + @state() private startingGold: boolean = false; + @state() private startingGoldValue: number | undefined = undefined; @state() private lobbyId = ""; @state() private copySuccess = false; @state() private lobbyUrlSuffix = ""; @@ -739,6 +743,158 @@ export class HostLobbyModal extends BaseModal { ${translateText("host_modal.player_immunity_duration")}
+ + +
this.goldMultiplier, + (val) => (this.goldMultiplier = val), + () => this.goldMultiplierValue, + (val) => (this.goldMultiplierValue = val), + 2, + ).click} + @keydown=${this.createToggleHandlers( + () => this.goldMultiplier, + (val) => (this.goldMultiplier = val), + () => this.goldMultiplierValue, + (val) => (this.goldMultiplierValue = val), + 2, + ).keydown} + class="relative p-3 rounded-xl border transition-all duration-200 flex flex-col items-center justify-between gap-2 h-full cursor-pointer min-h-[100px] ${this + .goldMultiplier + ? "bg-blue-500/20 border-blue-500/50" + : "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"}" + > +
+
+ ${this.goldMultiplier + ? html` + + ` + : ""} +
+
+ + ${this.goldMultiplier + ? html`` + : html`
`} + +
+ ${translateText("single_modal.gold_multiplier")} +
+
+ + +
this.startingGold, + (val) => (this.startingGold = val), + () => this.startingGoldValue, + (val) => (this.startingGoldValue = val), + 5000000, + ).click} + @keydown=${this.createToggleHandlers( + () => this.startingGold, + (val) => (this.startingGold = val), + () => this.startingGoldValue, + (val) => (this.startingGoldValue = val), + 5000000, + ).keydown} + class="relative p-3 rounded-xl border transition-all duration-200 flex flex-col items-center justify-between gap-2 h-full cursor-pointer min-h-[100px] ${this + .startingGold + ? "bg-blue-500/20 border-blue-500/50" + : "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"}" + > +
+
+ ${this.startingGold + ? html` + + ` + : ""} +
+
+ + ${this.startingGold + ? html`` + : html`
`} + +
+ ${translateText("single_modal.starting_gold")} +
+
@@ -968,6 +1124,10 @@ export class HostLobbyModal extends BaseModal { this.lobbyCreatorClientID = ""; this.lobbyIdVisible = true; this.nationCount = 0; + this.goldMultiplier = false; + this.goldMultiplierValue = undefined; + this.startingGold = false; + this.startingGoldValue = undefined; this.leaveLobbyOnClose = true; } @@ -1036,6 +1196,44 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); } + private handleGoldMultiplierValueKeyDown(e: KeyboardEvent) { + if (["+", "-", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleGoldMultiplierValueChanges(e: Event) { + const input = e.target as HTMLInputElement; + const value = parseFloat(input.value); + + if (isNaN(value) || value < 0.1 || value > 1000) { + this.goldMultiplierValue = undefined; + input.value = ""; + } else { + this.goldMultiplierValue = value; + } + this.putGameConfig(); + } + + private handleStartingGoldValueKeyDown(e: KeyboardEvent) { + if (["-", "+", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleStartingGoldValueChanges(e: Event) { + const input = e.target as HTMLInputElement; + input.value = input.value.replace(/[eE+-]/g, ""); + const value = parseInt(input.value); + + if (isNaN(value) || value < 0 || value > 1000000000) { + this.startingGoldValue = undefined; + } else { + this.startingGoldValue = value; + } + this.putGameConfig(); + } + private handleRandomSpawnChange = (val: boolean) => { this.randomSpawn = val; this.putGameConfig(); @@ -1151,6 +1349,12 @@ export class HostLobbyModal extends BaseModal { }), maxTimerValue: this.maxTimer === true ? this.maxTimerValue : undefined, + goldMultiplier: + this.goldMultiplier === true + ? this.goldMultiplierValue + : undefined, + startingGold: + this.startingGold === true ? this.startingGoldValue : undefined, } satisfies Partial, }, bubbles: true, diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index c7516804d..4c895ab8f 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -374,6 +374,9 @@ export class PublicLobby extends LitElement { if (publicGameModifiers.isCompact) { labels.push(translateText("public_game_modifier.compact_map")); } + if (publicGameModifiers.startingGold) { + labels.push(translateText("public_game_modifier.starting_gold")); + } return labels; } diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 12c805751..c34dfe268 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -52,6 +52,10 @@ export class SinglePlayerModal extends BaseModal { @state() private showAchievements: boolean = false; @state() private mapWins: Map> = new Map(); @state() private userMeResponse: UserMeResponse | false = false; + @state() private goldMultiplier: boolean = false; + @state() private goldMultiplierValue: number | undefined = undefined; + @state() private startingGold: boolean = false; + @state() private startingGoldValue: number | undefined = undefined; @state() private disabledUnits: UnitType[] = []; @@ -601,6 +605,180 @@ export class SinglePlayerModal extends BaseModal { ${translateText("single_modal.max_timer")}
+ + +
{ + if ( + (e.target as HTMLElement).tagName.toLowerCase() === + "input" + ) + return; + this.goldMultiplier = !this.goldMultiplier; + if (!this.goldMultiplier) { + this.goldMultiplierValue = undefined; + } else { + if ( + !this.goldMultiplierValue || + this.goldMultiplierValue <= 0 + ) { + this.goldMultiplierValue = 2; + } + setTimeout(() => { + const input = this.renderRoot.querySelector( + "#gold-multiplier-value", + ) as HTMLInputElement; + if (input) { + input.focus(); + input.select(); + } + }, 0); + } + }} + > +
+
+ ${this.goldMultiplier + ? html` + + ` + : ""} +
+
+ + ${this.goldMultiplier + ? html`` + : html`
`} + +
+ ${translateText("single_modal.gold_multiplier")} +
+
+ + +
{ + if ( + (e.target as HTMLElement).tagName.toLowerCase() === + "input" + ) + return; + this.startingGold = !this.startingGold; + if (!this.startingGold) { + this.startingGoldValue = undefined; + } else { + if ( + !this.startingGoldValue || + this.startingGoldValue < 0 + ) { + this.startingGoldValue = 5000000; + } + setTimeout(() => { + const input = this.renderRoot.querySelector( + "#starting-gold-value", + ) as HTMLInputElement; + if (input) { + input.focus(); + input.select(); + } + }, 0); + } + }} + > +
+
+ ${this.startingGold + ? html` + + ` + : ""} +
+
+ + ${this.startingGold + ? html`` + : html`
`} + +
+ ${translateText("single_modal.starting_gold")} +
+
@@ -714,6 +892,10 @@ export class SinglePlayerModal extends BaseModal { this.randomSpawn = false; this.teamCount = 2; this.disabledUnits = []; + this.goldMultiplier = false; + this.goldMultiplierValue = undefined; + this.startingGold = false; + this.startingGoldValue = undefined; } private handleSelectRandomMap() { @@ -767,6 +949,42 @@ export class SinglePlayerModal extends BaseModal { } } + private handleGoldMultiplierValueKeyDown(e: KeyboardEvent) { + if (["+", "-", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleGoldMultiplierValueChanges(e: Event) { + const input = e.target as HTMLInputElement; + const value = parseFloat(input.value); + + if (isNaN(value) || value < 0.1 || value > 1000) { + this.goldMultiplierValue = undefined; + input.value = ""; + } else { + this.goldMultiplierValue = value; + } + } + + private handleStartingGoldValueKeyDown(e: KeyboardEvent) { + if (["-", "+", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleStartingGoldValueChanges(e: Event) { + const input = e.target as HTMLInputElement; + input.value = input.value.replace(/[eE+-]/g, ""); + const value = parseInt(input.value); + + if (isNaN(value) || value < 0 || value > 1000000000) { + this.startingGoldValue = undefined; + } else { + this.startingGoldValue = value; + } + } + private handleGameModeSelection(value: GameMode) { this.gameMode = value; } @@ -888,6 +1106,12 @@ export class SinglePlayerModal extends BaseModal { : { disableNations: this.disableNations, }), + ...(this.goldMultiplier && this.goldMultiplierValue + ? { goldMultiplier: this.goldMultiplierValue } + : {}), + ...(this.startingGold && this.startingGoldValue !== undefined + ? { startingGold: this.startingGoldValue } + : {}), }, lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP }, diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 10a1a84b2..28362063f 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -190,6 +190,7 @@ export const GameConfigSchema = z.object({ .object({ isCompact: z.boolean(), isRandomSpawn: z.boolean(), + startingGold: z.number().int().min(0).optional(), }) .optional(), disableNations: z.boolean(), @@ -204,6 +205,8 @@ export const GameConfigSchema = z.object({ spawnImmunityDuration: z.number().int().min(0).optional(), // In ticks disabledUnits: z.enum(UnitType).array().optional(), playerTeams: TeamCountConfigSchema.optional(), + goldMultiplier: z.number().min(0.1).max(1000).optional(), + startingGold: z.number().int().min(0).max(1000000000).optional(), }); export const TeamSchema = z.string(); diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 4c9fce54d..ac1d9ee4a 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -76,6 +76,8 @@ export interface Config { numSpawnPhaseTurns(): number; userSettings(): UserSettings; playerTeams(): TeamCountConfig; + goldMultiplier(): number; + startingGold(playerInfo: PlayerInfo): Gold; startManpower(playerInfo: PlayerInfo): number; troopIncreaseRate(player: Player | PlayerView): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 7311cb60c..36057bdad 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -245,6 +245,15 @@ export class DefaultConfig implements Config { donateTroops(): boolean { return this._gameConfig.donateTroops; } + goldMultiplier(): number { + return this._gameConfig.goldMultiplier ?? 1; + } + startingGold(playerInfo: PlayerInfo): Gold { + if (playerInfo.playerType === PlayerType.Bot) { + return 0n; + } + return BigInt(this._gameConfig.startingGold ?? 0); + } trainSpawnRate(numPlayerFactories: number): number { // hyperbolic decay, midpoint at 10 factories @@ -252,15 +261,21 @@ export class DefaultConfig implements Config { return (numPlayerFactories + 10) * 18; } trainGold(rel: "self" | "team" | "ally" | "other"): Gold { + const multiplier = this.goldMultiplier(); + let baseGold: bigint; switch (rel) { case "ally": - return 35_000n; + baseGold = 35_000n; + break; case "team": case "other": - return 25_000n; + baseGold = 25_000n; + break; case "self": - return 10_000n; + baseGold = 10_000n; + break; } + return BigInt(Math.floor(Number(baseGold) * multiplier)); } trainStationMinRange(): number { @@ -281,7 +296,8 @@ export class DefaultConfig implements Config { const numPortBonus = numPorts - 1; // Hyperbolic decay, midpoint at 5 ports, 3x bonus max. const bonus = 1 + 2 * (numPortBonus / (numPortBonus + 5)); - return BigInt(Math.floor(baseGold * bonus)); + const multiplier = this.goldMultiplier(); + return BigInt(Math.floor(baseGold * bonus * multiplier)); } // Probability of trade ship spawn = 1 / tradeShipSpawnRate @@ -791,10 +807,14 @@ export class DefaultConfig implements Config { } goldAdditionRate(player: Player): Gold { + const multiplier = this.goldMultiplier(); + let baseRate: bigint; if (player.type() === PlayerType.Bot) { - return 50n; + baseRate = 50n; + } else { + baseRate = 100n; } - return 100n; + return BigInt(Math.floor(Number(baseRate) * multiplier)); } nukeMagnitudes(unitType: UnitType): NukeMagnitude { diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 4ead6efe5..fdfff12d8 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -211,6 +211,7 @@ export enum GameMapSize { export interface PublicGameModifiers { isCompact: boolean; isRandomSpawn: boolean; + startingGold?: number; } export interface UnitInfo { diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 3b773576e..e09360acc 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -112,7 +112,7 @@ export class PlayerImpl implements Player { ) { this._name = playerInfo.name; this._troops = toInt(startTroops); - this._gold = 0n; + this._gold = mg.config().startingGold(playerInfo); this._displayName = this._name; this._pseudo_random = new PseudoRandom(simpleHash(this.playerInfo.id)); } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 068253920..1f685a72e 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -127,14 +127,18 @@ export class GameServer { if (gameConfig.gameMode !== undefined) { this.gameConfig.gameMode = gameConfig.gameMode; } - if (gameConfig.disabledUnits !== undefined) { this.gameConfig.disabledUnits = gameConfig.disabledUnits; } - if (gameConfig.playerTeams !== undefined) { this.gameConfig.playerTeams = gameConfig.playerTeams; } + if (gameConfig.goldMultiplier !== undefined) { + this.gameConfig.goldMultiplier = gameConfig.goldMultiplier; + } + if (gameConfig.startingGold !== undefined) { + this.gameConfig.startingGold = gameConfig.startingGold; + } } public joinClient(client: Client) { diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index a9ba0e78d..beadd0bec 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -94,7 +94,9 @@ export class MapPlaylist { const playerTeams = mode === GameMode.Team ? this.getTeamCount() : undefined; - let { isCompact, isRandomSpawn } = this.getRandomPublicGameModifiers(); + const modifiers = this.getRandomPublicGameModifiers(); + const { startingGold } = modifiers; + let { isCompact, isRandomSpawn } = modifiers; // Duos, Trios, and Quads should not get random spawn (as it defeats the purpose) if ( @@ -122,7 +124,8 @@ export class MapPlaylist { maxPlayers: await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact), gameType: GameType.Public, gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal, - publicGameModifiers: { isCompact, isRandomSpawn }, + publicGameModifiers: { isCompact, isRandomSpawn, startingGold }, + startingGold, difficulty: playerTeams === HumansVsNations ? Difficulty.Impossible @@ -198,6 +201,7 @@ export class MapPlaylist { return { isRandomSpawn: Math.random() < 0.1, // 10% chance isCompact: Math.random() < 0.05, // 5% chance + startingGold: Math.random() < 0.03 ? 5_000_000 : undefined, // 3% chance }; } From 6bd95d48844a58836bcd3f665c78468eefc754b7 Mon Sep 17 00:00:00 2001 From: Arkadiusz Sygulski Date: Sat, 17 Jan 2026 00:10:55 +0100 Subject: [PATCH 021/109] Pathfinding - optimize naval invasions (#2932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pathfinding pt. 4 https://pf-pt-4.openfront.dev/ ## Description: Hello again! Pathfinding. It's fast, but inaccurate. This PR makes it more accurate and actually faster. Sadly it is _faster_ because of a blunder in previous PR (using BucketQueue where MinHeap would be better), not because of a new tech. More importantly, it is more accurate. And that's what people apparently want. ## What changed? Most of the functional changes relate to `SpatialQuery` module. This is the thingy that answers "we know the target, which tile of my territory is the best to launch an invasion". To make it compute a path from South America to the deep inland China river, it has to work on a coerced map, one with a very small resolution, so small in fact, that every 4096 map tiles gets compressed to just one pixel. I hope you see where this is going. Previously we selected a random coastal tile within this big pixel (honestly it wasn't random at all, but could very well be for the illustrative purposes). Now, we try to be a bit more deliberate. Since we already know the rough location of the probably best tile, we can exclude all other tiles from the computation. Imagine a player's territory spans both Americas on global map - that's a lot of shores. But since we already know the best tile is somewhere close to Miami, the problem space was greatly reduced, no need to consider all other shores. But pathing to the target in China from Miami is still crazy expensive. This is where second trick comes to play - instead of pathing all the way to China, we select a _waypoint_ in the rough direction of China, about 100 to 200 tiles away. This way we fairly cheaply select best tile to launch an invasion towards this abstract point. And chances are, this point is far enough, the newly computed path is very close to being optimal. When you throw a dart from far away, the difference between scoring 10 and missing is very small. This is why aiming in the general direction of the board - as opposed to the ceiling - is usually good enough. ## Okay, but what about the crazy paths when I send invasion to the opposed bank of a river?! Well, pathing from America to China is cool, but most players wouldn't notice the difference on such long paths, what about the short ones? We now try more accurate pathing first and defer to hierarchy only if it fails. This produces much better paths for short invasions. While the fix described above ensures the accuracy is improved also on medium-to-long routes. ## Playground Yes. https://github.com/user-attachments/assets/9cf9586f-c99a-416d-b856-8cf0a21c35ed ## CodeRabbit Grab a 🥕. Remember `tests/pathfinding/playground` is mostly generated code and go easy on it. It's enough for it to work and do it's job of visualizing the paths. No need for throughout review of these files. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: moleole --- .../pathfinding/algorithms/AStar.Water.ts | 8 +- .../algorithms/AStar.WaterBounded.ts | 42 +- .../algorithms/AStar.WaterHierarchical.ts | 48 ++ .../pathfinding/algorithms/PriorityQueue.ts | 43 +- src/core/pathfinding/spatial/SpatialQuery.ts | 143 ++++- .../transformers/SmoothingWaterTransformer.ts | 3 + .../playground/api/spatialQuery.ts | 161 ++++++ tests/pathfinding/playground/public/client.js | 535 +++++++++++++++++- .../pathfinding/playground/public/index.html | 79 ++- .../pathfinding/playground/public/styles.css | 120 +++- tests/pathfinding/playground/server.ts | 53 ++ 11 files changed, 1171 insertions(+), 64 deletions(-) create mode 100644 tests/pathfinding/playground/api/spatialQuery.ts diff --git a/src/core/pathfinding/algorithms/AStar.Water.ts b/src/core/pathfinding/algorithms/AStar.Water.ts index 6452114a5..d6a366293 100644 --- a/src/core/pathfinding/algorithms/AStar.Water.ts +++ b/src/core/pathfinding/algorithms/AStar.Water.ts @@ -1,6 +1,6 @@ import { GameMap, TileRef } from "../../game/GameMap"; import { PathFinder } from "../types"; -import { BucketQueue, PriorityQueue } from "./PriorityQueue"; +import { MinHeap, PriorityQueue } from "./PriorityQueue"; const LAND_BIT = 7; // Bit 7 in terrain indicates land const MAGNITUDE_MASK = 0x1f; @@ -45,11 +45,7 @@ export class AStarWater implements PathFinder { this.gScore = new Uint32Array(this.numNodes); this.cameFrom = new Int32Array(this.numNodes); - // Account for scaled costs + tie-breaker headroom - const maxDim = map.width() + map.height(); - const maxF = - (this.heuristicWeight + 1) * BASE_COST * maxDim + COST_SCALE * maxDim; - this.queue = new BucketQueue(maxF); + this.queue = new MinHeap(this.numNodes); } findPath(start: number | number[], goal: number): number[] | null { diff --git a/src/core/pathfinding/algorithms/AStar.WaterBounded.ts b/src/core/pathfinding/algorithms/AStar.WaterBounded.ts index aa8bdd693..8af127ef4 100644 --- a/src/core/pathfinding/algorithms/AStar.WaterBounded.ts +++ b/src/core/pathfinding/algorithms/AStar.WaterBounded.ts @@ -1,6 +1,6 @@ import { GameMap, TileRef } from "../../game/GameMap"; import { PathFinder } from "../types"; -import { BucketQueue } from "./PriorityQueue"; +import { MinHeap } from "./PriorityQueue"; const LAND_BIT = 7; const MAGNITUDE_MASK = 0x1f; @@ -33,7 +33,7 @@ export class AStarWaterBounded implements PathFinder { private readonly gScoreStamp: Uint32Array; private readonly gScore: Uint32Array; private readonly cameFrom: Int32Array; - private readonly queue: BucketQueue; + private readonly queue: MinHeap; private readonly terrain: Uint8Array; private readonly mapWidth: number; private readonly heuristicWeight: number; @@ -54,11 +54,7 @@ export class AStarWaterBounded implements PathFinder { this.gScore = new Uint32Array(maxSearchArea); this.cameFrom = new Int32Array(maxSearchArea); - const maxDim = Math.ceil(Math.sqrt(maxSearchArea)); - // Account for scaled costs + tie-breaker headroom - const maxF = - (this.heuristicWeight + 1) * BASE_COST * maxDim * 2 + COST_SCALE * maxDim; - this.queue = new BucketQueue(maxF); + this.queue = new MinHeap(maxSearchArea * 4); } findPath(start: number | number[], goal: number): number[] | null { @@ -209,6 +205,8 @@ export class AStarWaterBounded implements PathFinder { closedStamp[neighborLocal] !== stamp && (neighbor === goal || (neighborTerrain & landMask) === 0) ) { + const ny = currentY - 1; + const distToGoal = Math.abs(currentX - goalX) + Math.abs(ny - goalY); const magnitude = neighborTerrain & MAGNITUDE_MASK; const cost = BASE_COST + getMagnitudePenalty(magnitude); const tentativeG = currentG + cost; @@ -219,11 +217,7 @@ export class AStarWaterBounded implements PathFinder { cameFrom[neighborLocal] = currentLocal; gScore[neighborLocal] = tentativeG; gScoreStamp[neighborLocal] = stamp; - const ny = currentY - 1; - const h = - weight * - BASE_COST * - (Math.abs(currentX - goalX) + Math.abs(ny - goalY)); + const h = weight * BASE_COST * distToGoal; const f = tentativeG + h + crossTieBreaker(currentX, ny); queue.push(neighborLocal, f); } @@ -238,6 +232,8 @@ export class AStarWaterBounded implements PathFinder { closedStamp[neighborLocal] !== stamp && (neighbor === goal || (neighborTerrain & landMask) === 0) ) { + const ny = currentY + 1; + const distToGoal = Math.abs(currentX - goalX) + Math.abs(ny - goalY); const magnitude = neighborTerrain & MAGNITUDE_MASK; const cost = BASE_COST + getMagnitudePenalty(magnitude); const tentativeG = currentG + cost; @@ -248,11 +244,7 @@ export class AStarWaterBounded implements PathFinder { cameFrom[neighborLocal] = currentLocal; gScore[neighborLocal] = tentativeG; gScoreStamp[neighborLocal] = stamp; - const ny = currentY + 1; - const h = - weight * - BASE_COST * - (Math.abs(currentX - goalX) + Math.abs(ny - goalY)); + const h = weight * BASE_COST * distToGoal; const f = tentativeG + h + crossTieBreaker(currentX, ny); queue.push(neighborLocal, f); } @@ -267,6 +259,8 @@ export class AStarWaterBounded implements PathFinder { closedStamp[neighborLocal] !== stamp && (neighbor === goal || (neighborTerrain & landMask) === 0) ) { + const nx = currentX - 1; + const distToGoal = Math.abs(nx - goalX) + Math.abs(currentY - goalY); const magnitude = neighborTerrain & MAGNITUDE_MASK; const cost = BASE_COST + getMagnitudePenalty(magnitude); const tentativeG = currentG + cost; @@ -277,11 +271,7 @@ export class AStarWaterBounded implements PathFinder { cameFrom[neighborLocal] = currentLocal; gScore[neighborLocal] = tentativeG; gScoreStamp[neighborLocal] = stamp; - const nx = currentX - 1; - const h = - weight * - BASE_COST * - (Math.abs(nx - goalX) + Math.abs(currentY - goalY)); + const h = weight * BASE_COST * distToGoal; const f = tentativeG + h + crossTieBreaker(nx, currentY); queue.push(neighborLocal, f); } @@ -296,6 +286,8 @@ export class AStarWaterBounded implements PathFinder { closedStamp[neighborLocal] !== stamp && (neighbor === goal || (neighborTerrain & landMask) === 0) ) { + const nx = currentX + 1; + const distToGoal = Math.abs(nx - goalX) + Math.abs(currentY - goalY); const magnitude = neighborTerrain & MAGNITUDE_MASK; const cost = BASE_COST + getMagnitudePenalty(magnitude); const tentativeG = currentG + cost; @@ -306,11 +298,7 @@ export class AStarWaterBounded implements PathFinder { cameFrom[neighborLocal] = currentLocal; gScore[neighborLocal] = tentativeG; gScoreStamp[neighborLocal] = stamp; - const nx = currentX + 1; - const h = - weight * - BASE_COST * - (Math.abs(nx - goalX) + Math.abs(currentY - goalY)); + const h = weight * BASE_COST * distToGoal; const f = tentativeG + h + crossTieBreaker(nx, currentY); queue.push(neighborLocal, f); } diff --git a/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts b/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts index ce8ceb2a7..78a8ff6bc 100644 --- a/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts +++ b/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts @@ -12,6 +12,7 @@ export class AStarWaterHierarchical implements PathFinder { private abstractAStar: AbstractGraphAStar; private localAStar: AStarWaterBounded; private localAStarMultiCluster: AStarWaterBounded; + private localAStarShortPath: AStarWaterBounded; private sourceResolver: SourceResolver; constructor( @@ -41,6 +42,11 @@ export class AStarWaterHierarchical implements PathFinder { maxMultiClusterNodes, ); + // BoundedAStar for short path multi-source (120 + 2*10 padding = 140) + const shortPathSize = 140; + const maxShortPathNodes = shortPathSize * shortPathSize; + this.localAStarShortPath = new AStarWaterBounded(map, maxShortPathNodes); + // SourceResolver for multi-source search this.sourceResolver = new SourceResolver(this.map, this.graph); } @@ -62,6 +68,10 @@ export class AStarWaterHierarchical implements PathFinder { sources: TileRef[], target: TileRef, ): TileRef[] | null { + // Early exit: try bounded A* for sources close to target + const shortPath = this.tryShortPathMultiSource(sources, target); + if (shortPath) return shortPath; + // 1. Resolve target to abstract node const targetNode = this.sourceResolver.resolveTarget(target); if (!targetNode) return null; @@ -82,6 +92,44 @@ export class AStarWaterHierarchical implements PathFinder { return this.findPathSingle(winningSource, target); } + private tryShortPathMultiSource( + sources: TileRef[], + target: TileRef, + ): TileRef[] | null { + const SHORT_PATH_THRESHOLD = 120; + const PADDING = 10; + + const candidates = sources.filter( + (s) => this.map.manhattanDist(s, target) <= SHORT_PATH_THRESHOLD, + ); + if (candidates.length === 0) return null; + + const toX = this.map.x(target); + const toY = this.map.y(target); + let minX = toX, + maxX = toX, + minY = toY, + maxY = toY; + + for (const s of candidates) { + const sx = this.map.x(s); + const sy = this.map.y(s); + minX = Math.min(minX, sx); + maxX = Math.max(maxX, sx); + minY = Math.min(minY, sy); + maxY = Math.max(maxY, sy); + } + + const bounds = { + minX: Math.max(0, minX - PADDING), + maxX: Math.min(this.map.width() - 1, maxX + PADDING), + minY: Math.max(0, minY - PADDING), + maxY: Math.min(this.map.height() - 1, maxY + PADDING), + }; + + return this.localAStarShortPath.searchBounded(candidates, target, bounds); + } + findPathSingle(from: TileRef, to: TileRef): TileRef[] | null { const dist = this.map.manhattanDist(from, to); diff --git a/src/core/pathfinding/algorithms/PriorityQueue.ts b/src/core/pathfinding/algorithms/PriorityQueue.ts index c8f525f0b..df7f52919 100644 --- a/src/core/pathfinding/algorithms/PriorityQueue.ts +++ b/src/core/pathfinding/algorithms/PriorityQueue.ts @@ -18,7 +18,20 @@ export class MinHeap implements PriorityQueue { push(node: number, priority: number): void { if (this.size >= this.capacity) { - throw new Error(`MinHeap capacity exceeded: ${this.capacity}`); + console.error( + `MinHeap capacity exceeded (${this.capacity}). ` + + "Resizing, but this indicates a bug. Please investigate.", + ); + + this.capacity *= 2; + + const newHeap = new Int32Array(this.capacity); + const newPri = new Float32Array(this.capacity); + newHeap.set(this.heap); + newPri.set(this.priorities); + + this.heap = newHeap; + this.priorities = newPri; } let i = this.size++; @@ -94,6 +107,8 @@ export class MinHeap implements PriorityQueue { export class BucketQueue implements PriorityQueue { private buckets: Int32Array[]; private bucketSizes: Int32Array; + private bucketStamp: Uint32Array; + private stamp = 0; private minBucket: number; private maxBucket: number; private size: number; @@ -102,6 +117,7 @@ export class BucketQueue implements PriorityQueue { this.maxBucket = maxPriority + 1; this.buckets = new Array(this.maxBucket); this.bucketSizes = new Int32Array(this.maxBucket); + this.bucketStamp = new Uint32Array(this.maxBucket); this.minBucket = this.maxBucket; this.size = 0; } @@ -113,7 +129,9 @@ export class BucketQueue implements PriorityQueue { this.buckets[bucket] = new Int32Array(64); } - const size = this.bucketSizes[bucket]; + const size = + this.bucketStamp[bucket] === this.stamp ? this.bucketSizes[bucket] : 0; + if (size >= this.buckets[bucket].length) { const newBucket = new Int32Array(this.buckets[bucket].length * 2); newBucket.set(this.buckets[bucket]); @@ -121,7 +139,8 @@ export class BucketQueue implements PriorityQueue { } this.buckets[bucket][size] = node; - this.bucketSizes[bucket]++; + this.bucketSizes[bucket] = size + 1; + this.bucketStamp[bucket] = this.stamp; this.size++; if (bucket < this.minBucket) { @@ -131,11 +150,13 @@ export class BucketQueue implements PriorityQueue { pop(): number { while (this.minBucket < this.maxBucket) { - const size = this.bucketSizes[this.minBucket]; - if (size > 0) { - this.bucketSizes[this.minBucket]--; - this.size--; - return this.buckets[this.minBucket][size - 1]; + if (this.bucketStamp[this.minBucket] === this.stamp) { + const size = this.bucketSizes[this.minBucket]; + if (size > 0) { + this.bucketSizes[this.minBucket]--; + this.size--; + return this.buckets[this.minBucket][size - 1]; + } } this.minBucket++; } @@ -147,7 +168,11 @@ export class BucketQueue implements PriorityQueue { } clear(): void { - this.bucketSizes.fill(0); + this.stamp++; + if (this.stamp > 0xffffffff) { + this.bucketStamp.fill(0); + this.stamp = 1; + } this.minBucket = this.maxBucket; this.size = 0; } diff --git a/src/core/pathfinding/spatial/SpatialQuery.ts b/src/core/pathfinding/spatial/SpatialQuery.ts index 1336a636f..9128dd0b4 100644 --- a/src/core/pathfinding/spatial/SpatialQuery.ts +++ b/src/core/pathfinding/spatial/SpatialQuery.ts @@ -1,12 +1,27 @@ import { Game, Player, TerraNullius } from "../../game/Game"; import { TileRef } from "../../game/GameMap"; +import { DebugSpan } from "../../utilities/DebugSpan"; import { PathFinding } from "../PathFinder"; +import { AStarWaterBounded } from "../algorithms/AStar.WaterBounded"; type Owner = Player | TerraNullius; +const REFINE_MAX_SEARCH_AREA = 100 * 100; + export class SpatialQuery { + private boundedAStar: AStarWaterBounded | null = null; + constructor(private game: Game) {} + private getBoundedAStar(): AStarWaterBounded { + this.boundedAStar ??= new AStarWaterBounded( + this.game.map(), + REFINE_MAX_SEARCH_AREA, + ); + + return this.boundedAStar; + } + /** * Find nearest tile matching predicate using BFS traversal. * Uses Manhattan distance filter, ignores terrain barriers. @@ -64,27 +79,125 @@ export class SpatialQuery { * Returns null for terra nullius (no borderTiles). */ closestShoreByWater(owner: Owner, target: TileRef): TileRef | null { - if (!owner.isPlayer()) return null; + return DebugSpan.wrap("SpatialQuery.closestShoreByWater", () => { + if (!owner.isPlayer()) return null; - const gm = this.game; - const player = owner as Player; + const gm = this.game; + const player = owner as Player; - // Target must be water or shore (land adjacent to water) - if (!gm.isWater(target) && !gm.isShore(target)) return null; + // Target must be water or shore (land adjacent to water) + if (!gm.isWater(target) && !gm.isShore(target)) return null; - const targetComponent = gm.getWaterComponent(target); - if (targetComponent === null) return null; + const targetComponent = gm.getWaterComponent(target); + if (targetComponent === null) return null; - const isValidTile = (t: TileRef) => { - if (!gm.isShore(t) || !gm.isLand(t)) return false; - const tComponent = gm.getWaterComponent(t); - return tComponent === targetComponent; + const isValidTile = (t: TileRef) => { + if (!gm.isShore(t) || !gm.isLand(t)) return false; + const tComponent = gm.getWaterComponent(t); + return tComponent === targetComponent; + }; + + const shores = Array.from(player.borderTiles()).filter(isValidTile); + if (shores.length === 0) return null; + + const path = PathFinding.Water(gm).findPath(shores, target); + if (!path || path.length === 0) return null; + + return DebugSpan.wrap("SpatialQuery.refineStartTile", () => + this.refineStartTile(path, shores, gm), + ); + }); + } + + private refineStartTile( + path: TileRef[], + shores: TileRef[], + gm: Game, + ): TileRef { + const CANDIDATE_RADIUS = 20; + const MIN_WAYPOINT_DIST = 50; + const MAX_WAYPOINT_DIST = 200; + const PADDING = 10; + + if (path.length <= MIN_WAYPOINT_DIST) { + return path[0]; + } + + const bestTile = path[0]; + const map = gm.map(); + + const candidates = shores.filter( + (s) => map.manhattanDist(s, bestTile) <= CANDIDATE_RADIUS, + ); + + if (candidates.length <= 1) return bestTile; + + // Precompute candidate bounds + let candMinX = map.x(candidates[0]); + let candMaxX = candMinX; + let candMinY = map.y(candidates[0]); + let candMaxY = candMinY; + + for (let i = 1; i < candidates.length; i++) { + const sx = map.x(candidates[i]); + const sy = map.y(candidates[i]); + candMinX = Math.min(candMinX, sx); + candMaxX = Math.max(candMaxX, sx); + candMinY = Math.min(candMinY, sy); + candMaxY = Math.max(candMaxY, sy); + } + + // Binary search for furthest waypoint that keeps bounds within limit + let lo = MIN_WAYPOINT_DIST; + let hi = Math.min(MAX_WAYPOINT_DIST, path.length - 1); + let bestWaypointIdx = lo; + + for (let i = 0; i < 5 && lo <= hi; i++) { + const mid = (lo + hi) >> 1; + const wp = path[mid]; + const wpX = map.x(wp); + const wpY = map.y(wp); + + const minX = Math.min(candMinX, wpX) - PADDING; + const maxX = Math.max(candMaxX, wpX) + PADDING; + const minY = Math.min(candMinY, wpY) - PADDING; + const maxY = Math.max(candMaxY, wpY) + PADDING; + + const area = (maxX - minX + 1) * (maxY - minY + 1); + if (area <= REFINE_MAX_SEARCH_AREA) { + bestWaypointIdx = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + const waypoint = path[bestWaypointIdx]; + const wpX = map.x(waypoint); + const wpY = map.y(waypoint); + + const bounds = { + minX: Math.max(0, Math.min(candMinX, wpX) - PADDING), + maxX: Math.min(map.width() - 1, Math.max(candMaxX, wpX) + PADDING), + minY: Math.max(0, Math.min(candMinY, wpY) - PADDING), + maxY: Math.min(map.height() - 1, Math.max(candMaxY, wpY) + PADDING), }; - const shores = Array.from(player.borderTiles()).filter(isValidTile); - if (shores.length === 0) return null; + const boundsArea = + (bounds.maxX - bounds.minX + 1) * (bounds.maxY - bounds.minY + 1); + if (boundsArea > REFINE_MAX_SEARCH_AREA) return bestTile; - const path = PathFinding.Water(gm).findPath(shores, target); - return path?.[0] ?? null; + const refinedPath = this.getBoundedAStar().searchBounded( + candidates, + waypoint, + bounds, + ); + + DebugSpan.set("$candidates", () => candidates); + DebugSpan.set("$refinedPath", () => refinedPath); + DebugSpan.set("$originalBestTile", () => bestTile); + DebugSpan.set("$newBestTile", () => refinedPath?.[0] ?? bestTile); + + return refinedPath?.[0] ?? bestTile; } } diff --git a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts index 63b30c97f..5b4bd0b0c 100644 --- a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts +++ b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts @@ -54,6 +54,9 @@ export class SmoothingWaterTransformer implements PathFinder { this.refineEndpoints(smoothed), ); + // Pass 3: LOS smoothing again (refinement may create new shortcut opportunities) + smoothed = DebugSpan.wrap("smoother:los2", () => this.losSmooth(smoothed)); + return smoothed; } diff --git a/tests/pathfinding/playground/api/spatialQuery.ts b/tests/pathfinding/playground/api/spatialQuery.ts new file mode 100644 index 000000000..df209fd77 --- /dev/null +++ b/tests/pathfinding/playground/api/spatialQuery.ts @@ -0,0 +1,161 @@ +import { TileRef } from "../../../../src/core/game/GameMap.js"; +import { PathFinding } from "../../../../src/core/pathfinding/PathFinder.js"; +import { SpatialQuery } from "../../../../src/core/pathfinding/spatial/SpatialQuery.js"; +import { DebugSpan } from "../../../../src/core/utilities/DebugSpan.js"; +import { loadMap } from "./maps.js"; + +export interface SpatialQueryResult { + selectedShore: [number, number] | null; + path: Array<[number, number]> | null; + shores: Array<[number, number]>; + debug: { + candidates: Array<[number, number]> | null; + refinedPath: Array<[number, number]> | null; + originalBestTile: [number, number] | null; + newBestTile: [number, number] | null; + timings: Record; + }; +} + +/** + * Extract timings from DebugSpan hierarchy + */ +function extractTimings(span: { + name: string; + duration?: number; + children: any[]; +}): Record { + const timings: Record = {}; + + if (span.duration !== undefined) { + timings[span.name] = span.duration; + } + + for (const child of span.children) { + Object.assign(timings, extractTimings(child)); + } + + return timings; +} + +/** + * Convert TileRef to coordinate tuple + */ +function tileToCoord(tile: TileRef, game: any): [number, number] { + return [game.x(tile), game.y(tile)]; +} + +/** + * Convert TileRef array to coordinate array + */ +function tilesToCoords( + tiles: TileRef[] | null | undefined, + game: any, +): Array<[number, number]> | null { + if (!tiles) return null; + return tiles.map((tile) => tileToCoord(tile, game)); +} + +/** + * Compute spatial query for transport ship launch + */ +export async function computeSpatialQuery( + mapName: string, + ownedTiles: number[], + target: [number, number], +): Promise { + const { game } = await loadMap(mapName); + + const targetRef = game.ref(target[0], target[1]) as TileRef; + + // Validate target is water or shore + if (!game.isWater(targetRef) && !game.isShore(targetRef)) { + throw new Error( + `Target (${target[0]}, ${target[1]}) must be water or shore`, + ); + } + + // Convert owned tile indices to TileRefs + const ownedRefs = ownedTiles.map((idx) => { + const x = idx % game.width(); + const y = Math.floor(idx / game.width()); + return game.ref(x, y) as TileRef; + }); + + // Create mock player that returns owned tiles as border tiles + // The SpatialQuery will filter to actual shore tiles + const mockPlayer = { + isPlayer: () => true, + smallID: () => 999, // Arbitrary ID for visualization + borderTiles: function* () { + for (const tile of ownedRefs) { + yield tile; + } + }, + }; + + // Get target water component for filtering + const targetComponent = game.getWaterComponent(targetRef); + + // Pre-compute all valid shore tiles for visualization + const allShores: TileRef[] = []; + for (const tile of ownedRefs) { + if (game.isShore(tile) && game.isLand(tile)) { + const tComponent = game.getWaterComponent(tile); + if (tComponent === targetComponent) { + allShores.push(tile); + } + } + } + + // Enable DebugSpan to capture internal state + DebugSpan.enable(); + + // Run spatial query + const spatialQuery = new SpatialQuery(game); + const selectedShore = spatialQuery.closestShoreByWater( + mockPlayer as any, + targetRef, + ); + + // Get span data + const span = DebugSpan.getLastSpan(); + DebugSpan.disable(); + + // Extract debug info from span + let candidates: TileRef[] | null = null; + let refinedPath: TileRef[] | null = null; + let originalBestTile: TileRef | null = null; + let newBestTile: TileRef | null = null; + + if (span?.data) { + candidates = (span.data.$candidates as TileRef[] | undefined) ?? null; + refinedPath = (span.data.$refinedPath as TileRef[] | undefined) ?? null; + originalBestTile = + (span.data.$originalBestTile as TileRef | undefined) ?? null; + newBestTile = (span.data.$newBestTile as TileRef | undefined) ?? null; + } + + // Compute full path if we have a selected shore + let path: TileRef[] | null = null; + if (selectedShore) { + path = PathFinding.Water(game).findPath(selectedShore, targetRef); + } + + const timings = span ? extractTimings(span) : {}; + + return { + selectedShore: selectedShore ? tileToCoord(selectedShore, game) : null, + path: tilesToCoords(path, game), + shores: allShores.map((t) => tileToCoord(t, game)), + debug: { + candidates: tilesToCoords(candidates, game), + refinedPath: tilesToCoords(refinedPath, game), + originalBestTile: originalBestTile + ? tileToCoord(originalBestTile, game) + : null, + newBestTile: newBestTile ? tileToCoord(newBestTile, game) : null, + timings, + }, + }; +} diff --git a/tests/pathfinding/playground/public/client.js b/tests/pathfinding/playground/public/client.js index 0016ccdb7..c40a5ef9f 100644 --- a/tests/pathfinding/playground/public/client.js +++ b/tests/pathfinding/playground/public/client.js @@ -16,6 +16,11 @@ const state = { isMapLoading: false, // Loading state for map switching isHpaLoading: false, // Separate loading state for HPA* activeRefreshButton: null, // Track which refresh button is spinning + // Transport Ship mode + mode: "pathfinding", // "pathfinding" | "transport" + paintedTiles: new Set(), // Set of tile indices (y * width + x) + brushSize: 5, + transportResult: null, // Result from spatial query }; // Colors for comparison paths @@ -36,6 +41,8 @@ let dragStartX = 0; let dragStartY = 0; let dragStartPanX = 0; let dragStartPanY = 0; +let isPainting = false; +let isErasing = false; let mapCanvas, overlayCanvas, interactiveCanvas; let mapCtx, overlayCtx, interactiveCtx; @@ -203,6 +210,109 @@ function initializeControls() { document.getElementById("clearPoints").addEventListener("click", () => { clearPoints(); }); + + // Mode switch buttons + document.querySelectorAll(".mode-button").forEach((btn) => { + btn.addEventListener("click", () => { + const newMode = btn.dataset.mode; + if (newMode !== state.mode) { + setMode(newMode); + } + }); + }); + + // Transport controls + const brushSizeInput = document.getElementById("brushSize"); + const brushSizeValue = document.getElementById("brushSizeValue"); + brushSizeInput.addEventListener("input", (e) => { + state.brushSize = parseInt(e.target.value); + brushSizeValue.textContent = state.brushSize; + }); + + document.getElementById("clearTerritory").addEventListener("click", () => { + state.paintedTiles.clear(); + state.transportResult = null; + updateTransportInfo(); + renderInteractive(); + }); +} + +// Set application mode +function setMode(newMode) { + state.mode = newMode; + + // Update UI + document.querySelectorAll(".mode-button").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.mode === newMode); + }); + + const transportControls = document.getElementById("transportControls"); + const timingsPanel = document.getElementById("timingsPanel"); + const debugPanel = document.querySelector(".debug-panel"); + + if (newMode === "transport") { + transportControls.style.display = "block"; + timingsPanel.style.top = "280px"; + debugPanel.style.display = "none"; + setStatus("Paint territory, then click water target"); + } else { + transportControls.style.display = "none"; + timingsPanel.style.top = "280px"; + debugPanel.style.display = "flex"; + if (state.startPoint && state.endPoint) { + setStatus("Path computed successfully"); + } else if (state.startPoint) { + setStatus("Click on map to set end point"); + } else { + setStatus("Click on map to set start point"); + } + } + + renderInteractive(); +} + +// Update transport info display +function updateTransportInfo() { + const paintedCount = document.getElementById("paintedCount"); + const shoreCount = document.getElementById("shoreCount"); + + paintedCount.textContent = state.paintedTiles.size; + + // Count shore tiles + let shores = 0; + if (state.mapData) { + for (const idx of state.paintedTiles) { + if (isLandShore(idx)) { + shores++; + } + } + } + shoreCount.textContent = shores; +} + +// Check if tile is a land shore (land adjacent to water) +function isLandShore(tileIdx) { + const x = tileIdx % state.mapWidth; + const y = Math.floor(tileIdx / state.mapWidth); + + // Must be land + if (state.mapData[tileIdx] !== 0) return false; + + // Check 4 neighbors for water + const neighbors = [ + [x - 1, y], + [x + 1, y], + [x, y - 1], + [x, y + 1], + ]; + + for (const [nx, ny] of neighbors) { + if (nx < 0 || nx >= state.mapWidth || ny < 0 || ny >= state.mapHeight) + continue; + const nIdx = ny * state.mapWidth + nx; + if (state.mapData[nIdx] === 1) return true; + } + return false; } // Helper function to check if mouse is over a start/end point @@ -250,6 +360,20 @@ function schedulePathRecalc() { // If not enough time has passed, skip this call (throttle) } +// Throttled spatial query recalculation (max once per 50ms for heavier computation) +let lastSpatialQueryTime = 0; +function scheduleSpatialQueryRecalc() { + const now = Date.now(); + const timeSinceLastCall = now - lastSpatialQueryTime; + + if (timeSinceLastCall >= 50) { + lastSpatialQueryTime = now; + if (state.endPoint && state.paintedTiles.size > 0) { + requestSpatialQuery(state.endPoint); + } + } +} + // Initialize drag and click controls function initializeDragControls() { const wrapper = document.getElementById("canvasWrapper"); @@ -260,10 +384,46 @@ function initializeDragControls() { const canvasX = (e.clientX - rect.left - panX) / zoomLevel; const canvasY = (e.clientY - rect.top - panY) / zoomLevel; - // Check if clicking on a point + // Transport mode: check for dragging end point first, then painting + if (state.mode === "transport") { + // Check if clicking on end point to drag it + const pointAtMouse = getPointAtPosition(canvasX, canvasY); + if (pointAtMouse === "end") { + draggingPoint = "end"; + wrapper.style.cursor = "move"; + dragStartX = e.clientX; + dragStartY = e.clientY; + return; + } + + const tileX = Math.floor(canvasX); + const tileY = Math.floor(canvasY); + + if ( + tileX >= 0 && + tileX < state.mapWidth && + tileY >= 0 && + tileY < state.mapHeight + ) { + const tileIdx = tileY * state.mapWidth + tileX; + const isLand = state.mapData[tileIdx] === 0; + + if (isLand) { + // Start painting (or erasing with ctrl/right-click) + isErasing = e.ctrlKey || e.button === 2; + isPainting = true; + paintAtPosition(tileX, tileY, isErasing); + wrapper.style.cursor = isErasing ? "crosshair" : "pointer"; + return; + } + } + // Fall through to panning if not on land + } + + // Pathfinding mode: check if clicking on a point const pointAtMouse = getPointAtPosition(canvasX, canvasY); - if (pointAtMouse) { + if (pointAtMouse && state.mode === "pathfinding") { // Start dragging the point draggingPoint = pointAtMouse; wrapper.style.cursor = "move"; @@ -284,6 +444,53 @@ function initializeDragControls() { const canvasX = (e.clientX - rect.left - panX) / zoomLevel; const canvasY = (e.clientY - rect.top - panY) / zoomLevel; + // Transport mode: continue painting + if (isPainting && state.mode === "transport") { + const tileX = Math.floor(canvasX); + const tileY = Math.floor(canvasY); + paintAtPosition(tileX, tileY, isErasing); + return; + } + + // Transport mode: dragging end point + if (draggingPoint === "end" && state.mode === "transport") { + const tileX = Math.floor(canvasX); + const tileY = Math.floor(canvasY); + + if ( + tileX >= 0 && + tileX < state.mapWidth && + tileY >= 0 && + tileY < state.mapHeight + ) { + const tileIndex = tileY * state.mapWidth + tileX; + const isWater = state.mapData[tileIndex] === 1; + + if (isWater) { + draggingPointPosition = [tileX, tileY]; + state.endPoint = [tileX, tileY]; + renderInteractive(); + + // Throttled spatial query recomputation + if (state.paintedTiles.size > 0) { + scheduleSpatialQueryRecalc(); + } + } + } + return; + } + + // Transport mode: check hover over end point + if (state.mode === "transport" && !isDragging) { + const pointAtMouse = getPointAtPosition(canvasX, canvasY); + if (pointAtMouse !== hoveredPoint) { + hoveredPoint = pointAtMouse; + renderInteractive(); + wrapper.style.cursor = hoveredPoint ? "move" : "grab"; + } + return; + } + if (draggingPoint) { // Dragging a start/end point - snap to water tile const tileX = Math.floor(canvasX); @@ -395,6 +602,26 @@ function initializeDragControls() { const dx = Math.abs(e.clientX - dragStartX); const dy = Math.abs(e.clientY - dragStartY); + // Transport mode: finish painting + if (isPainting) { + isPainting = false; + isErasing = false; + wrapper.style.cursor = "grab"; + return; + } + + // Transport mode: finish dragging end point + if (draggingPoint === "end" && state.mode === "transport") { + if (state.endPoint && state.paintedTiles.size > 0) { + requestSpatialQuery(state.endPoint); + } + draggingPoint = null; + draggingPointPosition = null; + renderInteractive(); + wrapper.style.cursor = "grab"; + return; + } + if (draggingPoint) { // Finished dragging a point // Request final path update to ensure we have the path for the final position @@ -408,7 +635,11 @@ function initializeDragControls() { updateURLState(); } else if (isDragging && dx < 5 && dy < 5) { // Was panning but didn't move much - treat as click - handleMapClick(e); + if (state.mode === "transport") { + handleTransportClick(e); + } else { + handleMapClick(e); + } } isDragging = false; @@ -418,13 +649,16 @@ function initializeDragControls() { const canvasX = (e.clientX - rect.left - panX) / zoomLevel; const canvasY = (e.clientY - rect.top - panY) / zoomLevel; const pointAtMouse = getPointAtPosition(canvasX, canvasY); - wrapper.style.cursor = pointAtMouse ? "move" : "grab"; + wrapper.style.cursor = + pointAtMouse && state.mode === "pathfinding" ? "move" : "grab"; }); wrapper.addEventListener("mouseleave", () => { isDragging = false; draggingPoint = null; draggingPointPosition = null; + isPainting = false; + isErasing = false; tooltip.classList.remove("visible"); wrapper.style.cursor = "grab"; @@ -437,6 +671,13 @@ function initializeDragControls() { } }); + // Prevent context menu on right-click (for erasing) + wrapper.addEventListener("contextmenu", (e) => { + if (state.mode === "transport") { + e.preventDefault(); + } + }); + wrapper.addEventListener("wheel", (e) => { e.preventDefault(); @@ -446,7 +687,7 @@ function initializeDragControls() { const oldZoom = zoomLevel; const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1; - zoomLevel = Math.max(0.1, Math.min(5, zoomLevel * zoomDelta)); + zoomLevel = Math.max(0.1, Math.min(10, zoomLevel * zoomDelta)); panX = mouseX - (mouseX - panX) * (zoomLevel / oldZoom); panY = mouseY - (mouseY - panY) * (zoomLevel / oldZoom); @@ -535,6 +776,155 @@ function clearPoints() { renderInteractive(); } +// Paint tiles in a brush area +function paintAtPosition(centerX, centerY, erase = false) { + const radius = Math.floor(state.brushSize / 2); + let changed = false; + + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const x = centerX + dx; + const y = centerY + dy; + + if (x < 0 || x >= state.mapWidth || y < 0 || y >= state.mapHeight) + continue; + + const idx = y * state.mapWidth + x; + const isLand = state.mapData[idx] === 0; + + if (!isLand) continue; + + if (erase) { + if (state.paintedTiles.has(idx)) { + state.paintedTiles.delete(idx); + changed = true; + } + } else { + if (!state.paintedTiles.has(idx)) { + state.paintedTiles.add(idx); + changed = true; + } + } + } + } + + if (changed) { + updateTransportInfo(); + renderInteractive(); + } +} + +// Handle clicks in transport mode +function handleTransportClick(e) { + if (!state.currentMap || state.isMapLoading) return; + + const wrapper = document.getElementById("canvasWrapper"); + const rect = wrapper.getBoundingClientRect(); + + const canvasX = (e.clientX - rect.left - panX) / zoomLevel; + const canvasY = (e.clientY - rect.top - panY) / zoomLevel; + const tileX = Math.floor(canvasX); + const tileY = Math.floor(canvasY); + + if ( + tileX < 0 || + tileX >= state.mapWidth || + tileY < 0 || + tileY >= state.mapHeight + ) { + return; + } + + const idx = tileY * state.mapWidth + tileX; + const isWater = state.mapData[idx] === 1; + + if (!isWater) { + return; + } + + // Clicked on water - run spatial query + if (state.paintedTiles.size === 0) { + showError("Paint some territory first"); + return; + } + + requestSpatialQuery([tileX, tileY]); +} + +// Request spatial query computation +async function requestSpatialQuery(target) { + setStatus("Computing spatial query...", true); + + try { + // Only send shore tiles (land adjacent to water) - much smaller payload + const ownedTiles = Array.from(state.paintedTiles).filter((idx) => + isLandShore(idx), + ); + + const response = await fetch("/api/spatial-query", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + map: state.currentMap, + ownedTiles, + target, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "Spatial query failed"); + } + + const result = await response.json(); + state.transportResult = result; + state.endPoint = target; + + renderInteractive(); + updateTransportTimings(result); + + if (result.selectedShore) { + setStatus( + `Shore selected: (${result.selectedShore[0]}, ${result.selectedShore[1]})`, + ); + } else { + setStatus("No valid shore found"); + } + } catch (error) { + showError(`Spatial query failed: ${error.message}`); + } +} + +// Update timings panel for transport mode +function updateTransportTimings(result) { + const hpaTimeEl = document.getElementById("hpaTime"); + const hpaTilesEl = document.getElementById("hpaTiles"); + + if (result.path) { + hpaTilesEl.textContent = `- ${result.path.length} tiles`; + } else { + hpaTilesEl.textContent = ""; + } + + const totalTime = + result.debug?.timings?.["SpatialQuery.closestShoreByWater"] ?? 0; + if (totalTime > 0) { + hpaTimeEl.textContent = `${totalTime.toFixed(2)}ms`; + hpaTimeEl.classList.remove("faded"); + } else { + hpaTimeEl.textContent = "0.00ms"; + hpaTimeEl.classList.add("faded"); + } + + // Hide pathfinding-specific timing breakdown in transport mode + document.getElementById("timingEarlyExit").style.display = "none"; + document.getElementById("timingFindNodes").style.display = "none"; + document.getElementById("timingAbstractPath").style.display = "none"; + document.getElementById("timingInitialPath").style.display = "none"; + document.getElementById("timingSmoothPath").style.display = "none"; + document.getElementById("comparisonsSection").style.display = "none"; +} + // Update transform for pan/zoom function updateTransform() { const transform = `translate(${panX}px, ${panY}px) scale(${zoomLevel})`; @@ -1164,6 +1554,135 @@ function mapToScreen(mapX, mapY) { }; } +// Render transport mode elements +function renderTransportMode() { + const tileSize = Math.max(1, zoomLevel); + + // Draw painted territory + if (state.paintedTiles.size > 0) { + interactiveCtx.fillStyle = "rgba(66, 135, 245, 0.5)"; + + for (const idx of state.paintedTiles) { + const x = idx % state.mapWidth; + const y = Math.floor(idx / state.mapWidth); + const screen = mapToScreen(x, y); + interactiveCtx.fillRect(screen.x, screen.y, tileSize, tileSize); + } + } + + // Draw all shore tiles (dark blue squares) + if (state.transportResult && state.transportResult.shores) { + interactiveCtx.fillStyle = "#2a4a6a"; + + for (const [x, y] of state.transportResult.shores) { + const screen = mapToScreen(x, y); + interactiveCtx.fillRect(screen.x, screen.y, tileSize, tileSize); + } + } + + // Draw refinement candidates (muted yellow/gold squares) + if (state.transportResult?.debug?.candidates) { + interactiveCtx.fillStyle = "rgba(200, 170, 80, 0.7)"; + + for (const [x, y] of state.transportResult.debug.candidates) { + const screen = mapToScreen(x, y); + interactiveCtx.fillRect(screen.x, screen.y, tileSize, tileSize); + } + } + + // Draw refined path (magenta) + if (state.transportResult?.debug?.refinedPath) { + interactiveCtx.strokeStyle = "#ff00ff"; + interactiveCtx.lineWidth = Math.max(1, zoomLevel * 0.8); + interactiveCtx.lineCap = "round"; + interactiveCtx.lineJoin = "round"; + interactiveCtx.beginPath(); + + for (let i = 0; i < state.transportResult.debug.refinedPath.length; i++) { + const [x, y] = state.transportResult.debug.refinedPath[i]; + const screen = mapToScreen(x + 0.5, y + 0.5); + if (i === 0) { + interactiveCtx.moveTo(screen.x, screen.y); + } else { + interactiveCtx.lineTo(screen.x, screen.y); + } + } + interactiveCtx.stroke(); + } + + // Draw full path (cyan) + if (state.transportResult && state.transportResult.path) { + interactiveCtx.strokeStyle = "#00ffff"; + interactiveCtx.lineWidth = Math.max(1, zoomLevel); + interactiveCtx.lineCap = "round"; + interactiveCtx.lineJoin = "round"; + interactiveCtx.beginPath(); + + for (let i = 0; i < state.transportResult.path.length; i++) { + const [x, y] = state.transportResult.path[i]; + const screen = mapToScreen(x + 0.5, y + 0.5); + if (i === 0) { + interactiveCtx.moveTo(screen.x, screen.y); + } else { + interactiveCtx.lineTo(screen.x, screen.y); + } + } + interactiveCtx.stroke(); + } + + // Draw original best tile (orange square) if different from new best + if (state.transportResult?.debug?.originalBestTile) { + const [ox, oy] = state.transportResult.debug.originalBestTile; + const newBest = state.transportResult.debug.newBestTile; + + // Only show if different from new best + if (!newBest || ox !== newBest[0] || oy !== newBest[1]) { + const screen = mapToScreen(ox, oy); + interactiveCtx.fillStyle = "#ff8800"; + interactiveCtx.fillRect(screen.x, screen.y, tileSize, tileSize); + } + } + + // Draw selected shore (green square) + if (state.transportResult && state.transportResult.selectedShore) { + const [sx, sy] = state.transportResult.selectedShore; + const screen = mapToScreen(sx, sy); + interactiveCtx.fillStyle = "#44ff44"; + interactiveCtx.fillRect(screen.x, screen.y, tileSize, tileSize); + } + + // Draw target point (red circle, matching pathfinding mode style) + if (state.endPoint) { + const markerSize = Math.max(4, 3 * zoomLevel); + let mapX, mapY; + if (draggingPoint === "end" && draggingPointPosition) { + mapX = draggingPointPosition[0] + 0.5; + mapY = draggingPointPosition[1] + 0.5; + } else { + mapX = state.endPoint[0] + 0.5; + mapY = state.endPoint[1] + 0.5; + } + + const screen = mapToScreen(mapX, mapY); + + // Highlight ring if hovered + if (hoveredPoint === "end") { + interactiveCtx.strokeStyle = "#ff4444"; + interactiveCtx.lineWidth = Math.max(2, zoomLevel * 0.5); + interactiveCtx.globalAlpha = 0.5; + interactiveCtx.beginPath(); + interactiveCtx.arc(screen.x, screen.y, markerSize + 3, 0, Math.PI * 2); + interactiveCtx.stroke(); + interactiveCtx.globalAlpha = 1.0; + } + + interactiveCtx.fillStyle = "#ff4444"; + interactiveCtx.beginPath(); + interactiveCtx.arc(screen.x, screen.y, markerSize, 0, Math.PI * 2); + interactiveCtx.fill(); + } +} + // Render truly interactive/dynamic overlay (paths, points, highlights) at screen coordinates function renderInteractive() { // Clear viewport-sized canvas (super fast!) @@ -1178,6 +1697,12 @@ function renderInteractive() { const markerSize = Math.max(4, 3 * zoomLevel); + // Transport mode: render painted territory and results + if (state.mode === "transport") { + renderTransportMode(); + return; + } + // Check what to show const showUsedNodes = document.getElementById("showUsedNodes").dataset.active === "true"; diff --git a/tests/pathfinding/playground/public/index.html b/tests/pathfinding/playground/public/index.html index f03d041d3..f4dcbaf79 100644 --- a/tests/pathfinding/playground/public/index.html +++ b/tests/pathfinding/playground/public/index.html @@ -118,11 +118,88 @@
+
+ + +
+
Select a scenario to begin
+ + +
@@ -149,7 +226,7 @@
- + 1.0x
+
+ + +
+
Select a scenario to begin
+ + +
@@ -149,7 +226,7 @@
- + 1.0x
+
` : undefined, diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index e7d709c12..2ca75b207 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -1,6 +1,6 @@ import { TemplateResult, html } from "lit"; import { customElement, state } from "lit/decorators.js"; -import { copyToClipboard, translateText } from "../client/Utils"; +import { translateText } from "../client/Utils"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { Difficulty, @@ -15,7 +15,6 @@ import { mapCategories, } from "../core/game/Game"; import { getCompactMapNationCount } from "../core/game/NationCreation"; -import { UserSettings } from "../core/game/UserSettings"; import { ClientInfo, GameConfig, @@ -26,6 +25,7 @@ import { import { generateID } from "../core/Util"; import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; +import "./components/CopyButton"; import "./components/Difficulties"; import "./components/FluentSlider"; import "./components/LobbyTeamView"; @@ -65,19 +65,16 @@ export class HostLobbyModal extends BaseModal { @state() private startingGold: boolean = false; @state() private startingGoldValue: number | undefined = undefined; @state() private lobbyId = ""; - @state() private copySuccess = false; @state() private lobbyUrlSuffix = ""; @state() private clients: ClientInfo[] = []; @state() private useRandomMap: boolean = false; @state() private disabledUnits: UnitType[] = []; @state() private lobbyCreatorClientID: string = ""; - @state() private lobbyIdVisible: boolean = true; @state() private nationCount: number = 0; private playersInterval: NodeJS.Timeout | null = null; // Add a new timer for debouncing bot changes private botsUpdateTimer: number | null = null; - private userSettings: UserSettings = new UserSettings(); private mapLoader = terrainMapFileLoader; private leaveLobbyOnClose = true; @@ -144,91 +141,11 @@ export class HostLobbyModal extends BaseModal { }, ariaLabel: translateText("common.back"), rightContent: html` - -
- - - -
+ `, })} @@ -997,10 +914,6 @@ export class HostLobbyModal extends BaseModal { protected onOpen(): void { this.lobbyCreatorClientID = generateID(); - this.lobbyIdVisible = this.userSettings.get( - "settings.lobbyIdVisibility", - true, - ); createLobby(this.lobbyCreatorClientID) .then(async (lobby) => { @@ -1119,10 +1032,8 @@ export class HostLobbyModal extends BaseModal { this.useRandomMap = false; this.disabledUnits = []; this.lobbyId = ""; - this.copySuccess = false; this.clients = []; this.lobbyCreatorClientID = ""; - this.lobbyIdVisible = true; this.nationCount = 0; this.goldMultiplier = false; this.goldMultiplierValue = undefined; @@ -1403,15 +1314,6 @@ export class HostLobbyModal extends BaseModal { return response; } - private async copyToClipboard() { - const url = await this.buildLobbyUrl(); - await copyToClipboard( - url, - () => (this.copySuccess = true), - () => (this.copySuccess = false), - ); - } - private async pollPlayers() { const config = await getServerConfigFromClient(); fetch(`/${config.workerPath(this.lobbyId)}/api/game/${this.lobbyId}`, { diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index cb65b428f..117e6d753 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -1,6 +1,6 @@ import { html, TemplateResult } from "lit"; import { customElement, query, state } from "lit/decorators.js"; -import { copyToClipboard, translateText } from "../client/Utils"; +import { translateText } from "../client/Utils"; import { ClientInfo, GAME_ID_REGEX, @@ -11,10 +11,10 @@ import { import { generateID } from "../core/Util"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { GameMode } from "../core/game/Game"; -import { UserSettings } from "../core/game/UserSettings"; import { getApiBase } from "./Api"; import { JoinLobbyEvent } from "./Main"; import { BaseModal } from "./components/BaseModal"; +import "./components/CopyButton"; import "./components/Difficulties"; import "./components/LobbyTeamView"; import { modalHeader } from "./components/ui/ModalHeader"; @@ -26,12 +26,9 @@ export class JoinPrivateLobbyModal extends BaseModal { @state() private players: ClientInfo[] = []; @state() private gameConfig: GameConfig | null = null; @state() private lobbyCreatorClientID: string | null = null; - @state() private lobbyIdVisible: boolean = true; - @state() private copySuccess: boolean = false; @state() private currentLobbyId: string = ""; private playersInterval: NodeJS.Timeout | null = null; - private userSettings: UserSettings = new UserSettings(); private leaveLobbyOnClose = true; @@ -50,91 +47,7 @@ export class JoinPrivateLobbyModal extends BaseModal { ariaLabel: translateText("common.close"), rightContent: this.hasJoined ? html` - -
- -
{ - (e.currentTarget as HTMLElement).classList.add( - "select-all", - ); - }} - @mouseleave=${(e: Event) => { - (e.currentTarget as HTMLElement).classList.remove( - "select-all", - ); - }} - class="font-mono text-xs font-bold text-white px-2 cursor-pointer select-none min-w-[80px] text-center truncate tracking-wider" - title="${translateText("common.click_to_copy")}" - > - ${this.copySuccess - ? translateText("common.copied") - : this.lobbyIdVisible - ? this.currentLobbyId - : "••••••••"} -
- -
+ ` : undefined, })} @@ -347,10 +260,6 @@ export class JoinPrivateLobbyModal extends BaseModal { public open(id: string = "") { super.open(); - this.lobbyIdVisible = this.userSettings.get( - "settings.lobbyIdVisibility", - true, - ); if (id) { this.setLobbyId(id); this.joinLobby(); @@ -396,15 +305,6 @@ export class JoinPrivateLobbyModal extends BaseModal { this.close(); } - private async copyToClipboard() { - const config = await getServerConfigFromClient(); - await copyToClipboard( - `${location.origin}/${config.workerPath(this.currentLobbyId)}/game/${this.currentLobbyId}`, - () => (this.copySuccess = true), - () => (this.copySuccess = false), - ); - } - private isValidLobbyId(value: string): boolean { return GAME_ID_REGEX.test(value); } diff --git a/src/client/components/CopyButton.ts b/src/client/components/CopyButton.ts new file mode 100644 index 000000000..13742cc58 --- /dev/null +++ b/src/client/components/CopyButton.ts @@ -0,0 +1,206 @@ +import { LitElement, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { getServerConfigFromClient } from "../../core/configuration/ConfigLoader"; +import { UserSettings } from "../../core/game/UserSettings"; +import { copyToClipboard, translateText } from "../Utils"; + +@customElement("copy-button") +export class CopyButton extends LitElement { + @property({ type: String, attribute: "lobby-id" }) lobbyId = ""; + @property({ type: String, attribute: "lobby-suffix" }) lobbySuffix = ""; + @property({ type: Boolean, attribute: "include-lobby-query" }) + includeLobbyQuery = false; + @property({ type: String, attribute: "copy-text" }) copyText = ""; + @property({ type: String, attribute: "display-text" }) displayText = ""; + @property({ type: Boolean, attribute: "show-visibility-toggle" }) + showVisibilityToggle = true; + @property({ type: Boolean, attribute: "show-copy-icon" }) + showCopyIcon = true; + @property({ type: Boolean }) compact = false; + + @state() private copySuccess = false; + @state() private lobbyIdVisible = true; + + private userSettings: UserSettings = new UserSettings(); + private maskLabel = html`••••••••`; + + createRenderRoot() { + return this; + } + + protected willUpdate( + changedProperties: Map, + ) { + if (changedProperties.has("lobbyId")) { + this.lobbyIdVisible = this.userSettings.get( + "settings.lobbyIdVisibility", + true, + ); + this.copySuccess = false; + } + if (changedProperties.has("copyText")) { + this.copySuccess = false; + } + if ( + changedProperties.has("showVisibilityToggle") || + changedProperties.has("compact") + ) { + if (!this.showVisibilityToggle || this.compact) { + this.lobbyIdVisible = true; + } + } + } + + private toggleVisibility() { + if (!this.showVisibilityToggle || this.compact) return; + this.lobbyIdVisible = !this.lobbyIdVisible; + } + + private enableSelectAll(e: Event) { + (e.currentTarget as HTMLElement).classList.add("select-all"); + } + + private clearSelectAll(e: Event) { + (e.currentTarget as HTMLElement).classList.remove("select-all"); + } + + private async buildCopyUrl(): Promise { + const config = await getServerConfigFromClient(); + let url = `${window.location.origin}/${config.workerPath(this.lobbyId)}/game/${this.lobbyId}`; + if (this.includeLobbyQuery) { + url += `?lobby&s=${encodeURIComponent(this.lobbySuffix)}`; + } + return url; + } + + private async resolveCopyText(): Promise { + if (this.copyText) return this.copyText; + if (!this.lobbyId) return ""; + return await this.buildCopyUrl(); + } + + private async handleCopy() { + const text = await this.resolveCopyText(); + if (!text) return; + await copyToClipboard( + text, + () => (this.copySuccess = true), + () => (this.copySuccess = false), + ); + } + + private canCopy() { + return Boolean(this.copyText || this.lobbyId); + } + + render() { + const canCopy = this.canCopy(); + const allowMask = this.showVisibilityToggle && !this.compact; + const rawLabel = this.displayText || this.lobbyId || this.copyText; + const label = this.copySuccess + ? translateText("common.copied") + : allowMask && !this.lobbyIdVisible + ? this.maskLabel + : rawLabel; + const disabledClass = canCopy ? "" : "opacity-60 cursor-not-allowed"; + const toggleDisabled = !this.lobbyId; + const toggleClass = toggleDisabled ? "opacity-60 cursor-not-allowed" : ""; + + if (this.compact) { + return html` + + `; + } + + return html` +
+ ${this.showVisibilityToggle + ? html`` + : ""} + + ${this.showCopyIcon + ? html`` + : ""} +
+ `; + } +} From b1d63533d54ea7aab01c56e893615e5b2dcc5974 Mon Sep 17 00:00:00 2001 From: Mattia Migliorini Date: Sat, 17 Jan 2026 02:09:08 +0100 Subject: [PATCH 024/109] Handle confirmation on popstate event if player is active in a game (#2777) Please merge it into v29, since in that version the back navigation out of a game is currently **broken** after switching from hash-based to path-based routing via #2740 ## Description: Protect against players accidentally leaving an active game by pressing the browser back button. Uses the same confirmation dialog as the game exit button. Partially handles issue #1877 (protects against back button, not closing tab or editing the URL directly). image Partial credit to PR #2141 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: deshack_82603 --- src/client/ClientGameRunner.ts | 39 +++++++++++++++++++++-- src/client/Main.ts | 58 ++++++++++++++++++++++++---------- 2 files changed, 78 insertions(+), 19 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 995decabe..971a9cd71 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -69,7 +69,7 @@ export function joinLobby( lobbyConfig: LobbyConfig, onPrestart: () => void, onJoin: () => void, -): () => void { +): (force?: boolean) => boolean { console.log( `joining lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}`, ); @@ -79,6 +79,8 @@ export function joinLobby( const transport = new Transport(lobbyConfig, eventBus); + let currentGameRunner: ClientGameRunner | null = null; + let hasJoined = false; const onconnect = () => { @@ -122,9 +124,15 @@ export function joinLobby( terrainLoad, terrainMapFileLoader, ) - .then((r) => r.start()) + .then((r) => { + currentGameRunner = r; + r.start(); + }) .catch((e) => { console.error("error creating client game", e); + + currentGameRunner = null; + const startingModal = document.querySelector( "game-starting-modal", ) as HTMLElement; @@ -165,9 +173,19 @@ export function joinLobby( } }; transport.connect(onconnect, onmessage); - return () => { + return (force: boolean = false) => { + if (!force && currentGameRunner?.shouldPreventWindowClose()) { + console.log("Player is active, prevent leaving game"); + + return false; + } + console.log("leaving game"); + + currentGameRunner = null; transport.leaveGame(); + + return true; }; } @@ -256,6 +274,21 @@ export class ClientGameRunner { this.lastMessageTime = Date.now(); } + /** + * Determines whether window closing should be prevented. + * + * Used to show a confirmation dialog when the user attempts to close + * the window or navigate away during an active game session. + * + * @returns {boolean} `true` if the window close should be prevented + * (when the player is alive in the game), `false` otherwise + * (when the player is not alive or doesn't exist) + */ + public shouldPreventWindowClose(): boolean { + // Show confirmation dialog if player is alive in the game + return !!this.myPlayer?.isAlive(); + } + private async saveGame(update: WinUpdate) { if (this.myPlayer === null) { return; diff --git a/src/client/Main.ts b/src/client/Main.ts index 88fe223f4..8d1df2004 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -208,9 +208,11 @@ export interface JoinLobbyEvent { } class Client { - private gameStop: (() => void) | null = null; + private gameStop: ((force?: boolean) => boolean) | null = null; private eventBus: EventBus = new EventBus(); + private currentUrl: string | null = null; + private usernameInput: UsernameInput | null = null; private flagInput: FlagInput | null = null; @@ -277,7 +279,7 @@ class Client { window.addEventListener("beforeunload", async () => { console.log("Browser is closing"); if (this.gameStop !== null) { - this.gameStop(); + this.gameStop(true); await crazyGamesSDK.gameplayStop(); } }); @@ -583,15 +585,7 @@ class Client { // Attempt to join lobby this.handleUrl(); - let preventHashUpdate = false; - const onHashUpdate = () => { - // Prevent double-handling when both popstate and hashchange fire - if (preventHashUpdate) { - preventHashUpdate = false; - return; - } - // Reset the UI to its initial state this.joinModal?.close(); if (this.gameStop !== null) { @@ -602,11 +596,39 @@ class Client { this.handleUrl(); }; + const onPopState = () => { + if (this.currentUrl !== null && this.gameStop !== null) { + console.info("Game is active"); + + if (!this.gameStop()) { + console.info("Player is active, ask before leaving game"); + + const isConfirmed = confirm( + translateText("help_modal.exit_confirmation"), + ); + + if (!isConfirmed) { + // Rollback navigator history + history.pushState(null, "", this.currentUrl); + return; + } + } + + console.info("Player is not active, leave the game immediately"); + + crazyGamesSDK.gameplayStop().then(() => { + // redirect to the home page + window.location.href = "/"; + }); + } else { + console.info("Game not active, handle hash update"); + + onHashUpdate(); + } + }; + // Handle browser navigation & manual hash edits - window.addEventListener("popstate", () => { - preventHashUpdate = true; - onHashUpdate(); - }); + window.addEventListener("popstate", onPopState); window.addEventListener("hashchange", onHashUpdate); window.addEventListener("join-changed", onHashUpdate); @@ -738,7 +760,7 @@ class Client { console.log(`joining lobby ${lobby.gameID}`); if (this.gameStop !== null) { console.log("joining lobby, stopping existing game"); - this.gameStop(); + this.gameStop(true); document.body.classList.remove("in-game"); } const config = await getServerConfigFromClient(); @@ -844,6 +866,9 @@ class Client { "", `/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`, ); + + // Store current URL for popstate confirmation + this.currentUrl = window.location.href; }, ); } @@ -865,8 +890,9 @@ class Client { return; } console.log("leaving lobby, cancelling game"); - this.gameStop(); + this.gameStop(true); this.gameStop = null; + this.currentUrl = null; document.body.classList.remove("in-game"); From c6021ab38e9fe374548b7f7c7430e70705c1af9b Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Sat, 17 Jan 2026 04:09:06 +0100 Subject: [PATCH 025/109] =?UTF-8?q?Fix=20for=20v29:=20Increase=20chance=20?= =?UTF-8?q?of=20starting=20gold=20in=20random=20game=20modifiers=20from=20?= =?UTF-8?q?3%=20to=205%=20=F0=9F=99=82=20(#2936)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: While looking at the game rotation on my localhost page I noticed that the cool new starting gold modifier came up veeeery rarely. Every 33th game is just too rare, lets do "Every 20th game" 🙂 ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/server/MapPlaylist.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index beadd0bec..c4430e332 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -201,7 +201,7 @@ export class MapPlaylist { return { isRandomSpawn: Math.random() < 0.1, // 10% chance isCompact: Math.random() < 0.05, // 5% chance - startingGold: Math.random() < 0.03 ? 5_000_000 : undefined, // 3% chance + startingGold: Math.random() < 0.05 ? 5_000_000 : undefined, // 5% chance }; } From dba04027df723880c096f0d3b975e831425f63fd Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Sat, 17 Jan 2026 15:35:26 +0100 Subject: [PATCH 026/109] Merge pull request #2933 from FloPinguin/fix-nation-loading Fix for v29: Add nation count loading for JoinPrivateLobbyModal; change HvN difficulty --- src/client/HostLobbyModal.ts | 69 ++++++------------- src/client/JoinPrivateLobbyModal.ts | 66 ++++++++++++------ .../{LobbyTeamView.ts => LobbyPlayerView.ts} | 57 ++++++++++++--- src/client/styles.css | 1 - src/server/MapPlaylist.ts | 4 +- 5 files changed, 115 insertions(+), 82 deletions(-) rename src/client/components/{LobbyTeamView.ts => LobbyPlayerView.ts} (83%) diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index e7d709c12..2401c6d3a 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -14,7 +14,6 @@ import { UnitType, mapCategories, } from "../core/game/Game"; -import { getCompactMapNationCount } from "../core/game/NationCreation"; import { UserSettings } from "../core/game/UserSettings"; import { ClientInfo, @@ -28,7 +27,7 @@ import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; import "./components/FluentSlider"; -import "./components/LobbyTeamView"; +import "./components/LobbyPlayerView"; import "./components/Maps"; import { modalHeader } from "./components/ui/ModalHeader"; import { crazyGamesSDK } from "./CrazyGamesSDK"; @@ -934,33 +933,16 @@ export class HostLobbyModal extends BaseModal {
-
-
-
- ${this.clients.length} - ${this.clients.length === 1 - ? translateText("host_modal.player") - : translateText("host_modal.players")} - - ${this.getEffectiveNationCount()} - ${this.getEffectiveNationCount() === 1 - ? translateText("host_modal.nation_player") - : translateText("host_modal.nation_players")} -
-
- - this.kickPlayer(clientID)} - > -
+ this.kickPlayer(clientID)} + >
@@ -1438,31 +1420,22 @@ export class HostLobbyModal extends BaseModal { } private async loadNationCount() { + const currentMap = this.selectedMap; try { - const mapData = this.mapLoader.getMapData(this.selectedMap); + const mapData = this.mapLoader.getMapData(currentMap); const manifest = await mapData.manifest(); - this.nationCount = manifest.nations.length; + // Only update if the map hasn't changed + if (this.selectedMap === currentMap) { + this.nationCount = manifest.nations.length; + } } catch (error) { console.warn("Failed to load nation count", error); - this.nationCount = 0; + // Only update if the map hasn't changed + if (this.selectedMap === currentMap) { + this.nationCount = 0; + } } } - - /** - * Returns the effective nation count for display purposes. - * In HumansVsNations mode, this equals the number of human players. - * For compact maps, only 25% of nations are used. - * Otherwise, it uses the manifest nation count (or 0 if nations are disabled). - */ - private getEffectiveNationCount(): number { - if (this.disableNations) { - return 0; - } - if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) { - return this.clients.length; - } - return getCompactMapNationCount(this.nationCount, this.compactMap); - } } async function createLobby(creatorClientID: string): Promise { diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index cb65b428f..a618a9ccc 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -10,13 +10,14 @@ import { } from "../core/Schemas"; import { generateID } from "../core/Util"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; -import { GameMode } from "../core/game/Game"; +import { GameMapSize, GameMode } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; import { getApiBase } from "./Api"; import { JoinLobbyEvent } from "./Main"; +import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; -import "./components/LobbyTeamView"; +import "./components/LobbyPlayerView"; import { modalHeader } from "./components/ui/ModalHeader"; @customElement("join-private-lobby-modal") export class JoinPrivateLobbyModal extends BaseModal { @@ -29,9 +30,11 @@ export class JoinPrivateLobbyModal extends BaseModal { @state() private lobbyIdVisible: boolean = true; @state() private copySuccess: boolean = false; @state() private currentLobbyId: string = ""; + @state() private nationCount: number = 0; private playersInterval: NodeJS.Timeout | null = null; private userSettings: UserSettings = new UserSettings(); + private mapLoader = terrainMapFileLoader; private leaveLobbyOnClose = true; @@ -180,26 +183,17 @@ export class JoinPrivateLobbyModal extends BaseModal { ${this.renderGameConfig()} ${this.hasJoined && this.players.length > 0 ? html` -
-
-
- ${this.players.length} - ${this.players.length === 1 - ? translateText("private_lobby.player") - : translateText("private_lobby.players")} -
-
- - -
+ ` : ""}
@@ -387,6 +381,7 @@ export class JoinPrivateLobbyModal extends BaseModal { this.hasJoined = false; this.message = ""; this.currentLobbyId = ""; + this.nationCount = 0; this.leaveLobbyOnClose = true; } @@ -612,11 +607,38 @@ export class JoinPrivateLobbyModal extends BaseModal { this.lobbyCreatorClientID = data.clients?.[0]?.clientID ?? null; this.players = data.clients ?? []; if (data.gameConfig) { + const mapChanged = + this.gameConfig?.gameMap !== data.gameConfig.gameMap; this.gameConfig = data.gameConfig; + if (mapChanged) { + this.loadNationCount(); + } } }) .catch((error) => { console.error("Error polling players:", error); }); } + + private async loadNationCount() { + if (!this.gameConfig) { + this.nationCount = 0; + return; + } + const currentMap = this.gameConfig.gameMap; + try { + const mapData = this.mapLoader.getMapData(currentMap); + const manifest = await mapData.manifest(); + // Only update if the map hasn't changed + if (this.gameConfig?.gameMap === currentMap) { + this.nationCount = manifest.nations.length; + } + } catch (error) { + console.warn("Failed to load nation count", error); + // Only update if the map hasn't changed + if (this.gameConfig?.gameMap === currentMap) { + this.nationCount = 0; + } + } + } } diff --git a/src/client/components/LobbyTeamView.ts b/src/client/components/LobbyPlayerView.ts similarity index 83% rename from src/client/components/LobbyTeamView.ts rename to src/client/components/LobbyPlayerView.ts index d85105c4b..2bcac3108 100644 --- a/src/client/components/LobbyTeamView.ts +++ b/src/client/components/LobbyPlayerView.ts @@ -13,6 +13,7 @@ import { Team, Trios, } from "../../core/game/Game"; +import { getCompactMapNationCount } from "../../core/game/NationCreation"; import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment"; import { ClientInfo, TeamCountConfig } from "../../core/Schemas"; import { translateText } from "../Utils"; @@ -22,7 +23,7 @@ export interface TeamPreviewData { players: ClientInfo[]; } -@customElement("lobby-team-view") +@customElement("lobby-player-view") export class LobbyTeamView extends LitElement { @property({ type: String }) gameMode: GameMode = GameMode.FFA; @property({ type: Array }) clients: ClientInfo[] = []; @@ -32,6 +33,8 @@ export class LobbyTeamView extends LitElement { @property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2; @property({ type: Function }) onKickPlayer?: (clientID: string) => void; @property({ type: Number }) nationCount: number = 0; + @property({ type: Boolean }) disableNations: boolean = false; + @property({ type: Boolean }) isCompactMap: boolean = false; private theme: PastelTheme = new PastelTheme(); @state() private showTeamColors: boolean = false; @@ -52,11 +55,32 @@ export class LobbyTeamView extends LitElement { } render() { - return html`
- ${this.gameMode === GameMode.Team - ? this.renderTeamMode() - : this.renderFreeForAll()} -
`; + return html` +
+
+
+ ${this.clients.length} + ${this.clients.length === 1 + ? translateText("host_modal.player") + : translateText("host_modal.players")} + + ${this.getEffectiveNationCount()} + ${this.getEffectiveNationCount() === 1 + ? translateText("host_modal.nation_player") + : translateText("host_modal.nation_players")} +
+
+
+ ${this.gameMode === GameMode.Team + ? this.renderTeamMode() + : this.renderFreeForAll()} +
+
+ `; } createRenderRoot() { @@ -148,14 +172,15 @@ export class LobbyTeamView extends LitElement { } private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) { + const effectiveNationCount = this.getEffectiveNationCount(); const displayCount = preview.team === ColoredTeams.Nations - ? this.nationCount + ? effectiveNationCount : preview.players.length; const maxTeamSize = preview.team === ColoredTeams.Nations - ? this.nationCount + ? effectiveNationCount : this.teamMaxSize; return html` @@ -308,4 +333,20 @@ export class LobbyTeamView extends LitElement { players: buckets.get(t) ?? [], })); } + + /** + * Returns the effective nation count for display purposes. + * In HumansVsNations mode, this equals the number of human players. + * For compact maps, only 25% of nations are used. + * Otherwise, it uses the manifest nation count (or 0 if nations are disabled). + */ + private getEffectiveNationCount(): number { + if (this.disableNations) { + return 0; + } + if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) { + return this.clients.length; + } + return getCompactMapNationCount(this.nationCount, this.isCompactMap); + } } diff --git a/src/client/styles.css b/src/client/styles.css index 9873cd4f0..7023676e8 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -546,7 +546,6 @@ label.option-card:hover { flex-wrap: wrap; gap: 8px; justify-content: center; - padding: 0 16px; } /* News Button Notification */ diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index c4430e332..97b23f0e4 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -127,9 +127,7 @@ export class MapPlaylist { publicGameModifiers: { isCompact, isRandomSpawn, startingGold }, startingGold, difficulty: - playerTeams === HumansVsNations - ? Difficulty.Impossible - : Difficulty.Easy, + playerTeams === HumansVsNations ? Difficulty.Hard : Difficulty.Easy, infiniteGold: false, infiniteTroops: false, maxTimerValue: undefined, From 0d8162676025c30aaa8e036457dabd6f65614b2b Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:46:45 +0000 Subject: [PATCH 027/109] Revert "Fix for v29: Add nation count loading for JoinPrivateLobbyModal; change HvN difficulty" (#2940) Reverts openfrontio/OpenFrontIO#2933 --- src/client/HostLobbyModal.ts | 69 +++++++++++++------ src/client/JoinPrivateLobbyModal.ts | 66 ++++++------------ .../{LobbyPlayerView.ts => LobbyTeamView.ts} | 57 +++------------ src/client/styles.css | 1 + src/server/MapPlaylist.ts | 4 +- 5 files changed, 82 insertions(+), 115 deletions(-) rename src/client/components/{LobbyPlayerView.ts => LobbyTeamView.ts} (83%) diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 2401c6d3a..e7d709c12 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -14,6 +14,7 @@ import { UnitType, mapCategories, } from "../core/game/Game"; +import { getCompactMapNationCount } from "../core/game/NationCreation"; import { UserSettings } from "../core/game/UserSettings"; import { ClientInfo, @@ -27,7 +28,7 @@ import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; import "./components/FluentSlider"; -import "./components/LobbyPlayerView"; +import "./components/LobbyTeamView"; import "./components/Maps"; import { modalHeader } from "./components/ui/ModalHeader"; import { crazyGamesSDK } from "./CrazyGamesSDK"; @@ -933,16 +934,33 @@ export class HostLobbyModal extends BaseModal {
- this.kickPlayer(clientID)} - > +
+
+
+ ${this.clients.length} + ${this.clients.length === 1 + ? translateText("host_modal.player") + : translateText("host_modal.players")} + + ${this.getEffectiveNationCount()} + ${this.getEffectiveNationCount() === 1 + ? translateText("host_modal.nation_player") + : translateText("host_modal.nation_players")} +
+
+ + this.kickPlayer(clientID)} + > +
@@ -1420,22 +1438,31 @@ export class HostLobbyModal extends BaseModal { } private async loadNationCount() { - const currentMap = this.selectedMap; try { - const mapData = this.mapLoader.getMapData(currentMap); + const mapData = this.mapLoader.getMapData(this.selectedMap); const manifest = await mapData.manifest(); - // Only update if the map hasn't changed - if (this.selectedMap === currentMap) { - this.nationCount = manifest.nations.length; - } + this.nationCount = manifest.nations.length; } catch (error) { console.warn("Failed to load nation count", error); - // Only update if the map hasn't changed - if (this.selectedMap === currentMap) { - this.nationCount = 0; - } + this.nationCount = 0; } } + + /** + * Returns the effective nation count for display purposes. + * In HumansVsNations mode, this equals the number of human players. + * For compact maps, only 25% of nations are used. + * Otherwise, it uses the manifest nation count (or 0 if nations are disabled). + */ + private getEffectiveNationCount(): number { + if (this.disableNations) { + return 0; + } + if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) { + return this.clients.length; + } + return getCompactMapNationCount(this.nationCount, this.compactMap); + } } async function createLobby(creatorClientID: string): Promise { diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index a618a9ccc..cb65b428f 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -10,14 +10,13 @@ import { } from "../core/Schemas"; import { generateID } from "../core/Util"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; -import { GameMapSize, GameMode } from "../core/game/Game"; +import { GameMode } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; import { getApiBase } from "./Api"; import { JoinLobbyEvent } from "./Main"; -import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; -import "./components/LobbyPlayerView"; +import "./components/LobbyTeamView"; import { modalHeader } from "./components/ui/ModalHeader"; @customElement("join-private-lobby-modal") export class JoinPrivateLobbyModal extends BaseModal { @@ -30,11 +29,9 @@ export class JoinPrivateLobbyModal extends BaseModal { @state() private lobbyIdVisible: boolean = true; @state() private copySuccess: boolean = false; @state() private currentLobbyId: string = ""; - @state() private nationCount: number = 0; private playersInterval: NodeJS.Timeout | null = null; private userSettings: UserSettings = new UserSettings(); - private mapLoader = terrainMapFileLoader; private leaveLobbyOnClose = true; @@ -183,17 +180,26 @@ export class JoinPrivateLobbyModal extends BaseModal { ${this.renderGameConfig()} ${this.hasJoined && this.players.length > 0 ? html` - +
+
+
+ ${this.players.length} + ${this.players.length === 1 + ? translateText("private_lobby.player") + : translateText("private_lobby.players")} +
+
+ + +
` : ""}
@@ -381,7 +387,6 @@ export class JoinPrivateLobbyModal extends BaseModal { this.hasJoined = false; this.message = ""; this.currentLobbyId = ""; - this.nationCount = 0; this.leaveLobbyOnClose = true; } @@ -607,38 +612,11 @@ export class JoinPrivateLobbyModal extends BaseModal { this.lobbyCreatorClientID = data.clients?.[0]?.clientID ?? null; this.players = data.clients ?? []; if (data.gameConfig) { - const mapChanged = - this.gameConfig?.gameMap !== data.gameConfig.gameMap; this.gameConfig = data.gameConfig; - if (mapChanged) { - this.loadNationCount(); - } } }) .catch((error) => { console.error("Error polling players:", error); }); } - - private async loadNationCount() { - if (!this.gameConfig) { - this.nationCount = 0; - return; - } - const currentMap = this.gameConfig.gameMap; - try { - const mapData = this.mapLoader.getMapData(currentMap); - const manifest = await mapData.manifest(); - // Only update if the map hasn't changed - if (this.gameConfig?.gameMap === currentMap) { - this.nationCount = manifest.nations.length; - } - } catch (error) { - console.warn("Failed to load nation count", error); - // Only update if the map hasn't changed - if (this.gameConfig?.gameMap === currentMap) { - this.nationCount = 0; - } - } - } } diff --git a/src/client/components/LobbyPlayerView.ts b/src/client/components/LobbyTeamView.ts similarity index 83% rename from src/client/components/LobbyPlayerView.ts rename to src/client/components/LobbyTeamView.ts index 2bcac3108..d85105c4b 100644 --- a/src/client/components/LobbyPlayerView.ts +++ b/src/client/components/LobbyTeamView.ts @@ -13,7 +13,6 @@ import { Team, Trios, } from "../../core/game/Game"; -import { getCompactMapNationCount } from "../../core/game/NationCreation"; import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment"; import { ClientInfo, TeamCountConfig } from "../../core/Schemas"; import { translateText } from "../Utils"; @@ -23,7 +22,7 @@ export interface TeamPreviewData { players: ClientInfo[]; } -@customElement("lobby-player-view") +@customElement("lobby-team-view") export class LobbyTeamView extends LitElement { @property({ type: String }) gameMode: GameMode = GameMode.FFA; @property({ type: Array }) clients: ClientInfo[] = []; @@ -33,8 +32,6 @@ export class LobbyTeamView extends LitElement { @property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2; @property({ type: Function }) onKickPlayer?: (clientID: string) => void; @property({ type: Number }) nationCount: number = 0; - @property({ type: Boolean }) disableNations: boolean = false; - @property({ type: Boolean }) isCompactMap: boolean = false; private theme: PastelTheme = new PastelTheme(); @state() private showTeamColors: boolean = false; @@ -55,32 +52,11 @@ export class LobbyTeamView extends LitElement { } render() { - return html` -
-
-
- ${this.clients.length} - ${this.clients.length === 1 - ? translateText("host_modal.player") - : translateText("host_modal.players")} - - ${this.getEffectiveNationCount()} - ${this.getEffectiveNationCount() === 1 - ? translateText("host_modal.nation_player") - : translateText("host_modal.nation_players")} -
-
-
- ${this.gameMode === GameMode.Team - ? this.renderTeamMode() - : this.renderFreeForAll()} -
-
- `; + return html`
+ ${this.gameMode === GameMode.Team + ? this.renderTeamMode() + : this.renderFreeForAll()} +
`; } createRenderRoot() { @@ -172,15 +148,14 @@ export class LobbyTeamView extends LitElement { } private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) { - const effectiveNationCount = this.getEffectiveNationCount(); const displayCount = preview.team === ColoredTeams.Nations - ? effectiveNationCount + ? this.nationCount : preview.players.length; const maxTeamSize = preview.team === ColoredTeams.Nations - ? effectiveNationCount + ? this.nationCount : this.teamMaxSize; return html` @@ -333,20 +308,4 @@ export class LobbyTeamView extends LitElement { players: buckets.get(t) ?? [], })); } - - /** - * Returns the effective nation count for display purposes. - * In HumansVsNations mode, this equals the number of human players. - * For compact maps, only 25% of nations are used. - * Otherwise, it uses the manifest nation count (or 0 if nations are disabled). - */ - private getEffectiveNationCount(): number { - if (this.disableNations) { - return 0; - } - if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) { - return this.clients.length; - } - return getCompactMapNationCount(this.nationCount, this.isCompactMap); - } } diff --git a/src/client/styles.css b/src/client/styles.css index 7023676e8..9873cd4f0 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -546,6 +546,7 @@ label.option-card:hover { flex-wrap: wrap; gap: 8px; justify-content: center; + padding: 0 16px; } /* News Button Notification */ diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 97b23f0e4..c4430e332 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -127,7 +127,9 @@ export class MapPlaylist { publicGameModifiers: { isCompact, isRandomSpawn, startingGold }, startingGold, difficulty: - playerTeams === HumansVsNations ? Difficulty.Hard : Difficulty.Easy, + playerTeams === HumansVsNations + ? Difficulty.Impossible + : Difficulty.Easy, infiniteGold: false, infiniteTroops: false, maxTimerValue: undefined, From 2383e057bccc4849dd90ed615382f688777fa3e2 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:02:32 +0100 Subject: [PATCH 028/109] Fix for v29: Add nation count loading for JoinPrivateLobbyModal; change HvN difficulty (#2941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: 1. In JoinPrivateLobbyModal the nation count loading was missing. That caused the team preview UI to show different player counts compared to the HostLobbyModal. For example it showed 0/0 nations for the HumansVsNations team mode (instead of 2/2): Screenshot 2026-01-16 211337 2. Turn down HvN difficulty from Impossible to Hard. We steamrolled over Hard nations in the playtest (at least in two of the three games) because we donated lots of troops to each other. But after some API data research I noticed that only 33% of players in public team games ever use the donate functionality. And we probably have less skilled players in public games than in the playtest. So its probably better to use the Hard difficulty to ensure balanced gameplay. I know, I'm overthinking this 😂 ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/client/HostLobbyModal.ts | 69 ++++++------------- src/client/JoinPrivateLobbyModal.ts | 66 ++++++++++++------ .../{LobbyTeamView.ts => LobbyPlayerView.ts} | 57 ++++++++++++--- src/client/styles.css | 1 - src/server/MapPlaylist.ts | 4 +- 5 files changed, 115 insertions(+), 82 deletions(-) rename src/client/components/{LobbyTeamView.ts => LobbyPlayerView.ts} (83%) diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 2ca75b207..03e0a6ed0 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -14,7 +14,6 @@ import { UnitType, mapCategories, } from "../core/game/Game"; -import { getCompactMapNationCount } from "../core/game/NationCreation"; import { ClientInfo, GameConfig, @@ -28,7 +27,7 @@ import { BaseModal } from "./components/BaseModal"; import "./components/CopyButton"; import "./components/Difficulties"; import "./components/FluentSlider"; -import "./components/LobbyTeamView"; +import "./components/LobbyPlayerView"; import "./components/Maps"; import { modalHeader } from "./components/ui/ModalHeader"; import { crazyGamesSDK } from "./CrazyGamesSDK"; @@ -851,33 +850,16 @@ export class HostLobbyModal extends BaseModal {
-
-
-
- ${this.clients.length} - ${this.clients.length === 1 - ? translateText("host_modal.player") - : translateText("host_modal.players")} - - ${this.getEffectiveNationCount()} - ${this.getEffectiveNationCount() === 1 - ? translateText("host_modal.nation_player") - : translateText("host_modal.nation_players")} -
-
- - this.kickPlayer(clientID)} - > -
+ this.kickPlayer(clientID)} + >
@@ -1340,31 +1322,22 @@ export class HostLobbyModal extends BaseModal { } private async loadNationCount() { + const currentMap = this.selectedMap; try { - const mapData = this.mapLoader.getMapData(this.selectedMap); + const mapData = this.mapLoader.getMapData(currentMap); const manifest = await mapData.manifest(); - this.nationCount = manifest.nations.length; + // Only update if the map hasn't changed + if (this.selectedMap === currentMap) { + this.nationCount = manifest.nations.length; + } } catch (error) { console.warn("Failed to load nation count", error); - this.nationCount = 0; + // Only update if the map hasn't changed + if (this.selectedMap === currentMap) { + this.nationCount = 0; + } } } - - /** - * Returns the effective nation count for display purposes. - * In HumansVsNations mode, this equals the number of human players. - * For compact maps, only 25% of nations are used. - * Otherwise, it uses the manifest nation count (or 0 if nations are disabled). - */ - private getEffectiveNationCount(): number { - if (this.disableNations) { - return 0; - } - if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) { - return this.clients.length; - } - return getCompactMapNationCount(this.nationCount, this.compactMap); - } } async function createLobby(creatorClientID: string): Promise { diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 117e6d753..c04b8fa93 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -10,13 +10,14 @@ import { } from "../core/Schemas"; import { generateID } from "../core/Util"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; -import { GameMode } from "../core/game/Game"; +import { GameMapSize, GameMode } from "../core/game/Game"; import { getApiBase } from "./Api"; import { JoinLobbyEvent } from "./Main"; +import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { BaseModal } from "./components/BaseModal"; import "./components/CopyButton"; import "./components/Difficulties"; -import "./components/LobbyTeamView"; +import "./components/LobbyPlayerView"; import { modalHeader } from "./components/ui/ModalHeader"; @customElement("join-private-lobby-modal") export class JoinPrivateLobbyModal extends BaseModal { @@ -27,8 +28,10 @@ export class JoinPrivateLobbyModal extends BaseModal { @state() private gameConfig: GameConfig | null = null; @state() private lobbyCreatorClientID: string | null = null; @state() private currentLobbyId: string = ""; + @state() private nationCount: number = 0; private playersInterval: NodeJS.Timeout | null = null; + private mapLoader = terrainMapFileLoader; private leaveLobbyOnClose = true; @@ -93,26 +96,17 @@ export class JoinPrivateLobbyModal extends BaseModal { ${this.renderGameConfig()} ${this.hasJoined && this.players.length > 0 ? html` -
-
-
- ${this.players.length} - ${this.players.length === 1 - ? translateText("private_lobby.player") - : translateText("private_lobby.players")} -
-
- - -
+ ` : ""} @@ -296,6 +290,7 @@ export class JoinPrivateLobbyModal extends BaseModal { this.hasJoined = false; this.message = ""; this.currentLobbyId = ""; + this.nationCount = 0; this.leaveLobbyOnClose = true; } @@ -512,11 +507,38 @@ export class JoinPrivateLobbyModal extends BaseModal { this.lobbyCreatorClientID = data.clients?.[0]?.clientID ?? null; this.players = data.clients ?? []; if (data.gameConfig) { + const mapChanged = + this.gameConfig?.gameMap !== data.gameConfig.gameMap; this.gameConfig = data.gameConfig; + if (mapChanged) { + this.loadNationCount(); + } } }) .catch((error) => { console.error("Error polling players:", error); }); } + + private async loadNationCount() { + if (!this.gameConfig) { + this.nationCount = 0; + return; + } + const currentMap = this.gameConfig.gameMap; + try { + const mapData = this.mapLoader.getMapData(currentMap); + const manifest = await mapData.manifest(); + // Only update if the map hasn't changed + if (this.gameConfig?.gameMap === currentMap) { + this.nationCount = manifest.nations.length; + } + } catch (error) { + console.warn("Failed to load nation count", error); + // Only update if the map hasn't changed + if (this.gameConfig?.gameMap === currentMap) { + this.nationCount = 0; + } + } + } } diff --git a/src/client/components/LobbyTeamView.ts b/src/client/components/LobbyPlayerView.ts similarity index 83% rename from src/client/components/LobbyTeamView.ts rename to src/client/components/LobbyPlayerView.ts index d85105c4b..2bcac3108 100644 --- a/src/client/components/LobbyTeamView.ts +++ b/src/client/components/LobbyPlayerView.ts @@ -13,6 +13,7 @@ import { Team, Trios, } from "../../core/game/Game"; +import { getCompactMapNationCount } from "../../core/game/NationCreation"; import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment"; import { ClientInfo, TeamCountConfig } from "../../core/Schemas"; import { translateText } from "../Utils"; @@ -22,7 +23,7 @@ export interface TeamPreviewData { players: ClientInfo[]; } -@customElement("lobby-team-view") +@customElement("lobby-player-view") export class LobbyTeamView extends LitElement { @property({ type: String }) gameMode: GameMode = GameMode.FFA; @property({ type: Array }) clients: ClientInfo[] = []; @@ -32,6 +33,8 @@ export class LobbyTeamView extends LitElement { @property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2; @property({ type: Function }) onKickPlayer?: (clientID: string) => void; @property({ type: Number }) nationCount: number = 0; + @property({ type: Boolean }) disableNations: boolean = false; + @property({ type: Boolean }) isCompactMap: boolean = false; private theme: PastelTheme = new PastelTheme(); @state() private showTeamColors: boolean = false; @@ -52,11 +55,32 @@ export class LobbyTeamView extends LitElement { } render() { - return html`
- ${this.gameMode === GameMode.Team - ? this.renderTeamMode() - : this.renderFreeForAll()} -
`; + return html` +
+
+
+ ${this.clients.length} + ${this.clients.length === 1 + ? translateText("host_modal.player") + : translateText("host_modal.players")} + + ${this.getEffectiveNationCount()} + ${this.getEffectiveNationCount() === 1 + ? translateText("host_modal.nation_player") + : translateText("host_modal.nation_players")} +
+
+
+ ${this.gameMode === GameMode.Team + ? this.renderTeamMode() + : this.renderFreeForAll()} +
+
+ `; } createRenderRoot() { @@ -148,14 +172,15 @@ export class LobbyTeamView extends LitElement { } private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) { + const effectiveNationCount = this.getEffectiveNationCount(); const displayCount = preview.team === ColoredTeams.Nations - ? this.nationCount + ? effectiveNationCount : preview.players.length; const maxTeamSize = preview.team === ColoredTeams.Nations - ? this.nationCount + ? effectiveNationCount : this.teamMaxSize; return html` @@ -308,4 +333,20 @@ export class LobbyTeamView extends LitElement { players: buckets.get(t) ?? [], })); } + + /** + * Returns the effective nation count for display purposes. + * In HumansVsNations mode, this equals the number of human players. + * For compact maps, only 25% of nations are used. + * Otherwise, it uses the manifest nation count (or 0 if nations are disabled). + */ + private getEffectiveNationCount(): number { + if (this.disableNations) { + return 0; + } + if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) { + return this.clients.length; + } + return getCompactMapNationCount(this.nationCount, this.isCompactMap); + } } diff --git a/src/client/styles.css b/src/client/styles.css index 9873cd4f0..7023676e8 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -546,7 +546,6 @@ label.option-card:hover { flex-wrap: wrap; gap: 8px; justify-content: center; - padding: 0 16px; } /* News Button Notification */ diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index beadd0bec..48a309622 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -127,9 +127,7 @@ export class MapPlaylist { publicGameModifiers: { isCompact, isRandomSpawn, startingGold }, startingGold, difficulty: - playerTeams === HumansVsNations - ? Difficulty.Impossible - : Difficulty.Easy, + playerTeams === HumansVsNations ? Difficulty.Hard : Difficulty.Easy, infiniteGold: false, infiniteTroops: false, maxTimerValue: undefined, From c4a86d643da8c93acfe93b0a2a20ca579eb322ff Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:42:45 +0100 Subject: [PATCH 029/109] Add nation count loading for JoinPrivateLobbyModal (Part 2) (#2942) ## Description: Use `this.getEffectiveNationCount()` everywhere inside of `LobbyPlayerView`, instead of `this.nationCount`. So the team player counts always update properly. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/client/components/LobbyPlayerView.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/client/components/LobbyPlayerView.ts b/src/client/components/LobbyPlayerView.ts index 2bcac3108..4c72fc21d 100644 --- a/src/client/components/LobbyPlayerView.ts +++ b/src/client/components/LobbyPlayerView.ts @@ -46,7 +46,9 @@ export class LobbyTeamView extends LitElement { changedProperties.has("gameMode") || changedProperties.has("clients") || changedProperties.has("teamCount") || - changedProperties.has("nationCount") + changedProperties.has("nationCount") || + changedProperties.has("disableNations") || + changedProperties.has("isCompactMap") ) { const teamsList = this.getTeamList(); this.computeTeamPreview(teamsList); @@ -237,7 +239,7 @@ export class LobbyTeamView extends LitElement { private getTeamList(): Team[] { if (this.gameMode !== GameMode.Team) return []; - const playerCount = this.clients.length + this.nationCount; + const playerCount = this.clients.length + this.getEffectiveNationCount(); const config = this.teamCount; if (config === HumansVsNations) { @@ -301,7 +303,7 @@ export class LobbyTeamView extends LitElement { const assignment = assignTeamsLobbyPreview( players, teams, - this.nationCount, + this.getEffectiveNationCount(), ); const buckets = new Map(); for (const t of teams) buckets.set(t, []); @@ -325,7 +327,9 @@ export class LobbyTeamView extends LitElement { // Fallback: divide players across teams; guard against 0 and empty lobbies this.teamMaxSize = Math.max( 1, - Math.ceil((this.clients.length + this.nationCount) / teams.length), + Math.ceil( + (this.clients.length + this.getEffectiveNationCount()) / teams.length, + ), ); } this.teamPreview = teams.map((t) => ({ From 1e629ca53131239e82b0bc6a75e0ecb9f23d96da Mon Sep 17 00:00:00 2001 From: Wraith <54374743+wraith4081@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:37:17 +0300 Subject: [PATCH 030/109] fix: performance overlay positioning (#2943) ## Description: fix: performance overlay positioning ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: wraith4081 --- src/client/graphics/layers/PerformanceOverlay.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index 50a782e10..564e94997 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -80,9 +80,9 @@ export class PerformanceOverlay extends LitElement implements Layer { static styles = css` .performance-overlay { position: fixed; - top: 20px; - left: 50%; - transform: translateX(-50%); + top: var(--top, 20px); + left: var(--left, 50%); + transform: var(--transform, translateX(-50%)); background: rgba(0, 0, 0, 0.8); color: white; padding: 8px 16px; @@ -551,10 +551,9 @@ export class PerformanceOverlay extends LitElement implements Layer { return html`
diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index c34dfe268..dece359e4 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -30,6 +30,10 @@ import { fetchCosmetics } from "./Cosmetics"; import { FlagInput } from "./FlagInput"; import { JoinLobbyEvent } from "./Main"; import { UsernameInput } from "./UsernameInput"; +import { + renderToggleInputCard, + renderToggleInputCardInput, +} from "./utilities/RenderToggleInputCard"; import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; import randomMap from "/images/RandomMap.webp?url"; @@ -522,20 +526,10 @@ export class SinglePlayerModal extends BaseModal { } }, )} - - -
{ - // Prevent toggling when clicking the input - if ( - (e.target as HTMLElement).tagName.toLowerCase() === - "input" - ) - return; + ${renderToggleInputCard({ + labelKey: "single_modal.max_timer", + checked: this.maxTimer, + onClick: () => { this.maxTimer = !this.maxTimer; if (!this.maxTimer) { this.maxTimerValue = undefined; @@ -553,71 +547,26 @@ export class SinglePlayerModal extends BaseModal { } }, 0); } - }} - > -
-
- ${this.maxTimer - ? html` - - ` - : ""} -
-
- - ${this.maxTimer - ? html`` - : html`
`} - - -
- ${translateText("single_modal.max_timer")} -
-
+ }, + input: renderToggleInputCardInput({ + id: "end-timer-value", + min: 1, + max: 120, + value: this.maxTimerValue ?? "", + ariaLabel: translateText("single_modal.max_timer"), + placeholder: translateText( + "single_modal.max_timer_placeholder", + ), + onInput: this.handleMaxTimerValueChanges, + onKeyDown: this.handleMaxTimerValueKeyDown, + }), + })} -
{ - if ( - (e.target as HTMLElement).tagName.toLowerCase() === - "input" - ) - return; + ${renderToggleInputCard({ + labelKey: "single_modal.gold_multiplier", + checked: this.goldMultiplier, + onClick: () => { this.goldMultiplier = !this.goldMultiplier; if (!this.goldMultiplier) { this.goldMultiplierValue = undefined; @@ -638,73 +587,27 @@ export class SinglePlayerModal extends BaseModal { } }, 0); } - }} - > -
-
- ${this.goldMultiplier - ? html` - - ` - : ""} -
-
- - ${this.goldMultiplier - ? html`` - : html`
`} - -
- ${translateText("single_modal.gold_multiplier")} -
-
+ }, + input: renderToggleInputCardInput({ + id: "gold-multiplier-value", + min: 0.1, + max: 1000, + step: "any", + value: this.goldMultiplierValue ?? "", + ariaLabel: translateText("single_modal.gold_multiplier"), + placeholder: translateText( + "single_modal.gold_multiplier_placeholder", + ), + onChange: this.handleGoldMultiplierValueChanges, + onKeyDown: this.handleGoldMultiplierValueKeyDown, + }), + })} -
{ - if ( - (e.target as HTMLElement).tagName.toLowerCase() === - "input" - ) - return; + ${renderToggleInputCard({ + labelKey: "single_modal.starting_gold", + checked: this.startingGold, + onClick: () => { this.startingGold = !this.startingGold; if (!this.startingGold) { this.startingGoldValue = undefined; @@ -725,60 +628,21 @@ export class SinglePlayerModal extends BaseModal { } }, 0); } - }} - > -
-
- ${this.startingGold - ? html` - - ` - : ""} -
-
- - ${this.startingGold - ? html`` - : html`
`} - -
- ${translateText("single_modal.starting_gold")} -
-
+ }, + input: renderToggleInputCardInput({ + id: "starting-gold-value", + min: 0, + max: 1000000000, + step: 100000, + value: this.startingGoldValue ?? "", + ariaLabel: translateText("single_modal.starting_gold"), + placeholder: translateText( + "single_modal.starting_gold_placeholder", + ), + onInput: this.handleStartingGoldValueChanges, + onKeyDown: this.handleStartingGoldValueKeyDown, + }), + })} diff --git a/src/client/utilities/RenderToggleInputCard.ts b/src/client/utilities/RenderToggleInputCard.ts new file mode 100644 index 000000000..44fbd437e --- /dev/null +++ b/src/client/utilities/RenderToggleInputCard.ts @@ -0,0 +1,162 @@ +import { TemplateResult, html, nothing } from "lit"; +import { translateText } from "../Utils"; + +export const TOGGLE_INPUT_CARD_CLASSES = { + containerActive: + "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]", + containerInactive: + "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 opacity-80", + labelBase: + "text-[10px] uppercase font-bold tracking-wider text-center w-full leading-tight break-words hyphens-auto", + labelActive: "text-white", + labelInactive: "text-white/60", + input: + "w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1", +}; + +export interface ToggleInputCardInputOptions { + id?: string; + type?: string; + min?: number | string; + max?: number | string; + step?: number | string; + value?: number | string; + ariaLabel?: string; + placeholder?: string; + onInput?: (e: Event) => void; + onChange?: (e: Event) => void; + onKeyDown?: (e: KeyboardEvent) => void; + onClick?: (e: Event) => void; + className?: string; +} + +export function renderToggleInputCardInput({ + id, + type = "number", + min, + max, + step, + value, + ariaLabel, + placeholder, + onInput, + onChange, + onKeyDown, + onClick, + className = TOGGLE_INPUT_CARD_CLASSES.input, +}: ToggleInputCardInputOptions): TemplateResult { + const resolvedValue = value ?? ""; + const handleClick = onClick ?? ((e: Event) => e.stopPropagation()); + + return html` + + `; +} + +export interface ToggleInputCardRenderContext { + labelKey: string; + checked: boolean; + input?: TemplateResult; + onClick?: (e: Event) => void; + onKeyDown?: (e: KeyboardEvent) => void; + activeClassName?: string; + inactiveClassName?: string; + labelBaseClassName?: string; + labelActiveClassName?: string; + labelInactiveClassName?: string; + role?: string; + tabIndex?: number; +} + +export function renderToggleInputCard({ + labelKey, + checked, + input, + onClick, + onKeyDown, + activeClassName = TOGGLE_INPUT_CARD_CLASSES.containerActive, + inactiveClassName = TOGGLE_INPUT_CARD_CLASSES.containerInactive, + labelBaseClassName = TOGGLE_INPUT_CARD_CLASSES.labelBase, + labelActiveClassName = TOGGLE_INPUT_CARD_CLASSES.labelActive, + labelInactiveClassName = TOGGLE_INPUT_CARD_CLASSES.labelInactive, + role, + tabIndex, +}: ToggleInputCardRenderContext): TemplateResult { + const shouldBehaveLikeButton = Boolean(onClick ?? onKeyDown); + const resolvedRole = role ?? (shouldBehaveLikeButton ? "button" : undefined); + const resolvedTabIndex = tabIndex ?? (shouldBehaveLikeButton ? 0 : undefined); + const resolvedOnKeyDown = + onKeyDown ?? + (onClick + ? (e: KeyboardEvent) => { + if ((e.target as HTMLElement).tagName.toLowerCase() === "input") { + return; + } + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(e); + } + } + : undefined); + + return html` +
+
+
+ ${checked + ? html` + + ` + : ""} +
+
+ + ${checked + ? (input ?? html``) + : html`
`} + +
+ ${translateText(labelKey)} +
+
+ `; +} From c8e0838b1543a1e12367ef8622827e660de2493d Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 17 Jan 2026 01:08:13 +0000 Subject: [PATCH 033/109] CopyButton, extract into component (#2934) ## Description: Extracted the CopyButton into its own component, and now reusing it in "Account" too. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- resources/lang/en.json | 3 +- src/client/AccountModal.ts | 34 ++--- src/client/HostLobbyModal.ts | 112 +-------------- src/client/JoinPrivateLobbyModal.ts | 106 +------------- src/client/components/CopyButton.ts | 206 ++++++++++++++++++++++++++++ 5 files changed, 228 insertions(+), 233 deletions(-) create mode 100644 src/client/components/CopyButton.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 6027fc282..c9adfcf33 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -198,7 +198,8 @@ "not_found": "Not Found", "clear_session": "Clear Session", "failed_to_send_recovery_email": "Failed to send recovery email", - "enter_email_address": "Please enter an email address" + "enter_email_address": "Please enter an email address", + "personal_player_id": "Personal Player ID:" }, "stats_modal": { "title": "Stats", diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 505537be6..8169e9566 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -13,16 +13,16 @@ import "./components/baseComponents/stats/GameList"; import "./components/baseComponents/stats/PlayerStatsTable"; import "./components/baseComponents/stats/PlayerStatsTree"; import { BaseModal } from "./components/BaseModal"; +import "./components/CopyButton"; import "./components/Difficulties"; import "./components/PatternButton"; import { modalHeader } from "./components/ui/ModalHeader"; -import { copyToClipboard, translateText } from "./Utils"; +import { translateText } from "./Utils"; @customElement("account-modal") export class AccountModal extends BaseModal { @state() private email: string = ""; @state() private isLoadingUser: boolean = false; - @state() private showCopied: boolean = false; private userMeResponse: UserMeResponse | null = null; private statsTree: PlayerStatsTree | null = null; @@ -47,17 +47,6 @@ export class AccountModal extends BaseModal { }); } - private async copyIdToClipboard() { - const id = this.userMeResponse?.player?.publicId; - if (!id) return; - - await copyToClipboard( - id, - () => (this.showCopied = true), - () => (this.showCopied = false), - ); - } - private hasAnyStats(): boolean { if (!this.statsTree) return false; // Check if statsTree has any data @@ -106,6 +95,8 @@ export class AccountModal extends BaseModal { private renderInner() { const isLoggedIn = !!this.userMeResponse?.user; const title = translateText("account_modal.title"); + const publicId = this.userMeResponse?.player?.publicId ?? ""; + const displayId = publicId || translateText("account_modal.not_found"); return html`
ID:${translateText("account_modal.personal_player_id")} - +
` : undefined, diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index e7d709c12..2ca75b207 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -1,6 +1,6 @@ import { TemplateResult, html } from "lit"; import { customElement, state } from "lit/decorators.js"; -import { copyToClipboard, translateText } from "../client/Utils"; +import { translateText } from "../client/Utils"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { Difficulty, @@ -15,7 +15,6 @@ import { mapCategories, } from "../core/game/Game"; import { getCompactMapNationCount } from "../core/game/NationCreation"; -import { UserSettings } from "../core/game/UserSettings"; import { ClientInfo, GameConfig, @@ -26,6 +25,7 @@ import { import { generateID } from "../core/Util"; import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; +import "./components/CopyButton"; import "./components/Difficulties"; import "./components/FluentSlider"; import "./components/LobbyTeamView"; @@ -65,19 +65,16 @@ export class HostLobbyModal extends BaseModal { @state() private startingGold: boolean = false; @state() private startingGoldValue: number | undefined = undefined; @state() private lobbyId = ""; - @state() private copySuccess = false; @state() private lobbyUrlSuffix = ""; @state() private clients: ClientInfo[] = []; @state() private useRandomMap: boolean = false; @state() private disabledUnits: UnitType[] = []; @state() private lobbyCreatorClientID: string = ""; - @state() private lobbyIdVisible: boolean = true; @state() private nationCount: number = 0; private playersInterval: NodeJS.Timeout | null = null; // Add a new timer for debouncing bot changes private botsUpdateTimer: number | null = null; - private userSettings: UserSettings = new UserSettings(); private mapLoader = terrainMapFileLoader; private leaveLobbyOnClose = true; @@ -144,91 +141,11 @@ export class HostLobbyModal extends BaseModal { }, ariaLabel: translateText("common.back"), rightContent: html` - -
- - - -
+ `, })} @@ -997,10 +914,6 @@ export class HostLobbyModal extends BaseModal { protected onOpen(): void { this.lobbyCreatorClientID = generateID(); - this.lobbyIdVisible = this.userSettings.get( - "settings.lobbyIdVisibility", - true, - ); createLobby(this.lobbyCreatorClientID) .then(async (lobby) => { @@ -1119,10 +1032,8 @@ export class HostLobbyModal extends BaseModal { this.useRandomMap = false; this.disabledUnits = []; this.lobbyId = ""; - this.copySuccess = false; this.clients = []; this.lobbyCreatorClientID = ""; - this.lobbyIdVisible = true; this.nationCount = 0; this.goldMultiplier = false; this.goldMultiplierValue = undefined; @@ -1403,15 +1314,6 @@ export class HostLobbyModal extends BaseModal { return response; } - private async copyToClipboard() { - const url = await this.buildLobbyUrl(); - await copyToClipboard( - url, - () => (this.copySuccess = true), - () => (this.copySuccess = false), - ); - } - private async pollPlayers() { const config = await getServerConfigFromClient(); fetch(`/${config.workerPath(this.lobbyId)}/api/game/${this.lobbyId}`, { diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index cb65b428f..117e6d753 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -1,6 +1,6 @@ import { html, TemplateResult } from "lit"; import { customElement, query, state } from "lit/decorators.js"; -import { copyToClipboard, translateText } from "../client/Utils"; +import { translateText } from "../client/Utils"; import { ClientInfo, GAME_ID_REGEX, @@ -11,10 +11,10 @@ import { import { generateID } from "../core/Util"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { GameMode } from "../core/game/Game"; -import { UserSettings } from "../core/game/UserSettings"; import { getApiBase } from "./Api"; import { JoinLobbyEvent } from "./Main"; import { BaseModal } from "./components/BaseModal"; +import "./components/CopyButton"; import "./components/Difficulties"; import "./components/LobbyTeamView"; import { modalHeader } from "./components/ui/ModalHeader"; @@ -26,12 +26,9 @@ export class JoinPrivateLobbyModal extends BaseModal { @state() private players: ClientInfo[] = []; @state() private gameConfig: GameConfig | null = null; @state() private lobbyCreatorClientID: string | null = null; - @state() private lobbyIdVisible: boolean = true; - @state() private copySuccess: boolean = false; @state() private currentLobbyId: string = ""; private playersInterval: NodeJS.Timeout | null = null; - private userSettings: UserSettings = new UserSettings(); private leaveLobbyOnClose = true; @@ -50,91 +47,7 @@ export class JoinPrivateLobbyModal extends BaseModal { ariaLabel: translateText("common.close"), rightContent: this.hasJoined ? html` - -
- -
{ - (e.currentTarget as HTMLElement).classList.add( - "select-all", - ); - }} - @mouseleave=${(e: Event) => { - (e.currentTarget as HTMLElement).classList.remove( - "select-all", - ); - }} - class="font-mono text-xs font-bold text-white px-2 cursor-pointer select-none min-w-[80px] text-center truncate tracking-wider" - title="${translateText("common.click_to_copy")}" - > - ${this.copySuccess - ? translateText("common.copied") - : this.lobbyIdVisible - ? this.currentLobbyId - : "••••••••"} -
- -
+ ` : undefined, })} @@ -347,10 +260,6 @@ export class JoinPrivateLobbyModal extends BaseModal { public open(id: string = "") { super.open(); - this.lobbyIdVisible = this.userSettings.get( - "settings.lobbyIdVisibility", - true, - ); if (id) { this.setLobbyId(id); this.joinLobby(); @@ -396,15 +305,6 @@ export class JoinPrivateLobbyModal extends BaseModal { this.close(); } - private async copyToClipboard() { - const config = await getServerConfigFromClient(); - await copyToClipboard( - `${location.origin}/${config.workerPath(this.currentLobbyId)}/game/${this.currentLobbyId}`, - () => (this.copySuccess = true), - () => (this.copySuccess = false), - ); - } - private isValidLobbyId(value: string): boolean { return GAME_ID_REGEX.test(value); } diff --git a/src/client/components/CopyButton.ts b/src/client/components/CopyButton.ts new file mode 100644 index 000000000..13742cc58 --- /dev/null +++ b/src/client/components/CopyButton.ts @@ -0,0 +1,206 @@ +import { LitElement, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { getServerConfigFromClient } from "../../core/configuration/ConfigLoader"; +import { UserSettings } from "../../core/game/UserSettings"; +import { copyToClipboard, translateText } from "../Utils"; + +@customElement("copy-button") +export class CopyButton extends LitElement { + @property({ type: String, attribute: "lobby-id" }) lobbyId = ""; + @property({ type: String, attribute: "lobby-suffix" }) lobbySuffix = ""; + @property({ type: Boolean, attribute: "include-lobby-query" }) + includeLobbyQuery = false; + @property({ type: String, attribute: "copy-text" }) copyText = ""; + @property({ type: String, attribute: "display-text" }) displayText = ""; + @property({ type: Boolean, attribute: "show-visibility-toggle" }) + showVisibilityToggle = true; + @property({ type: Boolean, attribute: "show-copy-icon" }) + showCopyIcon = true; + @property({ type: Boolean }) compact = false; + + @state() private copySuccess = false; + @state() private lobbyIdVisible = true; + + private userSettings: UserSettings = new UserSettings(); + private maskLabel = html`••••••••`; + + createRenderRoot() { + return this; + } + + protected willUpdate( + changedProperties: Map, + ) { + if (changedProperties.has("lobbyId")) { + this.lobbyIdVisible = this.userSettings.get( + "settings.lobbyIdVisibility", + true, + ); + this.copySuccess = false; + } + if (changedProperties.has("copyText")) { + this.copySuccess = false; + } + if ( + changedProperties.has("showVisibilityToggle") || + changedProperties.has("compact") + ) { + if (!this.showVisibilityToggle || this.compact) { + this.lobbyIdVisible = true; + } + } + } + + private toggleVisibility() { + if (!this.showVisibilityToggle || this.compact) return; + this.lobbyIdVisible = !this.lobbyIdVisible; + } + + private enableSelectAll(e: Event) { + (e.currentTarget as HTMLElement).classList.add("select-all"); + } + + private clearSelectAll(e: Event) { + (e.currentTarget as HTMLElement).classList.remove("select-all"); + } + + private async buildCopyUrl(): Promise { + const config = await getServerConfigFromClient(); + let url = `${window.location.origin}/${config.workerPath(this.lobbyId)}/game/${this.lobbyId}`; + if (this.includeLobbyQuery) { + url += `?lobby&s=${encodeURIComponent(this.lobbySuffix)}`; + } + return url; + } + + private async resolveCopyText(): Promise { + if (this.copyText) return this.copyText; + if (!this.lobbyId) return ""; + return await this.buildCopyUrl(); + } + + private async handleCopy() { + const text = await this.resolveCopyText(); + if (!text) return; + await copyToClipboard( + text, + () => (this.copySuccess = true), + () => (this.copySuccess = false), + ); + } + + private canCopy() { + return Boolean(this.copyText || this.lobbyId); + } + + render() { + const canCopy = this.canCopy(); + const allowMask = this.showVisibilityToggle && !this.compact; + const rawLabel = this.displayText || this.lobbyId || this.copyText; + const label = this.copySuccess + ? translateText("common.copied") + : allowMask && !this.lobbyIdVisible + ? this.maskLabel + : rawLabel; + const disabledClass = canCopy ? "" : "opacity-60 cursor-not-allowed"; + const toggleDisabled = !this.lobbyId; + const toggleClass = toggleDisabled ? "opacity-60 cursor-not-allowed" : ""; + + if (this.compact) { + return html` + + `; + } + + return html` +
+ ${this.showVisibilityToggle + ? html`` + : ""} + + ${this.showCopyIcon + ? html`` + : ""} +
+ `; + } +} From 4a0ce7128e80fe9671577038efb22284ac04420e Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:02:32 +0100 Subject: [PATCH 034/109] Fix for v29: Add nation count loading for JoinPrivateLobbyModal; change HvN difficulty (#2941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: 1. In JoinPrivateLobbyModal the nation count loading was missing. That caused the team preview UI to show different player counts compared to the HostLobbyModal. For example it showed 0/0 nations for the HumansVsNations team mode (instead of 2/2): Screenshot 2026-01-16 211337 2. Turn down HvN difficulty from Impossible to Hard. We steamrolled over Hard nations in the playtest (at least in two of the three games) because we donated lots of troops to each other. But after some API data research I noticed that only 33% of players in public team games ever use the donate functionality. And we probably have less skilled players in public games than in the playtest. So its probably better to use the Hard difficulty to ensure balanced gameplay. I know, I'm overthinking this 😂 ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/client/HostLobbyModal.ts | 69 ++++++------------- src/client/JoinPrivateLobbyModal.ts | 66 ++++++++++++------ .../{LobbyTeamView.ts => LobbyPlayerView.ts} | 57 ++++++++++++--- src/client/styles.css | 1 - src/server/MapPlaylist.ts | 4 +- 5 files changed, 115 insertions(+), 82 deletions(-) rename src/client/components/{LobbyTeamView.ts => LobbyPlayerView.ts} (83%) diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 2ca75b207..03e0a6ed0 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -14,7 +14,6 @@ import { UnitType, mapCategories, } from "../core/game/Game"; -import { getCompactMapNationCount } from "../core/game/NationCreation"; import { ClientInfo, GameConfig, @@ -28,7 +27,7 @@ import { BaseModal } from "./components/BaseModal"; import "./components/CopyButton"; import "./components/Difficulties"; import "./components/FluentSlider"; -import "./components/LobbyTeamView"; +import "./components/LobbyPlayerView"; import "./components/Maps"; import { modalHeader } from "./components/ui/ModalHeader"; import { crazyGamesSDK } from "./CrazyGamesSDK"; @@ -851,33 +850,16 @@ export class HostLobbyModal extends BaseModal { -
-
-
- ${this.clients.length} - ${this.clients.length === 1 - ? translateText("host_modal.player") - : translateText("host_modal.players")} - - ${this.getEffectiveNationCount()} - ${this.getEffectiveNationCount() === 1 - ? translateText("host_modal.nation_player") - : translateText("host_modal.nation_players")} -
-
- - this.kickPlayer(clientID)} - > -
+ this.kickPlayer(clientID)} + > @@ -1340,31 +1322,22 @@ export class HostLobbyModal extends BaseModal { } private async loadNationCount() { + const currentMap = this.selectedMap; try { - const mapData = this.mapLoader.getMapData(this.selectedMap); + const mapData = this.mapLoader.getMapData(currentMap); const manifest = await mapData.manifest(); - this.nationCount = manifest.nations.length; + // Only update if the map hasn't changed + if (this.selectedMap === currentMap) { + this.nationCount = manifest.nations.length; + } } catch (error) { console.warn("Failed to load nation count", error); - this.nationCount = 0; + // Only update if the map hasn't changed + if (this.selectedMap === currentMap) { + this.nationCount = 0; + } } } - - /** - * Returns the effective nation count for display purposes. - * In HumansVsNations mode, this equals the number of human players. - * For compact maps, only 25% of nations are used. - * Otherwise, it uses the manifest nation count (or 0 if nations are disabled). - */ - private getEffectiveNationCount(): number { - if (this.disableNations) { - return 0; - } - if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) { - return this.clients.length; - } - return getCompactMapNationCount(this.nationCount, this.compactMap); - } } async function createLobby(creatorClientID: string): Promise { diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 117e6d753..c04b8fa93 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -10,13 +10,14 @@ import { } from "../core/Schemas"; import { generateID } from "../core/Util"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; -import { GameMode } from "../core/game/Game"; +import { GameMapSize, GameMode } from "../core/game/Game"; import { getApiBase } from "./Api"; import { JoinLobbyEvent } from "./Main"; +import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { BaseModal } from "./components/BaseModal"; import "./components/CopyButton"; import "./components/Difficulties"; -import "./components/LobbyTeamView"; +import "./components/LobbyPlayerView"; import { modalHeader } from "./components/ui/ModalHeader"; @customElement("join-private-lobby-modal") export class JoinPrivateLobbyModal extends BaseModal { @@ -27,8 +28,10 @@ export class JoinPrivateLobbyModal extends BaseModal { @state() private gameConfig: GameConfig | null = null; @state() private lobbyCreatorClientID: string | null = null; @state() private currentLobbyId: string = ""; + @state() private nationCount: number = 0; private playersInterval: NodeJS.Timeout | null = null; + private mapLoader = terrainMapFileLoader; private leaveLobbyOnClose = true; @@ -93,26 +96,17 @@ export class JoinPrivateLobbyModal extends BaseModal { ${this.renderGameConfig()} ${this.hasJoined && this.players.length > 0 ? html` -
-
-
- ${this.players.length} - ${this.players.length === 1 - ? translateText("private_lobby.player") - : translateText("private_lobby.players")} -
-
- - -
+ ` : ""} @@ -296,6 +290,7 @@ export class JoinPrivateLobbyModal extends BaseModal { this.hasJoined = false; this.message = ""; this.currentLobbyId = ""; + this.nationCount = 0; this.leaveLobbyOnClose = true; } @@ -512,11 +507,38 @@ export class JoinPrivateLobbyModal extends BaseModal { this.lobbyCreatorClientID = data.clients?.[0]?.clientID ?? null; this.players = data.clients ?? []; if (data.gameConfig) { + const mapChanged = + this.gameConfig?.gameMap !== data.gameConfig.gameMap; this.gameConfig = data.gameConfig; + if (mapChanged) { + this.loadNationCount(); + } } }) .catch((error) => { console.error("Error polling players:", error); }); } + + private async loadNationCount() { + if (!this.gameConfig) { + this.nationCount = 0; + return; + } + const currentMap = this.gameConfig.gameMap; + try { + const mapData = this.mapLoader.getMapData(currentMap); + const manifest = await mapData.manifest(); + // Only update if the map hasn't changed + if (this.gameConfig?.gameMap === currentMap) { + this.nationCount = manifest.nations.length; + } + } catch (error) { + console.warn("Failed to load nation count", error); + // Only update if the map hasn't changed + if (this.gameConfig?.gameMap === currentMap) { + this.nationCount = 0; + } + } + } } diff --git a/src/client/components/LobbyTeamView.ts b/src/client/components/LobbyPlayerView.ts similarity index 83% rename from src/client/components/LobbyTeamView.ts rename to src/client/components/LobbyPlayerView.ts index d85105c4b..2bcac3108 100644 --- a/src/client/components/LobbyTeamView.ts +++ b/src/client/components/LobbyPlayerView.ts @@ -13,6 +13,7 @@ import { Team, Trios, } from "../../core/game/Game"; +import { getCompactMapNationCount } from "../../core/game/NationCreation"; import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment"; import { ClientInfo, TeamCountConfig } from "../../core/Schemas"; import { translateText } from "../Utils"; @@ -22,7 +23,7 @@ export interface TeamPreviewData { players: ClientInfo[]; } -@customElement("lobby-team-view") +@customElement("lobby-player-view") export class LobbyTeamView extends LitElement { @property({ type: String }) gameMode: GameMode = GameMode.FFA; @property({ type: Array }) clients: ClientInfo[] = []; @@ -32,6 +33,8 @@ export class LobbyTeamView extends LitElement { @property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2; @property({ type: Function }) onKickPlayer?: (clientID: string) => void; @property({ type: Number }) nationCount: number = 0; + @property({ type: Boolean }) disableNations: boolean = false; + @property({ type: Boolean }) isCompactMap: boolean = false; private theme: PastelTheme = new PastelTheme(); @state() private showTeamColors: boolean = false; @@ -52,11 +55,32 @@ export class LobbyTeamView extends LitElement { } render() { - return html`
- ${this.gameMode === GameMode.Team - ? this.renderTeamMode() - : this.renderFreeForAll()} -
`; + return html` +
+
+
+ ${this.clients.length} + ${this.clients.length === 1 + ? translateText("host_modal.player") + : translateText("host_modal.players")} + + ${this.getEffectiveNationCount()} + ${this.getEffectiveNationCount() === 1 + ? translateText("host_modal.nation_player") + : translateText("host_modal.nation_players")} +
+
+
+ ${this.gameMode === GameMode.Team + ? this.renderTeamMode() + : this.renderFreeForAll()} +
+
+ `; } createRenderRoot() { @@ -148,14 +172,15 @@ export class LobbyTeamView extends LitElement { } private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) { + const effectiveNationCount = this.getEffectiveNationCount(); const displayCount = preview.team === ColoredTeams.Nations - ? this.nationCount + ? effectiveNationCount : preview.players.length; const maxTeamSize = preview.team === ColoredTeams.Nations - ? this.nationCount + ? effectiveNationCount : this.teamMaxSize; return html` @@ -308,4 +333,20 @@ export class LobbyTeamView extends LitElement { players: buckets.get(t) ?? [], })); } + + /** + * Returns the effective nation count for display purposes. + * In HumansVsNations mode, this equals the number of human players. + * For compact maps, only 25% of nations are used. + * Otherwise, it uses the manifest nation count (or 0 if nations are disabled). + */ + private getEffectiveNationCount(): number { + if (this.disableNations) { + return 0; + } + if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) { + return this.clients.length; + } + return getCompactMapNationCount(this.nationCount, this.isCompactMap); + } } diff --git a/src/client/styles.css b/src/client/styles.css index 9873cd4f0..7023676e8 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -546,7 +546,6 @@ label.option-card:hover { flex-wrap: wrap; gap: 8px; justify-content: center; - padding: 0 16px; } /* News Button Notification */ diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index c4430e332..97b23f0e4 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -127,9 +127,7 @@ export class MapPlaylist { publicGameModifiers: { isCompact, isRandomSpawn, startingGold }, startingGold, difficulty: - playerTeams === HumansVsNations - ? Difficulty.Impossible - : Difficulty.Easy, + playerTeams === HumansVsNations ? Difficulty.Hard : Difficulty.Easy, infiniteGold: false, infiniteTroops: false, maxTimerValue: undefined, From 239f7910ad1690794805aedd57e5a113e9671930 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:42:45 +0100 Subject: [PATCH 035/109] Add nation count loading for JoinPrivateLobbyModal (Part 2) (#2942) ## Description: Use `this.getEffectiveNationCount()` everywhere inside of `LobbyPlayerView`, instead of `this.nationCount`. So the team player counts always update properly. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/client/components/LobbyPlayerView.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/client/components/LobbyPlayerView.ts b/src/client/components/LobbyPlayerView.ts index 2bcac3108..4c72fc21d 100644 --- a/src/client/components/LobbyPlayerView.ts +++ b/src/client/components/LobbyPlayerView.ts @@ -46,7 +46,9 @@ export class LobbyTeamView extends LitElement { changedProperties.has("gameMode") || changedProperties.has("clients") || changedProperties.has("teamCount") || - changedProperties.has("nationCount") + changedProperties.has("nationCount") || + changedProperties.has("disableNations") || + changedProperties.has("isCompactMap") ) { const teamsList = this.getTeamList(); this.computeTeamPreview(teamsList); @@ -237,7 +239,7 @@ export class LobbyTeamView extends LitElement { private getTeamList(): Team[] { if (this.gameMode !== GameMode.Team) return []; - const playerCount = this.clients.length + this.nationCount; + const playerCount = this.clients.length + this.getEffectiveNationCount(); const config = this.teamCount; if (config === HumansVsNations) { @@ -301,7 +303,7 @@ export class LobbyTeamView extends LitElement { const assignment = assignTeamsLobbyPreview( players, teams, - this.nationCount, + this.getEffectiveNationCount(), ); const buckets = new Map(); for (const t of teams) buckets.set(t, []); @@ -325,7 +327,9 @@ export class LobbyTeamView extends LitElement { // Fallback: divide players across teams; guard against 0 and empty lobbies this.teamMaxSize = Math.max( 1, - Math.ceil((this.clients.length + this.nationCount) / teams.length), + Math.ceil( + (this.clients.length + this.getEffectiveNationCount()) / teams.length, + ), ); } this.teamPreview = teams.map((t) => ({ From e08b8f8bdc3bd06d6284442775fb83f7352c6e65 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sat, 17 Jan 2026 21:33:54 -0800 Subject: [PATCH 036/109] add Sierpinski to public map rotation --- src/server/MapPlaylist.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 97b23f0e4..93926de44 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -63,6 +63,7 @@ const frequency: Partial> = { Surrounded: 4, DidierFrance: 1, AmazonRiver: 3, + Sierpinski: 10, }; interface MapWithMode { From c179249cdd7439fe132a8f5f7a518b9d0e4698af Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 18 Jan 2026 11:07:52 +0000 Subject: [PATCH 037/109] [BugFix] Join Lobby Bugfix (#2947) ## Description: If a WebSocket was "connecting", but the user un-clicks the lobby in that time, it doesn't remove them from the lobby, and they would still be put into the game. @evanpelle needed for v29 imo. It also fixes an issue where it wouldn't update your URL back to the home url when unclicking the lobby (websocket issue or not). This is a hotfix to fix that. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- src/client/Main.ts | 6 ++++++ src/client/Transport.ts | 14 +++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/client/Main.ts b/src/client/Main.ts index 88fe223f4..98414eca7 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -868,6 +868,12 @@ class Client { this.gameStop(); this.gameStop = null; + try { + history.replaceState(null, "", "/"); + } catch (e) { + console.warn("Failed to restore URL on leave:", e); + } + document.body.classList.remove("in-game"); crazyGamesSDK.gameplayStop(); diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 1f35131a4..d370c6ba8 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -725,10 +725,18 @@ export class Transport { this.socket.onclose = null; this.socket.onerror = null; - // Close the connection if it's still open - if (this.socket.readyState === WebSocket.OPEN) { - this.socket.close(); + // Close the connection if it's still open or still connecting + try { + if ( + this.socket.readyState === WebSocket.OPEN || + this.socket.readyState === WebSocket.CONNECTING + ) { + this.socket.close(); + } + } catch (e) { + console.warn("Error while closing WebSocket:", e); } + this.socket = null; } } From c123adc0ef50948ef5ce20e40ff73f6f663d8824 Mon Sep 17 00:00:00 2001 From: WillTHomeGit Date: Sun, 18 Jan 2026 09:19:55 -0600 Subject: [PATCH 038/109] fix (pathfinding): prioritize best connected water neighbor in ShoreCoercingTransformer (#2937) ## Description: **Describe the PR.** This PR improves how pathfinding finds a starting water tile when launching a transport ship from a shore. Previously, the code simply picked the first water neighbor it found. This caused issues where, if a boat were traveling east, it might launch out of a northern tile from a shore. image image The new logic checks all water neighbors and picks the "best" one by counting how many water tiles surround it. This ensures transport ships launch into the main body of water instead of suboptimal positions. If two tiles have water neighbors with the same score, they are tie-broken through a euclidean distance check. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: Scisyph --------- Co-authored-by: WilliamT-byte Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com> --- src/core/pathfinding/PathFinder.ts | 4 +- .../transformers/ShoreCoercingTransformer.ts | 39 ++++++++++++------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/core/pathfinding/PathFinder.ts b/src/core/pathfinding/PathFinder.ts index f77776c36..9a625956f 100644 --- a/src/core/pathfinding/PathFinder.ts +++ b/src/core/pathfinding/PathFinder.ts @@ -47,8 +47,8 @@ export class PathFinding { return PathFinderBuilder.create(pf) .wrap((pf) => new ComponentCheckTransformer(pf, componentCheckFn)) .wrap((pf) => new SmoothingWaterTransformer(pf, miniMap)) - .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap)) .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap)) + .wrap((pf) => new ShoreCoercingTransformer(pf, game.map())) .buildWithStepper(tileStepperConfig(game)); } @@ -57,8 +57,8 @@ export class PathFinding { const pf = new AStarWater(miniMap); return PathFinderBuilder.create(pf) - .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap)) .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap)) + .wrap((pf) => new ShoreCoercingTransformer(pf, game.map())) .buildWithStepper(tileStepperConfig(game)); } diff --git a/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts b/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts index 523387127..d0e8dbe25 100644 --- a/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts +++ b/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts @@ -1,5 +1,3 @@ -// Shore-coercing transformer that converts shore tiles to water tiles for pathfinding - import { GameMap, TileRef } from "../../game/GameMap"; import { PathFinder } from "../types"; @@ -7,9 +5,6 @@ import { PathFinder } from "../types"; * Wraps a PathFinder to handle shore tiles. * Coerces shore tiles to nearby water tiles before pathfinding, * then fixes the path extremes to include the original shore tiles. - * - * Works at whatever resolution the map provides - can be used with - * full map or minimap-based pathfinders. */ export class ShoreCoercingTransformer implements PathFinder { constructor( @@ -34,20 +29,18 @@ export class ShoreCoercingTransformer implements PathFinder { return null; } - // Coerce to tile const coercedTo = this.coerceToWater(to); if (coercedTo.water === null) { return null; } - // Search on water tiles const fromTiles = waterFrom.length === 1 ? waterFrom[0] : waterFrom; const path = this.inner.findPath(fromTiles, coercedTo.water); if (!path || path.length === 0) { return null; } - // Look up the actual path start in the map + // Restore original start shore tile const originalShore = waterToOriginal.get(path[0]); if (originalShore !== undefined && originalShore !== null) { path.unshift(originalShore); @@ -67,25 +60,43 @@ export class ShoreCoercingTransformer implements PathFinder { /** * Coerce a tile to water for pathfinding. * If tile is already water, returns it unchanged. - * If tile is shore (land with water neighbor), finds the nearest water neighbor. + * If tile is shore, finds the best adjacent water neighbor. */ private coerceToWater(tile: TileRef): { water: TileRef | null; original: TileRef | null; } { - // If already water, no coercion needed if (this.map.isWater(tile)) { return { water: tile, original: null }; } - // Find adjacent water neighbor + let best: TileRef | null = null; + let maxScore = -1; + for (const n of this.map.neighbors(tile)) { - if (this.map.isWater(n)) { - return { water: n, original: tile }; + if (!this.map.isWater(n)) continue; + + // Score by water neighbor count (connectivity) + const score = this.countWaterNeighbors(n); + + // Pick highest connectivity + if (score > maxScore) { + maxScore = score; + best = n; } } - // No water neighbor found - let HPA* handle at minimap level + if (best !== null) { + return { water: best, original: tile }; + } return { water: null, original: tile }; } + + private countWaterNeighbors(tile: TileRef): number { + let count = 0; + for (const n of this.map.neighbors(tile)) { + if (this.map.isWater(n)) count++; + } + return count; + } } From 216e8ca29f90b73220598b046f2ea07a39ccae3c Mon Sep 17 00:00:00 2001 From: Arkadiusz Sygulski Date: Sun, 18 Jan 2026 21:51:22 +0100 Subject: [PATCH 039/109] Fix rail pathfinding (#2950) ## Description: This PR resolves a crash related to rail pathfinding reported on Discord. ``` git checkout c179249cdd7439fe132a8f5f7a518b9d0e4698af npm run dev:staging Replay id: kEbHPSP3 ``` ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: moleole Co-authored-by: Claude Opus 4.5 Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com> --- src/core/game/RailNetworkImpl.ts | 2 +- src/core/pathfinding/PathFinder.Station.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index 711221120..d3aef952b 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -23,7 +23,7 @@ export interface StationManager { export class StationManagerImpl implements StationManager { private stations: Set = new Set(); private stationsById: (TrainStation | undefined)[] = []; - private nextId = 0; + private nextId = 1; // Start from 1; 0 is reserved as invalid/sentinel addStation(station: TrainStation) { station.id = this.nextId++; diff --git a/src/core/pathfinding/PathFinder.Station.ts b/src/core/pathfinding/PathFinder.Station.ts index 8510fb048..1efa20be6 100644 --- a/src/core/pathfinding/PathFinder.Station.ts +++ b/src/core/pathfinding/PathFinder.Station.ts @@ -42,7 +42,7 @@ class StationGraphAdapter implements AStarAdapter { } maxNeighbors(): number { - return 8; + return 32; } maxPriority(): number { From b75df821cdf0bf6731bfb73e2e54bedec6849f7d Mon Sep 17 00:00:00 2001 From: Arkadiusz Sygulski Date: Sun, 18 Jan 2026 21:51:22 +0100 Subject: [PATCH 040/109] Fix rail pathfinding (#2950) ## Description: This PR resolves a crash related to rail pathfinding reported on Discord. ``` git checkout c179249cdd7439fe132a8f5f7a518b9d0e4698af npm run dev:staging Replay id: kEbHPSP3 ``` ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: moleole Co-authored-by: Claude Opus 4.5 Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com> --- src/core/game/RailNetworkImpl.ts | 2 +- src/core/pathfinding/PathFinder.Station.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index 711221120..d3aef952b 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -23,7 +23,7 @@ export interface StationManager { export class StationManagerImpl implements StationManager { private stations: Set = new Set(); private stationsById: (TrainStation | undefined)[] = []; - private nextId = 0; + private nextId = 1; // Start from 1; 0 is reserved as invalid/sentinel addStation(station: TrainStation) { station.id = this.nextId++; diff --git a/src/core/pathfinding/PathFinder.Station.ts b/src/core/pathfinding/PathFinder.Station.ts index 8510fb048..1efa20be6 100644 --- a/src/core/pathfinding/PathFinder.Station.ts +++ b/src/core/pathfinding/PathFinder.Station.ts @@ -42,7 +42,7 @@ class StationGraphAdapter implements AStarAdapter { } maxNeighbors(): number { - return 8; + return 32; } maxPriority(): number { From be4cabdde9a226c4425e8d3fa877b453bf8909b7 Mon Sep 17 00:00:00 2001 From: WillTHomeGit Date: Sun, 18 Jan 2026 09:19:55 -0600 Subject: [PATCH 041/109] fix (pathfinding): prioritize best connected water neighbor in ShoreCoercingTransformer (#2937) ## Description: **Describe the PR.** This PR improves how pathfinding finds a starting water tile when launching a transport ship from a shore. Previously, the code simply picked the first water neighbor it found. This caused issues where, if a boat were traveling east, it might launch out of a northern tile from a shore. image image The new logic checks all water neighbors and picks the "best" one by counting how many water tiles surround it. This ensures transport ships launch into the main body of water instead of suboptimal positions. If two tiles have water neighbors with the same score, they are tie-broken through a euclidean distance check. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: Scisyph --------- Co-authored-by: WilliamT-byte Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com> --- src/core/pathfinding/PathFinder.ts | 4 +- .../transformers/ShoreCoercingTransformer.ts | 39 ++++++++++++------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/core/pathfinding/PathFinder.ts b/src/core/pathfinding/PathFinder.ts index f77776c36..9a625956f 100644 --- a/src/core/pathfinding/PathFinder.ts +++ b/src/core/pathfinding/PathFinder.ts @@ -47,8 +47,8 @@ export class PathFinding { return PathFinderBuilder.create(pf) .wrap((pf) => new ComponentCheckTransformer(pf, componentCheckFn)) .wrap((pf) => new SmoothingWaterTransformer(pf, miniMap)) - .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap)) .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap)) + .wrap((pf) => new ShoreCoercingTransformer(pf, game.map())) .buildWithStepper(tileStepperConfig(game)); } @@ -57,8 +57,8 @@ export class PathFinding { const pf = new AStarWater(miniMap); return PathFinderBuilder.create(pf) - .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap)) .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap)) + .wrap((pf) => new ShoreCoercingTransformer(pf, game.map())) .buildWithStepper(tileStepperConfig(game)); } diff --git a/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts b/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts index 523387127..d0e8dbe25 100644 --- a/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts +++ b/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts @@ -1,5 +1,3 @@ -// Shore-coercing transformer that converts shore tiles to water tiles for pathfinding - import { GameMap, TileRef } from "../../game/GameMap"; import { PathFinder } from "../types"; @@ -7,9 +5,6 @@ import { PathFinder } from "../types"; * Wraps a PathFinder to handle shore tiles. * Coerces shore tiles to nearby water tiles before pathfinding, * then fixes the path extremes to include the original shore tiles. - * - * Works at whatever resolution the map provides - can be used with - * full map or minimap-based pathfinders. */ export class ShoreCoercingTransformer implements PathFinder { constructor( @@ -34,20 +29,18 @@ export class ShoreCoercingTransformer implements PathFinder { return null; } - // Coerce to tile const coercedTo = this.coerceToWater(to); if (coercedTo.water === null) { return null; } - // Search on water tiles const fromTiles = waterFrom.length === 1 ? waterFrom[0] : waterFrom; const path = this.inner.findPath(fromTiles, coercedTo.water); if (!path || path.length === 0) { return null; } - // Look up the actual path start in the map + // Restore original start shore tile const originalShore = waterToOriginal.get(path[0]); if (originalShore !== undefined && originalShore !== null) { path.unshift(originalShore); @@ -67,25 +60,43 @@ export class ShoreCoercingTransformer implements PathFinder { /** * Coerce a tile to water for pathfinding. * If tile is already water, returns it unchanged. - * If tile is shore (land with water neighbor), finds the nearest water neighbor. + * If tile is shore, finds the best adjacent water neighbor. */ private coerceToWater(tile: TileRef): { water: TileRef | null; original: TileRef | null; } { - // If already water, no coercion needed if (this.map.isWater(tile)) { return { water: tile, original: null }; } - // Find adjacent water neighbor + let best: TileRef | null = null; + let maxScore = -1; + for (const n of this.map.neighbors(tile)) { - if (this.map.isWater(n)) { - return { water: n, original: tile }; + if (!this.map.isWater(n)) continue; + + // Score by water neighbor count (connectivity) + const score = this.countWaterNeighbors(n); + + // Pick highest connectivity + if (score > maxScore) { + maxScore = score; + best = n; } } - // No water neighbor found - let HPA* handle at minimap level + if (best !== null) { + return { water: best, original: tile }; + } return { water: null, original: tile }; } + + private countWaterNeighbors(tile: TileRef): number { + let count = 0; + for (const n of this.map.neighbors(tile)) { + if (this.map.isWater(n)) count++; + } + return count; + } } From 969b301aacb1b191f37c44088236ec4d61431d25 Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Mon, 19 Jan 2026 01:03:13 +0100 Subject: [PATCH 042/109] Fix: Ally won't conquer last tiles of so-called dead defender (#2954) ## Description: When a player is conquered (has less than 100 tiles left) their gold is transfered to the conqueror. And after that the conqueror gets the last tiles. But if some of those last tiles are not bordered by the conqueror, they are given to their neighbour player. However that neighbour player can be an ally. It is percieved as a bug if an ally conquers/annexes tiles. This PR fixes that by adding an isFriendly check to `handleDeadDefender` in `AttackExecution`. Now, there are already scenarios possible currently, where a player survives being conquered. If they have some tiles on a small island for example. Going from that, there should be no unexpected bugs following this change. A player can be conquered twice in a game already in the stats too. https://discord.com/channels/1359946986937258015/1359946989046989063/1462595261204533248 Example of this happening in an Enzo vid (with his surprised reaction) and explanation posted here: https://discord.com/channels/1359946986937258015/1359946989046989063/1460483209308536925 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: tryout33 --- src/core/execution/AttackExecution.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index bd4b72095..6021fbc09 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -371,7 +371,11 @@ export class AttackExecution implements Execution { } else { for (const neighbor of this.mg.neighbors(tile)) { const no = this.mg.owner(neighbor); - if (no.isPlayer() && no !== this.target) { + if ( + no.isPlayer() && + no !== this.target && + !no.isFriendly(this.target) + ) { this.mg.player(no.id()).conquer(tile); break; } From d92008f96bac8e643193a13f774e5ea7d07273d0 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Mon, 19 Jan 2026 13:46:08 +0900 Subject: [PATCH 043/109] mls (v4.14) (#2953) ## Description: mls for v29 Version identifier within MLS: 4.14 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: aotumuri --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- resources/lang/bg.json | 38 ++- resources/lang/ja.json | 38 ++- resources/lang/nl.json | 42 ++- resources/lang/ru.json | 291 +++++++++++++++------ resources/lang/tr.json | 521 ++++++++++++++++++++++++++++++-------- resources/lang/uk.json | 293 +++++++++++++++------ resources/lang/zh-CN.json | 239 +++++++++++++---- 7 files changed, 1128 insertions(+), 334 deletions(-) diff --git a/resources/lang/bg.json b/resources/lang/bg.json index ce630b90f..562270927 100644 --- a/resources/lang/bg.json +++ b/resources/lang/bg.json @@ -26,12 +26,15 @@ "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": "Самостоятелна игра", + "solo": "Самостоятелно", "instructions": "Инструкции", "game_info": "Информация за играта", "wiki": "Wiki", @@ -42,7 +45,7 @@ "play": "Играй", "news": "Новини", "store": "Магазин", - "options": "Опции", + "settings": "Настройки", "keys": "Клавиши", "stats": "Статистики", "account": "Акаунт", @@ -179,13 +182,8 @@ "title": "Акаунт", "connected_as": "Вписан като", "stats_overview": "Преглед на статистики", - "save_progress_title": "Запази си напредъка", - "save_progress_desc": "Свържи си акаунта, за да запазиш статистиките, ранка и козметиките си в безопасност.", "link_discord": "Свържи Discord акаунт", - "link_via_email_placeholder": "Свържи чрез имейл", - "link_button": "Свържи", "log_out": "Изход от профила", - "welcome_back": "Добре дошъл отново!", "sign_in_desc": "Впиши се, за да запазиш статистиките и напредъка си", "or": "ИЛИ", "email_placeholder": "Въведи имейл адреса си", @@ -237,7 +235,7 @@ "pirate": "Пират", "conquered": "Завладяно", "loading_game_info": "Зареждат се статистиките на играта", - "no_winner": "Играта е свършила без победител" + "no_winner": "Играта завърши без победител (или победа на нация)" }, "map": { "map": "Карта", @@ -429,7 +427,7 @@ "factory": "Фабрика" }, "user_setting": { - "title": "Потребителски настройки", + "title": "Настройки", "tab_basic": "Базови настройки", "tab_keybinds": "Бързи клавиши", "dark_mode_label": "Тъмен режим", @@ -487,6 +485,11 @@ "build_hydrogen_bomb_desc": "Пускане на водородна бомба под курсора Ви.", "build_mirv": "Пускане на МИРВ", "build_mirv_desc": "Пускане на МИРВ под курсора Ви.", + "menu_shortcuts": "Преки пътища за меню", + "build_menu_modifier": "Модификатор на менюто за изграждане", + "build_menu_modifier_desc": "Задръж този клавиш, докато кликаш, за да отвориш менюто за изграждане.", + "emoji_menu_modifier": "Модификатор на менюто с емоджита", + "emoji_menu_modifier_desc": "Задръж този клавиш, докато кликаш, за да отвориш менюто за емоджита.", "attack_ratio_controls": "Контроли за съотношение на атака", "attack_ratio_up": "Увеличаване на съотношение на атака", "attack_ratio_up_desc": "Увеличаване на съотношение на атака с 10%", @@ -497,6 +500,8 @@ "boat_attack_desc": "Изпраща атака с лодка към плочката под курсора ви.", "ground_attack": "Земна атака", "ground_attack_desc": "Изпраща земна атака към плочката под курсора ви.", + "swap_direction": "Размени посоката на ракетата", + "swap_direction_desc": "Превключване на посоката на изстрелване на ракетата (нагоре/надолу).", "zoom_controls": "Контроли за позиция на камерата", "zoom_out": "Отдалечаване на камерата", "zoom_out_desc": "Отдалечаване на камерата от картата", @@ -711,7 +716,20 @@ "wants_to_renew_alliance": "{name} иска да поднови съюза си с теб", "ignore": "Игнориране", "unit_voluntarily_deleted": "Елементът бе изтрит доброволно", - "betrayal_debuff_ends": "Остават {time} секунди до края на предателското отслабване" + "betrayal_debuff_ends": "Остават {time} секунди до края на предателското отслабване", + "attack_cancelled_retreat": "Атаката бе отменена, {troops} войници бяха убити по време на отстъплението", + "received_gold_from_captured_ship": "Получи {gold} злато от лодка на {name}, превзета от теб", + "received_gold_from_trade": "Получи {gold} злато от търговия с {name}", + "missile_intercepted": "Противоракетната установка прихвана {unit}", + "mirv_warheads_intercepted": "{count, plural, one {{count} МИРВ бе прихванат} other {{count} МИРВ-а бяха прихванати}}", + "sent_troops_to_player": "Изпрати {troops} войници на {name}", + "received_troops_from_player": "Получи {troops} войници от {name}", + "sent_gold_to_player": "Изпрати {gold} злато на {name}", + "received_gold_from_player": "Получи {gold} злато от {name}", + "unit_captured_by_enemy": "Твоят/а {unit} бе превзет от {name}", + "captured_enemy_unit": "Превзе {unit} от {name}", + "unit_destroyed": "Твоят/а {unit} бе унищожен", + "no_boats_available": "Няма свободни кораби, максимум {max}" }, "unit_info_modal": { "structure_info": "Информация за постройката", diff --git a/resources/lang/ja.json b/resources/lang/ja.json index 381fd0390..89751baae 100644 --- a/resources/lang/ja.json +++ b/resources/lang/ja.json @@ -26,12 +26,15 @@ "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": "1人のロビー", + "solo": "ソロ", "instructions": "説明書", "game_info": "ゲームの情報", "wiki": "ウィキ", @@ -42,7 +45,7 @@ "play": "プレイ", "news": "お知らせ", "store": "ストア", - "options": "設定", + "settings": "設定", "keys": "キー設定", "stats": "統計", "account": "アカウント", @@ -179,13 +182,8 @@ "title": "アカウント", "connected_as": "接続されたアカウント", "stats_overview": "統計の概要", - "save_progress_title": "進捗状況を保存する", - "save_progress_desc": "アカウントをリンクして、統計、ランク、コスメティックを安全に保ちます。", "link_discord": "Discordアカウントを連携する", - "link_via_email_placeholder": "メールで連携する", - "link_button": "連携", "log_out": "ログアウト", - "welcome_back": "おかえりなさい", "sign_in_desc": "統計と進捗状況を保存するにはサインインしてください", "or": "または", "email_placeholder": "メールアドレスを入力してください", @@ -237,7 +235,7 @@ "pirate": "海賊", "conquered": "征服された", "loading_game_info": "ゲームの統計を読み込んでいます", - "no_winner": "この試合の勝者はいなかった" + "no_winner": "このゲームは勝者なしで終了しました(または国家が勝利しました)" }, "map": { "map": "地図", @@ -429,7 +427,7 @@ "factory": "工場" }, "user_setting": { - "title": "ユーザー設定", + "title": "設定", "tab_basic": "基本設定", "tab_keybinds": "キーの割り当て", "dark_mode_label": "ダークモード", @@ -487,6 +485,11 @@ "build_hydrogen_bomb_desc": "選択した位置に水素爆弾を発射します。", "build_mirv": "MIRVを発射", "build_mirv_desc": "選択した位置にMIRVを発射します。", + "menu_shortcuts": "メニューのショートカット", + "build_menu_modifier": "ビルドメニューを表示", + "build_menu_modifier_desc": "ビルドメニューを開きます。", + "emoji_menu_modifier": "絵文字メニューを表示", + "emoji_menu_modifier_desc": "絵文字メニューを開きます。", "attack_ratio_controls": "攻撃比率の調整", "attack_ratio_up": "出撃兵力の割合を上げる", "attack_ratio_up_desc": "出撃兵力を10%増加させる", @@ -497,6 +500,8 @@ "boat_attack_desc": "カーソルの位置に合わせた土地にボート攻撃を送ります。", "ground_attack": "ボート攻撃", "ground_attack_desc": "カーソルの位置に合わせた土地にボート攻撃を送ります。", + "swap_direction": "核の撃つ向きを逆転", + "swap_direction_desc": "核の発射方向を切り替える(上方向/下方向)。", "zoom_controls": "ズーム操作", "zoom_out": "ズームアウト", "zoom_out_desc": "マップを縮小します", @@ -711,7 +716,20 @@ "wants_to_renew_alliance": "{name} が同盟の更新を提案しています", "ignore": "無視", "unit_voluntarily_deleted": "ユニットは自己破壊しました", - "betrayal_debuff_ends": "裏切りのデバフ終了まであと {time} 秒" + "betrayal_debuff_ends": "裏切りのデバフ終了まであと {time} 秒", + "attack_cancelled_retreat": "攻撃はキャンセルされました、撤退中に{troops} 人の兵士が死亡しました", + "received_gold_from_captured_ship": "{name} から捕獲した船から資金 {gold} を獲得しました", + "received_gold_from_trade": "{name} との貿易で資金 {gold}を獲得しました", + "missile_intercepted": "ミサイルが{unit}を迎撃しました", + "mirv_warheads_intercepted": "{count, plural, other {{count}発の MIRV 弾頭を迎撃}}", + "sent_troops_to_player": "{troops} の兵士を {name} に送信しました", + "received_troops_from_player": "{name}から{troops}の軍隊を受け取りました", + "sent_gold_to_player": "{gold} の資金を {name}に贈りました", + "received_gold_from_player": "{gold} から {name} の資金を受け取りました", + "unit_captured_by_enemy": "あなたの {unit} は {name}に鹵獲されました", + "captured_enemy_unit": "{unit}を{name}から奪い取りました", + "unit_destroyed": "あなたの{unit}は破壊されました", + "no_boats_available": "ボートをこれ以上出せません、最大は{max}隻までです" }, "unit_info_modal": { "structure_info": "建造物情報", diff --git a/resources/lang/nl.json b/resources/lang/nl.json index d103626c0..f8784c045 100644 --- a/resources/lang/nl.json +++ b/resources/lang/nl.json @@ -26,12 +26,15 @@ "title": "OpenFront (ALFA)", "join_discord": "Discord", "login_discord": "Login met Discord", + "sign_in": "Aanmelden", + "discord_avatar_alt": "Avatar Discord profiel", + "user_avatar_alt": "Avatar van {username}", "checking_login": "Inlog controleren...", "logged_in": "Ingelogd!", "log_out": "Uitloggen", "create": "Lobby aanmaken", "join": "Lobby toetreden", - "solo": "Solo-lobby", + "solo": "Solo", "instructions": "Instructies", "game_info": "Spelinformatie", "wiki": "Wiki", @@ -42,7 +45,7 @@ "play": "Spelen", "news": "Nieuws", "store": "Winkel", - "options": "Opties", + "settings": "Instellingen", "keys": "Sneltoetsen", "stats": "Statistieken", "account": "Account", @@ -179,14 +182,9 @@ "title": "Account", "connected_as": "Gekoppeld als", "stats_overview": "Overzicht van statistieken", - "save_progress_title": "Sla je voortgang op", - "save_progress_desc": "Koppel je account om je statistieken, rang en cosmetica veilig te houden.", "link_discord": "Discord-account koppelen", - "link_via_email_placeholder": "Koppel via e-mail", - "link_button": "Koppelen", "log_out": "Uitloggen", - "welcome_back": "Welkom terug", - "sign_in_desc": "Log in om je statistieken en voortgang op te slaan", + "sign_in_desc": "Meld je aan om statistieken en voortgang op te slaan", "or": "OF", "email_placeholder": "Voer je e-mailadres in", "get_magic_link": "Krijg Magische Link", @@ -237,7 +235,7 @@ "pirate": "Kapen", "conquered": "Veroverd", "loading_game_info": "Spelstatistieken worden geladen", - "no_winner": "Dit spel eindigde zonder winnaar" + "no_winner": "Dit spel eindigde zonder winnaar (of een Natie won)" }, "map": { "map": "Kaart", @@ -429,7 +427,7 @@ "factory": "Fabriek" }, "user_setting": { - "title": "Gebruikersinstellingen", + "title": "Instellingen", "tab_basic": "Basisinstellingen", "tab_keybinds": "Sneltoetsen", "dark_mode_label": "Donkere Modus", @@ -487,6 +485,11 @@ "build_hydrogen_bomb_desc": "Bouw een Waterstofbom onder je cursor.", "build_mirv": "Bouw MIRV", "build_mirv_desc": "Bouw een MIRV onder je cursor.", + "menu_shortcuts": "Menu sneltoetsen", + "build_menu_modifier": "Bouwmenu", + "build_menu_modifier_desc": "Houdt deze toets ingedrukt terwijl je klikt, om het bouwmenu te openen.", + "emoji_menu_modifier": "Emoji-menu", + "emoji_menu_modifier_desc": "Houdt deze toets ingedrukt terwijl je klikt, om het emoji-menu te openen.", "attack_ratio_controls": "Aanvalsverhouding-bediening", "attack_ratio_up": "Verhoog Aanvalsverhouding", "attack_ratio_up_desc": "Verhoog aanvalsverhouding met 10%", @@ -497,6 +500,8 @@ "boat_attack_desc": "Stuur een bootaanval naar de plek onder je cursor.", "ground_attack": "Grondaanval", "ground_attack_desc": "Stuur een grondaanval naar de plek onder je cursor.", + "swap_direction": "Omdraaien boogrichting atoom- / waterstofbom", + "swap_direction_desc": "Draai boogrichting raket om (opwaarts/neerwaarts).", "zoom_controls": "Zoombediening", "zoom_out": "Uitzoomen", "zoom_out_desc": "Kaart uitzoomen", @@ -711,7 +716,20 @@ "wants_to_renew_alliance": "{name} wil jullie alliantie vernieuwen", "ignore": "Negeren", "unit_voluntarily_deleted": "Eenheid vrijwillig verwijderd", - "betrayal_debuff_ends": "Nog {time} seconden tot de verraad-verzwakking afloopt" + "betrayal_debuff_ends": "Nog {time} seconden tot de verraad-verzwakking afloopt", + "attack_cancelled_retreat": "Aanval geannuleerd, {troops} soldaten gedood tijdens terugtrekken", + "received_gold_from_captured_ship": "{gold} Goud ontvangen van veroverd schip van {name}", + "received_gold_from_trade": "{gold} Goud ontvangen van handel met {name}", + "missile_intercepted": "Raket onderschepte {unit}", + "mirv_warheads_intercepted": "{count, plural, one {{count} MIRV-kernkop onderschept} other {{count} MIRV-kernkoppen onderschept}}", + "sent_troops_to_player": "{troops} Troepen naar {name} gestuurd", + "received_troops_from_player": "{troops} Troepen ontvangen van {name}", + "sent_gold_to_player": "{gold} Goud verstuurd aan {name}", + "received_gold_from_player": "{gold} Goud ontvangen van {name}", + "unit_captured_by_enemy": "Jouw {unit} werd veroverd door {name}", + "captured_enemy_unit": "{unit} veroverd van {name}", + "unit_destroyed": "Jouw {unit} werd vernietigd", + "no_boats_available": "Geen boten beschikbaar, max. {max}" }, "unit_info_modal": { "structure_info": "Gebouw Info", @@ -866,7 +884,7 @@ "ship_type": "Scheepstype", "weapon": "Wapen", "built": "Gebouwd", - "destroyed": "Verwoest", + "destroyed": "Vernietigd", "captured": "Veroverd", "lost": "Verloren", "hits": "Treffers", diff --git a/resources/lang/ru.json b/resources/lang/ru.json index ce7d1cfe6..89e9daf93 100644 --- a/resources/lang/ru.json +++ b/resources/lang/ru.json @@ -7,6 +7,7 @@ }, "common": { "close": "Закрыть", + "back": "Назад", "available": "Доступно", "preset_max": "Максимум", "summary_send": "Перевод", @@ -17,26 +18,42 @@ "cap_tooltip": "Оставшаяся ёмкость получателя", "target_dead": "Цель устранена", "target_dead_note": "Невозможно отправить ресурсы устранённому игроку.", - "none": "Ничего" + "none": "Ничего", + "copied": "Скопировано!", + "click_to_copy": "Нажмите, чтобы скопировать" }, "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_lobby": "Создать лобби", - "join_lobby": "Присоединиться к лобби", - "single_player": "Одиночная игра", + "create": "Создать лобби", + "join": "Присоединиться к лобби", + "solo": "Соло", "instructions": "Инструкции", + "game_info": "Информация об игре", "wiki": "Вики", "privacy_policy": "Политика конфиденциальности", "terms_of_service": "Пользовательское соглашение", - "reddit": "Reddit" + "copyright": "© OpenFront™ и участники", + "reddit": "Reddit", + "play": "Играть", + "news": "Новости", + "store": "Магазин", + "settings": "Настройки", + "keys": "Клавиши", + "stats": "Статистика", + "account": "Аккаунт", + "help": "Помощь", + "menu": "Меню", + "pick_pattern": "Выберите узор!" }, "news": { - "see_all_releases": "Посмотреть все выпуски", "github_link": "на GitHub", "title": "Список изменений" }, @@ -66,7 +83,7 @@ "ui_events": "Панель событий", "ui_events_desc": "Панель событий отображает последние события, запросы и сообщения быстрого чата. Некоторые примеры:", "ui_events_alliance": "Союз — Запросы на заключение союзов можно принимать или отклонять. Союзники могут обмениваться ресурсами и войсками, но не могут атаковать друг друга. Нажатие на «Осмотреть» перемещает вид на игрока, который отправил запрос.", - "ui_events_attack": "Атаки — Отображение входящих и исходящих атак. Нажмите на сообщение, чтобы центровать камеру на атаку, ракету или лодку (транспортный корабль). Вы можете отозвать войска, нажав на красную кнопку «X». Это будет стоить жизней 25% войск, которые атакуют. Если вы отозвёте лодку, она вернётся в исходное местоположение и совершит атаку, если территория была захвачена. Ракеты нельзя отозвать после запуска.", + "ui_events_attack": "Атаки — Отображение входящих и исходящих атак. Нажмите на сообщение, чтобы центровать камеру на атаку, ракету или лодку (транспортный корабль). Вы можете отозвать войска, нажав на красную кнопку «X». Это будет стоить жизней 25% войск, которые атакуют. Если вы отозвёте судо, оно вернётся в исходное местоположение и совершит атаку, если территория была захвачена. Ракеты нельзя отозвать после запуска.", "ui_events_quickchat": "Быстрый чат — Здесь вы можете увидеть отправленные и полученные сообщения. Отправьте сообщение игроку, нажав на значок быстрого чата в его меню информации.", "ui_options": "Настройки", "ui_options_desc": "Среди них можно найти следующие элементы:", @@ -76,13 +93,15 @@ "option_pause": "Приостановить/Продолжить игру — Доступно только в режиме одиночной игры.", "option_timer": "Таймер — Время, прошедшее с начала игры.", "option_exit": "Кнопка выхода.", - "option_settings": "Настройки — Открыть меню настроек. В нём вы можете включить/выключить альтернативное представление, эмодзи, тёмный режим, ниндзя (режим скрытых/случайных имён) и взаимодействие левой кнопкой мыши.", + "option_settings": "Настройки — Открыть меню настроек. В нём вы можете переключить альтернативное представление, эмодзи, тёмный режим, ниндзя (режим скрытых/случайных имён) и взаимодействие левой кнопкой мыши.", "radial_title": "Круговое меню", "radial_desc": "Щелчок правой кнопкой мыши (или нажатие на мобильном устройстве) открывает круговое меню. Щёлкните правой кнопкой мыши за его пределами, чтобы закрыть его. С этого меню вы можете:", "radial_build": "Открыть меню строительства.", "radial_attack": "Открыть меню атаки.", "radial_info": "Открыть меню информации.", - "radial_boat": "Отправить лодку (транспортный корабль) для атаки указанного места. Доступно только при наличии доступа к воде.", + "radial_boat": "Отправить судно (транспортный корабль) для атаки указанного места. Доступно только при наличии доступа к воде.", + "radial_donate_troops": "Пожертвовать войска, равные соотношению вашего ползунка атаки тому союзнику, на котором вы открыли круговое меню.", + "radial_donate_gold": "Открывает меню ползунка пожертвования золота для быстрой отправки золота союзникам.", "radial_close": "Закрыть меню.", "info_title": "Меню информации", "info_enemy_desc": "Содержит такую информацию о выбранном игроке, как его имя, количество золота, войск, состояние торговли с вами, запущенные на вас ракеты и метку предателя. Прекращённая торговля значит, что вы не будете получать от игрока золото и он не будет отправлять вам золото через торговые корабли. Вручную (если игрок нажал «Прекратить торговлю», что длится до тех пор, пока вы оба не нажмёте «Начать торговлю») или автоматически (если вы предали ваш союз, что длится до тех пор, пока вы не станете союзниками снова или через 5 минут). В поле «Предатель» будет указана метка «Да» в течение 30 секунд после того, как игрок предал и напал на игрока, который был в союзе с ними. Значки ниже обозначают следующие взаимодействия:", @@ -110,12 +129,12 @@ "build_port": "Порт", "build_port_desc": "Может быть построен только вблизи воды. Позволяет строить военные корабли. Автоматически посылает торговые суда между портами вашей и других стран (за исключением случаев, когда торговля прекращена), выдавая золото обеим сторонам. Торговля прекращается автоматически если вы атакуете или атакуют вас. Возобновляется через 5 минут или если вы становитесь союзниками. Вы можете вручную управлять торговлей с помощью кнопок «Прекратить торговлю» и «Начать торговлю».", "build_warship": "Военный корабль", - "build_warship_desc": "Патрулирует территорию, захватывая вражеские торговые корабли и разрушая вражеские лодки (транспортные корабли) и военные корабли. Появляется из ближайшего порта и патрулирует область, выбранную нажатием кнопкой мыши при создании. Вы можете управлять военными кораблями при помощью кнопки атаки (см. действие «Атака» в разделе «Горячие клавиши»): сначала нажмите на корабль, а затем — на новую область, к которой вы хотите переместиться.", + "build_warship_desc": "Патрулирует территорию, захватывая вражеские торговые корабли и разрушая вражеские суда (транспортные корабли) и военные корабли. Появляется из ближайшего порта и патрулирует область, выбранную нажатием кнопкой мыши при создании. Вы можете управлять военными кораблями при помощью кнопки атаки (см. действие «Атака» в разделе «Горячие клавиши»): сначала нажмите на корабль, а затем — на новую область, к которой вы хотите переместиться.", "build_silo": "Ракетная шахта", "build_silo_desc": "Позволяет запускать ракеты.", - "build_sam": "Пусковая установка ЗРК", - "build_sam_desc": "Позволяет перехватывать вражеские ракеты в радиусе 100 пикселей. Имеет шанс 100% на попадание в атомную бомбу, 80% — в водородную бомбу и 50% — в отдельные боеголовки РГЧ ИН. Перезарядка ЗРК составляет 7,5 секунды.", - "build_atom": "Атомная бомба", + "build_sam": "ПУ ЗРК", + "build_sam_desc": "Может перехватывать вражеские ракеты в радиусе 100 пикселей. ЗРК имеет период перезарядки в 7,5 секунд.", + "build_atom": "Ядерная бомба", "build_atom_desc": "Небольшая взрывная бомба, которая разрушает территорию, сооружения, корабли и лодки. Запускается из ближайшей ракетной шахты и наносит удар по области, выбранной нажатием кнопкой мыши.", "build_hydrogen": "Водородная бомба", "build_hydrogen_desc": "Большая взрывная бомба. Запускается из ближайшей ракетной шахты и наносит удар по области, выбранной нажатием кнопкой мыши.", @@ -129,12 +148,15 @@ "icon_embargo": "Перечёркнутый знак доллара — Эмбарго. Этот игрок перестал торговать с вами; автоматически или вручную.", "icon_request": "Конверт — Запрос на союз. Этот игрок отправил вам запрос на заключение союза.", "info_enemy_panel": "Панель информации о враге", - "exit_confirmation": "Вы уверены, что хотите выйти из игры?" + "exit_confirmation": "Вы уверены, что хотите выйти из игры?", + "bomb_direction": "Траектория полёта ядерной/водородной бомбы" }, "single_modal": { - "title": "Одиночная игра", + "title": "Соло", "random_spawn": "Случайное появление", "allow_alliances": "Разрешить союзы", + "toggle_achievements": "Переключение достижений", + "sign_in_for_achievements": "Войдите, чтобы получать достижения", "options_title": "Настройки", "bots": "Боты: ", "bots_disabled": "Отключены", @@ -145,6 +167,8 @@ "infinite_troops": "Неограниченные войска", "compact_map": "Компактная карта", "max_timer": "Продолжительность игры (минуты)", + "max_timer_placeholder": "Минуты", + "max_timer_invalid": "Пожалуйста, введите допустимое максимальное значение таймера (1–120 минут)", "disable_nukes": "Отключить бомбы", "enables_title": "Разрешения", "start": "Начать игру" @@ -156,10 +180,21 @@ }, "account_modal": { "title": "Аккаунт", - "logged_in_as": "Вы вошли как {email}", + "connected_as": "Вы вошли как", + "stats_overview": "Обзор статистики", + "link_discord": "Привязать учётную запись Discord", + "log_out": "Выйти", + "sign_in_desc": "Войдите, чтобы сохранить статистику и прогресс", + "or": "ИЛИ", + "email_placeholder": "Введите свою почту", + "get_magic_link": "Получить волшебную ссылку", + "linked_account": "Вы вошли как {account_name}", "fetching_account": "Получение информации об аккаунте...", - "logged_in_with_discord": "Вы вошли через Discord", - "recovery_email_sent": "Письмо для восстановления отправлено на {email}" + "recovery_email_sent": "Письмо для восстановления отправлено на {email}", + "not_found": "Не найдено", + "clear_session": "Очистить сессию", + "failed_to_send_recovery_email": "Не удалось отправить письмо для восстановления", + "enter_email_address": "Пожалуйста, введите адрес электронной почты" }, "stats_modal": { "title": "Статистика", @@ -167,11 +202,40 @@ "loading": "Загрузка...", "error": "Ошибка загрузки статистики кланов", "no_stats": "Статистика кланов недоступна", + "no_data_yet": "Пока нет данных", "clan": "Клан", "games": "Игры", "win_score": "Счёт побед", + "win_score_tooltip": "Взвешенные победы на основе участия клана и сложности матча", "loss_score": "Счёт поражений", - "win_loss_ratio": "Победы/Поражения" + "loss_score_tooltip": "Взвешенные поражения на основе участия клана и сложности матча", + "win_loss_ratio": "Победы/Поражения", + "ratio": "Соотношение", + "rank": "Ранг", + "try_again": "Попробуйте ещё раз" + }, + "game_info_modal": { + "title": "Информация об игре", + "players": "Игроки", + "atoms": "Ядерные бомбы", + "hydros": "Водородные бомбы", + "mirv": "РГЧ ИН", + "bombs": "Бомбы", + "total_gold": "Всего", + "all_gold": "Всё золото", + "trade": "Торговля", + "conquest_gold": "Захваченное золото игроков", + "stolen_gold": "Украдено с помощью военных кораблей", + "num_of_conquests": "Количество покорённых игроков", + "duration": "Продолжительность", + "survival_time": "Время выживания", + "war": "Война", + "economy": "Экономика", + "conquests": "Завоевания", + "pirate": "Пиратство", + "conquered": "Завоёвано", + "loading_game_info": "Загрузка игровой статистики", + "no_winner": "Эта игра закончилась без победителя (или выиграла нация)" }, "map": { "map": "Карта", @@ -186,6 +250,7 @@ "asia": "Азия", "mars": "Марс", "southamerica": "Южная Америка", + "britanniaclassic": "Британия (классическая)", "britannia": "Британия", "gatewaytotheatlantic": "Гибралтарский пролив", "australia": "Австралия", @@ -206,22 +271,36 @@ "yenisei": "Енисей", "pluto": "Плутон", "montreal": "Монреаль", + "newyorkcity": "Нью-Йорк", "achiran": "Акиран", "baikalnukewars": "Байкал (ядерные войны)", "fourislands": "Четыре острова", "gulfofstlawrence": "Залив Св. Лоуренса", - "lisbon": "Лиссабон" + "lisbon": "Лиссабон", + "svalmel": "Свалмель", + "manicouagan": "Маникуаган", + "lemnos": "Лемнос", + "sierpinski": "Серпинский", + "twolakes": "Два озера", + "straitofhormuz": "Ормузский пролив", + "surrounded": "Окружение", + "didier": "Дидье", + "didierfrance": "Дидье (Франция)", + "amazonriver": "Река Амазонка" }, "map_categories": { "continental": "Континентальные", "regional": "Региональные", - "fantasy": "Прочие" + "fantasy": "Прочие", + "special": "Особые", + "arcade": "Аркадные" }, "map_component": { - "loading": "Загрузка..." + "loading": "Загрузка...", + "error": "Ошибка" }, "private_lobby": { - "title": "Присоединиться к приватному лобби", + "title": "Присоединение к приватному лобби", "enter_id": "Введите ID лобби", "player": "Игрок", "players": "Игрока(-ов)", @@ -229,42 +308,55 @@ "checking": "Проверка лобби...", "not_found": "Лобби не найдено. Пожалуйста, проверьте правильность ID и попробуйте ещё раз.", "error": "Произошла ошибка. Пожалуйста, попробуйте ещё раз или обратитесь в службу поддержки.", - "joined_waiting": "Вы успешно присоединились! Ожидание начала игры...", - "version_mismatch": "Эта игра была создана в другой версии. Невозможно присоединиться." + "joined_waiting": "Лобби подключено! Ждём, пока хост начнёт игру...", + "version_mismatch": "Эта игра была создана в другой версии. Невозможно присоединиться.", + "disabled_units": "Отключённые сооружения" }, "public_lobby": { "join": "Присоединиться к следующей игре", "waiting": "игрока(-ов) в ожидании", - "teams_Duos": "по 2 (дуо)", - "teams_Trios": "по 3 (трио)", - "teams_Quads": "по 4 (квады)", + "teams_Duos": "{team_count} команды по 2 (дуо)", + "teams_Trios": "{team_count} команды по 3 (трио)", + "teams_Quads": "{team_count} команды по 4 (квады)", + "waiting_for_players": "Ожидание игроков", + "starting_game": "Запуск игры…", "teams_hvn": "Люди против наций", + "teams_hvn_detailed": "{num} людей против {num} наций", "teams": "Команд: {num}", - "players_per_team": "по {num}" + "players_per_team": "по {num}", + "started": "Начато" }, "matchmaking_modal": { - "title": "Подбор игроков", + "title": "Рейтинговый подбор 1v1 (АЛЬФА)", "connecting": "Подключение к серверу подбора игроков...", "searching": "Поиск игры...", - "waiting_for_game": "Ожидание начала игры..." + "waiting_for_game": "Ожидание начала игры...", + "elo": "Ваш ELO: {elo}" }, "username": { "enter_username": "Введите своё имя игрока", "not_string": "Имя игрока должно быть строкой.", "too_short": "Имя игрока должно содержать не менее {min} символов.", "too_long": "Имя игрока не должно превышать {max} символов.", - "invalid_chars": "Имя игрока может содержать только латинские буквы, цифры, пробелы, подчёркивания и [квадратные скобки]." + "invalid_chars": "Имя игрока может содержать только латинские буквы, цифры, пробелы и подчёркивания.", + "tag": "ТЕГ", + "tag_too_short": "Тег клана должен состоять из 2–5 буквенно-цифровых символов.", + "tag_invalid_chars": "Тег клана может содержать только латинские буквы и цифры." }, "host_modal": { - "title": "Приватное лобби", + "title": "Создание приватного лобби", + "label": "Приватный", "mode": "Режим", "team_count": "Количество команд", + "team_type": "Тип команды", "options_title": "Настройки", "bots": "Боты: ", "bots_disabled": "Отключены", + "player_immunity_duration": "Продолжительность иммунитета в PVP (минуты)", "nations": "Нации: ", "disable_nations": "Отключить нации", "max_timer": "Продолжительность игры (минуты)", + "mins_placeholder": "Минуты", "instant_build": "Мгновенная стройка", "infinite_gold": "Неограниченное золото", "donate_gold": "Пожертвование золота", @@ -283,7 +375,11 @@ "assigned_teams": "Распределённые команды", "empty_teams": "Пустые команды", "empty_team": "Пусто", - "remove_player": "Удалить {username}" + "remove_player": "Удалить {username}", + "teams_Duos": "Дуо (команды по 2)", + "teams_Trios": "Трио (команды по 3)", + "teams_Quads": "Квады (команды по 4)", + "teams_Humans Vs Nations": "Люди против наций" }, "team_colors": { "red": "Красный", @@ -301,18 +397,22 @@ "code_license": "Код лицензирован согласно AGPL-3.0 (без гарантий)" }, "difficulty": { - "difficulty": "Сложность", - "Easy": "Расслабленная", - "Medium": "Уравновешенная", - "Hard": "Напряжённая", - "Impossible": "Невозможная" + "difficulty": "Сложность наций", + "easy": "Легко", + "medium": "Средне", + "hard": "Сложно", + "impossible": "Невозможно" }, "game_mode": { "ffa": "Каждый против каждого (FFA)", "teams": "Команды" }, + "public_game_modifier": { + "random_spawn": "Случайное появления", + "compact_map": "Компактная карта" + }, "select_lang": { - "title": "Выберите язык" + "title": "Выбор языка" }, "unit_type": { "city": "Город", @@ -320,44 +420,47 @@ "port": "Порт", "warship": "Военный корабль", "missile_silo": "Ракетная шахта", - "sam_launcher": "Пусковая установка ЗРК", - "atom_bomb": "Атомная бомба", + "sam_launcher": "ПУ ЗРК", + "atom_bomb": "Ядерная бомба", "hydrogen_bomb": "Водородная бомба", "mirv": "РГЧ ИН", "factory": "Фабрика" }, "user_setting": { - "title": "Пользовательские настройки", + "title": "Настройки", "tab_basic": "Основные настройки", "tab_keybinds": "Привязки клавиш", "dark_mode_label": "Тёмный режим", "dark_mode_desc": "Переключение внешнего вида сайта между светлой и тёмной темой", "emojis_label": "Эмодзи", - "emojis_desc": "Включение/выключение видимости эмодзи в игре", + "emojis_desc": "Переключить видимость эмодзи в игре", "alert_frame_label": "Рамка тревоги", - "alert_frame_desc": "Включить/выключить рамку тревоги. Когда включено, она будет отображаться, когда вас предают или атакуют по суше.", + "alert_frame_desc": "Переключить рамку тревоги. При включении рамка будет отображаться, когда вас предают или атакуют по суше.", "special_effects_label": "Спецэффекты", - "special_effects_desc": "Включить/выключить спецэффекты. Отключите для улучшения производительности", + "special_effects_desc": "Переключить спецэффекты. Отключите для улучшения производительности", "structure_sprites_label": "Спрайты структур", - "structure_sprites_desc": "Включение/выключение спрайтов структур", + "structure_sprites_desc": "Переключить спрайты структур", + "cursor_cost_label_label": "Цена постройки под указателем", + "cursor_cost_label_desc": "Показывать цену постройки под указателем", "anonymous_names_label": "Скрытые имена", "anonymous_names_desc": "Скрыть настоящие имена игроков и заменить их случайными.", "lobby_id_visibility_label": "Скрытые ID лобби", "lobby_id_visibility_desc": "Скрыть ID при создании приватного лобби", + "toggle_visibility": "Переключение видимости", "left_click_label": "Открытие меню левой кнопкой мыши", "left_click_desc": "ВКЛЮЧЕНО: щелчок левой кнопкой мыши открывает меню, атака совершается кнопкой с мечом. ВЫКЛЮЧЕНО: нажатие левой кнопкой мыши совершает атаку напрямую.", "left_click_menu": "Меню на левую кнопку мыши", "attack_ratio_label": "⚔️ Соотношение атаки", "attack_ratio_desc": "Какой процент ваших войск отправлять в бой (1–100%)", - "troop_ratio_desc": "Настройте соотношение между войсками (для боя) и рабочими (для добычи золота) (1–100%)", "territory_patterns_label": "🏳️ Скины территории", "territory_patterns_desc": "Выберите, показывать ли скины территорий в игре", "performance_overlay_label": "Оверлей производительности", - "performance_overlay_desc": "Включить/выключить оверлей производительности. Если включено, будет отображаться оверлей производительности. Нажмите Shift+D во время игры для включения/выключения.", + "performance_overlay_desc": "Переключить оверлей производительности. При включении будет показан оверлей производительности. Нажмите Shift+D во время игры для переключения.", "easter_writing_speed_label": "Множитель скорости печати", "easter_writing_speed_desc": "Настройте скорость, с которой вы делаете вид, что программируете (x1–x100)", "easter_bug_count_label": "Количество багов", "easter_bug_count_desc": "Количество багов, которое вы считаете приемлемым (0–1000, эмоционально)", + "press_a_key": "Нажмите клавишу", "view_options": "Настройки просмотра", "toggle_view": "Переключить представление", "toggle_view_desc": "Альтернативное представление (рельеф/страны)", @@ -374,24 +477,31 @@ "build_warship_desc": "Разместить военный корабль под указателем.", "build_missile_silo": "Разместить ракетную шахту", "build_missile_silo_desc": "Разместить ракетную шахту под указателем.", - "build_sam_launcher": "Разместить установку ЗРК", - "build_sam_launcher_desc": "Разместить установку ЗРК под указателем.", + "build_sam_launcher": "Разместить ПУ ЗРК", + "build_sam_launcher_desc": "Разместить ПУ ЗРК под указателем.", "build_atom_bomb": "Разместить ядерную бомбу", "build_atom_bomb_desc": "Разместить ядерную бомбу под указателем.", "build_hydrogen_bomb": "Разместить водородную бомбу", "build_hydrogen_bomb_desc": "Разместить водородную бомбу под указателем.", "build_mirv": "Разместить РГЧ ИН", "build_mirv_desc": "Разместить РГЧ ИН под указателем.", + "menu_shortcuts": "Горячие клавиши меню", + "build_menu_modifier": "Модификатор меню строительства", + "build_menu_modifier_desc": "Удерживайте эту клавишу при нажатии, чтобы открыть меню строительства.", + "emoji_menu_modifier": "Модификатор меню эмодзи", + "emoji_menu_modifier_desc": "Удерживайте эту клавишу при нажатии, чтобы открыть меню эмодзи.", "attack_ratio_controls": "Управление соотношением атаки", "attack_ratio_up": "Увеличить соотношение атаки", "attack_ratio_up_desc": "Увеличить соотношение атаки на 10%", "attack_ratio_down": "Уменьшить соотношение атаки", "attack_ratio_down_desc": "Уменьшить соотношение атаки на 10%", "attack_keybinds": "Привязки клавиш атаки", - "boat_attack": "Атака лодкой", + "boat_attack": "Атака судом", "boat_attack_desc": "Отправить атаку лодкой на ячейку под указателем.", "ground_attack": "Наземная атака", "ground_attack_desc": "Отправить наземную атаку на ячейку под указателем.", + "swap_direction": "Поменять направление ракеты", + "swap_direction_desc": "Переключить направление ракеты (вверх/вниз).", "zoom_controls": "Масштабирование", "zoom_out": "Отдалить", "zoom_out_desc": "Отдалить карту", @@ -412,11 +522,12 @@ "unbind": "Освободить", "on": "Включено", "off": "Выключено", - "toggle_terrain": "Включение/выключение рельефа", + "toggle_terrain": "Переключить рельеф", "exit_game_label": "Выйти из игры", "exit_game_info": "Вернуться в главное меню", "background_music_volume": "Громкость фоновой музыки", - "sound_effects_volume": "Громкость звуковых эффектов" + "sound_effects_volume": "Громкость звуковых эффектов", + "keybind_conflict_error": "Клавиша {key} уже привязана к другому действию." }, "chat": { "title": "Быстрый чат", @@ -512,7 +623,7 @@ "hydrogen_bomb": "Большой взрыв", "mirv": "Огромный взрыв, нацеленный только на выбранного игрока", "missile_silo": "Используется для запуска ракет", - "sam_launcher": "Защищает от атомных ракет", + "sam_launcher": "Защищает от ядерных ударов", "warship": "Захватывает торговые суда, уничтожает суда и лодки", "port": "Отправляет торговые корабли для генерации золота", "defense_post": "Укрепляет защиту ближайших границ", @@ -529,6 +640,7 @@ "other_team": "Команда «{team}» победила!", "you_won": "Вы победили!", "other_won": "Игрок {player} победил!", + "nation_won": "Нация {nation} победила!", "exit": "Выйти из игры", "keep": "Продолжить игру", "spectate": "Наблюдать", @@ -537,7 +649,7 @@ "ofm_winter_description": "Присоединяйтесь к турниру и состязайтесь с лучшими игроками", "join_tournament": "Присоединиться к турниру", "join_discord": "Присоединяйтесь к нашему сообществу в Discord!", - "discord_description": "Связывайтесь с другими игроками, получайте новости и делитесь стратегиями", + "discord_description": "Связывайтесь с игроками, открывайте новые возможности и выигрывайте призы!", "join_server": "Присоединиться к серверу", "youtube_tutorial": "Нужна помощь?" }, @@ -549,7 +661,7 @@ "team": "Команда", "owned": "Территории", "gold": "Золото", - "troops": "Войска", + "maxtroops": "Максимум войск", "launchers": "Установки", "sams": "ЗРК", "warships": "Военные корабли", @@ -565,6 +677,7 @@ "team": "Команда", "alliance_timeout": "Конец союза через", "troops": "Войска", + "maxtroops": "Максимум войск", "a_troops": "Войска атаки", "gold": "Золото", "ports": "Порты", @@ -575,12 +688,14 @@ "warships": "Военные корабли", "health": "Здоровье", "attitude": "Отношение", - "levels": "Уровни" + "levels": "Уровни", + "wilderness_title": "Пустошь", + "irradiated_wilderness_title": "Радиоактивная пустошь" }, "events_display": { "retreating": "отступает", "retaliate": "Напасть в ответ", - "boat": "Лодка", + "boat": "Судно", "alliance_request_status": "{name} {status} ваш запрос", "alliance_accepted": "принял", "alliance_rejected": "отклонил", @@ -600,8 +715,21 @@ "alliance_renewed": "Ваш союз с {name} был продлён", "wants_to_renew_alliance": "{name} хочет продлить ваш союз", "ignore": "Игнорировать", - "unit_voluntarily_deleted": "Объект добровольно удалён", - "betrayal_debuff_ends": "Осталось {time} сек до окончания наказания предателя" + "unit_voluntarily_deleted": "Сооружение добровольно удалено", + "betrayal_debuff_ends": "Осталось {time} сек до окончания наказания предателя", + "attack_cancelled_retreat": "Атака отменена, {troops} солдат погибло во время отступления", + "received_gold_from_captured_ship": "Получено {gold} золота с корабля, захваченного у {name}", + "received_gold_from_trade": "Получено {gold} золота от торговли с {name}", + "missile_intercepted": "{unit} перехватывает ракету", + "mirv_warheads_intercepted": "{count, plural, one {Перехвачено {count} боеголовку РГЧ ИН} few {Перехвачено {count} боеголовки РГЧ ИН} many {Перехвачено {count} боеголовок РГЧ ИН} other {Перехвачено {count} боеголовок РГЧ ИН}}", + "sent_troops_to_player": "Отправлено {troops} войск к {name}", + "received_troops_from_player": "Получено {troops} войск от {name}", + "sent_gold_to_player": "Отправлено {gold} золота для {name}", + "received_gold_from_player": "Получено {gold} золота от {name}", + "unit_captured_by_enemy": "{name} захватывает ваше сооружение «{unit}»", + "captured_enemy_unit": "Захвачено сооружение «{unit}» у {name}", + "unit_destroyed": "Ваше сооружение «{unit}» было уничтожено", + "no_boats_available": "Нет доступных судов, максимум — {max}" }, "unit_info_modal": { "structure_info": "Информация о структуре", @@ -653,7 +781,10 @@ "send_alliance": "Отправить предложение союза", "send_troops": "Отправить войска", "send_gold": "Отправить золото", - "emotes": "Эмодзи" + "emotes": "Эмодзи", + "arc_up": "Верхняя дуга", + "arc_down": "Нижняя дуга", + "flip_rocket_trajectory": "Отразить траекторию ракеты" }, "send_troops_modal": { "title_with_name": "Отправить войска игроку {name}", @@ -702,20 +833,26 @@ }, "heads_up_message": { "choose_spawn": "Выберите стартовое местоположение", - "random_spawn": "Случайное появление включено. Выбираем стартовое местоположение за вас..." + "random_spawn": "Случайное появление включено. Выбираем стартовое местоположение за вас...", + "singleplayer_game_paused": "Игра приостановлена", + "multiplayer_game_paused": "Игра приостановлена владельцем лобби" }, "territory_patterns": { "title": "Скины", "colors": "Цвета", "purchase": "Купить", "show_only_owned": "Мои скины", + "all_owned": "Все узоры куплены! Возвращайтесь позже за новыми товарами.", + "not_logged_in": "Вы не авторизованы", "blocked": { "login": "Вы должны войти, чтобы получить доступ к этому скину.", "purchase": "Купите этот скин, чтобы разблокировать его." }, "pattern": { "default": "По умолчанию" - } + }, + "select_skin": "Выберете узор", + "selected": "выбрано" }, "flag_input": { "title": "Выберите флаг", @@ -732,8 +869,8 @@ "contact_admin": "Если вы считаете, что видите это сообщение по ошибке, пожалуйста, свяжитесь с администратором сайта." }, "radial_menu": { - "delete_unit_title": "Удалить объект", - "delete_unit_description": "Нажмите, чтобы удалить ближайший объект" + "delete_unit_title": "Удалить сооружение", + "delete_unit_description": "Нажмите, чтобы удалить ближайшее сооружение" }, "discord_user_header": { "avatar_alt": "Аватар" @@ -743,7 +880,7 @@ "ship_arrivals": "Прибытия кораблей", "nuke_stats": "Статистика бомбардирования", "player_metrics": "Статистика игрока", - "building": "Строительство", + "building": "Сооружение", "ship_type": "Тип корабля", "weapon": "Оружие", "built": "Построено", @@ -762,19 +899,19 @@ "gold": "Золото", "workers": "Рабочие", "war": "Войны", - "trade": "Обмен", + "trade": "Торговля", "steal": "Украдено", "unit": { "city": "Город", "port": "Порт", "defp": "Укрепление", - "saml": "Пусковая установка ЗРК", + "saml": "ПУ ЗРК", "silo": "Ракетная шахта", "wshp": "Военный корабль", "fact": "Фабрика", "trade": "Торговый корабль", "trans": "Транспортный корабль", - "abomb": "Атомная бомба", + "abomb": "Ядерная бомба", "hbomb": "Водородная бомба", "mirv": "РГЧ ИН", "mirvw": "Боеголовка РГЧ ИН" @@ -786,8 +923,9 @@ "mode": "Режим", "mode_ffa": "Каждый против каждого", "mode_team": "Команда", - "view": "Осмотреть", + "replay": "Повтор", "details": "Подробности", + "ranking": "Рейтинг", "started": "Начато", "map": "Карта", "difficulty": "Сложность", @@ -796,13 +934,20 @@ "player_stats_tree": { "public": "Публичный", "private": "Приватный", - "singleplayer": "Одиночная игра", + "singleplayer": "Соло", "mode": "Режим", "stats_wins": "Победы", "stats_losses": "Поражения", "stats_wlr": "Соотношение побед:поражений", "stats_games_played": "Игр сыграно", "mode_ffa": "Все против всех", - "mode_team": "Команда" + "mode_team": "Команда", + "no_stats": "Нет данных для этой выборки." + }, + "matchmaking_button": { + "play_ranked": "Рейтинговый подбор 1v1", + "description": "(АЛЬФА)", + "login_required": "Войдите, чтобы играть в рейтинговом режиме!", + "must_login": "Вы должны войти в систему, чтобы играть в рейтинговом режиме." } } diff --git a/resources/lang/tr.json b/resources/lang/tr.json index 0afa36091..940e12098 100644 --- a/resources/lang/tr.json +++ b/resources/lang/tr.json @@ -6,27 +6,54 @@ "lang_code": "tr" }, "common": { - "close": "Kapat" + "close": "Kapat", + "back": "Geri", + "available": "Mevcut", + "preset_max": "Maksimum", + "summary_send": "Gönder", + "summary_keep": "Sakla", + "cancel": "İptal Et", + "send": "Gönder", + "cap_label": "Limit", + "cap_tooltip": "Alıcının kalan kapasitesi", + "target_dead": "Hedef saf dışı kaldı", + "target_dead_note": "Saf dışı kalmış bir oyuncuya kaynak gönderemezsin.", + "none": "Hiçbiri", + "copied": "Kopyalandı!", + "click_to_copy": "Kopyalamak için tıkla" }, "main": { "title": "OpenFront (ALFA)", - "join_discord": "Discord'a katılın!", + "join_discord": "Discord", "login_discord": "Discord'la giriş yap", + "sign_in": "Oturum Aç", + "discord_avatar_alt": "Discord profil avatarı", + "user_avatar_alt": "{username}'in avatarı", "checking_login": "Giriş kontrol ediliyor...", "logged_in": "Giriş yapıldı!", "log_out": "Çıkış yap", - "create_lobby": "Lobi Oluştur", - "join_lobby": "Lobiye Katıl", - "single_player": "Tek Oyunculu", + "create": "Lobi Oluştur", + "join": "Lobiye Katıl", + "solo": "Tekli", "instructions": "Rehber", - "how_to_play": "Nasıl Oynanır", - "advertise": "Reklam Ver", + "game_info": "Oyun bilgisi", "wiki": "Wiki", "privacy_policy": "Gizlilik Politikası", - "terms_of_service": "Hizmet Şartları" + "terms_of_service": "Hizmet Şartları", + "copyright": "© OpenFront™ ve Katkıda Bulunanlar", + "reddit": "Reddit", + "play": "Oyna", + "news": "Haberler", + "store": "Mağaza", + "settings": "Seçenekler", + "keys": "Tuşlar", + "stats": "İstatistikler", + "account": "Hesap", + "help": "Yardım", + "menu": "Menü", + "pick_pattern": "Desen seç!" }, "news": { - "see_all_releases": "Tüm sürümleri gör", "github_link": "GitHub'da", "title": "Sürüm Notları" }, @@ -57,7 +84,7 @@ "ui_events_desc": "Olay paneli en son olayları, istekleri ve Hızlı Sohbet mesajlarını görüntüler. Bazı örnekler şunlardır:", "ui_events_alliance": "İttifak - İttifak istekleri kabul edilebilir veya reddedilebilir. Müttefikler kaynakları ve askerleri paylaşabilir, ancak birbirlerine saldıramazlar. Odaklan'a tıklamak görünümü isteği gönderen oyuncuya taşır.", "ui_events_attack": "Saldırılar - Gelen saldırılar ve giden saldırılarınız gösterilir. Saldırı, nükleer veya Tekne (nakliye gemisi) üzerine görünümü ortalamak için mesaja tıklayın. Kırmızı X düğmesine tıklayarak askerleri geri çekebilirsiniz. Bu, saldıran askerlerinizin %25'inin hayatına mal olur. Bir Tekne saldırısını geri alırsanız, tekne başlangıç noktasına döner ve o zamandan beri toprak ele geçirildiyse orada saldırır. Nükleerler fırlatıldıktan sonra geri alınamaz.", - "ui_events_quickchat": "Hızlı Sohbet - Gönderilen ve alınan sohbet mesajlarını burada görebilirsiniz. Bilgi menüsündeki Hızlı Sohbet simgesine tıklayarak bir oyuncuya mesaj gönderin.", + "ui_events_quickchat": "Hızlı Sohbet - Burada gönderilen ve alınan sohbet mesajlarını görebilirsin. Oyuncunun Bilgi menüsündeki Hızlı Sohbet simgesine tıklayarak oyuncuya mesaj gönderebilirsin.", "ui_options": "Seçenekler", "ui_options_desc": "İçerisinde aşağıdaki öğeler bulunabilir:", "ui_playeroverlay": "Oyuncu bilgi katmanı", @@ -73,6 +100,8 @@ "radial_attack": "Saldırı menüsünü aç.", "radial_info": "Bilgi menüsünü aç.", "radial_boat": "Seçilen konuma saldırması için bir Tekne (nakliye gemisi) gönder. Sadece suya erişiminiz varsa kullanılabilir.", + "radial_donate_troops": "Saldırı oranı kaydırma çubuğundaki yüzdeye eşdeğer sayıda askerleri, radyal menüyü açtığınız müttefikinize bağışlayın.", + "radial_donate_gold": "Müttefiklerinize hızlıca altın yollayabilmeniz için altın bağışı kaydırma menüsünü açar.", "radial_close": "Menüyü kapat.", "info_title": "Bilgi menüsü", "info_enemy_desc": "Seçilen oyuncunun adı, altını, askerleri, sizinle ticareti durdurmuş olup olmadığı, size gönderdiği nükleerler ve oyuncunun hain olup olmadığı gibi bilgileri içerir. Ticareti durdurmuş olmak, onlardan altın almayacağınız ve onlara ticaret gemileri aracılığıyla altın göndermeyeceğiniz anlamına gelir. Manuel olarak (oyuncu \"Ticareti durdur\"a tıklarsa, her ikiniz de \"Ticareti başlat\"a tıklayana kadar sürer) veya otomatik olarak (ittifakınıza ihanet ederseniz, tekrar müttefik olana kadar veya 5 dakika sonra kadar sürer). Hain, oyuncunun kendisiyle ittifak halinde olan bir oyuncuya ihanet edip saldırdığında 30 saniye boyunca Evet gösterir. Aşağıdaki simgeler şu etkileşimleri temsil eder:", @@ -104,7 +133,7 @@ "build_silo": "Füze Silosu", "build_silo_desc": "Füze fırlatmaya izin verir.", "build_sam": "SAM Fırlatıcı", - "build_sam_desc": "100 piksel menzili içindeki düşman füzelerini engelleyebilir. Atom Bombası için %100, Hidrojen Bombası için %80 ve bireysel MIRV Savaş Başlıkları için %50 isabet şansı ile. SAM'ın 7.5 saniye bekleme süresi vardır.", + "build_sam_desc": "100 piksele kadar menzildeki düşman füzelerini önler. SAM 7,5 saniye bekleme süresine sahiptir.", "build_atom": "Atom Bombası", "build_atom_desc": "Bölgeyi, binaları, gemileri ve tekneleri yok eden küçük patlayıcı bomba. En yakın Füze Silosundan doğar ve ilk inşa etmek için tıkladığınız alana düşer.", "build_hydrogen": "Hidrojen Bombası", @@ -119,20 +148,27 @@ "icon_embargo": "Üstü çizili Dolar - Ambargo. Bu oyuncu sizinle ticareti otomatik veya manuel olarak durdurmuş.", "icon_request": "Zarf - İttifak isteği. Bu oyuncu size ittifak isteği göndermiş.", "info_enemy_panel": "Düşman bilgi paneli", - "exit_confirmation": "Oyundan çıkmak istediğine emin misin?" + "exit_confirmation": "Oyundan çıkmak istediğine emin misin?", + "bomb_direction": "Atom / Hidrojen bombası yay yönü" }, "single_modal": { - "title": "Tek Oyunculu", + "title": "Tekli", + "random_spawn": "Rastgele doğma", "allow_alliances": "İttifaklara izin ver", + "toggle_achievements": "Başarımları aç/kapat", + "sign_in_for_achievements": "Başarımlar için oturum aç", "options_title": "Seçenekler", "bots": "Botlar", "bots_disabled": "Devre Dışı", + "nations": "Ülkeler: ", "disable_nations": "Ulusları Devre Dışı Bırak", "instant_build": "Anında İnşa", "infinite_gold": "Sınırsız Altın", - "donate_gold": "Altın bağışla", "infinite_troops": "Sınırsız Asker", - "donate_troops": "Asker bağışla", + "compact_map": "Sıkıştırılmış Harita", + "max_timer": "Oyun süresi (dakika)", + "max_timer_placeholder": "Dakika", + "max_timer_invalid": "Lütfen geçerli bir maksimum zamanlayıcı değeri girin (1-120 dakika)", "disable_nukes": "Nükleerleri Devre Dışı Bırak", "enables_title": "Ayarları Etkinleştir", "start": "Oyunu Başlat" @@ -144,9 +180,62 @@ }, "account_modal": { "title": "Hesap", - "logged_in_as": "{email} olarak oturum açıldı", - "logged_in_with_discord": "Discord'la giriş yapıldı", - "recovery_email_sent": "Kurtarma e-postası {email}'a gönderildi" + "connected_as": "Şu olarak bağlandı", + "stats_overview": "İstatistiklere Genel Bakış", + "link_discord": "Discord Hesabı Bağla", + "log_out": "Çıkış Yap", + "sign_in_desc": "İstatistiklerini ve ilerlemeni kaydetmek için oturum aç", + "or": "YA DA", + "email_placeholder": "E-posta adresini gir", + "get_magic_link": "Sihirli Linkini Al", + "linked_account": "{account_name} olarak giriş yapıldı", + "fetching_account": "Hesap bilgisi alınıyor...", + "recovery_email_sent": "Kurtarma e-postası {email}'a gönderildi", + "not_found": "Bulunamadı", + "clear_session": "Oturumu Temizle", + "failed_to_send_recovery_email": "Kurtarma e-postası gönderimi başarısız", + "enter_email_address": "Lütfen bir e-posta adresi giriniz" + }, + "stats_modal": { + "title": "İstatistikler", + "clan_stats": "Klan İstatistikleri", + "loading": "Yükleniyor...", + "error": "Klan istatistikleri yüklenirken hata", + "no_stats": "Klan istatistikleri mevcut değil", + "no_data_yet": "Henüz Veri Yok", + "clan": "Klan", + "games": "Oyunlar", + "win_score": "Zafer Skoru", + "win_score_tooltip": "Klan katılımı ve maç zorluğuna göre ağırlıklı kazançlar", + "loss_score": "Yenilgi Skoru", + "loss_score_tooltip": "Klan katılımı ve maç zorluğuna göre ağırlıklı kayıplar", + "win_loss_ratio": "Zafer/Yenilgi", + "ratio": "Oran", + "rank": "Sıra", + "try_again": "Tekrar Dene" + }, + "game_info_modal": { + "title": "Oyun bilgisi", + "players": "Oyuncular", + "atoms": "Atomlar", + "hydros": "Hidrojenler", + "mirv": "MIRV", + "bombs": "Bombalar", + "total_gold": "Toplam", + "all_gold": "Tüm altın", + "trade": "Ticaret", + "conquest_gold": "Fethedilen oyuncu altını", + "stolen_gold": "Savaş gemileriyle çalınan", + "num_of_conquests": "Fethedilen oyuncu sayısı", + "duration": "Süre", + "survival_time": "Hayatta kalma süresi", + "war": "Savaş", + "economy": "Ekonomi", + "conquests": "Fetihler", + "pirate": "Korsan", + "conquered": "Fethedildi", + "loading_game_info": "Oyun verileri yükleniyor", + "no_winner": "Bu oyun kazanan olmadan bitti (ya da bir Ülke kazanmadan)" }, "map": { "map": "Harita", @@ -161,6 +250,7 @@ "asia": "Asya", "mars": "Mars", "southamerica": "Güney Amerika", + "britanniaclassic": "Britanya (Klasik)", "britannia": "Britanya", "gatewaytotheatlantic": "Atlantik'e Açılan Kapı", "australia": "Avustralya", @@ -177,16 +267,37 @@ "halkidiki": "Halkidiki", "straitofgibraltar": "Cebelitarık Boğazı", "italia": "İtalya", + "japan": "Japonya", "yenisei": "Yenisey", - "pluto": "Plüto" + "pluto": "Plüto", + "montreal": "Montreal", + "newyorkcity": "New York Şehri", + "achiran": "Achiran", + "baikalnukewars": "Baykal (Nükleer Savaşlar)", + "fourislands": "Dört Adalar", + "gulfofstlawrence": "St. Lawrence Körfezi", + "lisbon": "Lizbon", + "svalmel": "Svalmel", + "manicouagan": "Manicouagan", + "lemnos": "Lemnos", + "sierpinski": "Sierpinski", + "twolakes": "İki Nehir", + "straitofhormuz": "Hürmüz Boğazı", + "surrounded": "Etrafı Çevrili", + "didier": "Didier", + "didierfrance": "Didier (Fransa)", + "amazonriver": "Amazon Nehri" }, "map_categories": { "continental": "Kıtasal", "regional": "Bölgesel", - "fantasy": "Diğer" + "fantasy": "Diğer", + "special": "Özel", + "arcade": "Arcade" }, "map_component": { - "loading": "Yükleniyor..." + "loading": "Yükleniyor...", + "error": "Hata" }, "private_lobby": { "title": "Özel Lobiye Katıl", @@ -196,43 +307,79 @@ "join_lobby": "Lobiye katıl", "checking": "Lobi kontrol ediliyor...", "not_found": "Lobi bulunamadı. Lütfen ID'yi kontrol edip tekrar deneyin.", - "error": "Bir hata oluştu. Lütfen tekrar deneyin.", - "joined_waiting": "Başarıyla katıldınız! Oyunun başlaması bekleniyor..." + "error": "Bir hata oluştu. Lütfen tekrar deneyin ya da destek ile iletişime geçin.", + "joined_waiting": "Lobiye katıldınız! Oda sahibinin başlatması bekleniyor...", + "version_mismatch": "Bu lobi oyunun başka bir sürümü ile oluşturuldu. Katılamazsın.", + "disabled_units": "Devre Dışı Bırakılmış Birimler" }, "public_lobby": { "join": "Sıradaki Oyuna Katıl", "waiting": "oyuncu bekliyor", - "teams_Duos": "İkili (2'li takımlar)", - "teams_Trios": "Üçlü (3'lü takımlar)", - "teams_Quads": "Dörtlü (4'lü takımlar)", - "teams": "{num} takımlar" + "teams_Duos": "{team_count} adet 2 kişilik takımlar (İkili)", + "teams_Trios": "{team_count} adet 3 kişilik takımlar (Üçlü)", + "teams_Quads": "{team_count} adet 4 kişilik takımlar (Dörtlü)", + "waiting_for_players": "Oyuncular bekleniyor", + "starting_game": "Oyun başlatılıyor…", + "teams_hvn": "İnsanlar vs Ülkeler", + "teams_hvn_detailed": "{num} İnsan vs {num} Ülke", + "teams": "{num} takımlar", + "players_per_team": "{num}", + "started": "Başladı" + }, + "matchmaking_modal": { + "title": "1v1 Aşamalı Eşleştirme (ALFA)", + "connecting": "Maç bulma sunucusuna bağlanılıyor...", + "searching": "Oyun aranıyor...", + "waiting_for_game": "Oyunun başlaması bekleniyor...", + "elo": "Senin ELO'n: {elo}" }, "username": { "enter_username": "Kullanıcı adınızı girin", "not_string": "Kullanıcı adı bir metin olmalıdır.", "too_short": "Kullanıcı adı en az {min} karakter uzunluğunda olmalıdır.", "too_long": "Kullanıcı adı {max} karakteri geçmemelidir.", - "invalid_chars": "Kullanıcı adı yalnızca harf, rakam, boşluk, alt çizgi ve [köşeli parantez] içerebilir." + "invalid_chars": "Kullanıcı adı sadece harfler, sayılar, boşluklar ve alt çizgi içerebilir.", + "tag": "ETİKET", + "tag_too_short": "Klan etiketi 2-5 harf arasında olmalı.", + "tag_invalid_chars": "Klan etiketi sadece harf ve sayı içerebilir." }, "host_modal": { - "title": "Özel Lobi", + "title": "Özel Lobi Oluştur", + "label": "Özel", "mode": "Mod", "team_count": "Takım Sayısı", + "team_type": "Takım Türü", "options_title": "Seçenekler", "bots": "Botları:", "bots_disabled": "Devre Dışı", + "player_immunity_duration": "PVP bağışıklık süresi (dakika)", + "nations": "Ülkeler: ", "disable_nations": "Ulusları Devre Dışı Bırak", + "max_timer": "Oyun süresi (dakika)", + "mins_placeholder": "Dakika", "instant_build": "Anında İnşa", "infinite_gold": "Sınırsız Altın", "donate_gold": "Altın bağışla", "infinite_troops": "Sınırsız Asker", "donate_troops": "Asker bağışla", + "compact_map": "Sıkıştırılmış Harita", "enables_title": "Ayarları Etkinleştir", "player": "Oyuncu", "players": "Oyuncular", + "nation_players": "Ülkeler", + "nation_player": "Millet", "waiting": "Oyuncular bekleniyor...", + "random_spawn": "Rastgele doğma", "start": "Oyunu Başlat", - "host_badge": "Host" + "host_badge": "Host", + "assigned_teams": "Atanmış Takımlar", + "empty_teams": "Boş Takımlar", + "empty_team": "Boş", + "remove_player": "{username}'i Kaldır", + "teams_Duos": "İkili (2 kişilik takımlar)", + "teams_Trios": "Üçlü (3 kişilik takımlar)", + "teams_Quads": "Dörtlü (4 kişilik takımlar)", + "teams_Humans Vs Nations": "İnsanlar vs Ülkeler" }, "team_colors": { "red": "Kırmızı", @@ -246,19 +393,24 @@ }, "game_starting_modal": { "title": "Oyun Başlıyor...", - "desc": "Oyun başlamak üzere hazırlanıyor. Lütfen bekleyin." + "credits": "Atıflar", + "code_license": "Kod AGPL-3.0 altında lisanslanmıştır (garanti yok)" }, "difficulty": { - "difficulty": "Zorluk", - "Easy": "Rahat", - "Medium": "Dengeli", - "Hard": "Yoğun", - "Impossible": "İmkansız" + "difficulty": "Ülke Zorluğu", + "easy": "Kolay", + "medium": "Orta", + "hard": "Zor", + "impossible": "İmkansız" }, "game_mode": { "ffa": "Herkes Tek", "teams": "Takımlar" }, + "public_game_modifier": { + "random_spawn": "Rastgele Doğma", + "compact_map": "Sıkıştırılmış Harita" + }, "select_lang": { "title": "Dil seç" }, @@ -275,52 +427,69 @@ "factory": "Fabrika" }, "user_setting": { - "title": "Kullanıcı Ayarları", + "title": "Seçenekler", "tab_basic": "Temel Ayarlar", "tab_keybinds": "Kısayollar", "dark_mode_label": "Karanlık Tema", "dark_mode_desc": "Sitenin görünümünü açık ve koyu tema arasında değiştir", - "dark_mode_enabled": "Karanlık tema etkinleştirildi", - "light_mode_enabled": "Açık tema etkinleştirildi", "emojis_label": "Emojiler", - "emojis_visible": "Emojiler görünüyor", - "emojis_hidden": "Emojiler gizleniyor", "emojis_desc": "Emojilerin oyunda gösterilip gösterilmeyeceğini değiştir", "alert_frame_label": "Uyarı Çerçevesi", - "alert_frame_desc": "Uyarı çerçevesini açın/kapatın. Etkinleştirildiğinde, ihanete uğradığınızda çerçeve gözükür.", + "alert_frame_desc": "Uyarı çerçevesini etkinleştir. Etkinleştirildiğinde, ihanete uğradığınızda veya kara saldırısına uğradığınızda çerçeve gözükür.", "special_effects_label": "Özel efektler", "special_effects_desc": "Özel efektleri aç/kapat. Performansı artırmak için devre dışı bırakın", - "special_effects_enabled": "Özel efektler açık", - "special_effects_disabled": "Özel efektler kapalı", "structure_sprites_label": "Yapı Simgeleri", "structure_sprites_desc": "Yapı simgelerini aç/kapat", - "structure_sprites_enabled": "Yapı Simgeleri etkinleştirildi", - "structure_sprites_disabled": "Yapı Simgeleri devre dışı bırakıldı", + "cursor_cost_label_label": "İmleç Yapım Maliyeti", + "cursor_cost_label_desc": "Oluşturma imleci simgesinin altında bir maliyet hapı göster", "anonymous_names_label": "Adları Gizle", "anonymous_names_desc": "Gerçek oyuncu isimlerini ekranında rastgele isimlerle gizle.", - "anonymous_names_enabled": "Adları gizleme açık", "lobby_id_visibility_label": "Gizli Lobi Kimlikleri", "lobby_id_visibility_desc": "Özel lobi oluştururken lobi kimliğini gizle", - "real_names_shown": "Gerçek adlar görünüyor", + "toggle_visibility": "Görünürlüğü Aç/Kapat", "left_click_label": "Menüyü Açmak için Sol Tık", "left_click_desc": "AÇIK olduğunda, sol tıklama menüyü açar ve kılıç düğmesi saldırır. KAPALI olduğunda, sol tıklama doğrudan saldırır.", "left_click_menu": "Sol Tık Menüsü", - "left_click_opens_menu": "Sol tık menüyü açar", - "right_click_opens_menu": "Sağ tık menüyü açar", "attack_ratio_label": "⚔️ Saldırı Oranı", "attack_ratio_desc": "Bir saldırıda birliklerinin yüzde kaçını göndereceksin (%1-100)", - "troop_ratio_desc": "Askerler (savaş için) ve işçiler (altın üretimi için) arasındaki dengeyi ayarlayın (%1–100)", - "territory_patterns_label": "🏳️ Bölge Desenleri", - "territory_patterns_desc": "Oyunda bölge desenlerinin gösterilip gösterilmeyeceğini seç", + "territory_patterns_label": "🏳️Bölge Desenleri", + "territory_patterns_desc": "Bölge desenlerinin oyunda gösterip gösterilmeyeceğini seç", "performance_overlay_label": "Performans Katmanı", "performance_overlay_desc": "Performans katmanını açın veya kapatın. Etkinleştirildiğinde, performans katmanı görüntülenir. Oyun sırasında Shift-D tuşlarına basarak açıp kapatabilirsiniz.", "easter_writing_speed_label": "Yazma Hızı Çarpanı", "easter_writing_speed_desc": "Kod yazıyormuş gibi yapma hızınızı ayarlayın (x1–x100)", "easter_bug_count_label": "Hata Sayısı", "easter_bug_count_desc": "Kabul edebileceğiniz hata sayısı (0–1000, duygusal olarak)", + "press_a_key": "Bir tuşa bas", "view_options": "Görüntü Seçenekleri", "toggle_view": "Görüntüyü Değiştir", "toggle_view_desc": "Alternatif görünüm (arazi/ülkeler)", + "build_controls": "İnşaat Kontrolleri", + "build_city": "Şehir İnşa Et", + "build_city_desc": "İmlecinin altına bir Şehir inşa et.", + "build_factory": "Fabrika İnşa Et", + "build_factory_desc": "İmlecinin altına bir Fabrika inşa et.", + "build_defense_post": "Bir Savunma Karakolu İnşa Et", + "build_defense_post_desc": "İmlecinin altına bir Savunma Karakolu kur.", + "build_port": "Liman İnşa Et", + "build_port_desc": "İmlecinin altına bir Liman inşa et.", + "build_warship": "Savaş Gemisi İnşa Et", + "build_warship_desc": "İmlecinin altına bir Savaş Gemisi inşa et.", + "build_missile_silo": "Füze Silosu İnşa Et", + "build_missile_silo_desc": "İmlecinin altına bir Füze silosu inşa et.", + "build_sam_launcher": "SAM Fırlatıcı İnşa Et", + "build_sam_launcher_desc": "İmlecinin altına bir SAM Fırlatıcı inşa et.", + "build_atom_bomb": "Atom Bombası İnşa Et", + "build_atom_bomb_desc": "İmlecinin altına bir Atom Bombası inşa et.", + "build_hydrogen_bomb": "Hidrojen Bombası İnşa Et", + "build_hydrogen_bomb_desc": "İmlecinin altına bir Hidrojen Bombası inşa et.", + "build_mirv": "MIRV İnşa Et", + "build_mirv_desc": "İmlecinin altına MIRV inşa et.", + "menu_shortcuts": "Menü Kısayolları", + "build_menu_modifier": "Yapı Menüsü Değiştiricisi", + "build_menu_modifier_desc": "Yapı menüsünü açmak için tıklarken bu tuşa basılı tut.", + "emoji_menu_modifier": "Emoji Menüsü Değiştiricisi", + "emoji_menu_modifier_desc": "Emoji menüsünü açmak için tıklarken bu tuşa basılı tut.", "attack_ratio_controls": "Saldırı Oranı Kontrolleri", "attack_ratio_up": "Saldırı Oranını Artır", "attack_ratio_up_desc": "Saldırı oranını %10 artır", @@ -331,6 +500,8 @@ "boat_attack_desc": "İmlecinizin altındaki kareye tekne saldırısı gönder.", "ground_attack": "Kara Saldırısı", "ground_attack_desc": "İmlecinin altındaki kareye kara saldırısı gönderir.", + "swap_direction": "Roket Yönünü Değiştir", + "swap_direction_desc": "Roket fırlatma yönünü aç/kapat (yukarı/aşağı).", "zoom_controls": "Yakınlaştırma Kontrolleri", "zoom_out": "Uzaklaştır", "zoom_out_desc": "Haritayı uzaklaştır", @@ -352,10 +523,11 @@ "on": "Açık", "off": "Kapalı", "toggle_terrain": "Araziyi Göster", - "terrain_enabled": "Arazi görünümü etkinleştirildi", - "terrain_disabled": "Arazi görünümü kapatıldı", "exit_game_label": "Oyundan Çık", - "exit_game_info": "Ana menüye dön" + "exit_game_info": "Ana menüye dön", + "background_music_volume": "Arkaplan Müziği Sesi", + "sound_effects_volume": "Ses Efektleri Sesi", + "keybind_conflict_error": "{key} tuşu zaten başka bir eyleme atanmış." }, "chat": { "title": "Hızlı Sohbet", @@ -462,15 +634,24 @@ }, "win_modal": { "support_openfront": "OpenFront'u Destekle!", - "territory_pattern": "OpenFront'u desteklemek için bölge deseni satın al!", + "territory_pattern": "Bir toprak kaplaması alarak reklamlardan kurtul!", "died": "Öldün", "your_team": "Takımınız kazandı!", "other_team": "{team} takımı kazandı!", "you_won": "Kazandın!", "other_won": "{player} kazandı!", + "nation_won": "{nation} ülkesi kazandı!", "exit": "Oyundan Çık", "keep": "Oynamaya Devam Et", - "wishlist": "Steam'de İstek Listesine Ekle!" + "spectate": "İzle", + "wishlist": "Steam'de İstek Listesine Ekle!", + "ofm_winter": "OpenFront Masters Kış Turnuvası!", + "ofm_winter_description": "Rekabetçi turnuvaya katıl ve en iyi oyunculara karşı mücadele et", + "join_tournament": "Turnuvaya Katıl", + "join_discord": "Discord Topluluğumuza Katıl!", + "discord_description": "Oyuncularla bağlantı kurun, yeni özellikleri keşfedin ve ödüller kazanın!", + "join_server": "Sunucuya Katıl", + "youtube_tutorial": "Yardım lazım mı?" }, "leaderboard": { "title": "Lider Tablosu", @@ -480,7 +661,7 @@ "team": "Takım", "owned": "Sahip Olunan", "gold": "Altın", - "troops": "Askerler", + "maxtroops": "Maks birlikler", "launchers": "Fırlatıcılar", "sams": "SAM'ler", "warships": "Savaş Gemileri", @@ -494,7 +675,9 @@ "nation": "Ulus", "player": "Oyuncu", "team": "Takım", - "d_troops": "Savunma birliği", + "alliance_timeout": "İttifak şu sürede bitiyor", + "troops": "Birlikler", + "maxtroops": "Maks birlikler", "a_troops": "Saldırı birliği", "gold": "Altın", "ports": "Limanlar", @@ -505,10 +688,13 @@ "warships": "Savaş gemileri", "health": "Sağlık", "attitude": "Tutum", - "levels": "Seviyeler" + "levels": "Seviyeler", + "wilderness_title": "Vahşi Doğa", + "irradiated_wilderness_title": "Radyoaktif Vahşi Doğa" }, "events_display": { "retreating": "geri çekiliyor", + "retaliate": "Karşılık Ver", "boat": "Tekne", "alliance_request_status": "{name} {status} ittifak isteğiniz", "alliance_accepted": "kabul edildi", @@ -527,8 +713,23 @@ "accept_alliance": "Kabul et", "reject_alliance": "Reddet", "alliance_renewed": "{name} ile ittifakın yenilendi", + "wants_to_renew_alliance": "{name} ittifakı yenilemek istiyor", "ignore": "Yoksay", - "unit_voluntarily_deleted": "Birim gönüllü olarak silindi" + "unit_voluntarily_deleted": "Birim gönüllü olarak silindi", + "betrayal_debuff_ends": "İhanet zayıflatmasının bitmesine {time} saniye kaldı", + "attack_cancelled_retreat": "Saldırı iptal edildi, {troops} asker geri çekilme sırasında öldürüldü", + "received_gold_from_captured_ship": "{name}'den ele geçirilen gemiden {gold} altın alındı", + "received_gold_from_trade": "{name} ile yapılan ticaretten {gold} altın alındı", + "missile_intercepted": "{unit} füzesi önlendi", + "mirv_warheads_intercepted": "{count, plural,one {{count} MIRV başlığı önlendi} other {{count} MIRV başlığı önlendi}}", + "sent_troops_to_player": "{name}'e {troops} birlik gönderildi", + "received_troops_from_player": "{name}'den {troops} birlik alındı", + "sent_gold_to_player": "{name}'e {gold} altın gönderildi", + "received_gold_from_player": "{name}'den {gold} altın alındı", + "unit_captured_by_enemy": "{unit} {name} tarafından ele geçirildi", + "captured_enemy_unit": "{name}'den {unit} ele geçirildi", + "unit_destroyed": "{unit} yok edildi", + "no_boats_available": "Bot mevcut değil, maks {max}" }, "unit_info_modal": { "structure_info": "Bina Bilgisi", @@ -539,6 +740,11 @@ "upgrade": "Yükselt", "level": "Seviye" }, + "player_type": { + "player": "Oyuncu", + "nation": "Ulus", + "bot": "Bot" + }, "relation": { "hostile": "Düşman", "distrustful": "Güvensiz", @@ -554,23 +760,50 @@ "player_panel": { "gold": "Altın", "troops": "Birlik", - "betrayals": "İhanet sayısı", + "betrayals": "İhanetler", "traitor": "Hain", + "trading": "Ticaret", + "active": "Aktif", + "stopped": "Durduruldu", "alliance_time_remaining": "İttifak Şu Süre Sonra Bitecek", "embargo": "Sizinle ticareti durdurdu", "nuke": "Onlar tarafından size gönderilen nükleerler", - "start_trade": "Ticareti başlat", - "stop_trade": "Ticareti durdur", - "yes": "Evet", - "no": "Hayır", - "none": "Hiçbiri", + "start_trade": "Ticarete Başla", + "stop_trade": "Ticareti Durdur", + "stop_trade_all": "Herkesle Ticareti Durdur", + "start_trade_all": "Herkesle Ticareti Başlat", "alliances": "İttifaklar", - "flag": "Bayrak" + "flag": "Bayrak", + "chat": "Sohbet", + "target": "Hedef", + "break_alliance": "İttifakı Boz", + "alliance": "İttifak", + "send_alliance": "İttifaklık İsteği Gönder", + "send_troops": "Birlik Gönder", + "send_gold": "Altın Gönder", + "emotes": "Emojiler", + "arc_up": "Yukarı yay", + "arc_down": "Aşağı yay", + "flip_rocket_trajectory": "Roket yörüngesini çevir" + }, + "send_troops_modal": { + "title_with_name": "{name}'e Birlik Gönder", + "available_tooltip": "Şu anda mevcut olan birliklerin", + "min_keep": "Minimum Elinde Tutma", + "slider_tooltip": "%{{percent}} •{{amount}}", + "aria_slider": "Birlik çubuğu", + "capacity_note": "Alıcı ancak {{amount}} kadar alabilir." + }, + "send_gold_modal": { + "title_with_name": "{name}'e Altın Gönder", + "available_tooltip": "Mevcut altının", + "aria_slider": "Miktar çubuğu", + "slider_tooltip": "%{{percent}} • {{amount}}" }, "replay_panel": { "replay_speed": "Tekrar oynatma hızı", "game_speed": "Oyun hızı", - "fastest_game_speed": "maks" + "fastest_game_speed": "Maks" }, "error_modal": { "crashed": "Oyun çöktü!", @@ -579,53 +812,47 @@ "copy_clipboard": "Panoya kopyala", "copied": "Kopyalandı!", "failed_copy": "Kopyalama başarısız", + "spawn_failed": { + "title": "Doğma başarısız", + "description": "Otomatik doğma seçimi başarısız. Bu oyunu oynayamazsın." + }, "desync_notice": "Diğer oyuncularla senkronizasyonunuz bozuldu. Gördükleriniz diğer oyunculardan farklı olabilir." }, + "performance_overlay": { + "reset": "Sıfırla", + "copy_json_title": "Mevcut performans metriklerini JSON olarak kopyala", + "copy_clipboard": "JSON'ı kopyala", + "copied": "Kopyalandı!", + "failed_copy": "Kopyalama başarısız", + "fps": "FPS:", + "avg_60s": "Ortalama (60s):", + "frame": "Kare:", + "tick_exec": "Tik Yürütme:", + "tick_delay": "Tik Gecikmesi:", + "layers_header": "Katmanlar (ort / maks, toplam vakte göre sıralanmış):" + }, "heads_up_message": { - "choose_spawn": "Başlangıç konumu seçin" + "choose_spawn": "Başlangıç konumu seçin", + "random_spawn": "Rastgele doğma aktif. Başlangıç noktası senin için seçiliyor...", + "singleplayer_game_paused": "Oyun durduruldu", + "multiplayer_game_paused": "Oyun Lobi Sahibi tarafından durduruldu" }, "territory_patterns": { - "title": "Bölge Deseni Seç", + "title": "Kaplamalar", + "colors": "Renkler", "purchase": "Satın al", + "show_only_owned": "Kaplamalarım", + "all_owned": "Bütün kaplamalara sahipsin! Yeni eşyalar için sonra tekrar kontrol et.", + "not_logged_in": "Giriş yapılmadı", "blocked": { - "login": "Bu desene erişmek için oturum açmanız gerekir.", - "purchase": "Bu deseni satın alarak kilidini açın." + "login": "Bu kaplamaya erişmek için giriş yapmış olman lazım.", + "purchase": "Bu kaplamayı açmak için satın al." }, "pattern": { - "default": "Varsayılan", - "custom": "Özel", - "stripes_v": "Dikey", - "stripes_h": "Yatay", - "horizontal_stripes": "Yatay (Alt)", - "vertical_bars": "Dikey (Alt)", - "checkerboard": "Dama tahtası", - "choco": "Çiko", - "diagonal": "Çapraz", - "cross": "Çarpı", - "mini_cross": "Mini Çarpı", - "sword": "Kılıç", - "sparse_dots": "Seyrek Noktalar", - "evan": "Evan", - "diagonal_stripe": "Çapraz Çizgi", - "mountain_ridge": "Dağ Sırtı", - "scattered_dots": "Dağınık Noktalar", - "circuit_board": "Devre Kartı", - "shells": "Kabuklar", - "-w-": ".w.", - "white_rabbit": "Beyaz Tavşan", - "goat": "Keçi", - "cats": "Kediler", - "cursor": "İmleç", - "hand": "El", - "radiation": "Radyasyon", - "openfront_qr": "OpenFront.io QR Kodu", - "openfront": "OpenFront", - "t_rex": "T-Rex", - "embelem": "Amblem", - "contributor": "Katkıda Bulunan", - "grogu_head": "Grogu Başı", - "grogu": "Grogu" - } + "default": "Varsayılan" + }, + "select_skin": "Kaplama Seç", + "selected": "seçildi" }, "flag_input": { "title": "Bayrak Seç", @@ -644,5 +871,83 @@ "radial_menu": { "delete_unit_title": "Birimi Sil", "delete_unit_description": "En yakın birimi silmek için tıklayın" + }, + "discord_user_header": { + "avatar_alt": "Profil Resmi" + }, + "player_stats_table": { + "building_stats": "Yapı İstatistikleri", + "ship_arrivals": "Gelen Gemiler", + "nuke_stats": "Nükleer İstatistikleri", + "player_metrics": "Oyuncu İstatistikleri", + "building": "Yapı", + "ship_type": "Gemi Türü", + "weapon": "Silah", + "built": "İnşa Edildi", + "destroyed": "Yok Edildi", + "captured": "Ele Geçirildi", + "lost": "Kaybedildi", + "hits": "Vuruşlar", + "launched": "Fırlatıldı", + "landed": "İniş Yaptı", + "sent": "Gönderildi", + "arrived": "Ulaştı", + "attack": "Saldır", + "received": "Alındı", + "cancelled": "İptal Edildi", + "count": "Miktar", + "gold": "Altın", + "workers": "İşçiler", + "war": "Savaş", + "trade": "Ticaret", + "steal": "Çal", + "unit": { + "city": "Şehir", + "port": "Liman", + "defp": "Savunma Karakolu", + "saml": "SAM Fırlatıcı", + "silo": "Füze Silosu", + "wshp": "Savaş Gemisi", + "fact": "Fabrika", + "trade": "Ticaret Gemisi", + "trans": "Nakliye Gemisi", + "abomb": "Atom Bombası", + "hbomb": "Hidrojen Bombası", + "mirv": "MIRV", + "mirvw": "MIRV Savaş Başlığı" + } + }, + "game_list": { + "recent_games": "Son Oyunlar", + "game_id": "Oyun ID'si", + "mode": "Mod", + "mode_ffa": "Herkes Tek", + "mode_team": "Takım", + "replay": "Tekrar", + "details": "Detaylar", + "ranking": "Sıralama", + "started": "Başladı", + "map": "Harita", + "difficulty": "Zorluk", + "type": "Tür" + }, + "player_stats_tree": { + "public": "Herkese Açık", + "private": "Özel", + "singleplayer": "Tekli", + "mode": "Mod", + "stats_wins": "Galibiyetler", + "stats_losses": "Yenilgiler", + "stats_wlr": "Kazanma:Kaybetme Oranı", + "stats_games_played": "Oynanan Oyunlar", + "mode_ffa": "Herkes Tek", + "mode_team": "Takım", + "no_stats": "Bu seçenek için veri kaydedilmedi." + }, + "matchmaking_button": { + "play_ranked": "1v1 Aşamalı Eşleştirme", + "description": "(ALFA)", + "login_required": "Aşamalı oynamak için giriş yap!", + "must_login": "Aşamalı eşleştirme oynamak için giriş yapmanız gerek." } } diff --git a/resources/lang/uk.json b/resources/lang/uk.json index 2c11971d3..80e797bac 100644 --- a/resources/lang/uk.json +++ b/resources/lang/uk.json @@ -7,6 +7,7 @@ }, "common": { "close": "Закрити", + "back": "Назад", "available": "Доступно", "preset_max": "Максимум", "summary_send": "Переказ", @@ -17,32 +18,48 @@ "cap_tooltip": "Залишкова місткість отримувача", "target_dead": "Ціль знищено", "target_dead_note": "Неможливо надіслати ресурси полеглому гравцю.", - "none": "Немає" + "none": "Немає", + "copied": "Скопійовано!", + "click_to_copy": "Натисніть, щоб скопіювати" }, "main": { "title": "OpenFront (АЛЬФА)", "join_discord": "Discord", "login_discord": "Увійти з Discord", + "sign_in": "Увійти", + "discord_avatar_alt": "Аватар профілю Discord", + "user_avatar_alt": "Аватар {username}", "checking_login": "Перевірка входу...", - "logged_in": "Вхід виконано!", + "logged_in": "Вхід здійснено!", "log_out": "Вийти", - "create_lobby": "Створити лобі", - "join_lobby": "Приєднатися до лобі", - "single_player": "Гра наодинці", + "create": "Створити лобі", + "join": "Приєднатися до лобі", + "solo": "Соло", "instructions": "Інструкції", + "game_info": "Інформація про гру", "wiki": "Вікі", "privacy_policy": "Політика конфіденційності", "terms_of_service": "Умови користування", - "reddit": "Reddit" + "copyright": "© OpenFront™ і співавтори", + "reddit": "Reddit", + "play": "Грати", + "news": "Новини", + "store": "Крамниця", + "settings": "Налаштування", + "keys": "Клавіші", + "stats": "Статистика", + "account": "Акаунт", + "help": "Допомога", + "menu": "Меню", + "pick_pattern": "Оберіть візерунок!" }, "news": { - "see_all_releases": "Переглянути всі випуски", "github_link": "на GitHub", "title": "Список змін" }, "help_modal": { "hotkeys": "Гарячі клавіші", - "table_key": "Клавіш", + "table_key": "Клавіша", "table_action": "Дія", "action_alt_view": "Альтернативний вигляд (рельєф/країни)", "action_attack_altclick": "Атака (коли лівий клац призначено на відкриття меню)", @@ -60,7 +77,7 @@ "ui_leaderboard_desc": "Показує найкращих гравців гри та їхні імена, % підконтрольних територій, кількість золота та військ. За допомогою кнопки «Показати все» ви можете переглянути всіх гравців у грі. Якщо ви не бажаєте бачити таблицю лідерів, натисніть «Приховати».", "ui_control": "Панель керування", "ui_control_desc": "Панель керування містить наступні елементи:", - "ui_pop": "Населення — Кількість ваших підрозділів, максимальне населення та темп його приросту.", + "ui_pop": "Населення — Кількість ваших підрозділів, ліміт населення та темп його приросту.", "ui_gold": "Золото — Обсяг вашого золота та швидкість, з якою ви отримуєте його.", "ui_attack_ratio": "Коефіцієнт атаки — Кількість військ, що беруть участь в атаці. Ви можете налаштувати коефіцієнт атаки за допомогою повзунка. Якщо наступальних військ більше ніж оборонних, то буде зменшено втрати під час атаки, а якщо менше — буде збільшено шкоду, що буде завдано вашим наступальним військам. Ефективність не збільшується після коефіцієнту 2:1.", "ui_events": "Панель подій", @@ -76,13 +93,15 @@ "option_pause": "Призупинити/Продовжити гру — Доступно лише в режимі гри наодинці.", "option_timer": "Таймер — Час, що минув із початку гри.", "option_exit": "Кнопка виходу.", - "option_settings": "Налаштування — Відкрити меню налаштувань. У ньому можна увімкнути/вимкнути режим альтернативного вигляду, емоджі, темний режим, нінджя (режим прихованих/випадкових імен) та виконання дії при клацанні лівою кнопкою миші.", + "option_settings": "Налаштування — Відкрити меню налаштувань. У ньому можна перемкнути режим альтернативного вигляду, емоджі, темний режим, нінджя (режим прихованих/випадкових імен) та виконання дії при клацанні лівою кнопкою миші.", "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 секунд після того, як гравець зрадив й атакував гравця, який перебував у союзні з ним. Значки нижче позначають такі взаємодії:", @@ -102,7 +121,7 @@ "build_icon": "Значок", "build_desc": "Опис", "build_city": "Місто", - "build_city_desc": "Збільшує вашу максимальну кількість населення. Корисно, коли ви не можете розширити територію або населення сягає ліміту.", + "build_city_desc": "Збільшує ваш ліміт населення. Корисно, коли ви не можете розширити територію або населення незабаром досягне ліміту.", "build_factory": "Фабрика", "build_factory_desc": "Автоматично прокладає залізничні колії до найближчих міст, портів та інших фабрик. Також може обʼєднуватися з дружніми сусідніми країнами. Поїзди зʼявляються регулярно і дають сталу кількість золота за кожну будівлю, яку проїжджають на шляху, із бонусом за відвідування будівель сусідніх країн.", "build_defense": "Пункт оборони", @@ -114,7 +133,7 @@ "build_silo": "Ракетна шахта", "build_silo_desc": "Дає можливість запускати ракети.", "build_sam": "ПУ ЗРК", - "build_sam_desc": "Дозволяє перехоплювати ворожі ракети в радіусі 100 пікселів. Має 100% шанс на збиття атомної бомби, 80% — водневої бомби та 50% — окремих боєголовок РГЧ ІН. ЗРК має період перезаряджання в 7,5 секунд.", + "build_sam_desc": "Може перехоплювати ворожі ракети в радіусі 100 пікселів. ЗРК має період перезаряджання в 7,5 секунд.", "build_atom": "Атомна бомба", "build_atom_desc": "Невелика вибухова бомба, яка руйнує територію, будівлі, кораблі та човни. Запускається з найближчої ракетної шахти та вражає область, вибрану клацанням кнопкою миші.", "build_hydrogen": "Воднева бомба", @@ -129,12 +148,15 @@ "icon_embargo": "Закреслений знак долара — Ембарго. Цей гравець припинив торгівлю з вами; автоматично чи свідомо.", "icon_request": "Конверт — Запрошення до союзу. Гравець надіслав вам запит на укладення союзу.", "info_enemy_panel": "Панель інформації про ворога", - "exit_confirmation": "Ви впевнені, що хочете вийти з гри?" + "exit_confirmation": "Ви впевнені, що хочете вийти з гри?", + "bomb_direction": "Траєкторія польоту атомної/водородної бомби" }, "single_modal": { - "title": "Гра наодинці", + "title": "Соло", "random_spawn": "Випадкова поява", "allow_alliances": "Дозволити союзи", + "toggle_achievements": "Перемикання досягнень", + "sign_in_for_achievements": "Увійдіть, щоб отримувати досягнення", "options_title": "Налаштування", "bots": "Боти: ", "bots_disabled": "Відключені", @@ -145,6 +167,8 @@ "infinite_troops": "Необмежені війська", "compact_map": "Компактна мапа", "max_timer": "Тривалість гри (хвилини)", + "max_timer_placeholder": "Хвилини", + "max_timer_invalid": "Будь ласка, введіть дійсне максимальне значення таймера (1–120 хвилин)", "disable_nukes": "Вимкнути бомби", "enables_title": "Дозволи", "start": "Розпочати гру" @@ -156,10 +180,21 @@ }, "account_modal": { "title": "Акаунт", - "logged_in_as": "Ви увійшли як {email}", + "connected_as": "Підʼєднано як", + "stats_overview": "Огляд статистики", + "link_discord": "Повʼязати акаунт Discord", + "log_out": "Вийти", + "sign_in_desc": "Увійдіть, щоб зберегти статистику та прогрес", + "or": "АБО", + "email_placeholder": "Введіть свою електронну пошту", + "get_magic_link": "Отримати чарівне посилання", + "linked_account": "Ви увійшли як {account_name}", "fetching_account": "Отримання інформації про акаунт...", - "logged_in_with_discord": "Ви увійшли через Discord", - "recovery_email_sent": "Лист для відновлення надіслано на {email}" + "recovery_email_sent": "Лист для відновлення надіслано на {email}", + "not_found": "Не знайдено", + "clear_session": "Очистити сесію", + "failed_to_send_recovery_email": "Не вдалося надіслати електронний лист для відновлення", + "enter_email_address": "Будь ласка, введіть адресу електронної пошти" }, "stats_modal": { "title": "Статистика", @@ -167,11 +202,40 @@ "loading": "Завантаження...", "error": "Помилка завантаження статистики кланів", "no_stats": "Статистика кланів недоступна", + "no_data_yet": "Дані поки що відсутні", "clan": "Клан", "games": "Ігри", "win_score": "Рахунок перемог", + "win_score_tooltip": "Зважені перемоги на основі участі клану та складності матчу", "loss_score": "Рахунок поразок", - "win_loss_ratio": "Перемоги/Поразки" + "loss_score_tooltip": "Зважені поразки на основі участі клану та складності матчу", + "win_loss_ratio": "Перемоги/Поразки", + "ratio": "Коефіцієнт", + "rank": "Ранг", + "try_again": "Спробуйте ще раз" + }, + "game_info_modal": { + "title": "Інформація про гру", + "players": "Гравці", + "atoms": "Атомні бомби", + "hydros": "Водневі бомби", + "mirv": "РГЧ ІН", + "bombs": "Бомби", + "total_gold": "Загалом", + "all_gold": "Усе золото", + "trade": "Торгівля", + "conquest_gold": "Загарбане золото гравців", + "stolen_gold": "Викрадено воєнними кораблями", + "num_of_conquests": "Кількість підкорених гравців", + "duration": "Тривалість", + "survival_time": "Час виживання", + "war": "Війна", + "economy": "Економіка", + "conquests": "Завоювання", + "pirate": "Піратство", + "conquered": "Завойовано", + "loading_game_info": "Завантаження статистики ігор", + "no_winner": "Ця гра закінчилася без переможця (або перемогла нація)" }, "map": { "map": "Мапа", @@ -186,6 +250,7 @@ "asia": "Азія", "mars": "Марс", "southamerica": "Південна Америка", + "britanniaclassic": "Британія (класична)", "britannia": "Британія", "gatewaytotheatlantic": "Гібралтарська протока", "australia": "Австралія", @@ -206,22 +271,36 @@ "yenisei": "Єнісей", "pluto": "Плутон", "montreal": "Монреаль", + "newyorkcity": "Нью-Йорк", "achiran": "Акіран", "baikalnukewars": "Байкал (ядерні війни)", "fourislands": "Чотири острови", "gulfofstlawrence": "Затока Св. Лаврентія", - "lisbon": "Лісабон" + "lisbon": "Лісабон", + "svalmel": "Свалмел", + "manicouagan": "Манікуаган", + "lemnos": "Лемнос", + "sierpinski": "Серпінський", + "twolakes": "Два озера", + "straitofhormuz": "Ормузька протока", + "surrounded": "Оточення", + "didier": "Дідьє", + "didierfrance": "Дідьє (Франція)", + "amazonriver": "Річка Амазонка" }, "map_categories": { "continental": "Континентальні", "regional": "Регіональні", - "fantasy": "Інші" + "fantasy": "Інші", + "special": "Особливі", + "arcade": "Аркадні" }, "map_component": { - "loading": "Завантаження..." + "loading": "Завантаження...", + "error": "Помилка" }, "private_lobby": { - "title": "Приєднатися до приватного лобі", + "title": "Приєднання до приватного лобі", "enter_id": "Введіть ID лобі", "player": "Гравець", "players": "Гравці(в)", @@ -229,42 +308,55 @@ "checking": "Перевірка лобі...", "not_found": "Лобі не знайдено. Будь ласка, перевірте дійсність ID і спробуйте знову.", "error": "Сталася помилка. Спробуйте ще раз або зверніться до служби підтримки.", - "joined_waiting": "Ви успішно приєдналися! Очікування початку гри...", - "version_mismatch": "Цю гру створено в іншій версії. Неможливо приєднатися." + "joined_waiting": "Лобі приєднано! Очікуємо, доки хост почне гру...", + "version_mismatch": "Цю гру створено в іншій версії. Неможливо приєднатися.", + "disabled_units": "Вимкнені споруди" }, "public_lobby": { "join": "Приєднатися до наступної гри", "waiting": "гравці(в) очікують", - "teams_Duos": "по 2 (дуо)", - "teams_Trios": "по 3 (тріо)", - "teams_Quads": "по 4 (квади)", + "teams_Duos": "{team_count} команд по 2 (дуо)", + "teams_Trios": "{team_count} команд по 3 (тріо)", + "teams_Quads": "{team_count} команд по 4 (квади)", + "waiting_for_players": "Очікування гравців", + "starting_game": "Початок гри…", "teams_hvn": "Люди проти націй", - "teams": "Команд: {num}", - "players_per_team": "по {num}" + "teams_hvn_detailed": "{num} людей проти {num} націй", + "teams": "Команди: {num}", + "players_per_team": "по {num}", + "started": "Почато" }, "matchmaking_modal": { - "title": "Підбір гравців", + "title": "Рейтинговий підбір 1v1 (АЛЬФА)", "connecting": "Приєднання до сервера підбору гравців...", "searching": "Пошук гри...", - "waiting_for_game": "Очікування початку гри..." + "waiting_for_game": "Очікування початку гри...", + "elo": "Ваш ELO: {elo}" }, "username": { "enter_username": "Введіть своє імʼя гравця", "not_string": "Імʼя гравця має бути рядком.", "too_short": "Імʼя гравця повинно містити щонайменше {min} символів.", "too_long": "Довжина імʼя гравця не повинна перевищувати {max} символів.", - "invalid_chars": "Імʼя гравця може містити лише латинські літери, цифри, пробіли, знаки підкреслення та [квадратні дужки]." + "invalid_chars": "Імʼя гравця може містити лише латинські літери, цифри, пробіли та підкреслення.", + "tag": "ТЕГ", + "tag_too_short": "Тег клану має складатися з 2–5 абетко-цифрових символів.", + "tag_invalid_chars": "Тег клану може містити лише латинські літери та цифри." }, "host_modal": { - "title": "Приватне лобі", + "title": "Створення приватного лобі", + "label": "Приватний", "mode": "Режим", "team_count": "Кількість команд", + "team_type": "Тип команди", "options_title": "Налаштування", "bots": "Боти: ", "bots_disabled": "Відключені", + "player_immunity_duration": "Тривалість імунітету в PVP (хвилини)", "nations": "Нації: ", "disable_nations": "Вимкнути нації", "max_timer": "Тривалість гри (хвилини)", + "mins_placeholder": "Хвилини", "instant_build": "Миттєве будівництво", "infinite_gold": "Безмежне золото", "donate_gold": "Пожертвування золота", @@ -283,7 +375,11 @@ "assigned_teams": "Розподілені команди", "empty_teams": "Порожні команди", "empty_team": "Немає", - "remove_player": "Вилучити {username}" + "remove_player": "Вилучити {username}", + "teams_Duos": "Дуо (команди по 2)", + "teams_Trios": "Тріо (команди по 3)", + "teams_Quads": "Квади (команди по 4)", + "teams_Humans Vs Nations": "Люди проти націй" }, "team_colors": { "red": "Червоний", @@ -301,18 +397,22 @@ "code_license": "Код ліцензовано під AGPL-3.0 (без гарантій)" }, "difficulty": { - "difficulty": "Складність", - "Easy": "Розслаблена", - "Medium": "Збалансована", - "Hard": "Напружена", - "Impossible": "Неможлива" + "difficulty": "Складність націй", + "easy": "Легко", + "medium": "Середньо", + "hard": "Важко", + "impossible": "Неможливо" }, "game_mode": { "ffa": "Усі проти всіх", "teams": "Команди" }, + "public_game_modifier": { + "random_spawn": "Випадкова поява", + "compact_map": "Компактна мапа" + }, "select_lang": { - "title": "Виберіть мову" + "title": "Вибір мови" }, "unit_type": { "city": "Місто", @@ -327,51 +427,54 @@ "factory": "Фабрика" }, "user_setting": { - "title": "Користувацькі налаштування", + "title": "Налаштування", "tab_basic": "Основні налаштування", "tab_keybinds": "Призначення клавіш", "dark_mode_label": "Темний режим", "dark_mode_desc": "Перемикання зовнішнього вигляду сайту між світлою та темною темою", "emojis_label": "Емоджі", - "emojis_desc": "Увімкнення/вимкнення видимости емоджі під час гри", + "emojis_desc": "Перемкнути видимість емоджі під час гри", "alert_frame_label": "Рамка тривоги", - "alert_frame_desc": "Увімкнути/вимкнути рамку тривоги. Якщо увімкнено, вона показуватиметься, коли вас зраджують або атакують по суші.", + "alert_frame_desc": "Перемкнути рамку тривоги. Якщо увімкнено, рамка показуватиметься, коли вас зраджують або атакують по суші.", "special_effects_label": "Спецефекти", - "special_effects_desc": "Увімкнути/вимкнути спецефекти. Вимкніть для поліпшення продуктивности", + "special_effects_desc": "Перемкнути спецефекти. Вимкніть для поліпшення продуктивности", "structure_sprites_label": "Спрайти споруд", - "structure_sprites_desc": "Увімкнення/вимкнення спрайтів споруд", + "structure_sprites_desc": "Перемкнути спрайти споруд", + "cursor_cost_label_label": "Вартість будування під указівником", + "cursor_cost_label_desc": "Показувати вартість будівництва під указівником", "anonymous_names_label": "Приховані імена", "anonymous_names_desc": "Приховати справжні імена гравців і замінити їх випадковими.", "lobby_id_visibility_label": "Приховані ID лобі", "lobby_id_visibility_desc": "Приховати ID при створенні приватного лобі", + "toggle_visibility": "Перемикання видимости", "left_click_label": "Відкриття меню лівою кнопкою миші", "left_click_desc": "УВІМКНЕНО — лівий клац відкриває меню, кнопкою з мечем здійснює атаку. ВИМКНЕНО — лівий клац одразу атакує.", "left_click_menu": "Меню на лівий клац миші", "attack_ratio_label": "⚔️ Коефіцієнт атаки", - "attack_ratio_desc": "Який відсоток ваших бере учать в атаці (1–100%)", - "troop_ratio_desc": "Налаштуйте співвідношення між військами (для бою) та працівниками (для видобування золота) (1–100%)", + "attack_ratio_desc": "Який відсоток ваших військ відправляти в наступ (1–100%)", "territory_patterns_label": "🏳️ Скіни території", "territory_patterns_desc": "Виберіть, чи показувати скіни територій у грі", "performance_overlay_label": "Оверлей продуктивности", - "performance_overlay_desc": "Увімкнення/вимкнення оверлея продуктивности. Якщо увімкнено, буде показано оверлей продуктивности. Натисніть Shift+D під час гри, щоб увімкнути/вимкнути його.", + "performance_overlay_desc": "Перемкнути оверлей продуктивности. Якщо увімкнено, буде показано оверлей продуктивности. Натисніть Shift+D під час гри, щоб перемкнути його.", "easter_writing_speed_label": "Множник швидкості друку", "easter_writing_speed_desc": "Налаштуйте швидкість, з якою ви удаєте, що програмуєте (x1–x100)", "easter_bug_count_label": "Кількість багів", "easter_bug_count_desc": "Кількість багів, що ви вважаєте прийнятною (0–1000, емоційно)", + "press_a_key": "Натисніть клавішу", "view_options": "Налаштування вигляду", - "toggle_view": "Змінити вигляд", + "toggle_view": "Перемкнути вигляд", "toggle_view_desc": "Альтернативний вигляд (рельєф/країни)", "build_controls": "Керування розміщенням", "build_city": "Розмістити місто", "build_city_desc": "Розмістити місто під указівником.", "build_factory": "Розмістити фабрику", - "build_factory_desc": "Будувати фабрику під указівником.", + "build_factory_desc": "Розмістити фабрику під указівником.", "build_defense_post": "Розмістити пункт оборони", "build_defense_post_desc": "Розмістити пункт оборони під указівником.", "build_port": "Розмістити порт", "build_port_desc": "Розмістити порт під указівником.", "build_warship": "Розмістити військовий корабель", - "build_warship_desc": "Будувати військовий корабель під указівником.", + "build_warship_desc": "Розмістити військовий корабель під указівником.", "build_missile_silo": "Розмістити ракетну шахту", "build_missile_silo_desc": "Розмістити ракетну шахту під указівником.", "build_sam_launcher": "Розмістити ПУ ЗРК", @@ -382,6 +485,11 @@ "build_hydrogen_bomb_desc": "Розмістити водневу бомбу під указівником.", "build_mirv": "Розмістити РГЧ ІН", "build_mirv_desc": "Розмістити РГЧ ІН під указівником.", + "menu_shortcuts": "Скорочення меню", + "build_menu_modifier": "Модифікатор меню будівництва", + "build_menu_modifier_desc": "Утримуйте цю клавішу під час клацання, щоб відкрити меню будівництва.", + "emoji_menu_modifier": "Модифікатор меню емоджі", + "emoji_menu_modifier_desc": "Утримуйте цю клавішу під час клацання, щоб відкрити меню емоджі.", "attack_ratio_controls": "Керування коефіцієнтом атаки", "attack_ratio_up": "Збільшити коефіцієнт атаки", "attack_ratio_up_desc": "Збільшити коефіцієнт атаки на 10%", @@ -392,6 +500,8 @@ "boat_attack_desc": "Відправити човен на клітинку під указівником.", "ground_attack": "Наземна атака", "ground_attack_desc": "Відправити наземну атаку на клітинку під указівником.", + "swap_direction": "Змінити напрямок ракети", + "swap_direction_desc": "Перемкнути напрямок ракети (угору/вниз).", "zoom_controls": "Масштабування", "zoom_out": "Зменшити масштаб", "zoom_out_desc": "Зменшити масштаб мапи", @@ -412,11 +522,12 @@ "unbind": "Звільнити", "on": "Увімкнено", "off": "Вимкнено", - "toggle_terrain": "Увімкнення/вимкнення рельєфу", + "toggle_terrain": "Перемикання рельєфу", "exit_game_label": "Вийти з гри", "exit_game_info": "Повернутися до головного меню", "background_music_volume": "Гучність фонової музики", - "sound_effects_volume": "Гучність звукових ефектів" + "sound_effects_volume": "Гучність звукових ефектів", + "keybind_conflict_error": "Клавішу {key} вже привʼязано до іншої дії." }, "chat": { "title": "Швидкий чат", @@ -516,7 +627,7 @@ "warship": "Захоплює торгові кораблі, знищує кораблі та човни", "port": "Відправляє торгові кораблі для генерації золота", "defense_post": "Підсилює оборону найближчих кордонів", - "city": "Збільшує максимальне населення", + "city": "Збільшує ліміт населення", "factory": "Прокладає залізничні колії та створює поїзди" }, "not_enough_money": "Недостатньо коштів" @@ -529,6 +640,7 @@ "other_team": "Команда «{team}» перемогла!", "you_won": "Ви перемогли!", "other_won": "Гравець {player} переміг!", + "nation_won": "Нація {nation} перемогла!", "exit": "Вийти з гри", "keep": "Продовжити гру", "spectate": "Спостерігати", @@ -537,19 +649,19 @@ "ofm_winter_description": "Приєднуйтеся до турніру та змагайтеся з найкращими гравцями", "join_tournament": "Приєднатися до турніру", "join_discord": "Приєднуйтеся до нашої спільноти Discord!", - "discord_description": "Спілкуйтеся з іншими гравцями, отримуйте новини та діліться стратегіями", + "discord_description": "Звʼязуйтеся з гравцями, відкривайте нові можливості та вигравайте призи!", "join_server": "Приєднатися до сервера", "youtube_tutorial": "Потрібна допомога?" }, "leaderboard": { "title": "Таблиця лідерів", "hide": "Приховати", - "rank": "Місце", + "rank": "Ранг", "player": "Гравець", "team": "Команда", "owned": "Влада", "gold": "Золото", - "troops": "Війська", + "maxtroops": "Ліміт військ", "launchers": "Установки", "sams": "ЗРК", "warships": "Військові кораблі", @@ -565,6 +677,7 @@ "team": "Команда", "alliance_timeout": "Кінець союзу через", "troops": "Війська", + "maxtroops": "Ліміт військ", "a_troops": "Наступальні війська", "gold": "Золото", "ports": "Порти", @@ -575,7 +688,9 @@ "warships": "Військові кораблі", "health": "Здоровʼя", "attitude": "Ставлення", - "levels": "Рівні" + "levels": "Рівні", + "wilderness_title": "Пустир", + "irradiated_wilderness_title": "Радіоактивний пустир" }, "events_display": { "retreating": "відступає", @@ -600,8 +715,21 @@ "alliance_renewed": "Союз із {name} було поновлено", "wants_to_renew_alliance": "{name} хоче поновити ваш союз", "ignore": "Ігнорувати", - "unit_voluntarily_deleted": "Обʼєкт добровільно видалено", - "betrayal_debuff_ends": "Залишилося {time} сек до закінчення покарання зрадника" + "unit_voluntarily_deleted": "Споруду добровільно видалено", + "betrayal_debuff_ends": "Залишилося {time} сек до закінчення покарання зрадника", + "attack_cancelled_retreat": "Атаку скасовано, {troops} солдатів загинули під час відступу", + "received_gold_from_captured_ship": "Отримано {gold} золота з корабля, захопленого у {name}", + "received_gold_from_trade": "Отримано {gold} золота від торгівлі з {name}", + "missile_intercepted": "{unit} перехоплює ракету", + "mirv_warheads_intercepted": "{count, plural, one {Перехоплено {count} боєголовку РГЧ ІН} few {Перехоплено {count} боєголовки РГЧ ІН} many {Перехоплено {count} боєголовок РГЧ ІН} other {Перехоплено {count} боєголовок РГЧ ІН}}", + "sent_troops_to_player": "Відправлено {troops} військ до {name}", + "received_troops_from_player": "Отримано {troops} військ від {name}", + "sent_gold_to_player": "Надіслано {gold} золота для {name}", + "received_gold_from_player": "Отримано {gold} золота від {name}", + "unit_captured_by_enemy": "{name} захоплює вашу споруду «{unit}»", + "captured_enemy_unit": "Захоплено споруду «{unit}» у {name}", + "unit_destroyed": "Вашу споруду «{unit}» було знищено", + "no_boats_available": "Немає доступних човнів, максимум — {max}" }, "unit_info_modal": { "structure_info": "Інформація про споруду", @@ -653,7 +781,10 @@ "send_alliance": "Надіслати союз", "send_troops": "Надіслати війська", "send_gold": "Надіслати золото", - "emotes": "Емоджі" + "emotes": "Емоджі", + "arc_up": "Верхня дуга", + "arc_down": "Нижня дуга", + "flip_rocket_trajectory": "Обернути траєкторію ракети" }, "send_troops_modal": { "title_with_name": "Надіслати війська до {name}", @@ -672,7 +803,7 @@ "replay_panel": { "replay_speed": "Швидкість відтворення", "game_speed": "Швидкість гри", - "fastest_game_speed": "Максимальна" + "fastest_game_speed": "Макс." }, "error_modal": { "crashed": "Гра крашнулася!", @@ -698,27 +829,33 @@ "frame": "Кадр:", "tick_exec": "Виконання на тік:", "tick_delay": "Затримка на тік:", - "layers_header": "Шари (сер. / макс., відсортовано за заг. часом):" + "layers_header": "Шари (сер. / макс., відсортовано за загальним часом):" }, "heads_up_message": { "choose_spawn": "Оберіть стартове розташування", - "random_spawn": "Випадкову появу увімкнено. Обираємо стартове розташування за вас..." + "random_spawn": "Випадкову появу увімкнено. Обираємо стартове розташування за вас...", + "singleplayer_game_paused": "Гру призупинено", + "multiplayer_game_paused": "Гра призупинена творцем лобі" }, "territory_patterns": { "title": "Скіни", "colors": "Кольори", "purchase": "Придбати", "show_only_owned": "Мої скіни", + "all_owned": "Усі скіни придбані! Повертайтеся пізніше за новими товарами.", + "not_logged_in": "Вхід не здійснено", "blocked": { "login": "Ви повинні ввійти, щоб отримати доступ до цього скіна.", "purchase": "Придбайте цей скін, щоб розблокувати його." }, "pattern": { "default": "Типово" - } + }, + "select_skin": "Оберіть скін", + "selected": "обрано" }, "flag_input": { - "title": "Виберіть прапор", + "title": "Вибір прапора", "button_title": "Обери прапор!", "search_flag": "Пошук..." }, @@ -732,8 +869,8 @@ "contact_admin": "Якщо ви вважаєте, що бачите це повідомлення помилково, зверніться до адміністратора сайту." }, "radial_menu": { - "delete_unit_title": "Видалити обʼєкт", - "delete_unit_description": "Клацніть, щоб видалити найближчий обʼєкт" + "delete_unit_title": "Видалити споруду", + "delete_unit_description": "Клацніть, щоб видалити найближчу споруду" }, "discord_user_header": { "avatar_alt": "Аватар" @@ -743,7 +880,7 @@ "ship_arrivals": "Прибуття кораблів", "nuke_stats": "Статистика бомбардувань", "player_metrics": "Статистика гравця", - "building": "Будівництво", + "building": "Споруда", "ship_type": "Тип корабля", "weapon": "Зброя", "built": "Побудовано", @@ -762,7 +899,7 @@ "gold": "Золото", "workers": "Робітники", "war": "Війни", - "trade": "Обмін", + "trade": "Торгівля", "steal": "Украдено", "unit": { "city": "Місто", @@ -786,8 +923,9 @@ "mode": "Режим", "mode_ffa": "Усі проти всіх", "mode_team": "Команда", - "view": "Оглянути", + "replay": "Повтор", "details": "Подробиці", + "ranking": "Рейтинг", "started": "Почато", "map": "Мапа", "difficulty": "Складність", @@ -796,13 +934,20 @@ "player_stats_tree": { "public": "Публічний", "private": "Приватний", - "singleplayer": "Гра наодинці", + "singleplayer": "Соло", "mode": "Режим", "stats_wins": "Перемоги", "stats_losses": "Поразки", - "stats_wlr": "Співвідношення перемог і поразок", + "stats_wlr": "Коефіцієнт перемог і поразок", "stats_games_played": "Зіграні ігри", "mode_ffa": "Усі проти всіх", - "mode_team": "Команда" + "mode_team": "Команда", + "no_stats": "Немає даних для цієї вибірки." + }, + "matchmaking_button": { + "play_ranked": "Рейтинговий підбір 1v1", + "description": "(АЛЬФА)", + "login_required": "Увійдіть, щоб грати в рейтинговому режимі!", + "must_login": "Ви повинні увійти, щоб грати в рейтинговому режимі." } } diff --git a/resources/lang/zh-CN.json b/resources/lang/zh-CN.json index 1952eacd3..c090cd319 100644 --- a/resources/lang/zh-CN.json +++ b/resources/lang/zh-CN.json @@ -7,6 +7,7 @@ }, "common": { "close": "关闭", + "back": "返回", "available": "剩余", "preset_max": "最大", "summary_send": "发送", @@ -17,26 +18,42 @@ "cap_tooltip": "接收者的可接收数量", "target_dead": "目标已淘汰", "target_dead_note": "你不能向已淘汰玩家发送资源。", - "none": "空" + "none": "空", + "copied": "已复制!", + "click_to_copy": "点击复制" }, "main": { - "title": "OpenFront (ALPHA)", + "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_lobby": "创建房间", - "join_lobby": "加入房间", - "single_player": "单人游戏", + "create": "创建房间", + "join": "加入房间", + "solo": "单人模式", "instructions": "操作说明", + "game_info": "游戏信息", "wiki": "游戏百科", "privacy_policy": "隐私政策", "terms_of_service": "服务条款", - "reddit": "Reddit" + "copyright": "© OpenFront™ 和贡献者们", + "reddit": "Reddit", + "play": "游戏", + "news": "公告", + "store": "商店", + "settings": "设置", + "keys": "按键", + "stats": "统计", + "account": "账号", + "help": "帮助", + "menu": "菜单", + "pick_pattern": "选择一个图案!" }, "news": { - "see_all_releases": "查看所有版本信息", "github_link": "在 Github 上", "title": "发行说明" }, @@ -83,6 +100,8 @@ "radial_attack": "打开攻击菜单。", "radial_info": "打开信息菜单。", "radial_boat": "发送一艘运输船攻击选中的区域。仅当你与水域毗邻时才可用。", + "radial_donate_troops": "捐赠相当于你攻击比例的军队给该盟友。", + "radial_donate_gold": "打开黄金捐赠菜单,可快速向盟友发送黄金。", "radial_close": "关闭菜单。", "info_title": "信息菜单", "info_enemy_desc": "包含以下信息:所选玩家的名称、黄金数量、军队数量、是否已停止与你贸易、是否对你发射了核弹,以及该玩家是否为叛徒。“停止贸易”表示你将无法从该玩家处获得金币,对方也无法通过商船向你发送金币。这种状态可能是手动触发(该玩家点击了“停止贸易”,此状态将持续,直到你们双方都点击“开始贸易”)或自动触发(当你背叛了联盟时,此状态会持续,直到你们重新结盟或5分钟后自动结束)。当玩家背叛并攻击其盟友时,“叛徒”状态将显示为“是”,持续30秒。下方图标表示你与该玩家的互动关系:", @@ -114,7 +133,7 @@ "build_silo": "导弹发射井", "build_silo_desc": "允许发射导弹。", "build_sam": "防空塔", - "build_sam_desc": "可以截获100像素范围内的敌方导弹。原子弹、氢弹和单个MIRV弹头的拦截命中概率分别是100%、80%和50%。该防空导弹拥有7.5秒冷却。", + "build_sam_desc": "可以截获100像素范围内的敌方导弹。防空塔有7.5秒的冷却时间。", "build_atom": "原子弹", "build_atom_desc": "小型爆弹可摧毁领土、建筑、船只。从最近的导弹发射井发射并坠落在你初次点击部署它的区域。", "build_hydrogen": "氢弹", @@ -129,12 +148,15 @@ "icon_embargo": "美元符号停止标志 - 禁商。该玩家已自动或手动停止与您的交易。", "icon_request": "信封 - 结盟请求。该玩家已向你发送结盟请求。", "info_enemy_panel": "敌人信息面板", - "exit_confirmation": "确定要退出游戏吗?" + "exit_confirmation": "确定要退出游戏吗?", + "bomb_direction": "原子弹 / 氢弹抛物线方向" }, "single_modal": { - "title": "单人玩家", + "title": "单人模式", "random_spawn": "随机出生点", "allow_alliances": "允许结盟", + "toggle_achievements": "切换成就", + "sign_in_for_achievements": "登录以获取成就", "options_title": "选项", "bots": "机器人: ", "bots_disabled": "已禁用", @@ -145,6 +167,8 @@ "infinite_troops": "无限军队", "compact_map": "紧凑地图", "max_timer": "游戏时长(分钟)", + "max_timer_placeholder": "分钟", + "max_timer_invalid": "请输入一个有效的最大计时器值(1-120分钟)", "disable_nukes": "禁用核弹", "enables_title": "启用设置", "start": "开始游戏" @@ -156,10 +180,21 @@ }, "account_modal": { "title": "账号", - "logged_in_as": "以 {email} 身份登录成功", + "connected_as": "已连接为", + "stats_overview": "统计概览", + "link_discord": "链接 Discord 帐号", + "log_out": "退出登录", + "sign_in_desc": "登录以保存您的统计数据和进度", + "or": "或", + "email_placeholder": "请输入您的电子邮件地址", + "get_magic_link": "获取魔法链接", + "linked_account": "以 {account_name} 身份登录成功", "fetching_account": "正在获取帐户信息......", - "logged_in_with_discord": "使用 Discord 登录", - "recovery_email_sent": "账号找回邮件已发送至 {email}" + "recovery_email_sent": "账号找回邮件已发送至 {email}", + "not_found": "未找到", + "clear_session": "清除会话", + "failed_to_send_recovery_email": "发送恢复邮件失败", + "enter_email_address": "请输入电子邮件地址" }, "stats_modal": { "title": "统计", @@ -167,11 +202,40 @@ "loading": "正在加载……", "error": "加载军团统计数据时出错", "no_stats": "暂无军团统计数据", + "no_data_yet": "暂无数据", "clan": "军团", "games": "游戏场数", "win_score": "胜者积分", + "win_score_tooltip": "加权胜场数基于战队参与度和比赛难度计算", "loss_score": "败者积分", - "win_loss_ratio": "胜负比" + "loss_score_tooltip": "加权败场数基于战队参与度和比赛难度计算", + "win_loss_ratio": "胜负比", + "ratio": "比率", + "rank": "排名", + "try_again": "再试一次" + }, + "game_info_modal": { + "title": "游戏信息", + "players": "玩家", + "atoms": "原子弹", + "hydros": "氢弹", + "mirv": "MIRV", + "bombs": "炸弹", + "total_gold": "总计", + "all_gold": "总黄金", + "trade": "交易", + "conquest_gold": "已抢夺黄金", + "stolen_gold": "被军舰偷走", + "num_of_conquests": "征服的玩家数", + "duration": "时长", + "survival_time": "存活时长", + "war": "战争", + "economy": "经济", + "conquests": "征服数", + "pirate": "抢劫", + "conquered": "被征服", + "loading_game_info": "正在加载游戏统计数据", + "no_winner": "这场游戏最终无人胜出(或者一个人机国获胜了)" }, "map": { "map": "地图", @@ -186,6 +250,7 @@ "asia": "亚洲", "mars": "火星", "southamerica": "南美洲", + "britanniaclassic": "不列颠尼亚(经典)", "britannia": "不列颠尼亚", "gatewaytotheatlantic": "大西洋枢纽", "australia": "澳大利亚", @@ -196,7 +261,7 @@ "betweentwoseas": "二海之间", "faroeislands": "法罗群岛", "deglaciatedantarctica": "冰消的南极洲", - "europeclassic": "欧洲 (经典)", + "europeclassic": "欧洲(经典)", "falklandislands": "福克兰群岛", "baikal": "贝加尔湖", "halkidiki": "哈尔基季基", @@ -206,19 +271,33 @@ "yenisei": "叶尼塞河", "pluto": "冥王星", "montreal": "蒙特利尔", + "newyorkcity": "纽约城", "achiran": "阿基尔岛/阿伦群岛", "baikalnukewars": "贝加尔湖(核战争)", "fourislands": "四岛争霸", "gulfofstlawrence": "圣劳伦斯湾", - "lisbon": "里斯本" + "lisbon": "里斯本", + "svalmel": "斯瓦尔梅尔", + "manicouagan": "马尼夸根陨石坑", + "lemnos": "利姆诺斯岛", + "sierpinski": "谢尔宾斯基分形", + "twolakes": "双湖", + "straitofhormuz": "霍尔木兹海峡", + "surrounded": "环岛", + "didier": "迪迪埃", + "didierfrance": "迪迪埃(法国)", + "amazonriver": "亚马逊河" }, "map_categories": { "continental": "大陆", "regional": "地区", - "fantasy": "其他" + "fantasy": "其他", + "special": "特殊", + "arcade": "街机" }, "map_component": { - "loading": "正在加载..." + "loading": "正在加载...", + "error": "错误" }, "private_lobby": { "title": "加入私人房间", @@ -229,42 +308,55 @@ "checking": "正在确认房间...", "not_found": "找不到房间。请检查 ID 然后重试。", "error": "发生错误。请再试一次或联系支持人员。", - "joined_waiting": "加入成功!正在等待游戏开始...", - "version_mismatch": "这场游戏基于另一个版本,无法加入。" + "joined_waiting": "房间已加入!等待房主开始游戏……", + "version_mismatch": "这场游戏基于另一个版本,无法加入。", + "disabled_units": "禁用单位" }, "public_lobby": { "join": "加入下一场游戏", "waiting": "等待中的玩家", - "teams_Duos": "/ 2(2人小队)", - "teams_Trios": "/ 3(3人小队)", - "teams_Quads": "/ 4(4人小队)", + "teams_Duos": "{team_count} 个 2 人小队", + "teams_Trios": "{team_count} 个 3 人小队", + "teams_Quads": "{team_count} 个 4 人小队", + "waiting_for_players": "正在等待玩家", + "starting_game": "正在启动游戏……", "teams_hvn": "人类 VS 国家", + "teams_hvn_detailed": "{num} 个人类 VS {num} 个国家", "teams": "{num} 个队伍", - "players_per_team": "/ {num}" + "players_per_team": "每队 {num} 人", + "started": "已开始" }, "matchmaking_modal": { - "title": "匹配中", + "title": "1v1 排位赛(内测版)", "connecting": "正在连接到匹配服务器……", "searching": "正在搜索游戏……", - "waiting_for_game": "正在等待游戏开始……" + "waiting_for_game": "正在等待游戏开始……", + "elo": "你的 ELO 分:{elo}" }, "username": { "enter_username": "输入用户名", "not_string": "用户名必须是字符串。", "too_short": "用户名最少包含 {min} 个字符。", "too_long": "用户名不得超过 {max} 个字符。", - "invalid_chars": "用户名只能包含字母、数字、空格、下划线和 [方括号]。" + "invalid_chars": "用户名只能包含字母、数字、空格、下划线和 [方括号]。", + "tag": "标签", + "tag_too_short": "战队标签必须是 2-5 位字母或数字字符。", + "tag_invalid_chars": "战队标签只能包含字母和数字。" }, "host_modal": { - "title": "私人房间", + "title": "创建私人房间", + "label": "私有", "mode": "模式", "team_count": "队伍数量", + "team_type": "队伍类型", "options_title": "选项", "bots": "机器人: ", "bots_disabled": "禁用", + "player_immunity_duration": "PVP 豁免期限(分钟)", "nations": "国家:", "disable_nations": "禁用国家", "max_timer": "游戏时长(分钟)", + "mins_placeholder": "分钟", "instant_build": "立即建造", "infinite_gold": "无限金钱", "donate_gold": "捐赠金币", @@ -283,7 +375,11 @@ "assigned_teams": "已分配的队伍", "empty_teams": "空队伍", "empty_team": "空", - "remove_player": "移除 {username}" + "remove_player": "移除 {username}", + "teams_Duos": "2人小队", + "teams_Trios": "3人小队", + "teams_Quads": "4人小队", + "teams_Humans Vs Nations": "人类 VS 国家" }, "team_colors": { "red": "红色", @@ -301,16 +397,20 @@ "code_license": "代码采用 AGPL-3.0 许可证授权(无担保)" }, "difficulty": { - "difficulty": "难度", - "Easy": "休闲", - "Medium": "平衡", - "Hard": "困难", - "Impossible": "地狱" + "difficulty": "国家难度", + "easy": "简单", + "medium": "中等", + "hard": "困难", + "impossible": "地狱" }, "game_mode": { "ffa": "混战", "teams": "团队" }, + "public_game_modifier": { + "random_spawn": "随机出生点", + "compact_map": "紧凑地图" + }, "select_lang": { "title": "选择语言" }, @@ -327,7 +427,7 @@ "factory": "工厂" }, "user_setting": { - "title": "用户设置", + "title": "设置", "tab_basic": "基本设置", "tab_keybinds": "热键绑定", "dark_mode_label": "深色模式", @@ -340,16 +440,18 @@ "special_effects_desc": "切换特效开关。停用以改进性能", "structure_sprites_label": "建筑贴图", "structure_sprites_desc": "切换建筑贴图", + "cursor_cost_label_label": "建造按钮显示消耗", + "cursor_cost_label_desc": "在建造按钮下显示花费", "anonymous_names_label": "隐藏的名称", "anonymous_names_desc": "将真实玩家名字替换为随机名字。", "lobby_id_visibility_label": "隐藏的房间ID", "lobby_id_visibility_desc": "在创建私人房间时隐藏房间ID", + "toggle_visibility": "切换是否可见", "left_click_label": "左键单击打开菜单", "left_click_desc": "开启时,先左键单击打开菜单,然后再点进攻。关闭时,左键将直接进攻。", "left_click_menu": "左键点击菜单", "attack_ratio_label": "⚔️ 攻击比例", "attack_ratio_desc": "你要派出多少比例的军队进攻 (1–100%)", - "troop_ratio_desc": "调整军队 (用于战斗) 和工人 (用于生产黄金) 之间的比例 (1-100%)", "territory_patterns_label": "🏳️ 领土皮肤", "territory_patterns_desc": "选择是否在游戏中显示领土皮肤", "performance_overlay_label": "性能叠层", @@ -358,6 +460,7 @@ "easter_writing_speed_desc": "调节你“假装写代码”的速度 (x1–x100)", "easter_bug_count_label": "Bug 计数", "easter_bug_count_desc": "你能接受多少个 Bug? (0–1000,心理承受范围)", + "press_a_key": "按下一个按键", "view_options": "视图选项", "toggle_view": "切换视图", "toggle_view_desc": "备选视图 (地形/国家)", @@ -382,6 +485,11 @@ "build_hydrogen_bomb_desc": "向鼠标位置发射氢弹。", "build_mirv": "发射 MIRV", "build_mirv_desc": "向鼠标位置发射 MIRV。", + "menu_shortcuts": "菜单快捷键", + "build_menu_modifier": "建造菜单编辑器", + "build_menu_modifier_desc": "按住此键并点击以打开建造菜单。", + "emoji_menu_modifier": "Emoji 表情菜单编辑器", + "emoji_menu_modifier_desc": "按住此键并点击以打开 Emoji 表情菜单。", "attack_ratio_controls": "攻击比例控制", "attack_ratio_up": "增加攻击比例", "attack_ratio_up_desc": "增加 10% 攻击比例", @@ -392,6 +500,8 @@ "boat_attack_desc": "向鼠标所指地块发送船只攻击。", "ground_attack": "对地攻击", "ground_attack_desc": "向鼠标所指地块发送船只攻击。", + "swap_direction": "调换火箭方向", + "swap_direction_desc": "切换火箭发射方向(上/下)。", "zoom_controls": "缩放控制", "zoom_out": "缩小", "zoom_out_desc": "缩小地图", @@ -416,7 +526,8 @@ "exit_game_label": "退出游戏", "exit_game_info": "返回主菜单", "background_music_volume": "背景音量", - "sound_effects_volume": "音效音量" + "sound_effects_volume": "音效音量", + "keybind_conflict_error": "按键 {key} 已经绑定到另一动作上了。" }, "chat": { "title": "快捷聊天", @@ -529,6 +640,7 @@ "other_team": "{team} 队获胜了!", "you_won": "你获胜了!", "other_won": "{player} 获胜了!", + "nation_won": "国家 {nation} 获胜了!", "exit": "退出游戏", "keep": "继续游戏", "spectate": "观战", @@ -537,7 +649,7 @@ "ofm_winter_description": "加入竞技比赛,与最强玩家一较高下", "join_tournament": "加入比赛", "join_discord": "加入我们的 Discord 社区!", - "discord_description": "与其他玩家交流,获取最新消息,分享游戏战略", + "discord_description": "与玩家交流,发现新功能,并赢取奖品!", "join_server": "加入服务器", "youtube_tutorial": "需要帮助吗?" }, @@ -549,7 +661,7 @@ "team": "队伍", "owned": "已占领", "gold": "黄金", - "troops": "军队", + "maxtroops": "最大军队", "launchers": "导弹发射井", "sams": "防空塔", "warships": "军舰", @@ -565,6 +677,7 @@ "team": "队伍", "alliance_timeout": "结盟剩余时长", "troops": "军队", + "maxtroops": "最大军队", "a_troops": "进攻军队", "gold": "黄金", "ports": "港口", @@ -575,7 +688,9 @@ "warships": "军舰", "health": "生命值", "attitude": "态度", - "levels": "等级" + "levels": "等级", + "wilderness_title": "荒野", + "irradiated_wilderness_title": "受辐射的荒野" }, "events_display": { "retreating": "正在撤退", @@ -601,7 +716,20 @@ "wants_to_renew_alliance": "{name} 想与你续签盟约", "ignore": "忽略", "unit_voluntarily_deleted": "单位已自毁", - "betrayal_debuff_ends": "距离背叛减益效果结束还剩 {time} 秒" + "betrayal_debuff_ends": "距离背叛减益效果结束还剩 {time} 秒", + "attack_cancelled_retreat": "已取消进攻,在撤退时损失了 {troops} 兵力", + "received_gold_from_captured_ship": "捕获了 {name} 的商船,获得 {gold} 黄金", + "received_gold_from_trade": "与 {name} 贸易获得了 {gold} 黄金", + "missile_intercepted": "已拦截导弹 {unit}", + "mirv_warheads_intercepted": "{count, plural, one {{count} 个 MIRV 弹头被拦截} other {{count} 个 MIRV 弹头被拦截}}", + "sent_troops_to_player": "已向 {name} 发送 {troops} 军队", + "received_troops_from_player": "已从 {name} 收到 {troops} 军队", + "sent_gold_to_player": "已向 {name} 发送 {gold} 黄金", + "received_gold_from_player": "已从 {name} 收到 {gold} 黄金", + "unit_captured_by_enemy": "你的 {unit} 被 {name} 捕获", + "captured_enemy_unit": "已捕获 {name} 的 {unit}", + "unit_destroyed": "你的 {unit} 已被摧毁", + "no_boats_available": "无可用船,最多 {max} 个" }, "unit_info_modal": { "structure_info": "建筑信息", @@ -653,7 +781,10 @@ "send_alliance": "请求结盟", "send_troops": "发送军队", "send_gold": "发送黄金", - "emotes": "表情符号" + "emotes": "表情符号", + "arc_up": "向上的弧", + "arc_down": "向下的弧", + "flip_rocket_trajectory": "翻转火箭轨道" }, "send_troops_modal": { "title_with_name": "向 {name} 发送军队", @@ -702,20 +833,26 @@ }, "heads_up_message": { "choose_spawn": "选择出生点", - "random_spawn": "随机出生点已启用。正在为你选择出生点……" + "random_spawn": "随机出生点已启用。正在为你选择出生点……", + "singleplayer_game_paused": "游戏已暂停", + "multiplayer_game_paused": "游戏已被房主暂停" }, "territory_patterns": { "title": "皮肤", "colors": "颜色", "purchase": "购买", "show_only_owned": "我的皮肤", + "all_owned": "您已拥有所有皮肤!请稍后再来查看新皮肤。", + "not_logged_in": "未登录", "blocked": { "login": "您必须登录才能使用此皮肤。", "purchase": "购买以解锁此皮肤。" }, "pattern": { "default": "默认" - } + }, + "select_skin": "选择皮肤", + "selected": "已选择" }, "flag_input": { "title": "选择旗帜", @@ -786,8 +923,9 @@ "mode": "模式", "mode_ffa": "混战", "mode_team": "团队", - "view": "查看", + "replay": "回放", "details": "详情", + "ranking": "排行", "started": "已开始", "map": "地图", "difficulty": "难度", @@ -796,13 +934,20 @@ "player_stats_tree": { "public": "公开", "private": "私有", - "singleplayer": "单人玩家", + "singleplayer": "单人模式", "mode": "模式", "stats_wins": "胜场数", "stats_losses": "败场数", "stats_wlr": "胜败比", "stats_games_played": "游戏场数", "mode_ffa": "混战", - "mode_team": "团队" + "mode_team": "团队", + "no_stats": "所选项没有统计记录。" + }, + "matchmaking_button": { + "play_ranked": "1v1 排位赛", + "description": "(内测版)", + "login_required": "登录后开始排位赛!", + "must_login": "您必须登录才能玩排位赛。" } } From f367ea1940fb002e56082e856728c3e86d48bc31 Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Mon, 19 Jan 2026 05:51:12 +0100 Subject: [PATCH 044/109] Record human/nation/bot conquests (#2949) ## Description: Conquests are currently mixing all player types. This is not ideal as people wonders why a 50 player game can lead to hundred of kills. Having separate records can also help with achievements and better balancing. This PR splits the conquests record into 3 categories: human, nations and bots. It is linked to this infra PR: https://github.com/openfrontio/infra/pull/246 image While the recorded data make a distinction between bots/nations, it's only displayed here as a single "bot" category. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: IngloriousTom --- resources/lang/en.json | 3 ++- .../baseComponents/ranking/GameInfoRanking.ts | 20 ++++++++++---- .../baseComponents/ranking/PlayerRow.ts | 3 ++- .../baseComponents/ranking/RankingControls.ts | 18 ++++++++----- .../baseComponents/ranking/RankingHeader.ts | 21 +++++++++++---- .../baseComponents/stats/PlayerStatsTree.ts | 4 +-- src/core/StatsSchemas.ts | 7 ++++- src/core/game/StatsImpl.ts | 26 +++++++++++++------ tests/GameInfoRanking.test.ts | 16 ++++++------ tests/Stats.test.ts | 4 +-- 10 files changed, 83 insertions(+), 39 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index c9adfcf33..3cb7cfba5 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -233,7 +233,8 @@ "naval_trade": "Tradeship", "conquest_gold": "Conquered player gold", "stolen_gold": "Stolen with warships", - "num_of_conquests": "Number of conquered players", + "num_of_conquests_humans": "Player kills", + "num_of_conquests_bots": "Bot kills", "duration": "Duration", "survival_time": "Survival time", "war": "War", diff --git a/src/client/components/baseComponents/ranking/GameInfoRanking.ts b/src/client/components/baseComponents/ranking/GameInfoRanking.ts index fa78d36f0..dff65db0f 100644 --- a/src/client/components/baseComponents/ranking/GameInfoRanking.ts +++ b/src/client/components/baseComponents/ranking/GameInfoRanking.ts @@ -5,10 +5,14 @@ import { GOLD_INDEX_TRAIN_OTHER, GOLD_INDEX_TRAIN_SELF, GOLD_INDEX_WAR, + PLAYER_INDEX_BOT, + PLAYER_INDEX_HUMAN, + PLAYER_INDEX_NATION, } from "../../../../core/StatsSchemas"; export enum RankType { - Conquests = "Conquests", + ConquestHumans = "ConquestHumans", + ConquestBots = "ConquestBots", Atoms = "Atoms", Hydros = "Hydros", MIRV = "MIRV", @@ -27,7 +31,7 @@ export interface PlayerInfo { tag?: string; killedAt?: number; gold: bigint[]; - conquests: number; + conquests: bigint[]; flag?: string; winner: boolean; atoms: number; @@ -79,12 +83,13 @@ export class Ranking { username = match[2]; } const gold = (stats.gold ?? []).map((v) => BigInt(v ?? 0)); + const conquests = (stats.conquests ?? []).map((v) => BigInt(v ?? 0)); players[player.clientID] = { id: player.clientID, rawUsername: player.username, username, tag: player.clanTag, - conquests: Number(stats.conquests) || 0, + conquests, flag: player.cosmetics?.flag ?? undefined, killedAt: stats.killedAt !== null ? Number(stats.killedAt) : undefined, gold, @@ -125,8 +130,13 @@ export class Ranking { return (player.killedAt / Math.max(this.duration, 1)) * 10; } return 100; - case RankType.Conquests: - return player.conquests; + case RankType.ConquestHumans: + return Number(player.conquests[PLAYER_INDEX_HUMAN] ?? 0n); + case RankType.ConquestBots: + return ( + Number(player.conquests[PLAYER_INDEX_BOT] ?? 0n) + + Number(player.conquests[PLAYER_INDEX_NATION] ?? 0n) + ); case RankType.Atoms: return player.atoms; case RankType.Hydros: diff --git a/src/client/components/baseComponents/ranking/PlayerRow.ts b/src/client/components/baseComponents/ranking/PlayerRow.ts index 773188c8b..ed3cfc6ba 100644 --- a/src/client/components/baseComponents/ranking/PlayerRow.ts +++ b/src/client/components/baseComponents/ranking/PlayerRow.ts @@ -63,7 +63,8 @@ export class PlayerRow extends LitElement { private renderPlayerInfo() { switch (this.rankType) { case RankType.Lifetime: - case RankType.Conquests: + case RankType.ConquestHumans: + case RankType.ConquestBots: return this.renderScoreAsBar(); case RankType.Atoms: case RankType.Hydros: diff --git a/src/client/components/baseComponents/ranking/RankingControls.ts b/src/client/components/baseComponents/ranking/RankingControls.ts index e32933efe..527681c7f 100644 --- a/src/client/components/baseComponents/ranking/RankingControls.ts +++ b/src/client/components/baseComponents/ranking/RankingControls.ts @@ -10,19 +10,25 @@ const economyRankings = new Set([ RankType.NavalTrade, RankType.TrainTrade, ]); -const tradeRankings = new Set([RankType.NavalTrade, RankType.TrainTrade]); -const bombRankings = new Set([RankType.Atoms, RankType.Hydros, RankType.MIRV]); const warRankings = new Set([ - RankType.Conquests, + RankType.ConquestHumans, + RankType.ConquestBots, RankType.Atoms, RankType.Hydros, RankType.MIRV, ]); +const tradeRankings = new Set([RankType.NavalTrade, RankType.TrainTrade]); +const bombRankings = new Set([RankType.Atoms, RankType.Hydros, RankType.MIRV]); +const conquestRankings = new Set([ + RankType.ConquestHumans, + RankType.ConquestBots, +]); const isEconomyRanking = (t: RankType) => economyRankings.has(t); const isTradeRanking = (t: RankType) => tradeRankings.has(t); const isBombRanking = (t: RankType) => bombRankings.has(t); const isWarRanking = (t: RankType) => warRankings.has(t); +const isConquestRanking = (t: RankType) => conquestRankings.has(t); @customElement("ranking-controls") export class RankingControls extends LitElement { @@ -41,7 +47,7 @@ export class RankingControls extends LitElement { "game_info_modal.duration", )} ${this.renderButton( - RankType.Conquests, + RankType.ConquestHumans, isWarRanking(this.rankType), "game_info_modal.war", )} @@ -78,8 +84,8 @@ export class RankingControls extends LitElement { "game_info_modal.bombs", )} ${this.renderSubButton( - RankType.Conquests, - this.rankType === RankType.Conquests, + RankType.ConquestHumans, + isConquestRanking(this.rankType), "game_info_modal.conquests", )} diff --git a/src/client/components/baseComponents/ranking/RankingHeader.ts b/src/client/components/baseComponents/ranking/RankingHeader.ts index dae3db3c1..cd1270d15 100644 --- a/src/client/components/baseComponents/ranking/RankingHeader.ts +++ b/src/client/components/baseComponents/ranking/RankingHeader.ts @@ -14,7 +14,7 @@ export class RankingHeader extends LitElement { render() { return html`
  • ${this.renderHeaderContent()}
  • @@ -27,10 +27,21 @@ export class RankingHeader extends LitElement { return html`
    ${translateText("game_info_modal.survival_time")}
    `; - case RankType.Conquests: - return html`
    - ${translateText("game_info_modal.num_of_conquests")} -
    `; + case RankType.ConquestHumans: + case RankType.ConquestBots: + return html` +
    + ${this.renderMultipleChoiceHeaderButton( + translateText("game_info_modal.num_of_conquests_humans"), + RankType.ConquestHumans, + )} + / + ${this.renderMultipleChoiceHeaderButton( + translateText("game_info_modal.num_of_conquests_bots"), + RankType.ConquestBots, + )} +
    + `; case RankType.Atoms: case RankType.Hydros: case RankType.MIRV: diff --git a/src/client/components/baseComponents/stats/PlayerStatsTree.ts b/src/client/components/baseComponents/stats/PlayerStatsTree.ts index e703ee2a2..1f412e00f 100644 --- a/src/client/components/baseComponents/stats/PlayerStatsTree.ts +++ b/src/client/components/baseComponents/stats/PlayerStatsTree.ts @@ -149,7 +149,7 @@ export class PlayerStatsTreeView extends LitElement { attacks: this.mergeStatArrays(base.attacks, next.attacks), betrayals: this.mergeStatValue(base.betrayals, next.betrayals), killedAt: this.mergeStatValue(base.killedAt, next.killedAt), - conquests: this.mergeStatValue(base.conquests, next.conquests), + conquests: this.mergeStatArrays(base.conquests, next.conquests), boats: this.mergeStatRecord(base.boats, next.boats), bombs: this.mergeStatRecord(base.bombs, next.bombs), gold: this.mergeStatArrays(base.gold, next.gold), @@ -203,7 +203,7 @@ export class PlayerStatsTreeView extends LitElement { attacks: stats.attacks ? [...stats.attacks] : undefined, betrayals: stats.betrayals, killedAt: stats.killedAt, - conquests: stats.conquests, + conquests: stats.conquests ? [...stats.conquests] : undefined, boats: stats.boats ? { ...stats.boats } : undefined, bombs: stats.bombs ? { ...stats.bombs } : undefined, gold: stats.gold ? [...stats.gold] : undefined, diff --git a/src/core/StatsSchemas.ts b/src/core/StatsSchemas.ts index 7596e9fbf..c37607bd6 100644 --- a/src/core/StatsSchemas.ts +++ b/src/core/StatsSchemas.ts @@ -62,6 +62,11 @@ export const ATTACK_INDEX_SENT = 0; // Outgoing attack troops export const ATTACK_INDEX_RECV = 1; // Incmoing attack troops export const ATTACK_INDEX_CANCEL = 2; // Cancelled attack troops +// Player types +export const PLAYER_INDEX_HUMAN = 0; +export const PLAYER_INDEX_NATION = 1; +export const PLAYER_INDEX_BOT = 2; + // Boats export const BOAT_INDEX_SENT = 0; // Boats launched export const BOAT_INDEX_ARRIVE = 1; // Boats arrived @@ -102,7 +107,7 @@ export const PlayerStatsSchema = z attacks: AtLeastOneNumberSchema.optional(), betrayals: BigIntStringSchema.optional(), killedAt: BigIntStringSchema.optional(), - conquests: BigIntStringSchema.optional(), + conquests: AtLeastOneNumberSchema.optional(), boats: z.partialRecord(BoatUnitSchema, AtLeastOneNumberSchema).optional(), bombs: z.partialRecord(BombUnitSchema, AtLeastOneNumberSchema).optional(), gold: AtLeastOneNumberSchema.optional(), diff --git a/src/core/game/StatsImpl.ts b/src/core/game/StatsImpl.ts index 56e394769..c2195bf72 100644 --- a/src/core/game/StatsImpl.ts +++ b/src/core/game/StatsImpl.ts @@ -24,11 +24,14 @@ import { OTHER_INDEX_LOST, OTHER_INDEX_UPGRADE, OtherUnitType, + PLAYER_INDEX_BOT, + PLAYER_INDEX_HUMAN, + PLAYER_INDEX_NATION, PlayerStats, unitTypeToBombUnit, unitTypeToOtherUnit, } from "../StatsSchemas"; -import { Player, TerraNullius, UnitType } from "./Game"; +import { Player, PlayerType, TerraNullius, UnitType } from "./Game"; import { Stats } from "./Stats"; type BigIntLike = bigint | number; @@ -41,6 +44,12 @@ function _bigint(value: BigIntLike): bigint { } } +const conquest_by_type: Record = { + [PlayerType.Human]: PLAYER_INDEX_HUMAN, + [PlayerType.Nation]: PLAYER_INDEX_NATION, + [PlayerType.Bot]: PLAYER_INDEX_BOT, +}; + export class StatsImpl implements Stats { private readonly data: AllPlayersStats = {}; @@ -138,14 +147,12 @@ export class StatsImpl implements Stats { p.units[type][index] += _bigint(value); } - private _addConquest(player: Player) { + private _addConquest(player: Player, index: number) { const p = this._makePlayerStats(player); if (p === undefined) return; - if (p.conquests === undefined) { - p.conquests = _bigint(1); - } else { - p.conquests += _bigint(1); - } + p.conquests ??= [0n]; + while (p.conquests.length <= index) p.conquests.push(0n); + p.conquests[index] += _bigint(1); } private _addPlayerKilled(player: Player, tick: number) { @@ -249,7 +256,10 @@ export class StatsImpl implements Stats { goldWar(player: Player, captured: Player, gold: BigIntLike): void { this._addGold(player, GOLD_INDEX_WAR, gold); - this._addConquest(player); + const conquestType = conquest_by_type[captured.type()]; + if (conquestType !== undefined) { + this._addConquest(player, conquestType); + } } unitBuild(player: Player, type: OtherUnitType): void { diff --git a/tests/GameInfoRanking.test.ts b/tests/GameInfoRanking.test.ts index 1e523ae93..1eeefebb9 100644 --- a/tests/GameInfoRanking.test.ts +++ b/tests/GameInfoRanking.test.ts @@ -56,7 +56,7 @@ describe("Ranking class", () => { cosmetics: { flag: "USA" }, stats: { units: { port: [2n, 0n, 0n, 2n] }, - conquests: 5n, + conquests: [5n], gold: [0n, 100n, 20n, 0n, 15n, 5n], // total 140 bombs: { abomb: [1n], @@ -71,7 +71,7 @@ describe("Ranking class", () => { username: "Bob", stats: { units: { city: [2n, 0n, 0n, 2n] }, - conquests: 8n, + conquests: [8n], gold: [0n, 50n, 10n, 5n], // total 65, no train trade bombs: { abomb: [0n], @@ -86,7 +86,7 @@ describe("Ranking class", () => { username: "Charlie", stats: { // no units, but has conquests/killedAt to count as played - conquests: 8n, + conquests: [8n], killedAt: BigInt(600), gold: [0n, 10n, 2n, 10n, 0n, 5n], // total 27 bombs: {}, @@ -110,21 +110,21 @@ describe("Ranking class", () => { test("summarizes players correctly", () => { const r = new Ranking(makeSession()); - const players = r.sortedBy(RankType.Conquests); + const players = r.sortedBy(RankType.ConquestHumans); expect(players.length).toBe(3); const p1 = players.find((p) => p.id === "p1")!; expect(p1.username).toBe("Alice"); expect(p1.flag).toBe("USA"); - expect(p1.conquests).toBe(5); + expect(p1.conquests).toStrictEqual([5n]); expect(p1.atoms).toBe(1); expect(p1.mirv).toBe(2); }); test("correctly identifies winner", () => { const r = new Ranking(makeSession()); - const p2 = r.sortedBy(RankType.Conquests).find((p) => p.id === "p2")!; + const p2 = r.sortedBy(RankType.ConquestHumans).find((p) => p.id === "p2")!; expect(p2.winner).toBe(true); }); @@ -157,7 +157,7 @@ describe("Ranking class", () => { test("lifetime score is percentage of duration", () => { const r = new Ranking(makeSession()); - const p3 = r.sortedBy(RankType.Conquests).find((p) => p.id === "p3")!; + const p3 = r.sortedBy(RankType.ConquestHumans).find((p) => p.id === "p3")!; const expected = Number(BigInt(600)) / gameDuration; expect(r.score(p3, RankType.Lifetime)).toBe(expected); }); @@ -170,7 +170,7 @@ describe("Ranking class", () => { test("winners should be ahead of players with same score", () => { const r = new Ranking(makeSession()); - const sortedPlayers = r.sortedBy(RankType.Conquests); + const sortedPlayers = r.sortedBy(RankType.ConquestHumans); expect(sortedPlayers[0].id).toBe("p2"); // p2 & p3 same score but winner first }); diff --git a/tests/Stats.test.ts b/tests/Stats.test.ts index 1b654210b..4cab4b2fe 100644 --- a/tests/Stats.test.ts +++ b/tests/Stats.test.ts @@ -162,14 +162,14 @@ describe("Stats", () => { expect(stats.stats()).toStrictEqual({ client1: { gold: [0n, 1n], - conquests: 1n, + conquests: [1n], }, }); stats.goldWar(player1, player2, 1); expect(stats.stats()).toStrictEqual({ client1: { gold: [0n, 2n], - conquests: 2n, + conquests: [2n], }, }); }); From 3dadfbd23d731e450e042720430b65caa5e5699e Mon Sep 17 00:00:00 2001 From: bibizu <104801209+bibizu@users.noreply.github.com> Date: Sun, 18 Jan 2026 23:56:43 -0500 Subject: [PATCH 045/109] feat: Nuke trajectory prediction now accounts for alliance breakage. (#2912) ## Description: Nuke trajectory prediction now will show interception with allied SAMs if the alliance will break on nuke launch. Code was also refactored to be shared a bit more. In addition, if an incoming alliance would break if accepted, the nuke launch will break the alliance. nukepr ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: bibizu --- .../layers/NukeTrajectoryPreviewLayer.ts | 16 +++++++- src/core/execution/NukeExecution.ts | 40 ++++++------------- src/core/execution/Util.ts | 36 ++++++++++++++++- 3 files changed, 63 insertions(+), 29 deletions(-) diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts index 36bf818c7..0d1dfc9d0 100644 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts @@ -1,4 +1,5 @@ import { EventBus } from "../../../core/EventBus"; +import { listNukeBreakAlliance } from "../../../core/execution/Util"; import { UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView } from "../../../core/game/GameView"; @@ -258,6 +259,18 @@ export class NukeTrajectoryPreviewLayer implements Layer { break; } } + const playersToBreakAllianceWith = listNukeBreakAlliance({ + game: this.game, + targetTile, + magnitude: this.game.config().nukeMagnitudes(ghostStructure), + allySmallIds: new Set( + this.game + .myPlayer() + ?.allies() + .map((a) => a.smallID()), + ), + threshold: this.game.config().nukeAllianceBreakThreshold(), + }); // Find the point where SAM can intercept this.targetedIndex = this.trajectoryPoints.length; // Check trajectory @@ -270,7 +283,8 @@ export class NukeTrajectoryPreviewLayer implements Layer { )) { if ( sam.unit.owner().isMe() || - this.game.myPlayer()?.isFriendly(sam.unit.owner()) + (this.game.myPlayer()?.isFriendly(sam.unit.owner()) && + !playersToBreakAllianceWith.has(sam.unit.owner().smallID())) ) { continue; } diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 277057c5f..68db6bdf4 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -4,7 +4,6 @@ import { isStructureType, MessageType, Player, - StructureTypes, TerraNullius, TrajectoryTile, Unit, @@ -16,7 +15,7 @@ import { ParabolaUniversalPathFinder } from "../pathfinding/PathFinder.Parabola" import { PathStatus } from "../pathfinding/types"; import { PseudoRandom } from "../PseudoRandom"; import { NukeType } from "../StatsSchemas"; -import { computeNukeBlastCounts } from "./Util"; +import { listNukeBreakAlliance } from "./Util"; const SPRITE_RADIUS = 16; @@ -85,36 +84,22 @@ export class NukeExecution implements Execution { } const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type()); - const threshold = this.mg.config().nukeAllianceBreakThreshold(); - // Use shared utility to compute weighted tile counts per player - const blastCounts = computeNukeBlastCounts({ - gm: this.mg, + const playersToBreakAllianceWith = listNukeBreakAlliance({ + game: this.mg, targetTile: this.dst, magnitude, + allySmallIds: new Set(this.player.allies().map((a) => a.smallID())), + threshold: this.mg.config().nukeAllianceBreakThreshold(), }); - // Collect all players that should have alliance broken: - // either exceeds tile threshold OR has a structure in blast radius - const playersToBreakAllianceWith = new Set(); - - for (const [playerSmallId, totalWeight] of blastCounts) { - if (totalWeight > threshold) { - playersToBreakAllianceWith.add(playerSmallId); + // Automatically reject incoming alliance requests. + for (const incoming of this.player.incomingAllianceRequests()) { + if (playersToBreakAllianceWith.has(incoming.requestor().smallID())) { + incoming.reject(); } } - // Also check if any allied structures would be destroyed - this.mg - .nearbyUnits(this.dst, magnitude.outer, [...StructureTypes]) - .filter( - ({ unit }) => - unit.owner().isPlayer() && this.player.isAlliedWith(unit.owner()), - ) - .forEach(({ unit }) => - playersToBreakAllianceWith.add(unit.owner().smallID()), - ); - for (const playerSmallId of playersToBreakAllianceWith) { const attackedPlayer = this.mg.playerBySmallID(playerSmallId); if (!attackedPlayer.isPlayer()) { @@ -123,11 +108,12 @@ export class NukeExecution implements Execution { // Resolves exploit of alliance breaking in which a pending alliance request // was accepted in the middle of a missile attack. - const allianceRequest = attackedPlayer + const outgoingAllianceRequest = attackedPlayer .incomingAllianceRequests() .find((ar) => ar.requestor() === this.player); - if (allianceRequest) { - allianceRequest.reject(); + if (outgoingAllianceRequest) { + outgoingAllianceRequest.reject(); + continue; } const alliance = this.player.allianceWith(attackedPlayer); diff --git a/src/core/execution/Util.ts b/src/core/execution/Util.ts index f0b5b95b1..a4a414e89 100644 --- a/src/core/execution/Util.ts +++ b/src/core/execution/Util.ts @@ -36,7 +36,7 @@ export function computeNukeBlastCounts( } export interface NukeAllianceCheckParams { - game: GameView; + game: Game | GameView; targetTile: TileRef; magnitude: NukeMagnitude; allySmallIds: Set; @@ -93,6 +93,40 @@ export function wouldNukeBreakAlliance( return result; } +// Same as wouldNukeBreakAlliance(), but takes time to find every player +// that would be "angered" from this nuke. +// This includes unallied players! +export function listNukeBreakAlliance( + params: NukeAllianceCheckParams, +): Set { + const { game, targetTile, magnitude, threshold } = params; + + // Collect all players that should have alliance broken: + // either exceeds tile threshold OR has a structure in blast radius + const playersToBreakAllianceWith = new Set(); + + // compute tile breakage threshold + const blastCounts = computeNukeBlastCounts({ + gm: game, + targetTile, + magnitude, + }); + for (const [playerSmallId, totalWeight] of blastCounts) { + if (totalWeight > threshold) { + playersToBreakAllianceWith.add(playerSmallId); + } + } + + // Also check if any allied structures would be destroyed + game + .nearbyUnits(targetTile, magnitude.outer, [...StructureTypes]) + .forEach(({ unit }) => + playersToBreakAllianceWith.add(unit.owner().smallID()), + ); + + return playersToBreakAllianceWith; +} + export function getSpawnTiles(gm: GameMap, tile: TileRef): TileRef[] { return Array.from(gm.bfs(tile, euclDistFN(tile, 4, true))).filter( (t) => !gm.hasOwner(t) && gm.isLand(t), From a4a41ac9f4ddf1c9e77bd1230953dee3fe1d5ff3 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:55:06 +0100 Subject: [PATCH 046/109] =?UTF-8?q?Fix=20map=20name=20formatting=20for=20B?= =?UTF-8?q?aikal=20Nuke=20Wars=20=F0=9F=94=A7=20(#2922)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Fixes this little i18n problem: Screenshot 2026-01-16 050833 ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com> --- src/core/game/Game.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index fdfff12d8..313bd58de 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -103,7 +103,7 @@ export enum GameMapType { Montreal = "Montreal", NewYorkCity = "New York City", Achiran = "Achiran", - BaikalNukeWars = "Baikal (Nuke Wars)", + BaikalNukeWars = "Baikal Nuke Wars", FourIslands = "Four Islands", Svalmel = "Svalmel", GulfOfStLawrence = "Gulf of St. Lawrence", From 566113c4de8e439b844a402a20deac102bc84190 Mon Sep 17 00:00:00 2001 From: Arkadiusz Sygulski Date: Mon, 19 Jan 2026 17:09:17 +0100 Subject: [PATCH 047/109] Fix warship pathfinding (#2955) ## Description: As reported on Discord, warship could get stuck. This PR fixes the issue. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: moleole --- src/core/pathfinding/PathFinder.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/pathfinding/PathFinder.ts b/src/core/pathfinding/PathFinder.ts index 9a625956f..f77776c36 100644 --- a/src/core/pathfinding/PathFinder.ts +++ b/src/core/pathfinding/PathFinder.ts @@ -47,8 +47,8 @@ export class PathFinding { return PathFinderBuilder.create(pf) .wrap((pf) => new ComponentCheckTransformer(pf, componentCheckFn)) .wrap((pf) => new SmoothingWaterTransformer(pf, miniMap)) + .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap)) .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap)) - .wrap((pf) => new ShoreCoercingTransformer(pf, game.map())) .buildWithStepper(tileStepperConfig(game)); } @@ -57,8 +57,8 @@ export class PathFinding { const pf = new AStarWater(miniMap); return PathFinderBuilder.create(pf) + .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap)) .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap)) - .wrap((pf) => new ShoreCoercingTransformer(pf, game.map())) .buildWithStepper(tileStepperConfig(game)); } From c71af0e6026f64c6811ffe0b3feaf86f09af1759 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:55:06 +0100 Subject: [PATCH 048/109] =?UTF-8?q?Fix=20map=20name=20formatting=20for=20B?= =?UTF-8?q?aikal=20Nuke=20Wars=20=F0=9F=94=A7=20(#2922)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Fixes this little i18n problem: Screenshot 2026-01-16 050833 ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com> --- src/core/game/Game.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index fdfff12d8..313bd58de 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -103,7 +103,7 @@ export enum GameMapType { Montreal = "Montreal", NewYorkCity = "New York City", Achiran = "Achiran", - BaikalNukeWars = "Baikal (Nuke Wars)", + BaikalNukeWars = "Baikal Nuke Wars", FourIslands = "Four Islands", Svalmel = "Svalmel", GulfOfStLawrence = "Gulf of St. Lawrence", From ac56fccd8e17009d6a6e45eb7610be2d08b357d9 Mon Sep 17 00:00:00 2001 From: Arkadiusz Sygulski Date: Mon, 19 Jan 2026 17:09:17 +0100 Subject: [PATCH 049/109] Fix warship pathfinding (#2955) ## Description: As reported on Discord, warship could get stuck. This PR fixes the issue. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: moleole --- src/core/pathfinding/PathFinder.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/pathfinding/PathFinder.ts b/src/core/pathfinding/PathFinder.ts index 9a625956f..f77776c36 100644 --- a/src/core/pathfinding/PathFinder.ts +++ b/src/core/pathfinding/PathFinder.ts @@ -47,8 +47,8 @@ export class PathFinding { return PathFinderBuilder.create(pf) .wrap((pf) => new ComponentCheckTransformer(pf, componentCheckFn)) .wrap((pf) => new SmoothingWaterTransformer(pf, miniMap)) + .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap)) .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap)) - .wrap((pf) => new ShoreCoercingTransformer(pf, game.map())) .buildWithStepper(tileStepperConfig(game)); } @@ -57,8 +57,8 @@ export class PathFinding { const pf = new AStarWater(miniMap); return PathFinderBuilder.create(pf) + .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap)) .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap)) - .wrap((pf) => new ShoreCoercingTransformer(pf, game.map())) .buildWithStepper(tileStepperConfig(game)); } From f8156c550b4484e0b4a91ed7fd42cd2ade764b8d Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 20 Jan 2026 00:49:10 +0100 Subject: [PATCH 050/109] Fix random spawn (#2958) ## Description: "You can pick your spawn in random spawn games in v29. You need to open the menu and click on the attack button. That's it." Thats the fix for this problem. Radial menu no longer allows to attack (pick a spawn) while random spawn is enabled. And SpawnExecution got a check so you cannot send malicious intents. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/client/graphics/layers/RadialMenuElements.ts | 3 +++ src/core/execution/SpawnExecution.ts | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 6f72f7149..44d63bb1f 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -572,6 +572,9 @@ export const centerButtonElement: CenterButtonElement = { return true; } if (params.game.inSpawnPhase()) { + if (params.game.config().isRandomSpawn()) { + return true; + } if (tileOwner.isPlayer()) { return true; } diff --git a/src/core/execution/SpawnExecution.ts b/src/core/execution/SpawnExecution.ts index 5f0694fd8..4162e85fc 100644 --- a/src/core/execution/SpawnExecution.ts +++ b/src/core/execution/SpawnExecution.ts @@ -42,6 +42,11 @@ export class SpawnExecution implements Execution { player = this.mg.addPlayer(this.playerInfo); } + // Security: If random spawn is enabled, prevent players from re-rolling their spawn location + if (this.mg.config().isRandomSpawn() && player.hasSpawned()) { + return; + } + this.tile ??= this.randomSpawnLand(); if (this.tile === undefined) { From 21a035cdb41702cb6fe92c9f231c344b918e21db Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 20 Jan 2026 00:49:10 +0100 Subject: [PATCH 051/109] Fix random spawn (#2958) ## Description: "You can pick your spawn in random spawn games in v29. You need to open the menu and click on the attack button. That's it." Thats the fix for this problem. Radial menu no longer allows to attack (pick a spawn) while random spawn is enabled. And SpawnExecution got a check so you cannot send malicious intents. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/client/graphics/layers/RadialMenuElements.ts | 3 +++ src/core/execution/SpawnExecution.ts | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 6f72f7149..44d63bb1f 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -572,6 +572,9 @@ export const centerButtonElement: CenterButtonElement = { return true; } if (params.game.inSpawnPhase()) { + if (params.game.config().isRandomSpawn()) { + return true; + } if (tileOwner.isPlayer()) { return true; } diff --git a/src/core/execution/SpawnExecution.ts b/src/core/execution/SpawnExecution.ts index 5f0694fd8..4162e85fc 100644 --- a/src/core/execution/SpawnExecution.ts +++ b/src/core/execution/SpawnExecution.ts @@ -42,6 +42,11 @@ export class SpawnExecution implements Execution { player = this.mg.addPlayer(this.playerInfo); } + // Security: If random spawn is enabled, prevent players from re-rolling their spawn location + if (this.mg.config().isRandomSpawn() && player.hasSpawned()) { + return; + } + this.tile ??= this.randomSpawnLand(); if (this.tile === undefined) { From 957d0562e1f5ee92080915053323b712eb4ff269 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 20 Jan 2026 01:34:22 +0100 Subject: [PATCH 052/109] Quickfix: Disable nations in ranked and change map selection (#2957) ## Description: Quickfix: Disable nations in ranked and change map selection (Lewis wanted these, Australia three times so it occurs more often) Just a quickfix, we will probably have to improve the map selection later on, and maybe play on non-compact maps too? ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/server/MapPlaylist.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 93926de44..c9cf49a17 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -144,12 +144,11 @@ export class MapPlaylist { } public get1v1Config(): GameConfig { - const ffaMaps = [ + const maps = [ GameMapType.Iceland, - GameMapType.World, - GameMapType.EuropeClassic, GameMapType.Australia, - GameMapType.FaroeIslands, + GameMapType.Australia, + GameMapType.Australia, GameMapType.Pangaea, GameMapType.Italia, GameMapType.FalklandIslands, @@ -158,7 +157,7 @@ export class MapPlaylist { return { donateGold: false, donateTroops: false, - gameMap: ffaMaps[Math.floor(Math.random() * ffaMaps.length)], + gameMap: maps[Math.floor(Math.random() * maps.length)], maxPlayers: 2, gameType: GameType.Public, gameMapSize: GameMapSize.Compact, @@ -169,7 +168,7 @@ export class MapPlaylist { maxTimerValue: 10, // 10 minutes instantBuild: false, randomSpawn: false, - disableNations: false, + disableNations: true, gameMode: GameMode.FFA, bots: 100, spawnImmunityDuration: 5 * 10, From 9d0ae109128d38865bf58b3301492ada2eb3bbda Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 20 Jan 2026 01:34:22 +0100 Subject: [PATCH 053/109] Quickfix: Disable nations in ranked and change map selection (#2957) ## Description: Quickfix: Disable nations in ranked and change map selection (Lewis wanted these, Australia three times so it occurs more often) Just a quickfix, we will probably have to improve the map selection later on, and maybe play on non-compact maps too? ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/server/MapPlaylist.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 93926de44..c9cf49a17 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -144,12 +144,11 @@ export class MapPlaylist { } public get1v1Config(): GameConfig { - const ffaMaps = [ + const maps = [ GameMapType.Iceland, - GameMapType.World, - GameMapType.EuropeClassic, GameMapType.Australia, - GameMapType.FaroeIslands, + GameMapType.Australia, + GameMapType.Australia, GameMapType.Pangaea, GameMapType.Italia, GameMapType.FalklandIslands, @@ -158,7 +157,7 @@ export class MapPlaylist { return { donateGold: false, donateTroops: false, - gameMap: ffaMaps[Math.floor(Math.random() * ffaMaps.length)], + gameMap: maps[Math.floor(Math.random() * maps.length)], maxPlayers: 2, gameType: GameType.Public, gameMapSize: GameMapSize.Compact, @@ -169,7 +168,7 @@ export class MapPlaylist { maxTimerValue: 10, // 10 minutes instantBuild: false, randomSpawn: false, - disableNations: false, + disableNations: true, gameMode: GameMode.FFA, bots: 100, spawnImmunityDuration: 5 * 10, From 697a346c86fe8962b71152cca8b90c5bcda7ab7a Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 19 Jan 2026 19:26:09 -0800 Subject: [PATCH 054/109] Increase worker initailization timeout from 5=>20s to prevent worker timeout, add duration logging to some longer operations --- src/core/game/FetchGameMapLoader.ts | 4 ++++ src/core/game/GameImpl.ts | 6 ++++++ src/core/worker/WorkerClient.ts | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/core/game/FetchGameMapLoader.ts b/src/core/game/FetchGameMapLoader.ts index 00464c76f..ff6a3ca5f 100644 --- a/src/core/game/FetchGameMapLoader.ts +++ b/src/core/game/FetchGameMapLoader.ts @@ -51,6 +51,7 @@ export class FetchGameMapLoader implements GameMapLoader { } private async loadBinaryFromUrl(url: string) { + const startTime = performance.now(); const response = await fetch(url); if (!response.ok) { @@ -58,6 +59,9 @@ export class FetchGameMapLoader implements GameMapLoader { } const data = await response.arrayBuffer(); + console.log( + `[MapLoader] ${url}: ${(performance.now() - startTime).toFixed(0)}ms`, + ); return new Uint8Array(data); } diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index a2bd1c902..4439ae847 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -103,6 +103,8 @@ export class GameImpl implements Game { private _config: Config, private _stats: Stats, ) { + const constructorStart = performance.now(); + this._terraNullius = new TerraNulliusImpl(); this._width = _map.width(); this._height = _map.height(); @@ -123,6 +125,10 @@ export class GameImpl implements Game { { cachePaths: true }, ); } + + console.log( + `[GameImpl] Constructor total: ${(performance.now() - constructorStart).toFixed(0)}ms`, + ); } private populateTeams() { diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index e6e80b82d..fe0ac38fc 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -80,7 +80,7 @@ export class WorkerClient { this.messageHandlers.delete(messageId); reject(new Error("Worker initialization timeout")); } - }, 5000); // 5 second timeout + }, 20000); // 20 second timeout }); } From 18fb513326dfc6a8091f8342b1f0333322c8e4f9 Mon Sep 17 00:00:00 2001 From: Arkadiusz Sygulski Date: Tue, 20 Jan 2026 04:28:28 +0100 Subject: [PATCH 055/109] Pathfinding refinements (#2959) ## Description: ### Short path for multi-source HPA* Math was not mathing, increased the bounds to 260x260, it is a bit slower but should work better. The short path was breaking when player owned a lot of shores. This is because the bounding box of tiles with less than 120 distance + 10 padding could be as big as 260x260 and the optimized array was set to 140x140. I made mistake of calculating it as `2 * (60 + 10)` instead of `2 * (120 + 10)`. ### LoS path refinement Previously, we ran 2 passes of LoS smoothing on the path. However, since we are effectively tracing the same path, the line of sight is essentially the same. This PR makes second line of sight stop on water tiles with magnitude `n + 1` compared to first path. Practically, this means it'll attempt LoS exactly 1 tile after previous corner. See screenshot. image ### SendBoatAttackIntentEvent The flow of sending transport ships is currently strange. This PR makes the flow more sane. **Old flow** ``` - Player clicks TARGET tile, it can be deep inland - Client asks Worker for the best START tile to TARGET tile - Worker answers `false`, since the tile is inland - Client sends BoatAttackIntent with START=false and TARGET tiles set - Worker accepts BoatAttackIntent, computes DESTINATION as closest shore to TARGET - Worker re-computes best START to DESTINATION - Worker sends boat from START to DESTINATION ``` **New flow** ``` - Player clicks TARGET tile, it can be deep inland - Client sends BoatAttackIntent with TARGET - Worker accepts BoatAttackIntent, computes DESTINATION as closest shore to TARGET - Worker computes START as the best tile to DESTINATION - Worker sends boat from START to DESTINATION ``` ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: moleole --- src/client/ClientGameRunner.ts | 17 ++-- src/client/Transport.ts | 4 - .../graphics/layers/PlayerActionHandler.ts | 11 +-- .../graphics/layers/RadialMenuElements.ts | 12 +-- src/core/Schemas.ts | 2 - src/core/execution/ExecutionManager.ts | 8 +- src/core/execution/TransportShipExecution.ts | 89 ++++++------------- src/core/execution/utils/AiAttackBehavior.ts | 16 +--- .../algorithms/AStar.WaterHierarchical.ts | 4 +- .../transformers/SmoothingWaterTransformer.ts | 31 ++++--- tests/Attack.test.ts | 32 ++----- tests/Disconnected.test.ts | 24 +---- 12 files changed, 67 insertions(+), 183 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 971a9cd71..bd377d35d 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -713,17 +713,12 @@ export class ClientGameRunner { private sendBoatAttackIntent(tile: TileRef) { if (!this.myPlayer) return; - this.myPlayer.bestTransportShipSpawn(tile).then((spawn: number | false) => { - if (this.myPlayer === null) throw new Error("not initialized"); - this.eventBus.emit( - new SendBoatAttackIntentEvent( - this.gameView.owner(tile).id(), - tile, - this.myPlayer.troops() * this.renderer.uiState.attackRatio, - spawn === false ? null : spawn, - ), - ); - }); + this.eventBus.emit( + new SendBoatAttackIntentEvent( + tile, + this.myPlayer.troops() * this.renderer.uiState.attackRatio, + ), + ); } private canAutoBoat(actions: PlayerActions, tile: TileRef): boolean { diff --git a/src/client/Transport.ts b/src/client/Transport.ts index d370c6ba8..58307e113 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -81,10 +81,8 @@ export class SendAttackIntentEvent implements GameEvent { export class SendBoatAttackIntentEvent implements GameEvent { constructor( - public readonly targetID: PlayerID | null, public readonly dst: TileRef, public readonly troops: number, - public readonly src: TileRef | null = null, ) {} } @@ -498,10 +496,8 @@ export class Transport { this.sendIntent({ type: "boat", clientID: this.lobbyConfig.clientID, - targetID: event.targetID, troops: event.troops, dst: event.dst, - src: event.src, }); } diff --git a/src/client/graphics/layers/PlayerActionHandler.ts b/src/client/graphics/layers/PlayerActionHandler.ts index 672cc2baf..54714cadb 100644 --- a/src/client/graphics/layers/PlayerActionHandler.ts +++ b/src/client/graphics/layers/PlayerActionHandler.ts @@ -1,5 +1,5 @@ import { EventBus } from "../../../core/EventBus"; -import { PlayerActions, PlayerID } from "../../../core/game/Game"; +import { PlayerActions } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { PlayerView } from "../../../core/game/GameView"; import { @@ -39,18 +39,11 @@ export class PlayerActionHandler { ); } - handleBoatAttack( - player: PlayerView, - targetId: PlayerID | null, - targetTile: TileRef, - spawnTile: TileRef | null, - ) { + handleBoatAttack(player: PlayerView, targetTile: TileRef) { this.eventBus.emit( new SendBoatAttackIntentEvent( - targetId, targetTile, this.uiState.attackRatio * player.troops(), - spawnTile, ), ); } diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 44d63bb1f..1dcf3eb90 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -548,17 +548,7 @@ export const boatMenuElement: MenuElement = { color: COLORS.boat, action: async (params: MenuElementParams) => { - const spawn = await params.playerActionHandler.findBestTransportShipSpawn( - params.myPlayer, - params.tile, - ); - - params.playerActionHandler.handleBoatAttack( - params.myPlayer, - params.selected?.id() ?? null, - params.tile, - spawn !== false ? spawn : null, - ); + params.playerActionHandler.handleBoatAttack(params.myPlayer, params.tile); params.closeMenu(); }, diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 28362063f..d225857c5 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -284,10 +284,8 @@ export const SpawnIntentSchema = BaseIntentSchema.extend({ export const BoatAttackIntentSchema = BaseIntentSchema.extend({ type: z.literal("boat"), - targetID: ID.nullable(), troops: z.number().nonnegative(), dst: z.number(), - src: z.number().nullable(), }); export const AllianceRequestIntentSchema = BaseIntentSchema.extend({ diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index a161e5eb1..56d66e547 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -72,13 +72,7 @@ export class Executor { case "spawn": return new SpawnExecution(this.gameID, player.info(), intent.tile); case "boat": - return new TransportShipExecution( - player, - intent.targetID, - intent.dst, - intent.troops, - intent.src, - ); + return new TransportShipExecution(player, intent.dst, intent.troops); case "allianceRequest": return new AllianceRequestExecution(player, intent.recipient); case "allianceRequestReply": diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 6020d0489..0b93aa9fc 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -4,7 +4,6 @@ import { Game, MessageType, Player, - PlayerID, TerraNullius, Unit, UnitType, @@ -16,33 +15,28 @@ import { PathStatus, SteppingPathFinder } from "../pathfinding/types"; import { AttackExecution } from "./AttackExecution"; const malusForRetreat = 25; + export class TransportShipExecution implements Execution { - private lastMove: number; + private active = true; // TODO: make this configurable private ticksPerMove = 1; - - private active = true; + private lastMove: number; private mg: Game; private target: Player | TerraNullius; - - // TODO make private - public path: TileRef[]; - private dst: TileRef | null; - - private boat: Unit; - private pathFinder: SteppingPathFinder; + private dst: TileRef | null; + private src: TileRef | null; + private boat: Unit; + private originalOwner: Player; constructor( private attacker: Player, - private targetID: PlayerID | null, private ref: TileRef, - private startTroops: number, - private src: TileRef | null, + private troops: number, ) { this.originalOwner = this.attacker; } @@ -52,24 +46,15 @@ export class TransportShipExecution implements Execution { } init(mg: Game, ticks: number) { - if (this.targetID !== null && !mg.hasPlayer(this.targetID)) { - console.warn(`TransportShipExecution: target ${this.targetID} not found`); - this.active = false; - return; - } if (!mg.isValidRef(this.ref)) { console.warn(`TransportShipExecution: ref ${this.ref} not valid`); this.active = false; return; } - if (this.src !== null && !mg.isValidRef(this.src)) { - console.warn(`TransportShipExecution: src ${this.src} not valid`); - this.active = false; - return; - } this.lastMove = ticks; this.mg = mg; + this.target = mg.owner(this.ref); this.pathFinder = PathFinding.Water(mg); if ( @@ -87,73 +72,51 @@ export class TransportShipExecution implements Execution { return; } - if ( - this.targetID === null || - this.targetID === this.mg.terraNullius().id() - ) { - this.target = mg.terraNullius(); - } else { - this.target = mg.player(this.targetID); - } if (this.target.isPlayer() && !this.attacker.canAttackPlayer(this.target)) { this.active = false; return; } - this.startTroops ??= this.mg + this.troops ??= this.mg .config() .boatAttackAmount(this.attacker, this.target); - - this.startTroops = Math.min(this.startTroops, this.attacker.troops()); + this.troops = Math.min(this.troops, this.attacker.troops()); this.dst = targetTransportTile(this.mg, this.ref); + if (this.dst === null) { console.warn( - `${this.attacker} cannot send ship to ${this.target}, cannot find attack tile`, + `${this.attacker} cannot send ship to ${this.target}, cannot find target tile`, ); this.active = false; return; } - const closestTileSrc = this.attacker.canBuild( - UnitType.TransportShip, - this.dst, - ); - if (closestTileSrc === false) { - console.warn(`can't build transport ship`); + const src = this.attacker.canBuild(UnitType.TransportShip, this.dst); + + if (src === false) { + console.warn( + `${this.attacker} cannot send ship to ${this.target}, cannot find start tile`, + ); this.active = false; return; } - if (this.src === null) { - // Only update the src if it's not already set - // because we assume that the src is set to the best spawn tile - this.src = closestTileSrc; - } else { - if ( - this.mg.owner(this.src) !== this.attacker || - !this.mg.isShore(this.src) - ) { - console.warn( - `src is not a shore tile or not owned by: ${this.attacker.name()}`, - ); - this.src = closestTileSrc; - } - } + this.src = src; this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.src, { - troops: this.startTroops, - targetTile: this.dst ?? undefined, + troops: this.troops, + targetTile: this.dst, }); // Notify the target player about the incoming naval invasion - if (this.targetID && this.targetID !== mg.terraNullius().id()) { + if (this.target.id() !== mg.terraNullius().id()) { mg.displayIncomingUnit( this.boat.id(), // TODO TranslateText `Naval invasion incoming from ${this.attacker.displayName()}`, MessageType.NAVAL_INVASION_INBOUND, - this.targetID, + this.target.id(), ); } @@ -254,7 +217,7 @@ export class TransportShipExecution implements Execution { new AttackExecution( this.boat.troops(), this.attacker, - this.targetID, + this.target.id(), this.dst, false, ), @@ -278,7 +241,7 @@ export class TransportShipExecution implements Execution { const map = this.mg.map(); const boatTile = this.boat.tile(); console.warn( - `TransportShip path not found: boat@(${map.x(boatTile)},${map.y(boatTile)}) -> dst@(${map.x(this.dst)},${map.y(this.dst)}), attacker=${this.attacker.id()}, target=${this.targetID}`, + `TransportShip path not found: boat@(${map.x(boatTile)},${map.y(boatTile)}) -> dst@(${map.x(this.dst)},${map.y(this.dst)}), attacker=${this.attacker.id()}, target=${this.target.id()}`, ); this.attacker.addTroops(this.boat.troops()); this.boat.delete(false); diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index 4e27b6bb4..fd252316b 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -114,13 +114,7 @@ export class AiAttackBehavior { } this.game.addExecution( - new TransportShipExecution( - this.player, - this.game.owner(dst).id(), - dst, - this.player.troops() / 5, - null, - ), + new TransportShipExecution(this.player, dst, this.player.troops() / 5), ); return; } @@ -741,13 +735,7 @@ export class AiAttackBehavior { } this.game.addExecution( - new TransportShipExecution( - this.player, - target.id(), - closest.y, - troops, - null, - ), + new TransportShipExecution(this.player, closest.y, troops), ); } diff --git a/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts b/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts index 78a8ff6bc..2958de79a 100644 --- a/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts +++ b/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts @@ -42,8 +42,8 @@ export class AStarWaterHierarchical implements PathFinder { maxMultiClusterNodes, ); - // BoundedAStar for short path multi-source (120 + 2*10 padding = 140) - const shortPathSize = 140; + // BoundedAStar for short path multi-source + const shortPathSize = 260; // 2 * (120 + padding 10) const maxShortPathNodes = shortPathSize * shortPathSize; this.localAStarShortPath = new AStarWaterBounded(map, maxShortPathNodes); diff --git a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts index 5b4bd0b0c..549e047b5 100644 --- a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts +++ b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts @@ -8,13 +8,15 @@ import { PathFinder } from "../types"; const ENDPOINT_REFINEMENT_TILES = 50; const LOCAL_ASTAR_MAX_AREA = 100 * 100; -const LOS_MIN_MAGNITUDE = 3; +const LOS_MIN_MAGNITUDE_PASS1 = 2; +const LOS_MIN_MAGNITUDE_PASS2 = 3; const MAGNITUDE_MASK = 0x1f; /** - * Water path smoother transformer with two passes: + * Water path smoother transformer: * 1. Binary search LOS smoothing (avoids shallow water) * 2. Local A* refinement on endpoints (first/last N tiles) + * 3. Binary search LOS smoothing again (farther from shore) */ export class SmoothingWaterTransformer implements PathFinder { private readonly mapWidth: number; @@ -47,20 +49,24 @@ export class SmoothingWaterTransformer implements PathFinder { } // Pass 1: LOS smoothing with binary search - let smoothed = DebugSpan.wrap("smoother:los", () => this.losSmooth(path)); + let smoothed = DebugSpan.wrap("smoother:los", () => + this.losSmooth(path, LOS_MIN_MAGNITUDE_PASS1), + ); // Pass 2: Local A* refinement on endpoints smoothed = DebugSpan.wrap("smoother:refine", () => this.refineEndpoints(smoothed), ); - // Pass 3: LOS smoothing again (refinement may create new shortcut opportunities) - smoothed = DebugSpan.wrap("smoother:los2", () => this.losSmooth(smoothed)); + // Pass 3: LOS smoothing again, farther from the shore + smoothed = DebugSpan.wrap("smoother:los2", () => + this.losSmooth(smoothed, LOS_MIN_MAGNITUDE_PASS2), + ); return smoothed; } - private losSmooth(path: TileRef[]): TileRef[] { + private losSmooth(path: TileRef[], minMagnitude: number): TileRef[] { const result: TileRef[] = [path[0]]; let current = 0; @@ -72,7 +78,7 @@ export class SmoothingWaterTransformer implements PathFinder { while (lo <= hi) { const mid = (lo + hi) >>> 1; - if (this.canSee(path[current], path[mid])) { + if (this.canSee(path[current], path[mid], minMagnitude)) { farthest = mid; lo = mid + 1; } else { @@ -188,7 +194,7 @@ export class SmoothingWaterTransformer implements PathFinder { return this.localAStar.searchBounded(from, to, bounds); } - private canSee(from: TileRef, to: TileRef): boolean { + private canSee(from: TileRef, to: TileRef, minMagnitude: number): boolean { const x0 = from % this.mapWidth; const y0 = (from / this.mapWidth) | 0; const x1 = to % this.mapWidth; @@ -214,7 +220,7 @@ export class SmoothingWaterTransformer implements PathFinder { // Check magnitude - avoid shallow water const magnitude = this.terrain[tile] & MAGNITUDE_MASK; - if (magnitude < LOS_MIN_MAGNITUDE) return false; + if (magnitude < minMagnitude) return false; if (x === x1 && y === y1) return true; @@ -229,10 +235,7 @@ export class SmoothingWaterTransformer implements PathFinder { const intermediateTile = (y * this.mapWidth + x) as TileRef; const intMag = this.terrain[intermediateTile] & MAGNITUDE_MASK; - if ( - !this.isTraversable(intermediateTile) || - intMag < LOS_MIN_MAGNITUDE - ) { + if (!this.isTraversable(intermediateTile) || intMag < minMagnitude) { // Try alternative path x -= sx; err += dy; @@ -241,7 +244,7 @@ export class SmoothingWaterTransformer implements PathFinder { const altTile = (y * this.mapWidth + x) as TileRef; const altMag = this.terrain[altTile] & MAGNITUDE_MASK; - if (!this.isTraversable(altTile) || altMag < LOS_MIN_MAGNITUDE) + if (!this.isTraversable(altTile) || altMag < minMagnitude) return false; x += sx; diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index 75e536f08..e2bf619dc 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -21,10 +21,8 @@ let defender: Player; let defenderSpawn: TileRef; let attackerSpawn: TileRef; -function sendBoat(target: TileRef, source: TileRef, troops: number) { - game.addExecution( - new TransportShipExecution(defender, null, target, troops, source), - ); +function sendBoat(target: TileRef, troops: number) { + game.addExecution(new TransportShipExecution(defender, target, troops)); } const immunityPhaseTicks = 10; @@ -114,7 +112,7 @@ describe("Attack", () => { constructionExecution(game, defender, 1, 1, UnitType.MissileSilo); expect(defender.units(UnitType.MissileSilo)).toHaveLength(1); - sendBoat(game.ref(15, 8), game.ref(10, 5), 100); + sendBoat(game.ref(15, 8), 100); constructionExecution(game, defender, 0, 15, UnitType.AtomBomb, 3); const nuke = defender.units(UnitType.AtomBomb)[0]; @@ -133,7 +131,7 @@ describe("Attack", () => { const player_start_troops = defender.troops(); const boat_troops = player_start_troops * 0.5; - sendBoat(game.ref(15, 8), game.ref(10, 5), boat_troops); + sendBoat(game.ref(15, 8), boat_troops); game.executeNextTick(); @@ -357,7 +355,7 @@ describe("Attack immunity", () => { null, "playerB_id", ); - playerB = addPlayerToGame(playerBInfo, game, game.ref(0, 11)); + playerB = addPlayerToGame(playerBInfo, game, game.ref(7, 15)); while (game.inSpawnPhase()) { game.executeNextTick(); @@ -412,15 +410,7 @@ describe("Attack immunity", () => { test("Should not be able to send a boat during immunity phase", async () => { // Player A sends a boat targeting Player B - game.addExecution( - new TransportShipExecution( - playerA, - playerB.id(), - game.ref(15, 8), - 10, - game.ref(10, 5), - ), - ); + game.addExecution(new TransportShipExecution(playerA, game.ref(7, 15), 10)); game.executeNextTick(); expect(playerA.units(UnitType.TransportShip)).toHaveLength(0); }); @@ -428,15 +418,7 @@ describe("Attack immunity", () => { test("Should be able to send a boat after immunity phase", async () => { waitForImmunityToEnd(); // Player A sends a boat targeting Player B - game.addExecution( - new TransportShipExecution( - playerA, - playerB.id(), - game.ref(15, 8), - 10, - game.ref(7, 0), - ), - ); + game.addExecution(new TransportShipExecution(playerA, game.ref(7, 15), 10)); game.executeNextTick(); expect(playerA.units(UnitType.TransportShip)).toHaveLength(1); }); diff --git a/tests/Disconnected.test.ts b/tests/Disconnected.test.ts index 3dcc7011f..c52f00911 100644 --- a/tests/Disconnected.test.ts +++ b/tests/Disconnected.test.ts @@ -350,13 +350,7 @@ describe("Disconnected", () => { const enemyShoreTile = game.map().ref(coastX, 15); game.addExecution( - new TransportShipExecution( - player2, - null, - enemyShoreTile, - 100, - game.map().ref(coastX, 1), - ), + new TransportShipExecution(player2, enemyShoreTile, 100), ); executeTicks(game, 1); @@ -387,13 +381,7 @@ describe("Disconnected", () => { const enemyShoreTile = game.map().ref(coastX, 15); game.addExecution( - new TransportShipExecution( - player2, - null, - enemyShoreTile, - 100, - game.map().ref(coastX, 1), - ), + new TransportShipExecution(player2, enemyShoreTile, 100), ); executeTicks(game, 1); @@ -425,13 +413,7 @@ describe("Disconnected", () => { const boatTroops = 100; game.addExecution( - new TransportShipExecution( - player2, - null, - enemyShoreTile, - boatTroops, - game.map().ref(coastX, 1), - ), + new TransportShipExecution(player2, enemyShoreTile, boatTroops), ); executeTicks(game, 1); From f6454963b215e46b69456307425bbe0629d7fd3a Mon Sep 17 00:00:00 2001 From: Arkadiusz Sygulski Date: Tue, 20 Jan 2026 04:28:28 +0100 Subject: [PATCH 056/109] Pathfinding refinements (#2959) ## Description: ### Short path for multi-source HPA* Math was not mathing, increased the bounds to 260x260, it is a bit slower but should work better. The short path was breaking when player owned a lot of shores. This is because the bounding box of tiles with less than 120 distance + 10 padding could be as big as 260x260 and the optimized array was set to 140x140. I made mistake of calculating it as `2 * (60 + 10)` instead of `2 * (120 + 10)`. ### LoS path refinement Previously, we ran 2 passes of LoS smoothing on the path. However, since we are effectively tracing the same path, the line of sight is essentially the same. This PR makes second line of sight stop on water tiles with magnitude `n + 1` compared to first path. Practically, this means it'll attempt LoS exactly 1 tile after previous corner. See screenshot. image ### SendBoatAttackIntentEvent The flow of sending transport ships is currently strange. This PR makes the flow more sane. **Old flow** ``` - Player clicks TARGET tile, it can be deep inland - Client asks Worker for the best START tile to TARGET tile - Worker answers `false`, since the tile is inland - Client sends BoatAttackIntent with START=false and TARGET tiles set - Worker accepts BoatAttackIntent, computes DESTINATION as closest shore to TARGET - Worker re-computes best START to DESTINATION - Worker sends boat from START to DESTINATION ``` **New flow** ``` - Player clicks TARGET tile, it can be deep inland - Client sends BoatAttackIntent with TARGET - Worker accepts BoatAttackIntent, computes DESTINATION as closest shore to TARGET - Worker computes START as the best tile to DESTINATION - Worker sends boat from START to DESTINATION ``` ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: moleole --- src/client/ClientGameRunner.ts | 17 ++-- src/client/Transport.ts | 4 - .../graphics/layers/PlayerActionHandler.ts | 11 +-- .../graphics/layers/RadialMenuElements.ts | 12 +-- src/core/Schemas.ts | 2 - src/core/execution/ExecutionManager.ts | 8 +- src/core/execution/TransportShipExecution.ts | 89 ++++++------------- src/core/execution/utils/AiAttackBehavior.ts | 16 +--- .../algorithms/AStar.WaterHierarchical.ts | 4 +- .../transformers/SmoothingWaterTransformer.ts | 31 ++++--- tests/Attack.test.ts | 32 ++----- tests/Disconnected.test.ts | 24 +---- 12 files changed, 67 insertions(+), 183 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 971a9cd71..bd377d35d 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -713,17 +713,12 @@ export class ClientGameRunner { private sendBoatAttackIntent(tile: TileRef) { if (!this.myPlayer) return; - this.myPlayer.bestTransportShipSpawn(tile).then((spawn: number | false) => { - if (this.myPlayer === null) throw new Error("not initialized"); - this.eventBus.emit( - new SendBoatAttackIntentEvent( - this.gameView.owner(tile).id(), - tile, - this.myPlayer.troops() * this.renderer.uiState.attackRatio, - spawn === false ? null : spawn, - ), - ); - }); + this.eventBus.emit( + new SendBoatAttackIntentEvent( + tile, + this.myPlayer.troops() * this.renderer.uiState.attackRatio, + ), + ); } private canAutoBoat(actions: PlayerActions, tile: TileRef): boolean { diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 1f35131a4..69d8d1ac0 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -81,10 +81,8 @@ export class SendAttackIntentEvent implements GameEvent { export class SendBoatAttackIntentEvent implements GameEvent { constructor( - public readonly targetID: PlayerID | null, public readonly dst: TileRef, public readonly troops: number, - public readonly src: TileRef | null = null, ) {} } @@ -498,10 +496,8 @@ export class Transport { this.sendIntent({ type: "boat", clientID: this.lobbyConfig.clientID, - targetID: event.targetID, troops: event.troops, dst: event.dst, - src: event.src, }); } diff --git a/src/client/graphics/layers/PlayerActionHandler.ts b/src/client/graphics/layers/PlayerActionHandler.ts index 672cc2baf..54714cadb 100644 --- a/src/client/graphics/layers/PlayerActionHandler.ts +++ b/src/client/graphics/layers/PlayerActionHandler.ts @@ -1,5 +1,5 @@ import { EventBus } from "../../../core/EventBus"; -import { PlayerActions, PlayerID } from "../../../core/game/Game"; +import { PlayerActions } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { PlayerView } from "../../../core/game/GameView"; import { @@ -39,18 +39,11 @@ export class PlayerActionHandler { ); } - handleBoatAttack( - player: PlayerView, - targetId: PlayerID | null, - targetTile: TileRef, - spawnTile: TileRef | null, - ) { + handleBoatAttack(player: PlayerView, targetTile: TileRef) { this.eventBus.emit( new SendBoatAttackIntentEvent( - targetId, targetTile, this.uiState.attackRatio * player.troops(), - spawnTile, ), ); } diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 44d63bb1f..1dcf3eb90 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -548,17 +548,7 @@ export const boatMenuElement: MenuElement = { color: COLORS.boat, action: async (params: MenuElementParams) => { - const spawn = await params.playerActionHandler.findBestTransportShipSpawn( - params.myPlayer, - params.tile, - ); - - params.playerActionHandler.handleBoatAttack( - params.myPlayer, - params.selected?.id() ?? null, - params.tile, - spawn !== false ? spawn : null, - ); + params.playerActionHandler.handleBoatAttack(params.myPlayer, params.tile); params.closeMenu(); }, diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 28362063f..d225857c5 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -284,10 +284,8 @@ export const SpawnIntentSchema = BaseIntentSchema.extend({ export const BoatAttackIntentSchema = BaseIntentSchema.extend({ type: z.literal("boat"), - targetID: ID.nullable(), troops: z.number().nonnegative(), dst: z.number(), - src: z.number().nullable(), }); export const AllianceRequestIntentSchema = BaseIntentSchema.extend({ diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index a161e5eb1..56d66e547 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -72,13 +72,7 @@ export class Executor { case "spawn": return new SpawnExecution(this.gameID, player.info(), intent.tile); case "boat": - return new TransportShipExecution( - player, - intent.targetID, - intent.dst, - intent.troops, - intent.src, - ); + return new TransportShipExecution(player, intent.dst, intent.troops); case "allianceRequest": return new AllianceRequestExecution(player, intent.recipient); case "allianceRequestReply": diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 6020d0489..0b93aa9fc 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -4,7 +4,6 @@ import { Game, MessageType, Player, - PlayerID, TerraNullius, Unit, UnitType, @@ -16,33 +15,28 @@ import { PathStatus, SteppingPathFinder } from "../pathfinding/types"; import { AttackExecution } from "./AttackExecution"; const malusForRetreat = 25; + export class TransportShipExecution implements Execution { - private lastMove: number; + private active = true; // TODO: make this configurable private ticksPerMove = 1; - - private active = true; + private lastMove: number; private mg: Game; private target: Player | TerraNullius; - - // TODO make private - public path: TileRef[]; - private dst: TileRef | null; - - private boat: Unit; - private pathFinder: SteppingPathFinder; + private dst: TileRef | null; + private src: TileRef | null; + private boat: Unit; + private originalOwner: Player; constructor( private attacker: Player, - private targetID: PlayerID | null, private ref: TileRef, - private startTroops: number, - private src: TileRef | null, + private troops: number, ) { this.originalOwner = this.attacker; } @@ -52,24 +46,15 @@ export class TransportShipExecution implements Execution { } init(mg: Game, ticks: number) { - if (this.targetID !== null && !mg.hasPlayer(this.targetID)) { - console.warn(`TransportShipExecution: target ${this.targetID} not found`); - this.active = false; - return; - } if (!mg.isValidRef(this.ref)) { console.warn(`TransportShipExecution: ref ${this.ref} not valid`); this.active = false; return; } - if (this.src !== null && !mg.isValidRef(this.src)) { - console.warn(`TransportShipExecution: src ${this.src} not valid`); - this.active = false; - return; - } this.lastMove = ticks; this.mg = mg; + this.target = mg.owner(this.ref); this.pathFinder = PathFinding.Water(mg); if ( @@ -87,73 +72,51 @@ export class TransportShipExecution implements Execution { return; } - if ( - this.targetID === null || - this.targetID === this.mg.terraNullius().id() - ) { - this.target = mg.terraNullius(); - } else { - this.target = mg.player(this.targetID); - } if (this.target.isPlayer() && !this.attacker.canAttackPlayer(this.target)) { this.active = false; return; } - this.startTroops ??= this.mg + this.troops ??= this.mg .config() .boatAttackAmount(this.attacker, this.target); - - this.startTroops = Math.min(this.startTroops, this.attacker.troops()); + this.troops = Math.min(this.troops, this.attacker.troops()); this.dst = targetTransportTile(this.mg, this.ref); + if (this.dst === null) { console.warn( - `${this.attacker} cannot send ship to ${this.target}, cannot find attack tile`, + `${this.attacker} cannot send ship to ${this.target}, cannot find target tile`, ); this.active = false; return; } - const closestTileSrc = this.attacker.canBuild( - UnitType.TransportShip, - this.dst, - ); - if (closestTileSrc === false) { - console.warn(`can't build transport ship`); + const src = this.attacker.canBuild(UnitType.TransportShip, this.dst); + + if (src === false) { + console.warn( + `${this.attacker} cannot send ship to ${this.target}, cannot find start tile`, + ); this.active = false; return; } - if (this.src === null) { - // Only update the src if it's not already set - // because we assume that the src is set to the best spawn tile - this.src = closestTileSrc; - } else { - if ( - this.mg.owner(this.src) !== this.attacker || - !this.mg.isShore(this.src) - ) { - console.warn( - `src is not a shore tile or not owned by: ${this.attacker.name()}`, - ); - this.src = closestTileSrc; - } - } + this.src = src; this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.src, { - troops: this.startTroops, - targetTile: this.dst ?? undefined, + troops: this.troops, + targetTile: this.dst, }); // Notify the target player about the incoming naval invasion - if (this.targetID && this.targetID !== mg.terraNullius().id()) { + if (this.target.id() !== mg.terraNullius().id()) { mg.displayIncomingUnit( this.boat.id(), // TODO TranslateText `Naval invasion incoming from ${this.attacker.displayName()}`, MessageType.NAVAL_INVASION_INBOUND, - this.targetID, + this.target.id(), ); } @@ -254,7 +217,7 @@ export class TransportShipExecution implements Execution { new AttackExecution( this.boat.troops(), this.attacker, - this.targetID, + this.target.id(), this.dst, false, ), @@ -278,7 +241,7 @@ export class TransportShipExecution implements Execution { const map = this.mg.map(); const boatTile = this.boat.tile(); console.warn( - `TransportShip path not found: boat@(${map.x(boatTile)},${map.y(boatTile)}) -> dst@(${map.x(this.dst)},${map.y(this.dst)}), attacker=${this.attacker.id()}, target=${this.targetID}`, + `TransportShip path not found: boat@(${map.x(boatTile)},${map.y(boatTile)}) -> dst@(${map.x(this.dst)},${map.y(this.dst)}), attacker=${this.attacker.id()}, target=${this.target.id()}`, ); this.attacker.addTroops(this.boat.troops()); this.boat.delete(false); diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index 4e27b6bb4..fd252316b 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -114,13 +114,7 @@ export class AiAttackBehavior { } this.game.addExecution( - new TransportShipExecution( - this.player, - this.game.owner(dst).id(), - dst, - this.player.troops() / 5, - null, - ), + new TransportShipExecution(this.player, dst, this.player.troops() / 5), ); return; } @@ -741,13 +735,7 @@ export class AiAttackBehavior { } this.game.addExecution( - new TransportShipExecution( - this.player, - target.id(), - closest.y, - troops, - null, - ), + new TransportShipExecution(this.player, closest.y, troops), ); } diff --git a/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts b/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts index 78a8ff6bc..2958de79a 100644 --- a/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts +++ b/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts @@ -42,8 +42,8 @@ export class AStarWaterHierarchical implements PathFinder { maxMultiClusterNodes, ); - // BoundedAStar for short path multi-source (120 + 2*10 padding = 140) - const shortPathSize = 140; + // BoundedAStar for short path multi-source + const shortPathSize = 260; // 2 * (120 + padding 10) const maxShortPathNodes = shortPathSize * shortPathSize; this.localAStarShortPath = new AStarWaterBounded(map, maxShortPathNodes); diff --git a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts index 5b4bd0b0c..549e047b5 100644 --- a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts +++ b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts @@ -8,13 +8,15 @@ import { PathFinder } from "../types"; const ENDPOINT_REFINEMENT_TILES = 50; const LOCAL_ASTAR_MAX_AREA = 100 * 100; -const LOS_MIN_MAGNITUDE = 3; +const LOS_MIN_MAGNITUDE_PASS1 = 2; +const LOS_MIN_MAGNITUDE_PASS2 = 3; const MAGNITUDE_MASK = 0x1f; /** - * Water path smoother transformer with two passes: + * Water path smoother transformer: * 1. Binary search LOS smoothing (avoids shallow water) * 2. Local A* refinement on endpoints (first/last N tiles) + * 3. Binary search LOS smoothing again (farther from shore) */ export class SmoothingWaterTransformer implements PathFinder { private readonly mapWidth: number; @@ -47,20 +49,24 @@ export class SmoothingWaterTransformer implements PathFinder { } // Pass 1: LOS smoothing with binary search - let smoothed = DebugSpan.wrap("smoother:los", () => this.losSmooth(path)); + let smoothed = DebugSpan.wrap("smoother:los", () => + this.losSmooth(path, LOS_MIN_MAGNITUDE_PASS1), + ); // Pass 2: Local A* refinement on endpoints smoothed = DebugSpan.wrap("smoother:refine", () => this.refineEndpoints(smoothed), ); - // Pass 3: LOS smoothing again (refinement may create new shortcut opportunities) - smoothed = DebugSpan.wrap("smoother:los2", () => this.losSmooth(smoothed)); + // Pass 3: LOS smoothing again, farther from the shore + smoothed = DebugSpan.wrap("smoother:los2", () => + this.losSmooth(smoothed, LOS_MIN_MAGNITUDE_PASS2), + ); return smoothed; } - private losSmooth(path: TileRef[]): TileRef[] { + private losSmooth(path: TileRef[], minMagnitude: number): TileRef[] { const result: TileRef[] = [path[0]]; let current = 0; @@ -72,7 +78,7 @@ export class SmoothingWaterTransformer implements PathFinder { while (lo <= hi) { const mid = (lo + hi) >>> 1; - if (this.canSee(path[current], path[mid])) { + if (this.canSee(path[current], path[mid], minMagnitude)) { farthest = mid; lo = mid + 1; } else { @@ -188,7 +194,7 @@ export class SmoothingWaterTransformer implements PathFinder { return this.localAStar.searchBounded(from, to, bounds); } - private canSee(from: TileRef, to: TileRef): boolean { + private canSee(from: TileRef, to: TileRef, minMagnitude: number): boolean { const x0 = from % this.mapWidth; const y0 = (from / this.mapWidth) | 0; const x1 = to % this.mapWidth; @@ -214,7 +220,7 @@ export class SmoothingWaterTransformer implements PathFinder { // Check magnitude - avoid shallow water const magnitude = this.terrain[tile] & MAGNITUDE_MASK; - if (magnitude < LOS_MIN_MAGNITUDE) return false; + if (magnitude < minMagnitude) return false; if (x === x1 && y === y1) return true; @@ -229,10 +235,7 @@ export class SmoothingWaterTransformer implements PathFinder { const intermediateTile = (y * this.mapWidth + x) as TileRef; const intMag = this.terrain[intermediateTile] & MAGNITUDE_MASK; - if ( - !this.isTraversable(intermediateTile) || - intMag < LOS_MIN_MAGNITUDE - ) { + if (!this.isTraversable(intermediateTile) || intMag < minMagnitude) { // Try alternative path x -= sx; err += dy; @@ -241,7 +244,7 @@ export class SmoothingWaterTransformer implements PathFinder { const altTile = (y * this.mapWidth + x) as TileRef; const altMag = this.terrain[altTile] & MAGNITUDE_MASK; - if (!this.isTraversable(altTile) || altMag < LOS_MIN_MAGNITUDE) + if (!this.isTraversable(altTile) || altMag < minMagnitude) return false; x += sx; diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index 75e536f08..e2bf619dc 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -21,10 +21,8 @@ let defender: Player; let defenderSpawn: TileRef; let attackerSpawn: TileRef; -function sendBoat(target: TileRef, source: TileRef, troops: number) { - game.addExecution( - new TransportShipExecution(defender, null, target, troops, source), - ); +function sendBoat(target: TileRef, troops: number) { + game.addExecution(new TransportShipExecution(defender, target, troops)); } const immunityPhaseTicks = 10; @@ -114,7 +112,7 @@ describe("Attack", () => { constructionExecution(game, defender, 1, 1, UnitType.MissileSilo); expect(defender.units(UnitType.MissileSilo)).toHaveLength(1); - sendBoat(game.ref(15, 8), game.ref(10, 5), 100); + sendBoat(game.ref(15, 8), 100); constructionExecution(game, defender, 0, 15, UnitType.AtomBomb, 3); const nuke = defender.units(UnitType.AtomBomb)[0]; @@ -133,7 +131,7 @@ describe("Attack", () => { const player_start_troops = defender.troops(); const boat_troops = player_start_troops * 0.5; - sendBoat(game.ref(15, 8), game.ref(10, 5), boat_troops); + sendBoat(game.ref(15, 8), boat_troops); game.executeNextTick(); @@ -357,7 +355,7 @@ describe("Attack immunity", () => { null, "playerB_id", ); - playerB = addPlayerToGame(playerBInfo, game, game.ref(0, 11)); + playerB = addPlayerToGame(playerBInfo, game, game.ref(7, 15)); while (game.inSpawnPhase()) { game.executeNextTick(); @@ -412,15 +410,7 @@ describe("Attack immunity", () => { test("Should not be able to send a boat during immunity phase", async () => { // Player A sends a boat targeting Player B - game.addExecution( - new TransportShipExecution( - playerA, - playerB.id(), - game.ref(15, 8), - 10, - game.ref(10, 5), - ), - ); + game.addExecution(new TransportShipExecution(playerA, game.ref(7, 15), 10)); game.executeNextTick(); expect(playerA.units(UnitType.TransportShip)).toHaveLength(0); }); @@ -428,15 +418,7 @@ describe("Attack immunity", () => { test("Should be able to send a boat after immunity phase", async () => { waitForImmunityToEnd(); // Player A sends a boat targeting Player B - game.addExecution( - new TransportShipExecution( - playerA, - playerB.id(), - game.ref(15, 8), - 10, - game.ref(7, 0), - ), - ); + game.addExecution(new TransportShipExecution(playerA, game.ref(7, 15), 10)); game.executeNextTick(); expect(playerA.units(UnitType.TransportShip)).toHaveLength(1); }); diff --git a/tests/Disconnected.test.ts b/tests/Disconnected.test.ts index 3dcc7011f..c52f00911 100644 --- a/tests/Disconnected.test.ts +++ b/tests/Disconnected.test.ts @@ -350,13 +350,7 @@ describe("Disconnected", () => { const enemyShoreTile = game.map().ref(coastX, 15); game.addExecution( - new TransportShipExecution( - player2, - null, - enemyShoreTile, - 100, - game.map().ref(coastX, 1), - ), + new TransportShipExecution(player2, enemyShoreTile, 100), ); executeTicks(game, 1); @@ -387,13 +381,7 @@ describe("Disconnected", () => { const enemyShoreTile = game.map().ref(coastX, 15); game.addExecution( - new TransportShipExecution( - player2, - null, - enemyShoreTile, - 100, - game.map().ref(coastX, 1), - ), + new TransportShipExecution(player2, enemyShoreTile, 100), ); executeTicks(game, 1); @@ -425,13 +413,7 @@ describe("Disconnected", () => { const boatTroops = 100; game.addExecution( - new TransportShipExecution( - player2, - null, - enemyShoreTile, - boatTroops, - game.map().ref(coastX, 1), - ), + new TransportShipExecution(player2, enemyShoreTile, boatTroops), ); executeTicks(game, 1); From 776c644a84ce91019c10e090de56f0b20285d8a3 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 20 Jan 2026 06:25:36 -0800 Subject: [PATCH 057/109] increase frequency of didier --- src/server/MapPlaylist.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index c9cf49a17..d09da6eca 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -62,6 +62,7 @@ const frequency: Partial> = { StraitOfHormuz: 4, Surrounded: 4, DidierFrance: 1, + Didier: 40, AmazonRiver: 3, Sierpinski: 10, }; From 04d14853c449014e9e536cafdbe1de5d5f2205ca Mon Sep 17 00:00:00 2001 From: Efnilite <35348263+Efnilite@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:23:47 +0100 Subject: [PATCH 058/109] Fix trailing space in attack ratio troop count. (#2956) ## Description: Fixes a trailing space in the attack ratio troop count. **Before** Screenshot from 2026-01-19 20-36-57 **After** Screenshot from 2026-01-19 20-29-45 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: n/a --- src/client/graphics/layers/ControlPanel.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts index cbe0ef70c..f1c183e58 100644 --- a/src/client/graphics/layers/ControlPanel.ts +++ b/src/client/graphics/layers/ControlPanel.ts @@ -201,8 +201,7 @@ export class ControlPanel extends LitElement implements Layer { (${renderTroops( (this.game?.myPlayer()?.troops() ?? 0) * this.attackRatio, - )} - ) + )}) From 1da6836efe3442da246a2466cc78507930dbcb8e Mon Sep 17 00:00:00 2001 From: Wraith <54374743+wraith4081@users.noreply.github.com> Date: Tue, 20 Jan 2026 19:48:50 +0300 Subject: [PATCH 059/109] fix(ui): move the width definition for PerformanceOverlay's layer bars into the class (#2964) ## Description: move the width definition for PerformanceOverlay's layer bars into the class ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: wraith4081 Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com> --- src/client/graphics/layers/PerformanceOverlay.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index 564e94997..ad27aeaa5 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -209,6 +209,7 @@ export class PerformanceOverlay extends LitElement implements Layer { .layer-bar-fill { height: 100%; + width: var(--width); background: #38bdf8; border-radius: 3px; } @@ -611,7 +612,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
    From 1b3ab305df2590c6d9f0ccea5ab0c27b54801c97 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 20 Jan 2026 19:29:58 +0100 Subject: [PATCH 060/109] Optimize team game frequency (#2970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: I analyzed the avg fill time of team games (past 30 days) and was able to confirm what people in the main discord said: Duos / Trios / Quads fill slower. Might be something for v29. | Game Mode | Games | Avg Fill Time | |-----------|-------|---------------| | **FFA** (Excluding ranked) | 53,654 | **29s** | | Team: 2 teams | 3,379 | 33s | | Team: 3 teams | 3,291 | 32s | | Team: 4 teams | 3,242 | 31s | | Team: 5 teams | 3,364 | 32s | | Team: 6 teams | 3,381 | 31s | | Team: 7 teams | 3,227 | 31s | | Team: Duos | 3,295 | **43s** | | Team: Trios | 3,300 | 39s | | Team: Quads | 3,299 | 37s | | Team: Humans Vs Nations | 101 | **24s** | Therefore I propose to decrease the chance of Duos, Trios and Quads (especially Duos). Also, increase the chance of HumansVsNations because its special and unlike all the other team modes. | Team Config | Previous | New | |-------------|----------|-----| | 2 teams | 10% | 10% | | 3 teams | 10% | 10% | | 4 teams | 10% | 10% | | 5 teams | 10% | 10% | | 6 teams | 10% | 10% | | 7 teams | 10% | 10% | | **Duos** | 10% | **5%** ↓ | | **Trios** | 10% | **7.5%** ↓ | | **Quads** | 10% | **7.5%** ↓ | | **HumansVsNations** | 10% | **20%** ↑ | ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/server/MapPlaylist.ts | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index c9cf49a17..ddc33f769 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -71,18 +71,18 @@ interface MapWithMode { mode: GameMode; } -const TEAM_COUNTS = [ - 2, - 3, - 4, - 5, - 6, - 7, - Duos, - Trios, - Quads, - HumansVsNations, -] as const satisfies TeamCountConfig[]; +const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [ + { config: 2, weight: 10 }, + { config: 3, weight: 10 }, + { config: 4, weight: 10 }, + { config: 5, weight: 10 }, + { config: 6, weight: 10 }, + { config: 7, weight: 10 }, + { config: Duos, weight: 5 }, + { config: Trios, weight: 7.5 }, + { config: Quads, weight: 7.5 }, + { config: HumansVsNations, weight: 20 }, +]; export class MapPlaylist { private mapsPlaylist: MapWithMode[] = []; @@ -192,7 +192,17 @@ export class MapPlaylist { } private getTeamCount(): TeamCountConfig { - return TEAM_COUNTS[Math.floor(Math.random() * TEAM_COUNTS.length)]; + const totalWeight = TEAM_WEIGHTS.reduce((sum, w) => sum + w.weight, 0); + const roll = Math.random() * totalWeight; + + let cumulativeWeight = 0; + for (const { config, weight } of TEAM_WEIGHTS) { + cumulativeWeight += weight; + if (roll < cumulativeWeight) { + return config; + } + } + return TEAM_WEIGHTS[0].config; } private getRandomPublicGameModifiers(): PublicGameModifiers { From cf63340227d6d712ec46e7871adfd64e9b5256f8 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 20 Jan 2026 10:31:07 -0800 Subject: [PATCH 061/109] reduce frequency of didier 40=>1 --- src/server/MapPlaylist.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index d09da6eca..3eac4ce45 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -62,7 +62,7 @@ const frequency: Partial> = { StraitOfHormuz: 4, Surrounded: 4, DidierFrance: 1, - Didier: 40, + Didier: 1, AmazonRiver: 3, Sierpinski: 10, }; From 4d668e299c0d846a478db2f1d86cec48cfe09f13 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 20 Jan 2026 19:29:58 +0100 Subject: [PATCH 062/109] Optimize team game frequency (#2970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: I analyzed the avg fill time of team games (past 30 days) and was able to confirm what people in the main discord said: Duos / Trios / Quads fill slower. Might be something for v29. | Game Mode | Games | Avg Fill Time | |-----------|-------|---------------| | **FFA** (Excluding ranked) | 53,654 | **29s** | | Team: 2 teams | 3,379 | 33s | | Team: 3 teams | 3,291 | 32s | | Team: 4 teams | 3,242 | 31s | | Team: 5 teams | 3,364 | 32s | | Team: 6 teams | 3,381 | 31s | | Team: 7 teams | 3,227 | 31s | | Team: Duos | 3,295 | **43s** | | Team: Trios | 3,300 | 39s | | Team: Quads | 3,299 | 37s | | Team: Humans Vs Nations | 101 | **24s** | Therefore I propose to decrease the chance of Duos, Trios and Quads (especially Duos). Also, increase the chance of HumansVsNations because its special and unlike all the other team modes. | Team Config | Previous | New | |-------------|----------|-----| | 2 teams | 10% | 10% | | 3 teams | 10% | 10% | | 4 teams | 10% | 10% | | 5 teams | 10% | 10% | | 6 teams | 10% | 10% | | 7 teams | 10% | 10% | | **Duos** | 10% | **5%** ↓ | | **Trios** | 10% | **7.5%** ↓ | | **Quads** | 10% | **7.5%** ↓ | | **HumansVsNations** | 10% | **20%** ↑ | ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/server/MapPlaylist.ts | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 3eac4ce45..22b47c51b 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -72,18 +72,18 @@ interface MapWithMode { mode: GameMode; } -const TEAM_COUNTS = [ - 2, - 3, - 4, - 5, - 6, - 7, - Duos, - Trios, - Quads, - HumansVsNations, -] as const satisfies TeamCountConfig[]; +const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [ + { config: 2, weight: 10 }, + { config: 3, weight: 10 }, + { config: 4, weight: 10 }, + { config: 5, weight: 10 }, + { config: 6, weight: 10 }, + { config: 7, weight: 10 }, + { config: Duos, weight: 5 }, + { config: Trios, weight: 7.5 }, + { config: Quads, weight: 7.5 }, + { config: HumansVsNations, weight: 20 }, +]; export class MapPlaylist { private mapsPlaylist: MapWithMode[] = []; @@ -193,7 +193,17 @@ export class MapPlaylist { } private getTeamCount(): TeamCountConfig { - return TEAM_COUNTS[Math.floor(Math.random() * TEAM_COUNTS.length)]; + const totalWeight = TEAM_WEIGHTS.reduce((sum, w) => sum + w.weight, 0); + const roll = Math.random() * totalWeight; + + let cumulativeWeight = 0; + for (const { config, weight } of TEAM_WEIGHTS) { + cumulativeWeight += weight; + if (roll < cumulativeWeight) { + return config; + } + } + return TEAM_WEIGHTS[0].config; } private getRandomPublicGameModifiers(): PublicGameModifiers { From e5c91945afc3fdac52e4f60c99c63925d2200acd Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 20 Jan 2026 19:43:48 +0100 Subject: [PATCH 063/109] =?UTF-8?q?Humans=20are=20severely=20skill=20issue?= =?UTF-8?q?d=20=E2=9A=A0=EF=B8=8F=20Change=20HvN=20difficulty=20to=20Mediu?= =?UTF-8?q?m=20(#2971)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: For v29 HvN winrate is between 10 and 15%, but should be around 50%. 1. Change HvN difficulty to Medium 2. Little balance change in `NationAllianceBehavior` ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/core/execution/nation/NationAllianceBehavior.ts | 8 ++++---- src/server/MapPlaylist.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/core/execution/nation/NationAllianceBehavior.ts b/src/core/execution/nation/NationAllianceBehavior.ts index cbf78304b..5743b6d67 100644 --- a/src/core/execution/nation/NationAllianceBehavior.ts +++ b/src/core/execution/nation/NationAllianceBehavior.ts @@ -240,13 +240,13 @@ export class NationAllianceBehavior { const { difficulty } = this.game.config().gameConfig(); switch (difficulty) { case Difficulty.Easy: - return false; // 0% chance to reject on easy + return this.random.nextInt(0, 100) < 25; // 25% chance to reject on easy case Difficulty.Medium: - return this.random.nextInt(0, 100) < 20; // 20% chance to reject on medium + return this.random.nextInt(0, 100) < 50; // 50% chance to reject on medium case Difficulty.Hard: - return this.random.nextInt(0, 100) < 40; // 40% chance to reject on hard + return this.random.nextInt(0, 100) < 75; // 75% chance to reject on hard case Difficulty.Impossible: - return this.random.nextInt(0, 100) < 60; // 60% chance to reject on impossible + return true; // 100% chance to reject on impossible default: assertNever(difficulty); } diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index ddc33f769..690227305 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -128,7 +128,7 @@ export class MapPlaylist { publicGameModifiers: { isCompact, isRandomSpawn, startingGold }, startingGold, difficulty: - playerTeams === HumansVsNations ? Difficulty.Hard : Difficulty.Easy, + playerTeams === HumansVsNations ? Difficulty.Medium : Difficulty.Easy, infiniteGold: false, infiniteTroops: false, maxTimerValue: undefined, From e9e2f06d69c6ca5a3d4e33b147a67434d537095d Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 20 Jan 2026 19:43:48 +0100 Subject: [PATCH 064/109] =?UTF-8?q?Humans=20are=20severely=20skill=20issue?= =?UTF-8?q?d=20=E2=9A=A0=EF=B8=8F=20Change=20HvN=20difficulty=20to=20Mediu?= =?UTF-8?q?m=20(#2971)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: For v29 HvN winrate is between 10 and 15%, but should be around 50%. 1. Change HvN difficulty to Medium 2. Little balance change in `NationAllianceBehavior` ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/core/execution/nation/NationAllianceBehavior.ts | 8 ++++---- src/server/MapPlaylist.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/core/execution/nation/NationAllianceBehavior.ts b/src/core/execution/nation/NationAllianceBehavior.ts index cbf78304b..5743b6d67 100644 --- a/src/core/execution/nation/NationAllianceBehavior.ts +++ b/src/core/execution/nation/NationAllianceBehavior.ts @@ -240,13 +240,13 @@ export class NationAllianceBehavior { const { difficulty } = this.game.config().gameConfig(); switch (difficulty) { case Difficulty.Easy: - return false; // 0% chance to reject on easy + return this.random.nextInt(0, 100) < 25; // 25% chance to reject on easy case Difficulty.Medium: - return this.random.nextInt(0, 100) < 20; // 20% chance to reject on medium + return this.random.nextInt(0, 100) < 50; // 50% chance to reject on medium case Difficulty.Hard: - return this.random.nextInt(0, 100) < 40; // 40% chance to reject on hard + return this.random.nextInt(0, 100) < 75; // 75% chance to reject on hard case Difficulty.Impossible: - return this.random.nextInt(0, 100) < 60; // 60% chance to reject on impossible + return true; // 100% chance to reject on impossible default: assertNever(difficulty); } diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 22b47c51b..6e05022d8 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -129,7 +129,7 @@ export class MapPlaylist { publicGameModifiers: { isCompact, isRandomSpawn, startingGold }, startingGold, difficulty: - playerTeams === HumansVsNations ? Difficulty.Hard : Difficulty.Easy, + playerTeams === HumansVsNations ? Difficulty.Medium : Difficulty.Easy, infiniteGold: false, infiniteTroops: false, maxTimerValue: undefined, From 4e4e1799d70920d76a168ca57405ac2bf1f5ef7a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 19:16:26 -0800 Subject: [PATCH 065/109] Bump diff from 4.0.2 to 4.0.4 in the npm_and_yarn group across 1 directory (#2976) Bumps the npm_and_yarn group with 1 update in the / directory: [diff](https://github.com/kpdecker/jsdiff). Updates `diff` from 4.0.2 to 4.0.4
    Maintainer changes

    This version was pushed to npm by explodingcabbage, a new releaser for diff since your current version.


    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=diff&package-manager=npm_and_yarn&previous-version=4.0.2&new-version=4.0.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/openfrontio/OpenFrontIO/network/alerts).
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 107 ++++++++++++++++++++++++++++++++++------------ package.json | 2 +- 2 files changed, 80 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index 446dd7af2..26725da4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,7 +89,7 @@ "prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-sh": "^0.17.4", "protobufjs": "^7.5.3", - "sinon": "^21.0.0", + "sinon": "^21.0.1", "sinon-chai": "^4.0.0", "tailwindcss": "^4.1.18", "tsconfig-paths": "^4.2.0", @@ -2957,9 +2957,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.0.tgz", + "integrity": "sha512-cqfapCxwTGsrR80FEgOoPsTonoefMBY7dnUEbQ+GRcved0jvkJLzvX6F4WtN+HBqbPX/SiFsIRUp+IrCW/2I2w==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2967,14 +2967,13 @@ } }, "node_modules/@sinonjs/samsam": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", - "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "lodash.get": "^4.4.2", "type-detect": "^4.1.0" } }, @@ -3951,6 +3950,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", @@ -6847,9 +6906,9 @@ } }, "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -9148,14 +9207,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -11081,16 +11132,16 @@ "license": "MIT" }, "node_modules/sinon": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.0.tgz", - "integrity": "sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.1.tgz", + "integrity": "sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^13.0.5", - "@sinonjs/samsam": "^8.0.1", - "diff": "^7.0.0", + "@sinonjs/fake-timers": "^15.1.0", + "@sinonjs/samsam": "^8.0.3", + "diff": "^8.0.2", "supports-color": "^7.2.0" }, "funding": { @@ -11684,9 +11735,9 @@ "license": "MIT" }, "node_modules/ts-node/node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" diff --git a/package.json b/package.json index 5db065892..621205f5e 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-sh": "^0.17.4", "protobufjs": "^7.5.3", - "sinon": "^21.0.0", + "sinon": "^21.0.1", "sinon-chai": "^4.0.0", "tailwindcss": "^4.1.18", "tsconfig-paths": "^4.2.0", From 8aa3e26e70744e4b8ec6a7a51a840e827ab56041 Mon Sep 17 00:00:00 2001 From: Himansu Rawal Date: Wed, 21 Jan 2026 09:39:57 +0545 Subject: [PATCH 066/109] =?UTF-8?q?feat:=20Prevent=20`GameServer`=20from?= =?UTF-8?q?=20restarting=20after=20ending=20by=20introducin=E2=80=A6=20(#2?= =?UTF-8?q?923)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If this PR fixes an issue, link it below. If not, delete these two lines. Resolves #(issue number) #2919 In GameManager.tick(), when a game becomes active but hasn't started, a setTimeout for game.start() is scheduled with a 2-second delay. If the game finishes or is cancelled within those 2 seconds, game.end() is called, which clears the existing interval. However: 1.The 2-second timeout still fires. game.start() executes. 2. A NEW setInterval is created for turn execution. 3.Since the game is already ending/finished, it's removed from GameManager.games, but the interval continues to run forever in the background ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: codimo --- package.json | 2 +- src/server/GameServer.ts | 6 +- tests/server/GameLifecycle.test.ts | 121 +++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 tests/server/GameLifecycle.test.ts diff --git a/package.json b/package.json index 621205f5e..705132ef6 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dev:prod": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.io concurrently \"npm run start:client\" \"npm run start:server-dev\"", "docs:map-generator": "cd map-generator && go doc -cmd -u -all", "tunnel": "npm run build-prod && npm run start:server", - "test": "vitest run", + "test": "vitest run && vitest run tests/server", "perf": "npx tsx tests/perf/*.ts", "test:coverage": "vitest run --coverage", "format": "prettier --ignore-unknown --write .", diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 1f685a72e..22bac20d6 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -71,6 +71,8 @@ export class GameServer { { winner: ClientSendWinnerMessage; ips: Set } > = new Map(); + private _hasEnded = false; + public desyncCount = 0; constructor( @@ -536,7 +538,7 @@ export class GameServer { } public start() { - if (this._hasStarted) { + if (this._hasStarted || this._hasEnded) { return; } this._hasStarted = true; @@ -639,9 +641,11 @@ export class GameServer { } async end() { + this._hasEnded = true; // Close all WebSocket connections if (this.endTurnIntervalID) { clearInterval(this.endTurnIntervalID); + this.endTurnIntervalID = undefined; } this.websockets.forEach((ws) => { if (ws.readyState === WebSocket.OPEN) { diff --git a/tests/server/GameLifecycle.test.ts b/tests/server/GameLifecycle.test.ts new file mode 100644 index 000000000..067492617 --- /dev/null +++ b/tests/server/GameLifecycle.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/core/configuration/ConfigLoader", () => ({ + getServerConfigFromServer: () => ({ + otelEnabled: () => false, + otelAuthHeader: () => "", + otelEndpoint: () => "", + env: () => 0, // GameEnv.Dev + }), + getServerConfig: () => ({ + otelEnabled: () => false, + }), +})); + +vi.mock("../../src/core/Schemas", async () => { + const actual = (await vi.importActual("../../src/core/Schemas")) as any; + return { + ...actual, + GameStartInfoSchema: { + safeParse: (data: any) => ({ success: true, data: data }), + }, + ServerPrestartMessageSchema: { + safeParse: (data: any) => ({ success: true, data: data }), + }, + }; +}); + +import { GameEnv } from "../../src/core/configuration/Config"; +import { GameType } from "../../src/core/game/Game"; +import { GameServer } from "../../src/server/GameServer"; + +describe("GameLifecycle", () => { + let mockLogger: any; + let mockConfig: any; + + beforeEach(() => { + vi.useFakeTimers(); + mockLogger = { + child: vi.fn().mockReturnThis(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + mockConfig = { + turnIntervalMs: () => 100, + gameCreationRate: () => 1000, + env: () => GameEnv.Dev, + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllTimers(); + }); + + it("should not start turn interval if game has ended", async () => { + const game = new GameServer( + "test-game", + mockLogger, + Date.now(), + mockConfig, + { gameType: GameType.Private } as any, + ); + + // Call end() first - this should set _hasEnded + await game.end(); + + // Now call start() - this should be a no-op due to our fix + game.start(); + + // Check if the interval ID is set (it shouldn't be) + expect((game as any).endTurnIntervalID).toBeUndefined(); + + // Check if _hasStarted remained false (or at least no interval was created) + expect(game.hasStarted()).toBe(false); + }); + + it("should clear turn interval and set _hasEnded on end()", async () => { + // We need to initialize the game such that start() can succeed + const game = new GameServer( + "test-game", + mockLogger, + Date.now(), + mockConfig, + { + gameType: GameType.Private, + gameMap: "plains", + gameMapSize: 100, + } as any, + ); + + // Manually trigger prestart to fulfill some internal checks if necessary + game.prestart(); + + // start() should create the interval + game.start(); + expect((game as any).endTurnIntervalID).toBeDefined(); + + // end() should clear it + await game.end(); + expect((game as any).endTurnIntervalID).toBeUndefined(); + expect((game as any)._hasEnded).toBe(true); + }); + + it("should be resilient to multiple end() calls", async () => { + const game = new GameServer( + "test-game", + mockLogger, + Date.now(), + mockConfig, + { gameType: GameType.Private } as any, + ); + + await game.end(); + expect((game as any)._hasEnded).toBe(true); + + // Should not throw or crash + await expect(game.end()).resolves.toBeUndefined(); + expect((game as any)._hasEnded).toBe(true); + }); +}); From 1dadb5bc73974b583e42662f02d62954b59959b1 Mon Sep 17 00:00:00 2001 From: Mattia Migliorini Date: Wed, 21 Jan 2026 05:32:59 +0100 Subject: [PATCH 067/109] Change join-changed event listener to fix Game Replay functionality (#2968) Resolves #2967 ## Description: The "Replay" action on recent games doesn't work anymore after the release of v29. The problem arises because `AccountModal.viewGame()` correctly calls `history.pushState()` with the game URL and then dispatches the `join-changed` event. The `join-changed` event listener in `Main.ts` calls `onHashUpdate()`, which first calls `JoinPrivateLobbyModal.close()` and then handles the new URL. The problem is that `JoinPrivateLobbyModal.onClose()` resets the modal UI, but also replaces the history state with `/`, therefore `handleUrl()` receives the homepage URL instead of the game URL. This PR fixes the above by creating a dedicated callback for the `join-changed` event (which is dispatched only by `AccountModal` ATM), skipping the `JoinPrivateLobbyModal.close()` call. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: deshack_82603 --- src/client/Main.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/client/Main.ts b/src/client/Main.ts index baa592bab..8858b2f43 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -588,12 +588,8 @@ class Client { const onHashUpdate = () => { // Reset the UI to its initial state this.joinModal?.close(); - if (this.gameStop !== null) { - this.handleLeaveLobby(); - } - // Attempt to join lobby - this.handleUrl(); + onJoinChanged(); }; const onPopState = () => { @@ -627,10 +623,19 @@ class Client { } }; + const onJoinChanged = () => { + if (this.gameStop !== null) { + this.handleLeaveLobby(); + } + + // Attempt to join lobby + this.handleUrl(); + }; + // Handle browser navigation & manual hash edits window.addEventListener("popstate", onPopState); window.addEventListener("hashchange", onHashUpdate); - window.addEventListener("join-changed", onHashUpdate); + window.addEventListener("join-changed", onJoinChanged); function updateSliderProgress(slider: HTMLInputElement) { const percent = From 45b1550f6798b4c05050789fee8976b0bc97a5f4 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Thu, 22 Jan 2026 03:00:15 +0900 Subject: [PATCH 068/109] Allow full language names (#2980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If this PR fixes an issue, link it below. If not, delete these two lines. Resolves #(issue number) ## Description: Remove truncation so long language names wrap instead of ellipsizing before スクリーンショット 2026-01-21 20 47 27 after スクリーンショット 2026-01-21 20 47 50 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: aotumuri --- src/client/LanguageModal.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/LanguageModal.ts b/src/client/LanguageModal.ts index 9f6c09969..6d90c1d3e 100644 --- a/src/client/LanguageModal.ts +++ b/src/client/LanguageModal.ts @@ -75,13 +75,13 @@ export class LanguageModal extends BaseModal { />
    ${lang.native} ${lang.en}
    From fc5eec5fc0559befdfcad3245d8b88d5ef7de696 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Thu, 22 Jan 2026 03:00:55 +0900 Subject: [PATCH 069/109] fix: Update CODEOWNERS (#2981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: The maintainer team name has changed, but it was still being referenced under the old name, so I fixed that. I’m not entirely sure how CODEOWNERS works, but I created the branch in the upstream repository thinking it might be required for the checks. It may not actually be necessary. ## Please complete the following: - [ ] I have added screenshots for all UI updates - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: aotumuri --- CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 7ae1cd56c..a01d757a4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,4 +1,4 @@ # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners -* @openfrontio/review-approver +* @openfrontio/maintainer resources/lang @openfrontio/translation-approver -resources/lang/en.json @openfrontio/review-approver +resources/lang/en.json @openfrontio/maintainer From 207d6b0a2844fbf5de44ca1941db20a0dd0b2e02 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Wed, 21 Jan 2026 15:13:09 -0800 Subject: [PATCH 070/109] cache cosmetics.json on client --- src/client/Cosmetics.ts | 37 ++++++++++++++++++++++--------------- src/client/PatternInput.ts | 16 +--------------- 2 files changed, 23 insertions(+), 30 deletions(-) diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 821d8e25e..d1f00e88d 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -29,23 +29,30 @@ export async function handlePurchase( window.location.href = url; } +let __cosmetics: Promise | null = null; export async function fetchCosmetics(): Promise { - try { - const response = await fetch(`${getApiBase()}/cosmetics.json`); - if (!response.ok) { - console.error(`HTTP error! status: ${response.status}`); - return null; - } - const result = CosmeticsSchema.safeParse(await response.json()); - if (!result.success) { - console.error(`Invalid cosmetics: ${result.error.message}`); - return null; - } - return result.data; - } catch (error) { - console.error("Error getting cosmetics:", error); - return null; + if (__cosmetics !== null) { + return __cosmetics; } + __cosmetics = (async () => { + try { + const response = await fetch(`${getApiBase()}/cosmetics.json`); + if (!response.ok) { + console.error(`HTTP error! status: ${response.status}`); + return null; + } + const result = CosmeticsSchema.safeParse(await response.json()); + if (!result.success) { + console.error(`Invalid cosmetics: ${result.error.message}`); + return null; + } + return result.data; + } catch (error) { + console.error("Error getting cosmetics:", error); + return null; + } + })(); + return __cosmetics; } export function patternRelationship( diff --git a/src/client/PatternInput.ts b/src/client/PatternInput.ts index 7c435c63c..755c7c834 100644 --- a/src/client/PatternInput.ts +++ b/src/client/PatternInput.ts @@ -7,20 +7,6 @@ import { renderPatternPreview } from "./components/PatternButton"; import { fetchCosmetics } from "./Cosmetics"; import { translateText } from "./Utils"; -// Module-level cosmetics cache to avoid refetching on every component mount -let cosmeticsCache: Promise | null = null; - -function getCachedCosmetics(): Promise { - if (!cosmeticsCache) { - const fetchPromise = fetchCosmetics(); - cosmeticsCache = fetchPromise.catch((err) => { - cosmeticsCache = null; - throw err; - }); - } - return cosmeticsCache; -} - @customElement("pattern-input") export class PatternInput extends LitElement { @state() public pattern: PlayerPattern | null = null; @@ -63,7 +49,7 @@ export class PatternInput extends LitElement { super.connectedCallback(); this._abortController = new AbortController(); this.isLoading = true; - const cosmetics = await getCachedCosmetics(); + const cosmetics = await fetchCosmetics(); if (!this.isConnected) return; this.cosmetics = cosmetics; this.updateFromSettings(); From ee4b91a422a821b444731d5a18be20d75cee614d Mon Sep 17 00:00:00 2001 From: Vivacious Box Date: Thu, 22 Jan 2026 00:13:55 +0100 Subject: [PATCH 071/109] Fix nuke telegraph for allies (#2983) ## Description: Adds back the nuke overlay for teammates ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: Mr. Box --- src/client/graphics/layers/DynamicUILayer.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/client/graphics/layers/DynamicUILayer.ts b/src/client/graphics/layers/DynamicUILayer.ts index affd9f0f4..b151a1b83 100644 --- a/src/client/graphics/layers/DynamicUILayer.ts +++ b/src/client/graphics/layers/DynamicUILayer.ts @@ -116,14 +116,25 @@ export class DynamicUILayer implements Layer { } onBombEvent(unit: UnitView) { - if (this.createdThisTick(unit) && this.isOwnedByPlayer(unit)) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) { + return; + } + if ( + this.createdThisTick(unit) && + (unit.owner() === myPlayer || unit.owner().isOnSameTeam(myPlayer)) + ) { const target = new NukeTelegraph(this.transformHandler, this.game, unit); this.uiElements.push(target); } } onTransportShipEvent(unit: UnitView) { - if (this.createdThisTick(unit) && this.isOwnedByPlayer(unit)) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) { + return; + } + if (this.createdThisTick(unit) && unit.owner() === myPlayer) { const target = new NavalTarget(this.transformHandler, this.game, unit); this.uiElements.push(target); } @@ -146,11 +157,6 @@ export class DynamicUILayer implements Layer { } } - private isOwnedByPlayer(unit: UnitView): boolean { - const my = this.game.myPlayer(); - return my !== null && unit.owner() === my; - } - private createdThisTick(unit: UnitView): boolean { return unit.createdAt() === this.game.ticks(); } From ae3adf915c34b9767e114c4a964f74ab2a4af420 Mon Sep 17 00:00:00 2001 From: Vivacious Box Date: Thu, 22 Jan 2026 00:13:55 +0100 Subject: [PATCH 072/109] Fix nuke telegraph for allies (#2983) ## Description: Adds back the nuke overlay for teammates ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: Mr. Box --- src/client/graphics/layers/DynamicUILayer.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/client/graphics/layers/DynamicUILayer.ts b/src/client/graphics/layers/DynamicUILayer.ts index affd9f0f4..b151a1b83 100644 --- a/src/client/graphics/layers/DynamicUILayer.ts +++ b/src/client/graphics/layers/DynamicUILayer.ts @@ -116,14 +116,25 @@ export class DynamicUILayer implements Layer { } onBombEvent(unit: UnitView) { - if (this.createdThisTick(unit) && this.isOwnedByPlayer(unit)) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) { + return; + } + if ( + this.createdThisTick(unit) && + (unit.owner() === myPlayer || unit.owner().isOnSameTeam(myPlayer)) + ) { const target = new NukeTelegraph(this.transformHandler, this.game, unit); this.uiElements.push(target); } } onTransportShipEvent(unit: UnitView) { - if (this.createdThisTick(unit) && this.isOwnedByPlayer(unit)) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) { + return; + } + if (this.createdThisTick(unit) && unit.owner() === myPlayer) { const target = new NavalTarget(this.transformHandler, this.game, unit); this.uiElements.push(target); } @@ -146,11 +157,6 @@ export class DynamicUILayer implements Layer { } } - private isOwnedByPlayer(unit: UnitView): boolean { - const my = this.game.myPlayer(); - return my !== null && unit.owner() === my; - } - private createdThisTick(unit: UnitView): boolean { return unit.createdAt() === this.game.ticks(); } From 542ada969b6407d32b550ad1df57cd56f0c9d590 Mon Sep 17 00:00:00 2001 From: Mattia Migliorini Date: Thu, 22 Jan 2026 13:21:14 +0100 Subject: [PATCH 073/109] Replace donate buttons with attack ones for AFK friendly players in radial menu (#2987) Resolves #2986 ## Description: Shows donate actions in radial menu only when friendly player is NOT disconnected. This is needed in order to let mobile/touch users attack AFK teammates. Current behavior: image With this PR: image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: deshack_82603 --- src/client/graphics/layers/MainRadialMenu.ts | 4 +++- src/client/graphics/layers/RadialMenuElements.ts | 14 +++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts index 3151c6a48..c0b707577 100644 --- a/src/client/graphics/layers/MainRadialMenu.ts +++ b/src/client/graphics/layers/MainRadialMenu.ts @@ -134,7 +134,9 @@ export class MainRadialMenu extends LitElement implements Layer { }; const isFriendlyTarget = - recipient !== null && recipient.isFriendly(myPlayer); + recipient !== null && + recipient.isFriendly(myPlayer) && + !recipient.isDisconnected(); this.radialMenu.setCenterButtonAppearance( isFriendlyTarget ? donateTroopIcon : swordIcon, diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 1dcf3eb90..0cf28cebb 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -117,6 +117,14 @@ function isFriendlyTarget(params: MenuElementParams): boolean { return isFriendly.call(selectedPlayer, params.myPlayer); } +function isDisconnectedTarget(params: MenuElementParams): boolean { + const selectedPlayer = params.selected; + if (selectedPlayer === null) return false; + const isDisconnected = (selectedPlayer as PlayerView).isDisconnected; + if (typeof isDisconnected !== "function") return false; + return isDisconnected.call(selectedPlayer); +} + // eslint-disable-next-line @typescript-eslint/no-unused-vars const infoChatElement: MenuElement = { id: "info_chat", @@ -571,7 +579,7 @@ export const centerButtonElement: CenterButtonElement = { return false; } - if (isFriendlyTarget(params)) { + if (isFriendlyTarget(params) && !isDisconnectedTarget(params)) { return !params.playerActions.interaction?.canDonateTroops; } @@ -581,7 +589,7 @@ export const centerButtonElement: CenterButtonElement = { if (params.game.inSpawnPhase()) { params.playerActionHandler.handleSpawn(params.tile); } else { - if (isFriendlyTarget(params)) { + if (isFriendlyTarget(params) && !isDisconnectedTarget(params)) { const selectedPlayer = params.selected as PlayerView; const ratio = params.uiState?.attackRatio ?? 1; const troopsToDonate = Math.floor(ratio * params.myPlayer.troops()); @@ -626,7 +634,7 @@ export const rootMenuElement: MenuElement = { : [ boatMenuElement, ally, - isFriendlyTarget(params) + isFriendlyTarget(params) && !isDisconnectedTarget(params) ? donateGoldRadialElement : attackMenuElement, ]), From ec11d318b339f9fa394771350c3b2a177e008570 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Thu, 22 Jan 2026 23:34:57 +0100 Subject: [PATCH 074/109] Fix: Nuking an Ally that is Disconnected shows a Red background ghost. Shouldn't be Red as not a Betrayal (#2988) ## Description: Previous behavior: https://youtu.be/Lv0RuBYh9qw?t=1359 New behavior: https://github.com/user-attachments/assets/acfcc4f0-157e-44a0-be28-802927a3c787 ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/client/graphics/layers/StructureIconsLayer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index a8a7862dc..6f57e10c9 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -270,8 +270,8 @@ export class StructureIconsLayer implements Layer { myPlayer && (nukeType === UnitType.AtomBomb || nukeType === UnitType.HydrogenBomb) ) { - // Only check if player has allies - const allies = myPlayer.allies(); + // Only check connected allies - nuking disconnected allies doesn't cause a traitor debuff + const allies = myPlayer.allies().filter((a) => !a.isDisconnected()); if (allies.length > 0) { targetingAlly = wouldNukeBreakAlliance({ game: this.game, From c90435fc202d1e8ea10a19e88557b09263ac7683 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Thu, 22 Jan 2026 23:34:57 +0100 Subject: [PATCH 075/109] Fix: Nuking an Ally that is Disconnected shows a Red background ghost. Shouldn't be Red as not a Betrayal (#2988) ## Description: Previous behavior: https://youtu.be/Lv0RuBYh9qw?t=1359 New behavior: https://github.com/user-attachments/assets/acfcc4f0-157e-44a0-be28-802927a3c787 ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/client/graphics/layers/StructureIconsLayer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index a8a7862dc..6f57e10c9 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -270,8 +270,8 @@ export class StructureIconsLayer implements Layer { myPlayer && (nukeType === UnitType.AtomBomb || nukeType === UnitType.HydrogenBomb) ) { - // Only check if player has allies - const allies = myPlayer.allies(); + // Only check connected allies - nuking disconnected allies doesn't cause a traitor debuff + const allies = myPlayer.allies().filter((a) => !a.isDisconnected()); if (allies.length > 0) { targetingAlly = wouldNukeBreakAlliance({ game: this.game, From 20c9335d47294ea33b3b91488466f8175c2cbf42 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Thu, 22 Jan 2026 23:52:34 +0100 Subject: [PATCH 076/109] =?UTF-8?q?Fix=20NewsModal=20design=20a=20bit=20?= =?UTF-8?q?=F0=9F=96=8C=EF=B8=8F=20(#3002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Fix \
      margins. Maybe for v29, so people get less eye cancer 😄 Previous: Screenshot 2026-01-22 234602 Now: Screenshot 2026-01-22 234535 ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/client/NewsModal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/NewsModal.ts b/src/client/NewsModal.ts index 5a66d4c9e..4c67f10c8 100644 --- a/src/client/NewsModal.ts +++ b/src/client/NewsModal.ts @@ -33,7 +33,7 @@ export class NewsModal extends BaseModal { [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-4 [&_h1]:text-white [&_h1]:border-b [&_h1]:border-white/10 [&_h1]:pb-2 [&_h2]:text-xl [&_h2]:font-bold [&_h2]:mt-6 [&_h2]:mb-3 [&_h2]:text-blue-200 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mt-4 [&_h3]:mb-2 [&_h3]:text-blue-100 - [&_ul]:pl-5 [&_ul]:list-disc [&_ul]:space-y-1 + [&_ul]:pl-5 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:space-y-1 [&_li]:text-gray-300 [&_li]:leading-relaxed [&_p]:text-gray-300 [&_p]:mb-3 [&_strong]:text-white [&_strong]:font-bold scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent" From be958dd6c2f1ffe0a9471f97d0aabb326243fd2b Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Thu, 22 Jan 2026 23:52:34 +0100 Subject: [PATCH 077/109] =?UTF-8?q?Fix=20NewsModal=20design=20a=20bit=20?= =?UTF-8?q?=F0=9F=96=8C=EF=B8=8F=20(#3002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Fix \
        margins. Maybe for v29, so people get less eye cancer 😄 Previous: Screenshot 2026-01-22 234602 Now: Screenshot 2026-01-22 234535 ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/client/NewsModal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/NewsModal.ts b/src/client/NewsModal.ts index 5a66d4c9e..4c67f10c8 100644 --- a/src/client/NewsModal.ts +++ b/src/client/NewsModal.ts @@ -33,7 +33,7 @@ export class NewsModal extends BaseModal { [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-4 [&_h1]:text-white [&_h1]:border-b [&_h1]:border-white/10 [&_h1]:pb-2 [&_h2]:text-xl [&_h2]:font-bold [&_h2]:mt-6 [&_h2]:mb-3 [&_h2]:text-blue-200 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mt-4 [&_h3]:mb-2 [&_h3]:text-blue-100 - [&_ul]:pl-5 [&_ul]:list-disc [&_ul]:space-y-1 + [&_ul]:pl-5 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:space-y-1 [&_li]:text-gray-300 [&_li]:leading-relaxed [&_p]:text-gray-300 [&_p]:mb-3 [&_strong]:text-white [&_strong]:font-bold scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent" From 9415162f51e8ca08f6405937d07f95c24bac15be Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Fri, 23 Jan 2026 04:19:51 +0100 Subject: [PATCH 078/109] Split railroads when placing overlapping structures (#3003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Players wrongly assume that building a structure over an existing railroad will connect it properly. What actually happens is that the structure will connect on the network with its own railroad, even if the new railroads are overlapping over the existing network. To address this issue, this PR splits the overlapping railroad into two segments when a structure is built over it, and inserts the structure as a new node in the rail graph. It does not alter the rail network visually because the same railroad tiles are used for the new segments. Railroad tiles are not stored directly in the map, they exist only as edges in the rail graph, so looking for nearby rails would be terribly inefficient. To address that, this PR introduces a new `RailSpatialGrid` class which indexes rails on a 4×4 grid, allowing fast spatial queries. Alternative considered: removing overlapping rails and rebuilding them from the new structure. It would visually modify the rail network, which may be unexpected for the player. It's still missing a visual indicator so the player knows that the structures has been connected properly. ### Line placement: ![snap_line](https://github.com/user-attachments/assets/f24ddd36-1594-4316-91ff-093a5cebd576) ### Multi-railroad overlap: ![snap_cross](https://github.com/user-attachments/assets/b2cc962e-6dce-4444-b689-7e04a09de603) ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: IngloriousTom --- src/core/game/RailNetworkImpl.ts | 67 ++++++++++++++++++- src/core/game/Railroad.ts | 20 ++++++ src/core/game/RailroadSpatialGrid.ts | 97 ++++++++++++++++++++++++++++ tests/core/game/RailNetwork.test.ts | 2 + 4 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 src/core/game/RailroadSpatialGrid.ts diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index d3aef952b..b56599beb 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -4,6 +4,7 @@ import { Game, Unit, UnitType } from "./Game"; import { TileRef } from "./GameMap"; import { RailNetwork } from "./RailNetwork"; import { Railroad } from "./Railroad"; +import { RailSpatialGrid } from "./RailroadSpatialGrid"; import { Cluster, TrainStation } from "./TrainStation"; /** @@ -81,12 +82,17 @@ export function createRailNetwork(game: Game): RailNetwork { export class RailNetworkImpl implements RailNetwork { private maxConnectionDistance: number = 4; + private stationRadius: number = 3; + private gridCellSize: number = 4; + private railGrid: RailSpatialGrid; constructor( private game: Game, private _stationManager: StationManager, private pathService: RailPathFinderService, - ) {} + ) { + this.railGrid = new RailSpatialGrid(game, this.gridCellSize); // 4x4 tiles spatial grid + } stationManager(): StationManager { return this._stationManager; @@ -94,7 +100,9 @@ export class RailNetworkImpl implements RailNetwork { connectStation(station: TrainStation) { this._stationManager.addStation(station); - this.connectToNearbyStations(station); + if (!this.connectToExistingRails(station)) { + this.connectToNearbyStations(station); + } } removeStation(unit: Unit): void { @@ -126,6 +134,59 @@ export class RailNetworkImpl implements RailNetwork { return this.pathService.findStationsPath(from, to); } + private connectToExistingRails(station: TrainStation): boolean { + const rails = this.railGrid.query(station.tile(), this.stationRadius); + + const editedClusters = new Set(); + for (const rail of rails) { + const from = rail.from; + const to = rail.to; + const closestRailIndex = rail.getClosestTileIndex( + this.game, + station.tile(), + ); + if (closestRailIndex === 0 || closestRailIndex >= rail.tiles.length) { + continue; + } + + // Disconnect current rail as it will become invalid + from.removeRailroad(rail); + to.removeRailroad(rail); + this.railGrid.unregister(rail); + + const newRailFrom = new Railroad( + from, + station, + rail.tiles.slice(0, closestRailIndex), + ); + const newRailTo = new Railroad( + station, + to, + rail.tiles.slice(closestRailIndex), + ); + + // New station is connected to both new rails + station.addRailroad(newRailFrom); + station.addRailroad(newRailTo); + // From and to are connected to the new segments + from.addRailroad(newRailFrom); + to.addRailroad(newRailTo); + + this.railGrid.register(newRailTo); + this.railGrid.register(newRailFrom); + const cluster = from.getCluster(); + if (cluster) { + cluster.addStation(station); + editedClusters.add(cluster); + } + } + // If multiple clusters own the new station, merge them into a single cluster + if (editedClusters.size > 1) { + this.mergeClusters(editedClusters); + } + return editedClusters.size !== 0; + } + private connectToNearbyStations(station: TrainStation) { const neighbors = this.game.nearbyUnits( station.tile(), @@ -176,6 +237,7 @@ export class RailNetworkImpl implements RailNetwork { private disconnectFromNetwork(station: TrainStation) { for (const rail of station.getRailroads()) { rail.delete(this.game); + this.railGrid.unregister(rail); } station.clearRailroads(); const cluster = station.getCluster(); @@ -198,6 +260,7 @@ export class RailNetworkImpl implements RailNetwork { this.game.addExecution(new RailroadExecution(railRoad)); from.addRailroad(railRoad); to.addRailroad(railRoad); + this.railGrid.register(railRoad); return true; } return false; diff --git a/src/core/game/Railroad.ts b/src/core/game/Railroad.ts index 8b0f3086a..00b5d9c37 100644 --- a/src/core/game/Railroad.ts +++ b/src/core/game/Railroad.ts @@ -23,6 +23,26 @@ export class Railroad { this.from.removeRailroad(this); this.to.removeRailroad(this); } + + getClosestTileIndex(game: Game, to: TileRef): number { + if (this.tiles.length === 0) return -1; + const toX = game.x(to); + const toY = game.y(to); + let closestIndex = 0; + let minDistSquared = Infinity; + for (let i = 0; i < this.tiles.length; i++) { + const tile = this.tiles[i]; + const dx = game.x(tile) - toX; + const dy = game.y(tile) - toY; + const distSquared = dx * dx + dy * dy; + + if (distSquared < minDistSquared) { + minDistSquared = distSquared; + closestIndex = i; + } + } + return closestIndex; + } } export function getOrientedRailroad( diff --git a/src/core/game/RailroadSpatialGrid.ts b/src/core/game/RailroadSpatialGrid.ts new file mode 100644 index 000000000..27e57fcae --- /dev/null +++ b/src/core/game/RailroadSpatialGrid.ts @@ -0,0 +1,97 @@ +import { GameMap, TileRef } from "./GameMap"; +import { Railroad } from "./Railroad"; + +export class RailSpatialGrid { + private cells = new Map>(); + // Quick access to avoid iterating over the cells + private railToCells = new Map>(); + + constructor( + private game: GameMap, + private cellSize: number, + ) { + if (cellSize <= 0) { + throw new Error("cellSize must be > 0"); + } + } + + register(rail: Railroad) { + // Defensive: avoid double-registration but it should never happen + this.unregister(rail); + + const railCells = new Set(); + + for (const tile of rail.tiles) { + const { cx, cy } = this.cellOf(this.game.x(tile), this.game.y(tile)); + const k = this.key(cx, cy); + if (railCells.has(k)) continue; + + let set = this.cells.get(k); + if (!set) { + set = new Set(); + this.cells.set(k, set); + } + railCells.add(k); + set.add(rail); + } + + if (railCells.size > 0) { + this.railToCells.set(rail, railCells); + } + } + + unregister(rail: Railroad) { + const keys = this.railToCells.get(rail); + if (!keys) return; + + for (const k of keys) { + const set = this.cells.get(k); + if (!set) continue; + set.delete(rail); + + if (set.size === 0) { + this.cells.delete(k); + } + } + + this.railToCells.delete(rail); + } + + query(tile: TileRef, radius: number): Set { + const x = this.game.x(tile); + const y = this.game.y(tile); + + const minX = x - radius; + const minY = y - radius; + const maxX = x + radius; + const maxY = y + radius; + + const c0 = this.cellOf(minX, minY); + const c1 = this.cellOf(maxX, maxY); + + const result = new Set(); + + for (let cx = c0.cx; cx <= c1.cx; cx++) { + for (let cy = c0.cy; cy <= c1.cy; cy++) { + const set = this.cells.get(this.key(cx, cy)); + if (!set) continue; + for (const rail of set) { + result.add(rail); + } + } + } + + return result; + } + + private key(cx: number, cy: number): string { + return `${cx}:${cy}`; + } + + private cellOf(x: number, y: number): { cx: number; cy: number } { + return { + cx: Math.floor(x / this.cellSize), + cy: Math.floor(y / this.cellSize), + }; + } +} diff --git a/tests/core/game/RailNetwork.test.ts b/tests/core/game/RailNetwork.test.ts index d77ca10dd..fc39db81d 100644 --- a/tests/core/game/RailNetwork.test.ts +++ b/tests/core/game/RailNetwork.test.ts @@ -71,6 +71,8 @@ describe("RailNetworkImpl", () => { trainStationMinRange: () => 10, railroadMaxSize: () => 100, }), + x: vi.fn(() => 0), + y: vi.fn(() => 0), }; network = new RailNetworkImpl(game, stationManager, pathService); From f7d3c2e0bcc91b9f18aa0019af07328b07c11e1e Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:33:04 +0100 Subject: [PATCH 079/109] =?UTF-8?q?Nations=20donate=20troops=20now=20?= =?UTF-8?q?=F0=9F=92=80=20(In=20team=20games)=20(#2984)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: For v29, balances the HvN winrate. In team games, nations now donate troops to their weakest team members (if they have no attack options available). How often they donate depends on the difficulty. This PR also has some other little fixes: - For HvN games, always return true in `shouldAttack()` (make nations a bit more aggressive). - Early exit in `attackWithRandomBoat()` for performance - Early exit in `findNearestIslandEnemy()` for performance AND to make sure nations which are encircled by friends don't run into this method (=> no donation happening!) ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- .../execution/nation/NationEmojiBehavior.ts | 55 +---- src/core/execution/utils/AiAttackBehavior.ts | 206 ++++++++++++++---- src/core/game/Game.ts | 1 + src/core/game/GameImpl.ts | 6 + 4 files changed, 184 insertions(+), 84 deletions(-) diff --git a/src/core/execution/nation/NationEmojiBehavior.ts b/src/core/execution/nation/NationEmojiBehavior.ts index 7507466f4..e62515bfd 100644 --- a/src/core/execution/nation/NationEmojiBehavior.ts +++ b/src/core/execution/nation/NationEmojiBehavior.ts @@ -6,7 +6,6 @@ import { Player, PlayerType, Relation, - Team, Tick, } from "../../game/Game"; import { PseudoRandom } from "../../PseudoRandom"; @@ -55,6 +54,8 @@ export class NationEmojiBehavior { ) {} maybeSendCasualEmoji() { + if (this.gameOver) return; + this.checkOverwhelmedByAttacks(); this.checkVerySmallAttack(); this.congratulateWinner(); @@ -107,60 +108,23 @@ export class NationEmojiBehavior { // Check if game is over - send congratulations private congratulateWinner(): void { - if (this.gameOver) return; + const winner = this.game.getWinner(); + if (winner === null) return; + + this.gameOver = true; - const percentToWin = this.game.config().percentageTilesOwnedToWin(); - const numTilesWithoutFallout = - this.game.numLandTiles() - this.game.numTilesWithFallout(); const isTeamGame = this.game.config().gameConfig().gameMode === GameMode.Team; if (isTeamGame) { // Team game: all nations congratulate if another team won - const teamToTiles = new Map(); - for (const player of this.game.players()) { - const team = player.team(); - if (team === null) continue; - teamToTiles.set( - team, - (teamToTiles.get(team) ?? 0) + player.numTilesOwned(), - ); - } - - const sorted = Array.from(teamToTiles.entries()).sort( - (a, b) => b[1] - a[1], - ); - if (sorted.length === 0) return; - - const [winningTeam, winningTiles] = sorted[0]; - const winningPercent = (winningTiles / numTilesWithoutFallout) * 100; - if (winningPercent < percentToWin) return; - - this.gameOver = true; - // Don't congratulate if it's our own team - if (winningTeam === this.player.team()) return; + if (winner === this.player.team()) return; this.sendEmoji(AllPlayers, EMOJI_CONGRATULATE); } else { // FFA game: The largest nation congratulates if a human player won - const sorted = this.game - .players() - .sort((a, b) => b.numTilesOwned() - a.numTilesOwned()); - - if (sorted.length === 0) return; - - const firstPlace = sorted[0]; - - // Check if first place has won (crossed the win threshold) - const firstPlacePercent = - (firstPlace.numTilesOwned() / numTilesWithoutFallout) * 100; - if (firstPlacePercent < percentToWin) return; - - this.gameOver = true; - - // Only send if first place is a human - if (firstPlace.type() !== PlayerType.Human) return; + if (typeof winner === "string") return; // It's a team, not a player // Only the largest nation sends the congratulation const largestNation = this.game @@ -169,13 +133,12 @@ export class NationEmojiBehavior { .sort((a, b) => b.numTilesOwned() - a.numTilesOwned())[0]; if (largestNation !== this.player) return; - this.sendEmoji(firstPlace, EMOJI_CONGRATULATE); + this.sendEmoji(winner, EMOJI_CONGRATULATE); } } // Brag with our crown private brag(): void { - if (this.gameOver) return; if (!this.random.chance(300)) return; const sorted = this.game diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index fd252316b..5d6cedc08 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -1,11 +1,14 @@ import { Difficulty, Game, + GameMode, + HumansVsNations, Player, PlayerID, PlayerType, Relation, TerraNullius, + UnitType, } from "../../game/Game"; import { TileRef } from "../../game/GameMap"; import { canBuildTransportShip } from "../../game/TransportShipUtils"; @@ -16,6 +19,7 @@ import { calculateBoundingBoxCenter, } from "../../Util"; import { AttackExecution } from "../AttackExecution"; +import { DonateTroopsExecution } from "../DonateTroopExecution"; import { NationAllianceBehavior } from "../nation/NationAllianceBehavior"; import { EMOJI_ASSIST_ACCEPT, @@ -94,6 +98,16 @@ export class AiAttackBehavior { private attackWithRandomBoat(borderingEnemies: Player[] = []) { if (this.player === null) throw new Error("not initialized"); + + // Check if we've already sent out the maximum number of transport ships + if ( + this.player.unitCount(UnitType.TransportShip) >= + this.game.config().boatMaxNumber() + ) { + return; + } + + // Check if we have any ocean shore tiles to launch from const oceanShore = Array.from(this.player.borderTiles()).filter((t) => this.game.isOceanShore(t), ); @@ -309,6 +323,8 @@ export class AiAttackBehavior { return false; }; + const donate = (): boolean => this.donateTroops(); + // Return strategies in order based on difficulty // Easy nations get the dumbest order, impossible nations get the smartest order switch (difficulty) { @@ -317,13 +333,13 @@ export class AiAttackBehavior { return [nuked, bots, retaliate, assist, betray, hated, weakest]; case Difficulty.Medium: // prettier-ignore - return [bots, nuked, retaliate, assist, betray, hated, afk, traitor, weakest, island]; + return [bots, nuked, retaliate, assist, betray, hated, afk, traitor, weakest, island, donate]; case Difficulty.Hard: // prettier-ignore - return [bots, retaliate, assist, betray, nuked, traitor, afk, hated, veryWeak, victim, weakest, island]; + return [bots, retaliate, assist, betray, nuked, traitor, afk, hated, veryWeak, victim, weakest, island, donate]; case Difficulty.Impossible: // prettier-ignore - return [retaliate, bots, veryWeak, assist, traitor, afk, betray, victim, nuked, hated, weakest, island]; + return [retaliate, bots, veryWeak, assist, traitor, afk, betray, victim, nuked, hated, weakest, island, donate]; default: assertNever(difficulty); } @@ -519,54 +535,67 @@ export class AiAttackBehavior { } private findNearestIslandEnemy(): Player | null { - const myBorder = this.player.borderTiles(); - if (myBorder.size === 0) return null; + // Check if we've already sent out the maximum number of transport ships + if ( + this.player.unitCount(UnitType.TransportShip) >= + this.game.config().boatMaxNumber() + ) { + return null; + } + + // Check if we have any ocean shore tiles to launch from + const hasOceanShore = Array.from(this.player.borderTiles()).some((t) => + this.game.isOceanShore(t), + ); + if (!hasOceanShore) return null; const filteredPlayers = this.game.players().filter((p) => { if (p === this.player) return false; - if (!p.isAlive()) return false; - if (p.borderTiles().size === 0) return false; if (this.player.isFriendly(p)) return false; // Don't spam boats into players with more troops return p.troops() < this.player.troops(); }); - if (filteredPlayers.length > 0) { - const playerCenter = this.getPlayerCenter(this.player); + if (filteredPlayers.length === 0) return null; - const sortedPlayers = filteredPlayers - .map((filteredPlayer) => { - const filteredPlayerCenter = this.getPlayerCenter(filteredPlayer); + const playerCenter = this.getPlayerCenter(this.player); - const playerCenterTile = this.game.ref( - playerCenter.x, - playerCenter.y, - ); - const filteredPlayerCenterTile = this.game.ref( - filteredPlayerCenter.x, - filteredPlayerCenter.y, - ); + const sortedPlayers = filteredPlayers + .map((filteredPlayer) => { + const filteredPlayerCenter = this.getPlayerCenter(filteredPlayer); - const distance = this.game.manhattanDist( - playerCenterTile, - filteredPlayerCenterTile, - ); - return { player: filteredPlayer, distance }; - }) - .sort((a, b) => a.distance - b.distance); // Sort by distance (ascending) + const playerCenterTile = this.game.ref(playerCenter.x, playerCenter.y); + const filteredPlayerCenterTile = this.game.ref( + filteredPlayerCenter.x, + filteredPlayerCenter.y, + ); - // Select the nearest or second-nearest enemy (So our boat doesn't always run into the same warship, if there is one) - let selectedEnemy: Player | null; - if (sortedPlayers.length > 1 && this.random.chance(2)) { - selectedEnemy = sortedPlayers[1].player; - } else { - selectedEnemy = sortedPlayers[0].player; - } + const distance = this.game.manhattanDist( + playerCenterTile, + filteredPlayerCenterTile, + ); + return { player: filteredPlayer, distance }; + }) + .sort((a, b) => a.distance - b.distance); // Sort by distance (ascending) - if (selectedEnemy !== null) { - return selectedEnemy; + // Try players in order of distance until we find one reachable by boat + for (const entry of sortedPlayers) { + const closest = closestTwoTiles( + this.game, + Array.from(this.player.borderTiles()).filter((t) => + this.game.isOceanShore(t), + ), + Array.from(entry.player.borderTiles()).filter((t) => + this.game.isOceanShore(t), + ), + ); + if (closest === null) continue; + + if (canBuildTransportShip(this.game, this.player, closest.y)) { + return entry.player; } } + return null; } @@ -646,12 +675,14 @@ export class AiAttackBehavior { } shouldAttack(other: Player | TerraNullius): boolean { - // Always attack Terra Nullius, non-humans and traitors (or if we are a bot) if ( + // Always attack Terra Nullius, non-humans and traitors other.isPlayer() === false || other.type() !== PlayerType.Human || other.isTraitor() || - this.player.type() === PlayerType.Bot + // Always attack if we are a bot or in an HvN game + this.player.type() === PlayerType.Bot || + this.game.config().gameConfig().playerTeams === HumansVsNations ) { return true; } @@ -718,6 +749,10 @@ export class AiAttackBehavior { return; } + if (!canBuildTransportShip(this.game, this.player, closest.y)) { + return; + } + let troops; if (target.type() === PlayerType.Bot) { troops = this.calculateBotAttackTroops(target, this.player.troops() / 5); @@ -759,4 +794,99 @@ export class AiAttackBehavior { this.botAttackTroopsSent += troops; return troops; } + + private donateTroops(): boolean { + // Only donate in team games + if (this.game.config().gameConfig().gameMode !== GameMode.Team) { + return false; + } + + // Check if donating troops is allowed + if (this.game.config().donateTroops() === false) { + return false; + } + + // Don't donate if the game has a winner + if (this.game.getWinner() !== null) { + return false; + } + + // Skip donating based on difficulty + const { difficulty } = this.game.config().gameConfig(); + switch (difficulty) { + case Difficulty.Easy: + // Easy nations don't donate + return false; + case Difficulty.Medium: + // Medium nations donate 25% of the time + if (!this.random.chance(4)) { + return false; + } + break; + case Difficulty.Hard: + // Hard nations donate 50% of the time + if (!this.random.chance(2)) { + return false; + } + break; + case Difficulty.Impossible: + // Impossible nations always try to donate + break; + default: + assertNever(difficulty); + } + + // Find teammates who are currently in combat + const teammates = this.game + .players() + .filter((p) => this.player.isOnSameTeam(p)) + .filter( + (p) => p.incomingAttacks().length > 0 || p.outgoingAttacks().length > 0, + ); + + if (teammates.length === 0) { + return false; + } + + // Find teammate with lowest troop percentage (troops / maxTroops) + const teammatesWithTroopPercentage = teammates + .map((teammate) => { + const maxTroops = this.game.config().maxTroops(teammate); + const troopPercentage = teammate.troops() / Math.max(maxTroops, 1); + return { teammate, troopPercentage }; + }) + .sort((a, b) => a.troopPercentage - b.troopPercentage); + + // Try to donate to teammates in order of lowest troop percentage + let selectedTeammate: Player | null = null; + for (const entry of teammatesWithTroopPercentage) { + if (this.player.canDonateTroops(entry.teammate)) { + selectedTeammate = entry.teammate; + break; + } + } + + if (selectedTeammate === null) { + return false; + } + + // Donate a portion of our troops (keeping reserve) + const maxTroops = this.game.config().maxTroops(this.player); + const troopsToKeep = maxTroops * this.reserveRatio; + const availableTroops = this.player.troops() - troopsToKeep; + + if (availableTroops < 1) { + return false; + } + + this.game.addExecution( + new DonateTroopsExecution( + this.player, + selectedTeammate.id(), + availableTroops, + ), + ); + + return true; + } } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 313bd58de..1c56d5d46 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -755,6 +755,7 @@ export interface Game extends GameMap { inSpawnPhase(): boolean; executeNextTick(): GameUpdates; setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void; + getWinner(): Player | Team | null; config(): Config; isPaused(): boolean; setPaused(paused: boolean): void; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index a2bd1c902..4a82a20ea 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -92,6 +92,7 @@ export class GameImpl implements Game { private nextAllianceID: number = 0; private _isPaused: boolean = false; + private _winner: Player | Team | null = null; private _miniWaterGraph: AbstractGraph | null = null; private _miniWaterHPA: AStarWaterHierarchical | null = null; @@ -712,6 +713,7 @@ export class GameImpl implements Game { } setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void { + this._winner = winner; this.addUpdate({ type: GameUpdateType.Win, winner: this.makeWinner(winner), @@ -719,6 +721,10 @@ export class GameImpl implements Game { }); } + getWinner(): Player | Team | null { + return this._winner; + } + private makeWinner(winner: string | Player): Winner | undefined { if (typeof winner === "string") { return [ From d4e09644b018baeacf62a2a7d580aac311162c86 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 12:38:30 -0800 Subject: [PATCH 080/109] Bump lodash from 4.17.21 to 4.17.23 in the npm_and_yarn group across 1 directory (#3006) Bumps the npm_and_yarn group with 1 update in the / directory: [lodash](https://github.com/lodash/lodash). Updates `lodash` from 4.17.21 to 4.17.23
        Commits

        [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=lodash&package-manager=npm_and_yarn&previous-version=4.17.21&new-version=4.17.23)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
        Dependabot commands and options
        You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/openfrontio/OpenFrontIO/network/alerts).
        Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 26725da4d..a5a6901b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9201,9 +9201,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, From dfd6a1f5f9430c8da810b36b4c75b5bfdc726f51 Mon Sep 17 00:00:00 2001 From: Mattia Migliorini Date: Thu, 22 Jan 2026 13:21:14 +0100 Subject: [PATCH 081/109] Replace donate buttons with attack ones for AFK friendly players in radial menu (#2987) Resolves #2986 ## Description: Shows donate actions in radial menu only when friendly player is NOT disconnected. This is needed in order to let mobile/touch users attack AFK teammates. Current behavior: image With this PR: image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: deshack_82603 --- src/client/graphics/layers/MainRadialMenu.ts | 4 +++- src/client/graphics/layers/RadialMenuElements.ts | 14 +++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts index 3151c6a48..c0b707577 100644 --- a/src/client/graphics/layers/MainRadialMenu.ts +++ b/src/client/graphics/layers/MainRadialMenu.ts @@ -134,7 +134,9 @@ export class MainRadialMenu extends LitElement implements Layer { }; const isFriendlyTarget = - recipient !== null && recipient.isFriendly(myPlayer); + recipient !== null && + recipient.isFriendly(myPlayer) && + !recipient.isDisconnected(); this.radialMenu.setCenterButtonAppearance( isFriendlyTarget ? donateTroopIcon : swordIcon, diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 1dcf3eb90..0cf28cebb 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -117,6 +117,14 @@ function isFriendlyTarget(params: MenuElementParams): boolean { return isFriendly.call(selectedPlayer, params.myPlayer); } +function isDisconnectedTarget(params: MenuElementParams): boolean { + const selectedPlayer = params.selected; + if (selectedPlayer === null) return false; + const isDisconnected = (selectedPlayer as PlayerView).isDisconnected; + if (typeof isDisconnected !== "function") return false; + return isDisconnected.call(selectedPlayer); +} + // eslint-disable-next-line @typescript-eslint/no-unused-vars const infoChatElement: MenuElement = { id: "info_chat", @@ -571,7 +579,7 @@ export const centerButtonElement: CenterButtonElement = { return false; } - if (isFriendlyTarget(params)) { + if (isFriendlyTarget(params) && !isDisconnectedTarget(params)) { return !params.playerActions.interaction?.canDonateTroops; } @@ -581,7 +589,7 @@ export const centerButtonElement: CenterButtonElement = { if (params.game.inSpawnPhase()) { params.playerActionHandler.handleSpawn(params.tile); } else { - if (isFriendlyTarget(params)) { + if (isFriendlyTarget(params) && !isDisconnectedTarget(params)) { const selectedPlayer = params.selected as PlayerView; const ratio = params.uiState?.attackRatio ?? 1; const troopsToDonate = Math.floor(ratio * params.myPlayer.troops()); @@ -626,7 +634,7 @@ export const rootMenuElement: MenuElement = { : [ boatMenuElement, ally, - isFriendlyTarget(params) + isFriendlyTarget(params) && !isDisconnectedTarget(params) ? donateGoldRadialElement : attackMenuElement, ]), From 87dab71e42b7309daa99c65314978a8f03cd5c0d Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sat, 24 Jan 2026 15:50:54 -0800 Subject: [PATCH 082/109] delay 1v1 game start from 5s=>7s to give more time for players to join the game --- src/server/Worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 32b2eff6e..e9f340c27 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -531,7 +531,7 @@ async function startMatchmakingPolling(gm: GameManager) { // Wait a few seconds to allow clients to connect. console.log(`Starting game ${gameId}`); game.start(); - }, 5000); + }, 7000); } } catch (error) { log.error(`Error polling lobby:`, error); From de3794313db5bbef8bb0f697c1f011f4629cff3e Mon Sep 17 00:00:00 2001 From: Mitchell Zinck Date: Sat, 24 Jan 2026 23:55:58 -0500 Subject: [PATCH 083/109] feat: Kick player in game (#2969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If this PR fixes an issue, link it below. If not, delete these two lines. Resolves #2686 ## Description: - Implemented feature for lobby creator to kick players in game. - Added new moderation option for lobby creator, with a kick player option if they aren't the creator, a bot, and exist in game. - Includes a confirm kick option, and keeps track of kicked players so that the kick option changes to "Already Kicked" if the kicked player panel is opened again on the kicked player. Screenshot order: 1) Open player panel 2) Click on moderation 3) Click on kick player and confirm kick 4) Player is kicked, open same player panel again and observe change in kick status 5) Receiving player kick message Screenshot 2026-01-20 at 12 33
55 PM Screenshot 2026-01-20 at 11 58
58 AM Screenshot 2026-01-20 at 12 31
46 PM Screenshot 2026-01-20 at 11 57
58 AM Screenshot 2026-01-20 at 11 57
39 AM ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: mitchfz --- resources/lang/en.json | 8 + src/client/ClientGameRunner.ts | 5 +- .../graphics/layers/PlayerModerationModal.ts | 167 +++++++++++++++++ src/client/graphics/layers/PlayerPanel.ts | 59 ++++++ src/server/GameServer.ts | 34 +++- .../graphics/layers/PlayerPanelKick.test.ts | 170 ++++++++++++++++++ 6 files changed, 436 insertions(+), 7 deletions(-) create mode 100644 src/client/graphics/layers/PlayerModerationModal.ts create mode 100644 tests/client/graphics/layers/PlayerPanelKick.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 2ae02389d..a9663b2b0 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -796,10 +796,18 @@ "send_troops": "Send Troops", "send_gold": "Send Gold", "emotes": "Emojis", + "moderation": "Moderation", + "kick": "Kick player", + "kicked": "Already kicked", + "kick_confirm": "Kick {name}?\n\nThey won't be able to rejoin this game.", "arc_up": "Upward arc", "arc_down": "Downward arc", "flip_rocket_trajectory": "Flip rocket trajectory" }, + "kick_reason": { + "duplicate_session": "Kicked from game (you may have been playing on another tab)", + "lobby_creator": "Kicked by lobby creator" + }, "send_troops_modal": { "title_with_name": "Send Troops to {name}", "available_tooltip": "Your current available troops", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index bd377d35d..b71fb7cd6 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -770,6 +770,9 @@ function showErrorModal( return; } + const translatedError = translateText(error); + const displayError = translatedError === error ? error : translatedError; + const modal = document.createElement("div"); modal.id = "error-modal"; @@ -778,7 +781,7 @@ function showErrorModal( translateText(heading), `game id: ${gameID}`, `client id: ${clientID}`, - `Error: ${error}`, + `Error: ${displayError}`, message ? `Message: ${message}` : null, ] .filter(Boolean) diff --git a/src/client/graphics/layers/PlayerModerationModal.ts b/src/client/graphics/layers/PlayerModerationModal.ts new file mode 100644 index 000000000..c51f9efc1 --- /dev/null +++ b/src/client/graphics/layers/PlayerModerationModal.ts @@ -0,0 +1,167 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { EventBus } from "../../../core/EventBus"; +import { PlayerType } from "../../../core/game/Game"; +import { PlayerView } from "../../../core/game/GameView"; +import { actionButton } from "../../components/ui/ActionButton"; +import { SendKickPlayerIntentEvent } from "../../Transport"; +import { translateText } from "../../Utils"; +import kickIcon from "/images/ExitIconWhite.svg?url"; +import shieldIcon from "/images/ShieldIconWhite.svg?url"; + +@customElement("player-moderation-modal") +export class PlayerModerationModal extends LitElement { + @property({ attribute: false }) eventBus: EventBus | null = null; + @property({ attribute: false }) myPlayer: PlayerView | null = null; + @property({ attribute: false }) target: PlayerView | null = null; + + @property({ type: Boolean }) open: boolean = false; + @property({ type: Boolean }) alreadyKicked: boolean = false; + + createRenderRoot() { + return this; + } + + updated(changed: Map) { + if (changed.has("open") && this.open) { + queueMicrotask(() => + (this.querySelector('[role="dialog"]') as HTMLElement | null)?.focus(), + ); + } + } + + private closeModal() { + this.dispatchEvent(new CustomEvent("close")); + } + + private handleKeydown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + this.closeModal(); + } + }; + + private canKick(my: PlayerView, other: PlayerView): boolean { + return ( + my.isLobbyCreator() && + other !== my && + other.type() === PlayerType.Human && + !!other.clientID() + ); + } + + private handleKickClick = (e: MouseEvent) => { + e.stopPropagation(); + + const my = this.myPlayer; + const other = this.target; + const eventBus = this.eventBus; + + if (!my || !other) return; + if (!this.canKick(my, other) || this.alreadyKicked) return; + if (!eventBus) return; + + const targetClientID = other.clientID(); + if (!targetClientID || targetClientID.length === 0) return; + + const confirmed = confirm( + translateText("player_panel.kick_confirm", { name: other.name() }), + ); + if (!confirmed) return; + + eventBus.emit(new SendKickPlayerIntentEvent(targetClientID)); + this.dispatchEvent( + new CustomEvent("kicked", { detail: { playerId: String(other.id()) } }), + ); + this.closeModal(); + }; + + render() { + if (!this.open) return html``; + + const my = this.myPlayer; + const other = this.target; + if (!my || !other) return html``; + + const canKick = this.canKick(my, other); + const alreadyKicked = this.alreadyKicked; + + const moderationTitle = translateText("player_panel.moderation"); + const kickTitle = alreadyKicked + ? translateText("player_panel.kicked") + : translateText("player_panel.kick"); + + return html` +
        +
        this.closeModal()} + >
        + + +
        + `; + } +} diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 20883f88e..674b83e15 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -37,12 +37,14 @@ import { UIState } from "../UIState"; import { ChatModal } from "./ChatModal"; import { EmojiTable } from "./EmojiTable"; import { Layer } from "./Layer"; +import "./PlayerModerationModal"; import "./SendResourceModal"; import allianceIcon from "/images/AllianceIconWhite.svg?url"; import chatIcon from "/images/ChatIconWhite.svg?url"; import donateGoldIcon from "/images/DonateGoldIconWhite.svg?url"; import donateTroopIcon from "/images/DonateTroopIconWhite.svg?url"; import emojiIcon from "/images/EmojiIconWhite.svg?url"; +import shieldIcon from "/images/ShieldIconWhite.svg?url"; import stopTradingIcon from "/images/StopIconWhite.png?url"; import targetIcon from "/images/TargetIconWhite.svg?url"; import startTradingIcon from "/images/TradingIconWhite.png?url"; @@ -59,6 +61,7 @@ export class PlayerPanel extends LitElement implements Layer { private actions: PlayerActions | null = null; private tile: TileRef | null = null; private _profileForPlayerId: number | null = null; + private kickedPlayerIDs = new Set(); @state() private sendTarget: PlayerView | null = null; @state() private sendMode: "troops" | "gold" | "none" = "none"; @@ -67,6 +70,7 @@ export class PlayerPanel extends LitElement implements Layer { @state() private allianceExpirySeconds: number | null = null; @state() private otherProfile: PlayerProfile | null = null; @state() private suppressNextHide: boolean = false; + @state() private moderationTarget: PlayerView | null = null; private ctModal: ChatModal; @@ -142,6 +146,7 @@ export class PlayerPanel extends LitElement implements Layer { public show(actions: PlayerActions, tile: TileRef) { this.actions = actions; this.tile = tile; + this.moderationTarget = null; this.isVisible = true; this.requestUpdate(); } @@ -156,6 +161,7 @@ export class PlayerPanel extends LitElement implements Layer { this.tile = tile; this.sendTarget = target; this.sendMode = "gold"; + this.moderationTarget = null; this.isVisible = true; this.requestUpdate(); } @@ -164,6 +170,7 @@ export class PlayerPanel extends LitElement implements Layer { this.isVisible = false; this.sendMode = "none"; this.sendTarget = null; + this.moderationTarget = null; this.requestUpdate(); } @@ -305,6 +312,23 @@ export class PlayerPanel extends LitElement implements Layer { this.hide(); } + private openModeration(e: MouseEvent, other: PlayerView) { + e.stopPropagation(); + this.suppressNextHide = true; + this.moderationTarget = other; + } + + private closeModeration = () => { + this.moderationTarget = null; + }; + + private handleModerationKicked = (e: CustomEvent<{ playerId?: string }>) => { + const playerId = e.detail?.playerId; + if (playerId) this.kickedPlayerIDs.add(String(playerId)); + this.closeModeration(); + this.hide(); + }; + private handleToggleRocketDirection(e: Event) { e.stopPropagation(); const next = !this.uiState.rocketDirectionUp; @@ -419,6 +443,25 @@ export class PlayerPanel extends LitElement implements Layer { `; } + private renderModeration(my: PlayerView, other: PlayerView) { + if (!my.isLobbyCreator()) return html``; + const moderationTitle = translateText("player_panel.moderation"); + + return html` + +
        + ${actionButton({ + onClick: (e: MouseEvent) => this.openModeration(e, other), + icon: shieldIcon, + iconAlt: "Moderation", + title: moderationTitle, + label: moderationTitle, + type: "red", + })} +
        + `; + } + private renderRelationPillIfNation(other: PlayerView, my: PlayerView) { if (other.type() !== PlayerType.Nation) return html``; if (other.isTraitor()) return html``; @@ -804,6 +847,7 @@ export class PlayerPanel extends LitElement implements Layer { })} ` : ""} + ${this.renderModeration(my, other)} `; } @@ -914,6 +958,21 @@ export class PlayerPanel extends LitElement implements Layer { > ` : ""} + ${this.moderationTarget + ? html` + + ` + : ""} diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 22bac20d6..556f7bf97 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -31,6 +31,9 @@ export enum GamePhase { Finished = "FINISHED", } +const KICK_REASON_DUPLICATE_SESSION = "kick_reason.duplicate_session"; +const KICK_REASON_LOBBY_CREATOR = "kick_reason.lobby_creator"; + export class GameServer { private sentDesyncMessageClients = new Set(); @@ -219,7 +222,7 @@ export class GameServer { }); // Kick the existing client instead of the new one, because this was causing issues when // a client wanted to replay the game afterwards. - this.kickClient(conflicting.clientID); + this.kickClient(conflicting.clientID, KICK_REASON_DUPLICATE_SESSION); } } @@ -356,7 +359,10 @@ export class GameServer { kickMethod: "websocket", }); - this.kickClient(clientMsg.intent.target); + this.kickClient( + clientMsg.intent.target, + KICK_REASON_LOBBY_CREATOR, + ); return; } case "update_game_config": { @@ -776,33 +782,49 @@ export class GameServer { return this.gameConfig.gameType === GameType.Public; } - public kickClient(clientID: ClientID): void { + public kickClient( + clientID: ClientID, + reasonKey: string = KICK_REASON_DUPLICATE_SESSION, + ): void { if (this.kickedClients.has(clientID)) { this.log.warn(`cannot kick client, already kicked`, { clientID, + reasonKey, }); return; } + + if (!this.allClients.has(clientID)) { + this.log.warn(`cannot kick client, not found in game`, { + clientID, + reasonKey, + }); + return; + } + + this.kickedClients.add(clientID); + const client = this.activeClients.find((c) => c.clientID === clientID); if (client) { this.log.info("Kicking client from game", { clientID: client.clientID, persistentID: client.persistentID, + reasonKey, }); client.ws.send( JSON.stringify({ type: "error", - error: "Kicked from game (you may have been playing on another tab)", + error: reasonKey, } satisfies ServerErrorMessage), ); - client.ws.close(1000, "Kicked from game"); + client.ws.close(1000, reasonKey); this.activeClients = this.activeClients.filter( (c) => c.clientID !== clientID, ); - this.kickedClients.add(clientID); } else { this.log.warn(`cannot kick client, not found in game`, { clientID, + reasonKey, }); } } diff --git a/tests/client/graphics/layers/PlayerPanelKick.test.ts b/tests/client/graphics/layers/PlayerPanelKick.test.ts new file mode 100644 index 000000000..ea87feed3 --- /dev/null +++ b/tests/client/graphics/layers/PlayerPanelKick.test.ts @@ -0,0 +1,170 @@ +vi.mock("lit", () => ({ + html: (strings: TemplateStringsArray, ...values: unknown[]) => ({ + strings, + values, + }), + LitElement: class extends EventTarget { + requestUpdate() {} + }, +})); + +vi.mock("lit/decorators.js", () => ({ + customElement: () => (clazz: unknown) => clazz, + state: () => () => {}, + property: () => () => {}, + query: () => () => {}, +})); + +vi.mock("../../../../src/client/Utils", () => ({ + translateText: vi.fn((key: string) => key), + renderDuration: vi.fn(), + renderNumber: vi.fn(), + renderTroops: vi.fn(), +})); + +vi.mock("../../../../src/client/components/ui/ActionButton", () => ({ + actionButton: vi.fn((props: unknown) => props), +})); + +import { actionButton } from "../../../../src/client/components/ui/ActionButton"; +import { PlayerModerationModal } from "../../../../src/client/graphics/layers/PlayerModerationModal"; +import { PlayerPanel } from "../../../../src/client/graphics/layers/PlayerPanel"; +import { SendKickPlayerIntentEvent } from "../../../../src/client/Transport"; +import { PlayerType } from "../../../../src/core/game/Game"; +import { PlayerView } from "../../../../src/core/game/GameView"; + +describe("PlayerPanel - kick player moderation", () => { + let panel: PlayerPanel; + const originalConfirm = globalThis.confirm; + + beforeEach(() => { + panel = new PlayerPanel(); + (panel as any).requestUpdate = vi.fn(); + (panel as any).isVisible = true; + }); + + afterEach(() => { + vi.clearAllMocks(); + globalThis.confirm = originalConfirm; + }); + + test("renders moderation action only when allowed or already kicked", () => { + const my = { isLobbyCreator: () => true } as unknown as PlayerView; + const other = { + id: () => 2, + name: () => "Other", + type: () => PlayerType.Human, + clientID: () => "client-2", + } as unknown as PlayerView; + + (actionButton as unknown as ReturnType).mockClear(); + (panel as any).renderModeration(my, other); + expect(actionButton).toHaveBeenCalledTimes(1); + expect( + (actionButton as unknown as ReturnType).mock.calls[0][0], + ).toMatchObject({ + label: "player_panel.moderation", + title: "player_panel.moderation", + type: "red", + }); + + (actionButton as unknown as ReturnType).mockClear(); + (panel as any).kickedPlayerIDs.add("2"); + (panel as any).renderModeration(my, other); + expect(actionButton).toHaveBeenCalledTimes(1); + + const notCreator = { isLobbyCreator: () => false } as unknown as PlayerView; + (actionButton as unknown as ReturnType).mockClear(); + (panel as any).kickedPlayerIDs.clear(); + (panel as any).renderModeration(notCreator, other); + expect(actionButton).not.toHaveBeenCalled(); + }); + + test("opens moderation modal and hides after a kick", () => { + const other = { + id: () => 2, + name: () => "Other", + type: () => PlayerType.Human, + clientID: () => "client-2", + } as unknown as PlayerView; + + (panel as any).openModeration({ stopPropagation: vi.fn() }, other); + expect((panel as any).moderationTarget).toBe(other); + expect((panel as any).suppressNextHide).toBe(true); + + (panel as any).handleModerationKicked( + new CustomEvent("kicked", { detail: { playerId: "2" } }), + ); + + expect((panel as any).kickedPlayerIDs.has("2")).toBe(true); + expect((panel as any).moderationTarget).toBe(null); + expect((panel as any).isVisible).toBe(false); + }); +}); + +describe("PlayerModerationModal - kick confirmation", () => { + const originalConfirm = globalThis.confirm; + + afterEach(() => { + vi.clearAllMocks(); + globalThis.confirm = originalConfirm; + }); + + test("emits SendKickPlayerIntentEvent and dispatches kicked when confirmed", () => { + (globalThis as any).confirm = vi.fn(() => true); + + const modal = new PlayerModerationModal(); + const eventBus = { emit: vi.fn() }; + const my = { isLobbyCreator: () => true } as unknown as PlayerView; + const other = { + id: () => 2, + name: () => "Other", + type: () => PlayerType.Human, + clientID: () => "client-2", + } as unknown as PlayerView; + + modal.eventBus = eventBus as any; + modal.myPlayer = my; + modal.target = other; + + const kickedListener = vi.fn(); + modal.addEventListener("kicked", kickedListener as any); + + (modal as any).handleKickClick({ stopPropagation: vi.fn() }); + + expect(eventBus.emit).toHaveBeenCalledTimes(1); + const event = eventBus.emit.mock.calls[0][0] as SendKickPlayerIntentEvent; + expect(event).toBeInstanceOf(SendKickPlayerIntentEvent); + expect(event.target).toBe("client-2"); + + expect(kickedListener).toHaveBeenCalledTimes(1); + const kickedEvent = kickedListener.mock.calls[0][0] as CustomEvent; + expect(kickedEvent.detail).toEqual({ playerId: "2" }); + }); + + test("does not emit when confirmation is cancelled", () => { + (globalThis as any).confirm = vi.fn(() => false); + + const modal = new PlayerModerationModal(); + const eventBus = { emit: vi.fn() }; + const my = { isLobbyCreator: () => true } as unknown as PlayerView; + const other = { + id: () => 2, + name: () => "Other", + type: () => PlayerType.Human, + clientID: () => "client-2", + } as unknown as PlayerView; + + modal.eventBus = eventBus as any; + modal.myPlayer = my; + modal.target = other; + + const kickedListener = vi.fn(); + modal.addEventListener("kicked", kickedListener as any); + + (modal as any).handleKickClick({ stopPropagation: vi.fn() }); + + expect(eventBus.emit).not.toHaveBeenCalled(); + expect(kickedListener).not.toHaveBeenCalled(); + }); +}); From 0bfad91c047fe7695933d3f8a779752f675daa37 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Mon, 26 Jan 2026 05:14:55 +0100 Subject: [PATCH 084/109] perf(ui): switch UI layers to wall-time tick intervals (#3025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Preparatory change for the upcoming “unbounded worker” work: decouple expensive UI layer updates from game tick frequency by moving UI ticking to wall-clock intervals. This reduces redundant UI work when the simulation runs faster than real time (notably replays / singleplayer at speed > 1) while keeping the UI responsive and predictable. ## Changes: - Add optional `Layer.getTickIntervalMs()` and enforce it in `GameRenderer.tick()` using wall-clock time. - Convert key UI layers from tick-modulus gating to fixed intervals: - `ControlPanel`: 100ms - `GameRightSidebar`: 250ms - `MainRadialMenu`: 500ms - `Leaderboard`, `NameLayer`, `ReplayPanel`, `TeamStats`: 1000ms ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: DISCORD_USERNAME --- src/client/graphics/GameRenderer.ts | 24 ++++++++++- src/client/graphics/layers/ControlPanel.ts | 8 ++-- .../graphics/layers/GameRightSidebar.ts | 43 +++++++++++-------- src/client/graphics/layers/Layer.ts | 3 ++ src/client/graphics/layers/Leaderboard.ts | 8 ++-- src/client/graphics/layers/MainRadialMenu.ts | 26 +++++------ src/client/graphics/layers/NameLayer.ts | 8 ++-- src/client/graphics/layers/ReplayPanel.ts | 8 ++-- src/client/graphics/layers/TeamStats.ts | 8 ++-- 9 files changed, 90 insertions(+), 46 deletions(-) diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index b4cd3eb38..466ce19e7 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -305,6 +305,7 @@ export function createRenderer( export class GameRenderer { private context: CanvasRenderingContext2D; + private layerTickState = new Map(); constructor( private game: GameView, @@ -416,7 +417,28 @@ export class GameRenderer { } tick() { - this.layers.forEach((l) => l.tick?.()); + const nowMs = performance.now(); + + for (const layer of this.layers) { + if (!layer.tick) { + continue; + } + + const state = this.layerTickState.get(layer) ?? { + lastTickAtMs: -Infinity, + }; + + const intervalMs = layer.getTickIntervalMs?.() ?? 0; + if (intervalMs > 0 && nowMs - state.lastTickAtMs < intervalMs) { + this.layerTickState.set(layer, state); + continue; + } + + state.lastTickAtMs = nowMs; + this.layerTickState.set(layer, state); + + layer.tick(); + } } resize(width: number, height: number): void { diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts index f1c183e58..a6b03abad 100644 --- a/src/client/graphics/layers/ControlPanel.ts +++ b/src/client/graphics/layers/ControlPanel.ts @@ -39,6 +39,10 @@ export class ControlPanel extends LitElement implements Layer { private _lastTroopIncreaseRate: number; + getTickIntervalMs() { + return 100; + } + init() { this.attackRatio = Number( localStorage.getItem("settings.attackRatio") ?? "0.2", @@ -81,9 +85,7 @@ export class ControlPanel extends LitElement implements Layer { return; } - if (this.game.ticks() % 5 === 0) { - this.updateTroopIncrease(); - } + this.updateTroopIncrease(); this._maxTroops = this.game.config().maxTroops(player); this._gold = player.gold(); diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index e86317a7c..58880a992 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -2,10 +2,9 @@ import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; import { GameType } from "../../../core/game/Game"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; import { crazyGamesSDK } from "../../CrazyGamesSDK"; -import { PauseGameIntentEvent } from "../../Transport"; +import { PauseGameIntentEvent, SendWinnerEvent } from "../../Transport"; import { translateText } from "../../Utils"; import { Layer } from "./Layer"; import { ShowReplayPanelEvent } from "./ReplayPanel"; @@ -50,16 +49,20 @@ export class GameRightSidebar extends LitElement implements Layer { this._isVisible = true; this.game.inSpawnPhase(); + this.eventBus.on(SendWinnerEvent, () => { + this.hasWinner = true; + this.requestUpdate(); + }); + this.requestUpdate(); } + getTickIntervalMs() { + return 250; + } + tick() { // Timer logic - const updates = this.game.updatesSinceLastTick(); - if (updates) { - this.hasWinner = this.hasWinner || updates[GameUpdateType.Win].length > 0; - } - // Check if the player is the lobby creator if (!this.isLobbyCreator && this.game.myPlayer()?.isLobbyCreator()) { this.isLobbyCreator = true; @@ -67,18 +70,24 @@ export class GameRightSidebar extends LitElement implements Layer { } const maxTimerValue = this.game.config().gameConfig().maxTimerValue; + const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns(); + const ticks = this.game.ticks(); + const gameTicks = Math.max(0, ticks - spawnPhaseTurns); + const elapsedSeconds = Math.floor(gameTicks / 10); // 10 ticks per second + + if (this.game.inSpawnPhase()) { + this.timer = maxTimerValue !== undefined ? maxTimerValue * 60 : 0; + return; + } + + if (this.hasWinner) { + return; + } + if (maxTimerValue !== undefined) { - if (this.game.inSpawnPhase()) { - this.timer = maxTimerValue * 60; - } else if (!this.hasWinner && this.game.ticks() % 10 === 0) { - this.timer = Math.max(0, this.timer - 1); - } + this.timer = Math.max(0, maxTimerValue * 60 - elapsedSeconds); } else { - if (this.game.inSpawnPhase()) { - this.timer = 0; - } else if (!this.hasWinner && this.game.ticks() % 10 === 0) { - this.timer++; - } + this.timer = elapsedSeconds; } } diff --git a/src/client/graphics/layers/Layer.ts b/src/client/graphics/layers/Layer.ts index 239937435..456648f79 100644 --- a/src/client/graphics/layers/Layer.ts +++ b/src/client/graphics/layers/Layer.ts @@ -1,6 +1,9 @@ export interface Layer { init?: () => void; tick?: () => void; + // Optional hint to throttle expensive ticks by wall-clock. + // If omitted or <= 0, the layer ticks whenever GameRenderer ticks. + getTickIntervalMs?: () => number; renderLayer?: (context: CanvasRenderingContext2D) => void; shouldTransform?: () => boolean; redraw?: () => void; diff --git a/src/client/graphics/layers/Leaderboard.ts b/src/client/graphics/layers/Leaderboard.ts index 5dd8793f3..19aec6643 100644 --- a/src/client/graphics/layers/Leaderboard.ts +++ b/src/client/graphics/layers/Leaderboard.ts @@ -55,12 +55,14 @@ export class Leaderboard extends LitElement implements Layer { init() {} + getTickIntervalMs() { + return 1000; + } + tick() { if (this.game === null) throw new Error("Not initialized"); if (!this.visible) return; - if (this.game.ticks() % 10 === 0) { - this.updateLeaderboard(); - } + this.updateLeaderboard(); } private setSort(key: "tiles" | "gold" | "maxtroops") { diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts index c0b707577..989b5aa79 100644 --- a/src/client/graphics/layers/MainRadialMenu.ts +++ b/src/client/graphics/layers/MainRadialMenu.ts @@ -33,6 +33,10 @@ export class MainRadialMenu extends LitElement implements Layer { private clickedTile: TileRef | null = null; + getTickIntervalMs() { + return 500; + } + constructor( private eventBus: EventBus, private game: GameView, @@ -156,18 +160,16 @@ export class MainRadialMenu extends LitElement implements Layer { async tick() { if (!this.radialMenu.isMenuVisible() || this.clickedTile === null) return; - if (this.game.ticks() % 5 === 0) { - this.game - .myPlayer()! - .actions(this.clickedTile) - .then((actions) => { - this.updatePlayerActions( - this.game.myPlayer()!, - actions, - this.clickedTile!, - ); - }); - } + this.game + .myPlayer()! + .actions(this.clickedTile) + .then((actions) => { + this.updatePlayerActions( + this.game.myPlayer()!, + actions, + this.clickedTile!, + ); + }); } renderLayer(context: CanvasRenderingContext2D) { diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 1c0b94a22..e23d4d609 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -133,11 +133,11 @@ export class NameLayer implements Layer { } } - public tick() { - if (this.game.ticks() % 10 !== 0) { - return; - } + getTickIntervalMs() { + return 1000; + } + public tick() { // Precompute the first-place player for performance this.firstPlace = getFirstPlacePlayer(this.game); diff --git a/src/client/graphics/layers/ReplayPanel.ts b/src/client/graphics/layers/ReplayPanel.ts index eb5cf4f1a..7b2b43eea 100644 --- a/src/client/graphics/layers/ReplayPanel.ts +++ b/src/client/graphics/layers/ReplayPanel.ts @@ -44,11 +44,13 @@ export class ReplayPanel extends LitElement implements Layer { } } + getTickIntervalMs() { + return 1000; + } + tick() { if (!this.visible) return; - if (this.game!.ticks() % 10 === 0) { - this.requestUpdate(); - } + this.requestUpdate(); } onReplaySpeedChange(value: ReplaySpeedMultiplier) { diff --git a/src/client/graphics/layers/TeamStats.ts b/src/client/graphics/layers/TeamStats.ts index 1841a0636..5cf322e12 100644 --- a/src/client/graphics/layers/TeamStats.ts +++ b/src/client/graphics/layers/TeamStats.ts @@ -42,6 +42,10 @@ export class TeamStats extends LitElement implements Layer { init() {} + getTickIntervalMs() { + return 1000; + } + tick() { if (this.game.config().gameConfig().gameMode !== GameMode.Team) return; @@ -52,9 +56,7 @@ export class TeamStats extends LitElement implements Layer { if (!this.visible) return; - if (this.game.ticks() % 10 === 0) { - this.updateTeamStats(); - } + this.updateTeamStats(); } private updateTeamStats() { From ed9900e3139a75c46cf0da17b0a44d2bc8ea9006 Mon Sep 17 00:00:00 2001 From: Evan Date: Sun, 25 Jan 2026 20:34:48 -0800 Subject: [PATCH 085/109] Added afterEach cleanup to call inputHandler.destroy(), which clears the setInterval before jsdom tears down and removes window. (#3030) ## Description: Fixes the failing test:coverage ci. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- tests/InputHandler.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 132e88419..18f0c6497 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -40,6 +40,10 @@ describe("InputHandler AutoUpgrade", () => { ); }); + afterEach(() => { + inputHandler.destroy(); + }); + describe("Middle Mouse Button Handling", () => { test("should emit AutoUpgradeEvent on middle mouse button press", () => { const mockEmit = vi.spyOn(eventBus, "emit"); From 3f95a45eaffb6d556f1cd612d51e11ae97a0f449 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:33:04 +0100 Subject: [PATCH 086/109] =?UTF-8?q?Nations=20donate=20troops=20now=20?= =?UTF-8?q?=F0=9F=92=80=20(In=20team=20games)=20(#2984)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: For v29, balances the HvN winrate. In team games, nations now donate troops to their weakest team members (if they have no attack options available). How often they donate depends on the difficulty. This PR also has some other little fixes: - For HvN games, always return true in `shouldAttack()` (make nations a bit more aggressive). - Early exit in `attackWithRandomBoat()` for performance - Early exit in `findNearestIslandEnemy()` for performance AND to make sure nations which are encircled by friends don't run into this method (=> no donation happening!) ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- .../execution/nation/NationEmojiBehavior.ts | 55 +---- src/core/execution/utils/AiAttackBehavior.ts | 206 ++++++++++++++---- src/core/game/Game.ts | 1 + src/core/game/GameImpl.ts | 6 + 4 files changed, 184 insertions(+), 84 deletions(-) diff --git a/src/core/execution/nation/NationEmojiBehavior.ts b/src/core/execution/nation/NationEmojiBehavior.ts index 7507466f4..e62515bfd 100644 --- a/src/core/execution/nation/NationEmojiBehavior.ts +++ b/src/core/execution/nation/NationEmojiBehavior.ts @@ -6,7 +6,6 @@ import { Player, PlayerType, Relation, - Team, Tick, } from "../../game/Game"; import { PseudoRandom } from "../../PseudoRandom"; @@ -55,6 +54,8 @@ export class NationEmojiBehavior { ) {} maybeSendCasualEmoji() { + if (this.gameOver) return; + this.checkOverwhelmedByAttacks(); this.checkVerySmallAttack(); this.congratulateWinner(); @@ -107,60 +108,23 @@ export class NationEmojiBehavior { // Check if game is over - send congratulations private congratulateWinner(): void { - if (this.gameOver) return; + const winner = this.game.getWinner(); + if (winner === null) return; + + this.gameOver = true; - const percentToWin = this.game.config().percentageTilesOwnedToWin(); - const numTilesWithoutFallout = - this.game.numLandTiles() - this.game.numTilesWithFallout(); const isTeamGame = this.game.config().gameConfig().gameMode === GameMode.Team; if (isTeamGame) { // Team game: all nations congratulate if another team won - const teamToTiles = new Map(); - for (const player of this.game.players()) { - const team = player.team(); - if (team === null) continue; - teamToTiles.set( - team, - (teamToTiles.get(team) ?? 0) + player.numTilesOwned(), - ); - } - - const sorted = Array.from(teamToTiles.entries()).sort( - (a, b) => b[1] - a[1], - ); - if (sorted.length === 0) return; - - const [winningTeam, winningTiles] = sorted[0]; - const winningPercent = (winningTiles / numTilesWithoutFallout) * 100; - if (winningPercent < percentToWin) return; - - this.gameOver = true; - // Don't congratulate if it's our own team - if (winningTeam === this.player.team()) return; + if (winner === this.player.team()) return; this.sendEmoji(AllPlayers, EMOJI_CONGRATULATE); } else { // FFA game: The largest nation congratulates if a human player won - const sorted = this.game - .players() - .sort((a, b) => b.numTilesOwned() - a.numTilesOwned()); - - if (sorted.length === 0) return; - - const firstPlace = sorted[0]; - - // Check if first place has won (crossed the win threshold) - const firstPlacePercent = - (firstPlace.numTilesOwned() / numTilesWithoutFallout) * 100; - if (firstPlacePercent < percentToWin) return; - - this.gameOver = true; - - // Only send if first place is a human - if (firstPlace.type() !== PlayerType.Human) return; + if (typeof winner === "string") return; // It's a team, not a player // Only the largest nation sends the congratulation const largestNation = this.game @@ -169,13 +133,12 @@ export class NationEmojiBehavior { .sort((a, b) => b.numTilesOwned() - a.numTilesOwned())[0]; if (largestNation !== this.player) return; - this.sendEmoji(firstPlace, EMOJI_CONGRATULATE); + this.sendEmoji(winner, EMOJI_CONGRATULATE); } } // Brag with our crown private brag(): void { - if (this.gameOver) return; if (!this.random.chance(300)) return; const sorted = this.game diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index fd252316b..5d6cedc08 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -1,11 +1,14 @@ import { Difficulty, Game, + GameMode, + HumansVsNations, Player, PlayerID, PlayerType, Relation, TerraNullius, + UnitType, } from "../../game/Game"; import { TileRef } from "../../game/GameMap"; import { canBuildTransportShip } from "../../game/TransportShipUtils"; @@ -16,6 +19,7 @@ import { calculateBoundingBoxCenter, } from "../../Util"; import { AttackExecution } from "../AttackExecution"; +import { DonateTroopsExecution } from "../DonateTroopExecution"; import { NationAllianceBehavior } from "../nation/NationAllianceBehavior"; import { EMOJI_ASSIST_ACCEPT, @@ -94,6 +98,16 @@ export class AiAttackBehavior { private attackWithRandomBoat(borderingEnemies: Player[] = []) { if (this.player === null) throw new Error("not initialized"); + + // Check if we've already sent out the maximum number of transport ships + if ( + this.player.unitCount(UnitType.TransportShip) >= + this.game.config().boatMaxNumber() + ) { + return; + } + + // Check if we have any ocean shore tiles to launch from const oceanShore = Array.from(this.player.borderTiles()).filter((t) => this.game.isOceanShore(t), ); @@ -309,6 +323,8 @@ export class AiAttackBehavior { return false; }; + const donate = (): boolean => this.donateTroops(); + // Return strategies in order based on difficulty // Easy nations get the dumbest order, impossible nations get the smartest order switch (difficulty) { @@ -317,13 +333,13 @@ export class AiAttackBehavior { return [nuked, bots, retaliate, assist, betray, hated, weakest]; case Difficulty.Medium: // prettier-ignore - return [bots, nuked, retaliate, assist, betray, hated, afk, traitor, weakest, island]; + return [bots, nuked, retaliate, assist, betray, hated, afk, traitor, weakest, island, donate]; case Difficulty.Hard: // prettier-ignore - return [bots, retaliate, assist, betray, nuked, traitor, afk, hated, veryWeak, victim, weakest, island]; + return [bots, retaliate, assist, betray, nuked, traitor, afk, hated, veryWeak, victim, weakest, island, donate]; case Difficulty.Impossible: // prettier-ignore - return [retaliate, bots, veryWeak, assist, traitor, afk, betray, victim, nuked, hated, weakest, island]; + return [retaliate, bots, veryWeak, assist, traitor, afk, betray, victim, nuked, hated, weakest, island, donate]; default: assertNever(difficulty); } @@ -519,54 +535,67 @@ export class AiAttackBehavior { } private findNearestIslandEnemy(): Player | null { - const myBorder = this.player.borderTiles(); - if (myBorder.size === 0) return null; + // Check if we've already sent out the maximum number of transport ships + if ( + this.player.unitCount(UnitType.TransportShip) >= + this.game.config().boatMaxNumber() + ) { + return null; + } + + // Check if we have any ocean shore tiles to launch from + const hasOceanShore = Array.from(this.player.borderTiles()).some((t) => + this.game.isOceanShore(t), + ); + if (!hasOceanShore) return null; const filteredPlayers = this.game.players().filter((p) => { if (p === this.player) return false; - if (!p.isAlive()) return false; - if (p.borderTiles().size === 0) return false; if (this.player.isFriendly(p)) return false; // Don't spam boats into players with more troops return p.troops() < this.player.troops(); }); - if (filteredPlayers.length > 0) { - const playerCenter = this.getPlayerCenter(this.player); + if (filteredPlayers.length === 0) return null; - const sortedPlayers = filteredPlayers - .map((filteredPlayer) => { - const filteredPlayerCenter = this.getPlayerCenter(filteredPlayer); + const playerCenter = this.getPlayerCenter(this.player); - const playerCenterTile = this.game.ref( - playerCenter.x, - playerCenter.y, - ); - const filteredPlayerCenterTile = this.game.ref( - filteredPlayerCenter.x, - filteredPlayerCenter.y, - ); + const sortedPlayers = filteredPlayers + .map((filteredPlayer) => { + const filteredPlayerCenter = this.getPlayerCenter(filteredPlayer); - const distance = this.game.manhattanDist( - playerCenterTile, - filteredPlayerCenterTile, - ); - return { player: filteredPlayer, distance }; - }) - .sort((a, b) => a.distance - b.distance); // Sort by distance (ascending) + const playerCenterTile = this.game.ref(playerCenter.x, playerCenter.y); + const filteredPlayerCenterTile = this.game.ref( + filteredPlayerCenter.x, + filteredPlayerCenter.y, + ); - // Select the nearest or second-nearest enemy (So our boat doesn't always run into the same warship, if there is one) - let selectedEnemy: Player | null; - if (sortedPlayers.length > 1 && this.random.chance(2)) { - selectedEnemy = sortedPlayers[1].player; - } else { - selectedEnemy = sortedPlayers[0].player; - } + const distance = this.game.manhattanDist( + playerCenterTile, + filteredPlayerCenterTile, + ); + return { player: filteredPlayer, distance }; + }) + .sort((a, b) => a.distance - b.distance); // Sort by distance (ascending) - if (selectedEnemy !== null) { - return selectedEnemy; + // Try players in order of distance until we find one reachable by boat + for (const entry of sortedPlayers) { + const closest = closestTwoTiles( + this.game, + Array.from(this.player.borderTiles()).filter((t) => + this.game.isOceanShore(t), + ), + Array.from(entry.player.borderTiles()).filter((t) => + this.game.isOceanShore(t), + ), + ); + if (closest === null) continue; + + if (canBuildTransportShip(this.game, this.player, closest.y)) { + return entry.player; } } + return null; } @@ -646,12 +675,14 @@ export class AiAttackBehavior { } shouldAttack(other: Player | TerraNullius): boolean { - // Always attack Terra Nullius, non-humans and traitors (or if we are a bot) if ( + // Always attack Terra Nullius, non-humans and traitors other.isPlayer() === false || other.type() !== PlayerType.Human || other.isTraitor() || - this.player.type() === PlayerType.Bot + // Always attack if we are a bot or in an HvN game + this.player.type() === PlayerType.Bot || + this.game.config().gameConfig().playerTeams === HumansVsNations ) { return true; } @@ -718,6 +749,10 @@ export class AiAttackBehavior { return; } + if (!canBuildTransportShip(this.game, this.player, closest.y)) { + return; + } + let troops; if (target.type() === PlayerType.Bot) { troops = this.calculateBotAttackTroops(target, this.player.troops() / 5); @@ -759,4 +794,99 @@ export class AiAttackBehavior { this.botAttackTroopsSent += troops; return troops; } + + private donateTroops(): boolean { + // Only donate in team games + if (this.game.config().gameConfig().gameMode !== GameMode.Team) { + return false; + } + + // Check if donating troops is allowed + if (this.game.config().donateTroops() === false) { + return false; + } + + // Don't donate if the game has a winner + if (this.game.getWinner() !== null) { + return false; + } + + // Skip donating based on difficulty + const { difficulty } = this.game.config().gameConfig(); + switch (difficulty) { + case Difficulty.Easy: + // Easy nations don't donate + return false; + case Difficulty.Medium: + // Medium nations donate 25% of the time + if (!this.random.chance(4)) { + return false; + } + break; + case Difficulty.Hard: + // Hard nations donate 50% of the time + if (!this.random.chance(2)) { + return false; + } + break; + case Difficulty.Impossible: + // Impossible nations always try to donate + break; + default: + assertNever(difficulty); + } + + // Find teammates who are currently in combat + const teammates = this.game + .players() + .filter((p) => this.player.isOnSameTeam(p)) + .filter( + (p) => p.incomingAttacks().length > 0 || p.outgoingAttacks().length > 0, + ); + + if (teammates.length === 0) { + return false; + } + + // Find teammate with lowest troop percentage (troops / maxTroops) + const teammatesWithTroopPercentage = teammates + .map((teammate) => { + const maxTroops = this.game.config().maxTroops(teammate); + const troopPercentage = teammate.troops() / Math.max(maxTroops, 1); + return { teammate, troopPercentage }; + }) + .sort((a, b) => a.troopPercentage - b.troopPercentage); + + // Try to donate to teammates in order of lowest troop percentage + let selectedTeammate: Player | null = null; + for (const entry of teammatesWithTroopPercentage) { + if (this.player.canDonateTroops(entry.teammate)) { + selectedTeammate = entry.teammate; + break; + } + } + + if (selectedTeammate === null) { + return false; + } + + // Donate a portion of our troops (keeping reserve) + const maxTroops = this.game.config().maxTroops(this.player); + const troopsToKeep = maxTroops * this.reserveRatio; + const availableTroops = this.player.troops() - troopsToKeep; + + if (availableTroops < 1) { + return false; + } + + this.game.addExecution( + new DonateTroopsExecution( + this.player, + selectedTeammate.id(), + availableTroops, + ), + ); + + return true; + } } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 313bd58de..1c56d5d46 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -755,6 +755,7 @@ export interface Game extends GameMap { inSpawnPhase(): boolean; executeNextTick(): GameUpdates; setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void; + getWinner(): Player | Team | null; config(): Config; isPaused(): boolean; setPaused(paused: boolean): void; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 4439ae847..6d409fc58 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -92,6 +92,7 @@ export class GameImpl implements Game { private nextAllianceID: number = 0; private _isPaused: boolean = false; + private _winner: Player | Team | null = null; private _miniWaterGraph: AbstractGraph | null = null; private _miniWaterHPA: AStarWaterHierarchical | null = null; @@ -718,6 +719,7 @@ export class GameImpl implements Game { } setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void { + this._winner = winner; this.addUpdate({ type: GameUpdateType.Win, winner: this.makeWinner(winner), @@ -725,6 +727,10 @@ export class GameImpl implements Game { }); } + getWinner(): Player | Team | null { + return this._winner; + } + private makeWinner(winner: string | Player): Winner | undefined { if (typeof winner === "string") { return [ From a72c87baa0ee3eb460b2056187016e09a8d505c2 Mon Sep 17 00:00:00 2001 From: Mattia Migliorini Date: Wed, 21 Jan 2026 05:32:59 +0100 Subject: [PATCH 087/109] Change join-changed event listener to fix Game Replay functionality (#2968) Resolves #2967 ## Description: The "Replay" action on recent games doesn't work anymore after the release of v29. The problem arises because `AccountModal.viewGame()` correctly calls `history.pushState()` with the game URL and then dispatches the `join-changed` event. The `join-changed` event listener in `Main.ts` calls `onHashUpdate()`, which first calls `JoinPrivateLobbyModal.close()` and then handles the new URL. The problem is that `JoinPrivateLobbyModal.onClose()` resets the modal UI, but also replaces the history state with `/`, therefore `handleUrl()` receives the homepage URL instead of the game URL. This PR fixes the above by creating a dedicated callback for the `join-changed` event (which is dispatched only by `AccountModal` ATM), skipping the `JoinPrivateLobbyModal.close()` call. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: deshack_82603 --- src/client/Main.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/client/Main.ts b/src/client/Main.ts index 8d1df2004..5f4342e3e 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -588,12 +588,8 @@ class Client { const onHashUpdate = () => { // Reset the UI to its initial state this.joinModal?.close(); - if (this.gameStop !== null) { - this.handleLeaveLobby(); - } - // Attempt to join lobby - this.handleUrl(); + onJoinChanged(); }; const onPopState = () => { @@ -627,10 +623,19 @@ class Client { } }; + const onJoinChanged = () => { + if (this.gameStop !== null) { + this.handleLeaveLobby(); + } + + // Attempt to join lobby + this.handleUrl(); + }; + // Handle browser navigation & manual hash edits window.addEventListener("popstate", onPopState); window.addEventListener("hashchange", onHashUpdate); - window.addEventListener("join-changed", onHashUpdate); + window.addEventListener("join-changed", onJoinChanged); function updateSliderProgress(slider: HTMLInputElement) { const percent = From bc479af5c956a1277587e7be8dfbe54dc6dd4e4d Mon Sep 17 00:00:00 2001 From: Simon Schaarschmidt <112267398+xTonai@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:30:08 +0100 Subject: [PATCH 088/109] Fix: Extended spawn immunity in 1v1s (#3010) (#3028) If this PR fixes an issue, link it below. If not, delete these two lines. Resolves #3010 ## Description: Extended the spawn immunity in 1v1s from 5 to 30 seconds, to prevent spawn killing. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: @xtonai Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com> --- src/server/MapPlaylist.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 690227305..5bbc11b00 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -171,7 +171,7 @@ export class MapPlaylist { disableNations: true, gameMode: GameMode.FFA, bots: 100, - spawnImmunityDuration: 5 * 10, + spawnImmunityDuration: 30 * 10, disabledUnits: [], } satisfies GameConfig; } From 3d9f0aec6c5e95478db9c85ea50a30c0ce5d0727 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 26 Jan 2026 13:34:04 -0800 Subject: [PATCH 089/109] Migrate from publift to playwire ads (#3039) ## Description: Use playwire ad integration ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- index.html | 18 ++- src/client/GutterAds.ts | 152 +++++++++---------- src/client/Main.ts | 58 ++----- src/client/graphics/GameRenderer.ts | 12 +- src/client/graphics/layers/AdTimer.ts | 28 ---- src/client/graphics/layers/InGameHeaderAd.ts | 112 ++++++++++++++ 6 files changed, 219 insertions(+), 161 deletions(-) delete mode 100644 src/client/graphics/layers/AdTimer.ts create mode 100644 src/client/graphics/layers/InGameHeaderAd.ts diff --git a/index.html b/index.html index cad490b1c..b1f825ae2 100644 --- a/index.html +++ b/index.html @@ -75,11 +75,11 @@ defer > - - + +
        + +
        diff --git a/src/client/GutterAds.ts b/src/client/GutterAds.ts index caa33623a..91cc08e30 100644 --- a/src/client/GutterAds.ts +++ b/src/client/GutterAds.ts @@ -1,70 +1,46 @@ -import { LitElement, html } from "lit"; +import { LitElement, css, html } from "lit"; import { customElement, state } from "lit/decorators.js"; -import { UserMeResponse } from "../core/ApiSchemas"; -import { isInIframe } from "./Utils"; - -const LEFT_FUSE = "gutter-ad-container-left"; -const RIGHT_FUSE = "gutter-ad-container-right"; -// Minimum screen width to show ads (larger than typical Chromebook) -const MIN_SCREEN_WIDTH = 1400; @customElement("gutter-ads") export class GutterAds extends LitElement { @state() private isVisible: boolean = false; + @state() + private adLoaded: boolean = false; + + private leftAdType: string = "standard_iab_left2"; + private rightAdType: string = "standard_iab_rght1"; + private leftContainerId: string = "gutter-ad-container-left"; + private rightContainerId: string = "gutter-ad-container-right"; + private margin: string = "10px"; + // Override createRenderRoot to disable shadow DOM createRenderRoot() { return this; } - private readonly boundUserMeHandler = (event: Event) => - this.onUserMe((event as CustomEvent).detail); + static styles = css``; connectedCallback() { super.connectedCallback(); - document.addEventListener( - "userMeResponse", - this.boundUserMeHandler as EventListener, - ); - } - - private onUserMe(userMeResponse: UserMeResponse | false): void { - const flares = - userMeResponse === false ? [] : (userMeResponse.player.flares ?? []); - const hasFlare = flares.some((flare) => flare.startsWith("pattern:")); - if (hasFlare) { - console.log("No ads because you have patterns"); - window.enableAds = false; - } else { - console.log("No flares, showing ads"); - this.show(); - window.enableAds = true; - } - } - - private isScreenLargeEnough(): boolean { - return window.innerWidth >= MIN_SCREEN_WIDTH; + document.addEventListener("userMeResponse", () => { + if (window.adsEnabled) { + console.log("showing gutter ads"); + this.show(); + } else { + console.log("not showing gutter ads"); + } + }); } // Called after the component's DOM is first rendered firstUpdated() { // DOM is guaranteed to be available here - console.log("GutterAd DOM is ready"); + console.log("GutterAdModal DOM is ready"); } public show(): void { - if (!this.isScreenLargeEnough()) { - console.log("Screen too small for gutter ads, skipping"); - return; - } - - if (isInIframe()) { - console.log("In iframe, showing gutter ads"); - return; - } - - console.log("showing GutterAds"); this.isVisible = true; this.requestUpdate(); @@ -74,58 +50,57 @@ export class GutterAds extends LitElement { }); } - public hide(): void { - this.isVisible = false; - console.log("hiding GutterAds"); - this.destroyAds(); - document.removeEventListener( - "userMeResponse", - this.boundUserMeHandler as EventListener, - ); - this.requestUpdate(); - } - private loadAds(): void { + console.log("loading ramp ads"); // Ensure the container elements exist before loading ads - const leftContainer = this.querySelector(`#${LEFT_FUSE}`); - const rightContainer = this.querySelector(`#${RIGHT_FUSE}`); + const leftContainer = this.querySelector(`#${this.leftContainerId}`); + const rightContainer = this.querySelector(`#${this.rightContainerId}`); if (!leftContainer || !rightContainer) { console.warn("Ad containers not found in DOM"); return; } - if (!window.fusetag) { - console.warn("Fuse tag not available"); + if (!window.ramp) { + console.warn("Playwire RAMP not available"); + return; + } + + if (this.adLoaded) { + console.log("Ads already loaded, skipping"); return; } try { - console.log("registering zones"); - window.fusetag.que.push(() => { - window.fusetag.registerZone(LEFT_FUSE); - window.fusetag.registerZone(RIGHT_FUSE); + window.ramp.que.push(() => { + try { + window.ramp.spaAddAds([ + { + type: this.leftAdType, + selectorId: this.leftContainerId, + }, + { + type: this.rightAdType, + selectorId: this.rightContainerId, + }, + ]); + this.adLoaded = true; + console.log( + "Playwire ads loaded:", + this.leftAdType, + this.rightAdType, + ); + } catch (e) { + console.log(e); + } }); } catch (error) { - console.error("Failed to load fuse ads:", error); - this.hide(); + console.error("Failed to load Playwire ads:", error); } } - private destroyAds(): void { - if (!window.fusetag) { - return; - } - window.fusetag.que.push(() => { - window.fusetag.destroyZone(LEFT_FUSE); - window.fusetag.destroyZone(RIGHT_FUSE); - }); - this.requestUpdate(); - } - disconnectedCallback() { super.disconnectedCallback(); - this.hide(); } render() { @@ -134,11 +109,26 @@ export class GutterAds extends LitElement { } return html` -
        -
        + + -
        -
        + + + `; } diff --git a/src/client/Main.ts b/src/client/Main.ts index 5f4342e3e..57644d6b8 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -7,7 +7,7 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; import "./AccountModal"; -import { getUserMe } from "./Api"; +import { getUserMe, hasLinkedAccount } from "./Api"; import { userAuth } from "./Auth"; import { joinLobby } from "./ClientGameRunner"; import { fetchCosmetics } from "./Cosmetics"; @@ -163,19 +163,12 @@ declare global { GIT_COMMIT: string; INSTANCE_ID: string; turnstile: any; - enableAds: boolean; + adsEnabled: boolean; PageOS: { session: { newPageView: () => void; }; }; - fusetag: { - registerZone: (id: string) => void; - destroyZone: (id: string) => void; - pageInit: (options?: any) => void; - que: Array<() => void>; - destroySticky: () => void; - }; ramp: { que: Array<() => void>; passiveMode: boolean; @@ -184,7 +177,7 @@ declare global { settings?: { slots?: any; }; - spaNewPage: (url: string) => void; + spaNewPage: (url?: string) => void; }; showPage?: (pageId: string) => void; } @@ -475,15 +468,14 @@ class Client { const onUserMe = async (userMeResponse: UserMeResponse | false) => { // Check if user has actual authentication (discord or email), not just a publicId - const loggedIn = - userMeResponse !== false && - userMeResponse !== null && - typeof userMeResponse === "object" && - userMeResponse.user && - (userMeResponse.user.discord !== undefined || - userMeResponse.user.email !== undefined); - updateMatchmakingButton(loggedIn); + const isLinked: boolean = hasLinkedAccount(userMeResponse); + updateMatchmakingButton(isLinked); updateAccountNavButton(userMeResponse); + const adsEnabled = + !crazyGamesSDK.isOnCrazyGames() && + ((userMeResponse || null)?.player?.flares?.length ?? 0) === 0; + console.log("ads enabled: ", adsEnabled); + window.adsEnabled = adsEnabled; document.dispatchEvent( new CustomEvent("userMeResponse", { detail: userMeResponse, @@ -653,8 +645,6 @@ class Client { updateSliderProgress(slider); slider.addEventListener("input", () => updateSliderProgress(slider)); }); - - this.initializeFuseTag(); } private handleUrl() { @@ -847,7 +837,6 @@ class Client { if (startingModal && startingModal instanceof GameStartingModal) { startingModal.show(); } - this.gutterAds.hide(); }, () => { this.joinModal.close(); @@ -858,6 +847,9 @@ class Client { (ad as HTMLElement).style.display = "none"; }); + if (window.PageOS?.session?.newPageView) { + window.PageOS.session.newPageView(); + } crazyGamesSDK.loadingStop(); crazyGamesSDK.gameplayStart(); document.body.classList.add("in-game"); @@ -902,8 +894,6 @@ class Client { document.body.classList.remove("in-game"); crazyGamesSDK.gameplayStop(); - - this.gutterAds.hide(); this.publicLobby.leaveLobby(); } @@ -925,28 +915,6 @@ class Client { } } - private initializeFuseTag() { - const tryInitFuseTag = (): boolean => { - if (window.fusetag && typeof window.fusetag.pageInit === "function") { - console.log("initializing fuse tag"); - window.fusetag.que.push(() => { - window.fusetag.pageInit({ - blockingFuseIds: ["lhs_sticky_vrec", "rhs_sticky_vrec"], - }); - }); - return true; - } else { - return false; - } - }; - - const interval = setInterval(() => { - if (tryInitFuseTag()) { - clearInterval(interval); - } - }, 100); - } - private async getTurnstileToken( lobby: JoinLobbyEvent, ): Promise { diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index b4cd3eb38..d501c095f 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -6,7 +6,6 @@ import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler"; import { FrameProfiler } from "./FrameProfiler"; import { TransformHandler } from "./TransformHandler"; import { UIState } from "./UIState"; -import { AdTimer } from "./layers/AdTimer"; import { AlertFrame } from "./layers/AlertFrame"; import { BuildMenu } from "./layers/BuildMenu"; import { ChatDisplay } from "./layers/ChatDisplay"; @@ -20,6 +19,7 @@ import { GameLeftSidebar } from "./layers/GameLeftSidebar"; import { GameRightSidebar } from "./layers/GameRightSidebar"; import { HeadsUpMessage } from "./layers/HeadsUpMessage"; import { ImmunityTimer } from "./layers/ImmunityTimer"; +import { InGameHeaderAd } from "./layers/InGameHeaderAd"; import { Layer } from "./layers/Layer"; import { Leaderboard } from "./layers/Leaderboard"; import { MainRadialMenu } from "./layers/MainRadialMenu"; @@ -244,6 +244,14 @@ export function createRenderer( } immunityTimer.game = game; + const inGameHeaderAd = document.querySelector( + "in-game-header-ad", + ) as InGameHeaderAd; + if (!(inGameHeaderAd instanceof InGameHeaderAd)) { + console.error("in-game header ad not found"); + } + inGameHeaderAd.game = game; + // When updating these layers please be mindful of the order. // Try to group layers by the return value of shouldTransform. // Not grouping the layers may cause excessive calls to context.save() and context.restore(). @@ -287,7 +295,7 @@ export function createRenderer( playerPanel, headsUpMessage, multiTabModal, - new AdTimer(game), + inGameHeaderAd, alertFrame, performanceOverlay, ]; diff --git a/src/client/graphics/layers/AdTimer.ts b/src/client/graphics/layers/AdTimer.ts deleted file mode 100644 index 367744df9..000000000 --- a/src/client/graphics/layers/AdTimer.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { GameView } from "../../../core/game/GameView"; -import { Layer } from "./Layer"; - -const AD_SHOW_TICKS = 2 * 60 * 10; // 2 minute - -export class AdTimer implements Layer { - private isHidden: boolean = false; - - constructor(private g: GameView) {} - - init() {} - - public async tick() { - if (this.isHidden) { - return; - } - - const gameTicks = this.g.ticks() - this.g.config().numSpawnPhaseTurns(); - if (gameTicks > AD_SHOW_TICKS) { - console.log("destroying sticky ads"); - window.fusetag?.que?.push(() => { - window.fusetag?.destroySticky?.(); - }); - this.isHidden = true; - return; - } - } -} diff --git a/src/client/graphics/layers/InGameHeaderAd.ts b/src/client/graphics/layers/InGameHeaderAd.ts new file mode 100644 index 000000000..f3925508a --- /dev/null +++ b/src/client/graphics/layers/InGameHeaderAd.ts @@ -0,0 +1,112 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { GameView } from "../../../core/game/GameView"; +import { Layer } from "./Layer"; + +const AD_SHOW_TICKS = 2 * 60 * 10; // 2 minutes +const HEADER_AD_TYPE = "standard_iab_head1"; +const HEADER_AD_CONTAINER_ID = "header-ad-container"; +const TWO_XL_BREAKPOINT = 1536; + +@customElement("in-game-header-ad") +export class InGameHeaderAd extends LitElement implements Layer { + public game: GameView; + + private isHidden: boolean = false; + private adLoaded: boolean = false; + private shouldShow: boolean = false; + + createRenderRoot() { + return this; + } + + init() { + this.showHeaderAd(); + } + + private showHeaderAd(): void { + // Don't show header ad on screens smaller than 2xl + if (window.innerWidth < TWO_XL_BREAKPOINT) { + return; + } + if (!window.adsEnabled) { + return; + } + + this.shouldShow = true; + this.requestUpdate(); + + // Wait for the element to render before loading the ad + this.updateComplete.then(() => { + this.loadAd(); + }); + } + + private loadAd(): void { + if (!window.ramp) { + console.warn("Playwire RAMP not available for header ad"); + return; + } + + try { + window.ramp.que.push(() => { + try { + window.ramp.spaAddAds([ + { + type: HEADER_AD_TYPE, + selectorId: HEADER_AD_CONTAINER_ID, + }, + ]); + this.adLoaded = true; + console.log("Header ad loaded:", HEADER_AD_TYPE); + } catch (e) { + console.error("Failed to add header ad:", e); + } + }); + } catch (error) { + console.error("Failed to load header ad:", error); + } + } + + private hideHeaderAd(): void { + this.shouldShow = false; + this.adLoaded = false; + this.requestUpdate(); + } + + public tick() { + if (this.isHidden) { + return; + } + + const gameTicks = + this.game.ticks() - this.game.config().numSpawnPhaseTurns(); + if (gameTicks > AD_SHOW_TICKS) { + console.log("destroying header ad and refreshing PageOS"); + this.hideHeaderAd(); + this.isHidden = true; + + if (window.PageOS?.session?.newPageView) { + window.PageOS.session.newPageView(); + } + return; + } + } + + shouldTransform(): boolean { + return false; + } + + render() { + if (!this.shouldShow) { + return html``; + } + + return html` + + `; + } +} From 9aed372425d1ca3d50a76c17da086e598f39d81a Mon Sep 17 00:00:00 2001 From: Evan Date: Sun, 25 Jan 2026 20:34:48 -0800 Subject: [PATCH 090/109] Added afterEach cleanup to call inputHandler.destroy(), which clears the setInterval before jsdom tears down and removes window. (#3030) ## Description: Fixes the failing test:coverage ci. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- tests/InputHandler.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 132e88419..18f0c6497 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -40,6 +40,10 @@ describe("InputHandler AutoUpgrade", () => { ); }); + afterEach(() => { + inputHandler.destroy(); + }); + describe("Middle Mouse Button Handling", () => { test("should emit AutoUpgradeEvent on middle mouse button press", () => { const mockEmit = vi.spyOn(eventBus, "emit"); From 2984bec4d1b66db9d07fbe983ea8d961690389c8 Mon Sep 17 00:00:00 2001 From: Simon Schaarschmidt <112267398+xTonai@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:30:08 +0100 Subject: [PATCH 091/109] Fix: Extended spawn immunity in 1v1s (#3010) (#3028) If this PR fixes an issue, link it below. If not, delete these two lines. Resolves #3010 ## Description: Extended the spawn immunity in 1v1s from 5 to 30 seconds, to prevent spawn killing. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: @xtonai Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com> --- src/server/MapPlaylist.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 6e05022d8..20369f529 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -172,7 +172,7 @@ export class MapPlaylist { disableNations: true, gameMode: GameMode.FFA, bots: 100, - spawnImmunityDuration: 5 * 10, + spawnImmunityDuration: 30 * 10, disabledUnits: [], } satisfies GameConfig; } From 71c5102981dff42438ce847cb6f53457dba81d64 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Tue, 27 Jan 2026 06:56:10 +0900 Subject: [PATCH 092/109] mls (v4.15) (#3019) ## Description: mls for v29 Version identifier within MLS: 4.14 This is the last mls for v29. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: aotumuri --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- resources/lang/fr.json | 241 +++++++-- resources/lang/id.json | 953 +++++++++++++++++++++++++++++++++++ resources/lang/metadata.json | 6 + 3 files changed, 1152 insertions(+), 48 deletions(-) create mode 100644 resources/lang/id.json diff --git a/resources/lang/fr.json b/resources/lang/fr.json index 31328191e..0df822e4e 100644 --- a/resources/lang/fr.json +++ b/resources/lang/fr.json @@ -7,6 +7,7 @@ }, "common": { "close": "Fermer", + "back": "Retour", "available": "Disponible", "preset_max": "Max", "summary_send": "Envoyer", @@ -17,26 +18,42 @@ "cap_tooltip": "Capacité restante du destinataire", "target_dead": "Cible éliminée", "target_dead_note": "Vous ne pouvez pas envoyer de ressources à un joueur éliminé.", - "none": "Aucun" + "none": "Aucun", + "copied": "Copié !", + "click_to_copy": "Cliquer pour copier" }, "main": { "title": "OpenFront (ALPHA)", "join_discord": "Discord", "login_discord": "Se connecter avec Discord", + "sign_in": "Se connecter", + "discord_avatar_alt": "Avatar du profil Discord", + "user_avatar_alt": "Avatar de {username}", "checking_login": "Vérification de la connexion...", "logged_in": "Connecté !", "log_out": "Se déconnecter", - "create_lobby": "Créer un salon", - "join_lobby": "Rejoindre un salon", - "single_player": "Mode solo", + "create": "Créer un salon", + "join": "Rejoindre un salon", + "solo": "Solo", "instructions": "Instructions", + "game_info": "Infos sur la partie", "wiki": "Wiki", "privacy_policy": "Politique de confidentialité", "terms_of_service": "Conditions d'utilisation", - "reddit": "Reddit" + "copyright": "©️ OpenFront™ et Contributeurs", + "reddit": "Reddit", + "play": "Jouer", + "news": "Actus", + "store": "Boutique", + "settings": "Paramètres", + "keys": "Touches", + "stats": "Stats", + "account": "Compte", + "help": "Aide", + "menu": "Menu", + "pick_pattern": "Choisis un motif !" }, "news": { - "see_all_releases": "Voir toutes les versions", "github_link": "sur Github", "title": "Notes de version" }, @@ -67,7 +84,7 @@ "ui_events_desc": "Le panneau des événements affiche les derniers événements, demandes et messages de chat rapide. Quelques exemples sont :", "ui_events_alliance": "Alliance - Les demandes d'Alliance peuvent être acceptées ou rejetées. Les alliés peuvent partager des ressources et des troupes, mais ne peuvent pas s'attaquer. Cliquer sur Focus déplace la vue vers le joueur qui a envoyé la requête.", "ui_events_attack": "Attaques - Les attaques entrantes et sortantes sont affichées. Cliquez sur le message pour centrer la vue sur l'attaque, la bombe ou le bateau (navire de transport). Vous pouvez envoyer les troupes en retraite en cliquant sur le bouton rouge X. Cela coûtera la vie à 25% de vos troupes attaquantes. Si vous annulez une attaque de bateau, le bateau revient à son point de départ et y attaquera si la terre a été capturée depuis. Les bombes atomiques ne peuvent pas être retirées une fois lancées.", - "ui_events_quickchat": "Chat rapide - Vous pouvez voir les messages envoyés et reçus ici. Envoyez un message à un joueur en cliquant sur l'icône Chat rapide dans son menu Infos.", + "ui_events_quickchat": "Chat Rapide - Vous pouvez voir les messages envoyés et reçus ici. Envoyez un message à un joueur en cliquant sur l'icône Chat Rapide dans son menu Infos.", "ui_options": "Options", "ui_options_desc": "Les éléments suivants peuvent être trouvés à l'intérieur :", "ui_playeroverlay": "Informations sur le joueur", @@ -83,6 +100,8 @@ "radial_attack": "Ouvrez le menu d'attaque.", "radial_info": "Ouvrir le menu d'informations.", "radial_boat": "Envoyer un bateau (navire de transport) pour attaquer l'endroit sélectionné. Disponible uniquement si vous avez accès à l'eau.", + "radial_donate_troops": "Donner des troupes équivalent à votre ratio d'attaque à l'allié sur lequel vous avez ouvert le menu radial.", + "radial_donate_gold": "Ouvre le menu du curseur de don d'or pour que vous puissiez envoyer rapidement de l'or aux alliés.", "radial_close": "Fermer le menu.", "info_title": "Menu d'informations", "info_enemy_desc": "Contient des informations telles que le nom du joueur sélectionné, son or, ses troupes, s'il a cessé de commercer avec vous, les bombes qu'il vous a envoyées, et si le joueur est un traître. L'arrêt du commerce signifie que vous ne recevrez pas d'or de sa part et qu'il ne vous enverra pas d'or via des navires commerciaux. Manuellement (si le joueur a cliqué sur « Arrêter le commerce », qui dure jusqu'à ce que vous cliquiez sur « Commencer le commerce ») ou automatiquement (si vous avez trahi votre alliance, ce qui dure jusqu'à ce que vous deveniez alliés de nouveau ou après 5 minutes). Le traître affiche Oui pendant 30 secondes lorsque le joueur a trahi et attaqué un joueur qui était dans une alliance avec lui. Les icônes ci-dessous représentent les interactions suivantes :", @@ -114,7 +133,7 @@ "build_silo": "Silo à missiles", "build_silo_desc": "Permet de lancer des missiles.", "build_sam": "Lanceur SAM", - "build_sam_desc": "Vous pouvez intercepter les missiles ennemis à portée de 100 pixels. Avec une probabilité de 100% pour la Bombe Atomique, 80% pour la Bombe Hydrogène et 50% pour les Ogives MIRV individuelles. Le SAM a un temps de recharge de 7,5 secondes.", + "build_sam_desc": "Peut intercepter les missiles ennemis dans un rayon de 100 pixels. Le SAM a un temps de recharge de 7,5 secondes.", "build_atom": "Bombe atomique", "build_atom_desc": "Petite bombe explosive qui détruit le territoire, les bâtiments, les navires et les bateaux. Apparaît depuis le Silo à missiles le plus proche et atterrit dans la zone cliquée.", "build_hydrogen": "Bombe à hydrogène", @@ -129,12 +148,15 @@ "icon_embargo": "Signe dollar barré - Embargo. Ce joueur a cessé de commercer avec vous automatiquement ou manuellement.", "icon_request": "Enveloppe - Demande d'alliance. Ce joueur vous a envoyé une demande d'alliance.", "info_enemy_panel": "Panneau d'information de l'ennemi", - "exit_confirmation": "Êtes-vous sûr de vouloir quitter le jeu ?" + "exit_confirmation": "Êtes-vous sûr de vouloir quitter le jeu ?", + "bomb_direction": "Direction de l'arc de bombe Atomique / Hydrogène" }, "single_modal": { - "title": "Joueur seul", + "title": "Solo", "random_spawn": "Spawn aléatoire", "allow_alliances": "Autoriser les alliances", + "toggle_achievements": "Activer / Désactiver les succès", + "sign_in_for_achievements": "Connectez-vous pour obtenir des succès", "options_title": "Options", "bots": "Bots : ", "bots_disabled": "Désactivé", @@ -145,6 +167,8 @@ "infinite_troops": "Troupes infinies", "compact_map": "Carte compacte", "max_timer": "Durée de jeu (minutes)", + "max_timer_placeholder": "Mins", + "max_timer_invalid": "Veuillez entrer une valeur max valide pour le minuteur (1-120 minutes)", "disable_nukes": "Désactiver les armes nucléaires", "enables_title": "Activer les paramètres", "start": "Commencer la partie" @@ -156,10 +180,21 @@ }, "account_modal": { "title": "Compte", - "logged_in_as": "Connecté en tant que {email}", + "connected_as": "Connecté en tant que", + "stats_overview": "Aperçu des Statistiques", + "link_discord": "Lier un Compte Discord", + "log_out": "Se Déconnecter", + "sign_in_desc": "Connectez-vous pour enregistrer vos statistiques et progrès", + "or": "OU", + "email_placeholder": "Entrez votre adresse email", + "get_magic_link": "Obtenir un Lien Magique", + "linked_account": "Connecté en tant que {account_name}", "fetching_account": "Récupération des informations du compte...", - "logged_in_with_discord": "Connecté avec Discord", - "recovery_email_sent": "Courriel de récupération envoyé à {email}" + "recovery_email_sent": "Courriel de récupération envoyé à {email}", + "not_found": "Introuvable", + "clear_session": "Effacer la session", + "failed_to_send_recovery_email": "Échec de l'envoi de l'e-mail de récupération", + "enter_email_address": "Veuillez saisir une adresse e-mail" }, "stats_modal": { "title": "Statistiques", @@ -167,11 +202,40 @@ "loading": "Chargement...", "error": "Erreur lors du chargement des statistiques du clan", "no_stats": "Pas de statistique de clan disponible", + "no_data_yet": "Aucune donnée pour le moment", "clan": "Clan", "games": "Parties", "win_score": "Score de Victoire", + "win_score_tooltip": "Victoires pondérées en fonction de la participation du clan et de la difficulté du match", "loss_score": "Score de Défaite", - "win_loss_ratio": "Victoires/Défaites" + "loss_score_tooltip": "Défaites pondérées en fonction de la participation du clan et de la difficulté du match", + "win_loss_ratio": "Victoires/Défaites", + "ratio": "Coefficient", + "rank": "Rang", + "try_again": "Réessayer" + }, + "game_info_modal": { + "title": "Infos sur la partie", + "players": "Joueurs", + "atoms": "Atomes", + "hydros": "Hydros", + "mirv": "MIRV", + "bombs": "Bombes", + "total_gold": "Total", + "all_gold": "Tout l'or", + "trade": "Commercer", + "conquest_gold": "Or de joueur conquis", + "stolen_gold": "Volé avec des navires de guerre", + "num_of_conquests": "Nombre de joueurs conquis", + "duration": "Durée", + "survival_time": "Temps de survie", + "war": "Guerre", + "economy": "Économie", + "conquests": "Conquêtes", + "pirate": "Pirate", + "conquered": "Conquis", + "loading_game_info": "Chargement des stats du jeu", + "no_winner": "Cette partie s'est terminée sans aucun gagnant (ou une Nation a gagné)" }, "map": { "map": "Carte", @@ -186,6 +250,7 @@ "asia": "Asie", "mars": "Mars", "southamerica": "Amérique du Sud", + "britanniaclassic": "Grande-Bretagne (Classique)", "britannia": "Grande-Bretagne", "gatewaytotheatlantic": "Porte de l'Atlantique", "australia": "Australie", @@ -196,7 +261,7 @@ "betweentwoseas": "Entre deux mers", "faroeislands": "Îles Féroé", "deglaciatedantarctica": "Antarctique Déglacée", - "europeclassic": "Europe (classique)", + "europeclassic": "Europe (Classique)", "falklandislands": "Îles Malouines", "baikal": "Lac Baïkal", "halkidiki": "Chalcidique", @@ -206,19 +271,33 @@ "yenisei": "Ienisseï", "pluto": "Pluton", "montreal": "Montréal", + "newyorkcity": "New York City", "achiran": "Achiran", "baikalnukewars": "Baïkal (Guerres Nucléaires)", "fourislands": "Quatre Îles", "gulfofstlawrence": "Golfe du Saint-Laurent", - "lisbon": "Lisbonne" + "lisbon": "Lisbonne", + "svalmel": "Svalmel", + "manicouagan": "Manicouagan", + "lemnos": "Lemnos", + "sierpinski": "Sierpinski", + "twolakes": "Deux Lacs", + "straitofhormuz": "Détroit d'Ormuz", + "surrounded": "Encerclé", + "didier": "Didier", + "didierfrance": "Didier (France)", + "amazonriver": "Fleuve Amazone" }, "map_categories": { "continental": "Continental", "regional": "Régional", - "fantasy": "Autre" + "fantasy": "Autre", + "special": "Spéciales", + "arcade": "Arcade" }, "map_component": { - "loading": "Chargement..." + "loading": "Chargement...", + "error": "Erreur" }, "private_lobby": { "title": "Rejoindre un salon privé", @@ -229,42 +308,55 @@ "checking": "Vérification du salon...", "not_found": "Salon introuvable. Veuillez vérifier l'ID et réessayer.", "error": "Une erreur s'est produite. Veuillez réessayer ou contacter le support.", - "joined_waiting": "Rejoint avec succès ! En attente du début de la partie...", - "version_mismatch": "Cette partie a été créée avec une version différente. Impossible de rejoindre." + "joined_waiting": "Salon rejoint ! En attente du démarrage de l'hôte...", + "version_mismatch": "Cette partie a été créée avec une version différente. Impossible de rejoindre.", + "disabled_units": "Unités désactivées" }, "public_lobby": { "join": "Rejoindre la prochaine partie", "waiting": "joueurs en attente", - "teams_Duos": "de 2 (Duos)", - "teams_Trios": "de 3 (Trios)", - "teams_Quads": "de 4 (Quatuors)", - "teams_hvn": "Humains Vs Nations", + "teams_Duos": "{team_count} équipes de 2 (Duos)", + "teams_Trios": "{team_count} équipes de 3 (Trios)", + "teams_Quads": "{team_count} équipes de 4 (Quatuors)", + "waiting_for_players": "En attente de joueurs", + "starting_game": "Démarrage en cours...", + "teams_hvn": "Humains vs Nations", + "teams_hvn_detailed": "{num} Humains vs {num} Nations", "teams": "{num} équipes", - "players_per_team": "de {num}" + "players_per_team": "de {num}", + "started": "Lancé" }, "matchmaking_modal": { - "title": "Matchmaking", + "title": "Matchmaking 1v1 Classé (ALPHA)", "connecting": "Connexion au serveur de matchmaking...", "searching": "Recherche d'une partie...", - "waiting_for_game": "En attente du début de la partie..." + "waiting_for_game": "En attente du début de la partie...", + "elo": "Votre ELO : {elo}" }, "username": { "enter_username": "Entrez votre nom d'utilisateur", "not_string": "Le nom d'utilisateur doit être une chaîne de caractères.", "too_short": "Le nom d'utilisateur doit comporter au moins {min} caractères.", "too_long": "Le nom d'utilisateur ne doit pas dépasser {max} caractères.", - "invalid_chars": "Le nom d'utilisateur ne peut contenir que des lettres, des chiffres, des espaces, des tirets bas et des [crochets]." + "invalid_chars": "Le pseudonyme peut seulement contenir des lettres, chiffres, espaces et underscores.", + "tag": "TAG", + "tag_too_short": "Le tag de clan doit faire 2 à 5 caractères alphanumériques.", + "tag_invalid_chars": "Le tag de clan peut seulement contenir des lettres et des chiffres." }, "host_modal": { - "title": "Salon privé", + "title": "Créer un Salon Privé", + "label": "Privé", "mode": "Mode", "team_count": "Nombre d'équipes", + "team_type": "Type d'équipe", "options_title": "Paramètres", "bots": "Bots : ", "bots_disabled": "Désactivé", + "player_immunity_duration": "Immunité au JcJ (minutes)", "nations": "Nations : ", "disable_nations": "Désactiver les nations", "max_timer": "Durée de jeu (minutes)", + "mins_placeholder": "Mins", "instant_build": "Construction instantanée", "infinite_gold": "Or infini", "donate_gold": "Donner de l'or", @@ -283,7 +375,11 @@ "assigned_teams": "Équipes Attribuées", "empty_teams": "Équipes vides", "empty_team": "Vide", - "remove_player": "Retirer {username}" + "remove_player": "Retirer {username}", + "teams_Duos": "Duos (équipes de 2)", + "teams_Trios": "Trios (équipes de 3)", + "teams_Quads": "Quatuors (équipes de 4)", + "teams_Humans Vs Nations": "Humains vs Nations" }, "team_colors": { "red": "Rouge", @@ -301,16 +397,20 @@ "code_license": "Code sous licence AGPL-3.0 (sans garantie)" }, "difficulty": { - "difficulty": "Difficulté", - "Easy": "Détendu", - "Medium": "Équilibré", - "Hard": "Intense", - "Impossible": "Impossible" + "difficulty": "Difficulté des nations", + "easy": "Facile", + "medium": "Moyen", + "hard": "Difficile", + "impossible": "Impossible" }, "game_mode": { "ffa": "Chacun pour soi", "teams": "Équipes" }, + "public_game_modifier": { + "random_spawn": "Spawn aléatoire", + "compact_map": "Carte compacte" + }, "select_lang": { "title": "Sélectionner une langue" }, @@ -327,7 +427,7 @@ "factory": "Usine" }, "user_setting": { - "title": "Paramètres utilisateur", + "title": "Paramètres", "tab_basic": "Réglages de base", "tab_keybinds": "Raccourcis clavier", "dark_mode_label": "Mode sombre", @@ -340,16 +440,18 @@ "special_effects_desc": "Activer/désactiver les effets spéciaux. Désactiver pour améliorer les performances", "structure_sprites_label": "Sprites de structure", "structure_sprites_desc": "Activer/désactiver les sprites de structure", + "cursor_cost_label_label": "Coûts de construction", + "cursor_cost_label_desc": "Afficher une pastille indiquant le coût sous l'icône du curseur de construction", "anonymous_names_label": "Noms masqués", "anonymous_names_desc": "Cacher le vrai nom des joueurs avec des noms aléatoires sur votre écran.", "lobby_id_visibility_label": "ID du salon masqué", "lobby_id_visibility_desc": "Cacher l'ID du salon lors de la création du salon privé", + "toggle_visibility": "Changer la visibilité", "left_click_label": "Clic gauche pour ouvrir le menu", "left_click_desc": "Activé, un clic gauche ouvre le menu et le bouton épée d'attaque. Désactivé, un clic gauche attaque directement.", "left_click_menu": "Menu Clic gauche", "attack_ratio_label": "⚔️ Ratio d'attaque", "attack_ratio_desc": "Quel pourcentage de vos troupes envoyer dans une attaque (1–100%)", - "troop_ratio_desc": "Ajuster l'équilibre entre les troupes (pour le combat) et les ouvriers (pour la production d'or) (1–100%)", "territory_patterns_label": "🏳️ Skins de territoire", "territory_patterns_desc": "Choisissez d'afficher ou non les designs des skins de territoire dans le jeu", "performance_overlay_label": "Surcouche de performances", @@ -358,6 +460,7 @@ "easter_writing_speed_desc": "Ajuster la vitesse à laquelle vous prétendez coder (x1–x100)", "easter_bug_count_label": "Nombre de bugs", "easter_bug_count_desc": "Combien de bugs vous acceptez (0-1000, émotionnellement)", + "press_a_key": "Appuyez sur une touche", "view_options": "Options d'affichage", "toggle_view": "Activer/désactiver l'affichage", "toggle_view_desc": "Vue alternative (terrain/pays)", @@ -382,6 +485,11 @@ "build_hydrogen_bomb_desc": "Envoyer une bombe à hydrogène sous votre curseur.", "build_mirv": "Construire un MIRV", "build_mirv_desc": "Construire un MIRV sous votre curseur.", + "menu_shortcuts": "Menu des raccourcis ", + "build_menu_modifier": "Menu de modification des constructions", + "build_menu_modifier_desc": "Maintenez cette touche enfoncée en cliquant pour ouvrir le menu de construction.", + "emoji_menu_modifier": "Menu de modifications des émojis", + "emoji_menu_modifier_desc": "Maintenez cette touche enfoncée en cliquant pour ouvrir le menu des emojis.", "attack_ratio_controls": "Contrôles du ratio d'attaque", "attack_ratio_up": "Augmenter le ratio d'attaque", "attack_ratio_up_desc": "Augmenter le ratio d'attaque de 10%", @@ -392,6 +500,8 @@ "boat_attack_desc": "Envoyer une attaque navale à la tuile sous votre curseur.", "ground_attack": "Attaque au sol", "ground_attack_desc": "Envoyez une attaque au sol sur la tuile sous votre curseur.", + "swap_direction": "Inverser la trajectoire balistique", + "swap_direction_desc": "Inverser la trajectoire balistique de lancement", "zoom_controls": "Contrôles de zoom", "zoom_out": "Zoom arrière", "zoom_out_desc": "Dézoom de la carte", @@ -416,7 +526,8 @@ "exit_game_label": "Quitter la partie", "exit_game_info": "Retour au menu principal", "background_music_volume": "Volume de la musique de fond", - "sound_effects_volume": "Volume des effets sonores" + "sound_effects_volume": "Volume des effets sonores", + "keybind_conflict_error": "La clé {key} est déjà liée à une autre action." }, "chat": { "title": "Discussion", @@ -529,6 +640,7 @@ "other_team": "L'équipe {team} a gagné !", "you_won": "Vous avez gagné !", "other_won": "{player} a gagné !", + "nation_won": "La nation {nation} a gagné !", "exit": "Quitter la partie", "keep": "Continuer à jouer", "spectate": "Regarder", @@ -537,7 +649,7 @@ "ofm_winter_description": "Rejoignez le tournoi et affrontez les meilleurs joueurs", "join_tournament": "Rejoindre le tournoi", "join_discord": "Rejoignez notre communauté Discord !", - "discord_description": "Connectez-vous avec d'autres joueurs, recevez les nouvelles et partagez des stratégies", + "discord_description": "Parlez avec des joueurs, découvrez de nouvelles fonctionnalités et gagnez des prix !", "join_server": "Rejoindre le Serveur", "youtube_tutorial": "Besoin d'aide ?" }, @@ -549,7 +661,7 @@ "team": "Équipe", "owned": "Possédé", "gold": "Or", - "troops": "Troupes", + "maxtroops": "Troupes max", "launchers": "Lanceurs", "sams": "SAMs", "warships": "Vaisseaux de guerre", @@ -565,6 +677,7 @@ "team": "Équipe", "alliance_timeout": "L'alliance se termine dans", "troops": "Troupes", + "maxtroops": "Troupes max", "a_troops": "Troupes en attaque", "gold": "Or", "ports": "Ports", @@ -575,7 +688,9 @@ "warships": "Navires de guerre", "health": "Santé", "attitude": "Attitude", - "levels": "Niveaux" + "levels": "Niveaux", + "wilderness_title": "Étendues sauvages", + "irradiated_wilderness_title": "Terre irradiée" }, "events_display": { "retreating": "en retraite", @@ -601,7 +716,20 @@ "wants_to_renew_alliance": "{name} souhaite renouveler votre alliance", "ignore": "Ignorer", "unit_voluntarily_deleted": "Unité volontairement supprimée", - "betrayal_debuff_ends": "{time} secondes restantes jusqu'à la fin du malus de trahison" + "betrayal_debuff_ends": "{time} secondes restantes jusqu'à la fin du malus de trahison", + "attack_cancelled_retreat": "Attaque annulée, {troops} soldats ont été tués pendant la retraite", + "received_gold_from_captured_ship": "{gold} ors reçu pour la capture d'un navire de {name}", + "received_gold_from_trade": "{gold} ors reçu pour le commerce avec {name}", + "missile_intercepted": "Le missile a intercepté {unit}", + "mirv_warheads_intercepted": "{count, plural, one {{count} ogive nucléaire MIRV a été interceptée} other {{count} ogives nucléaire MIRV ont été interceptées}}", + "sent_troops_to_player": "Vous avez envoyé {troops} troupes à {name}", + "received_troops_from_player": "Vous avez reçu {troops} troupes de {name}", + "sent_gold_to_player": "Vous avez envoyé {gold} ors à {name}", + "received_gold_from_player": "Vous avez reçu {gold} ors de {name}", + "unit_captured_by_enemy": "Votre {unit} a été capturé par {name}", + "captured_enemy_unit": "{unit} de {name} capturé", + "unit_destroyed": "Votre {unit} a été détruit", + "no_boats_available": "Aucun bateau disponible, max {max}" }, "unit_info_modal": { "structure_info": "Infos sur la structure", @@ -653,7 +781,10 @@ "send_alliance": "Envoyer une alliance", "send_troops": "Envoyer des troupes", "send_gold": "Envoyer de l'or", - "emotes": "Émojis" + "emotes": "Émojis", + "arc_up": "Arc vers le haut", + "arc_down": "Arc vers le bas", + "flip_rocket_trajectory": "Inverser la trajectoire de la fusée" }, "send_troops_modal": { "title_with_name": "Envoyer des troupes à {name}", @@ -702,20 +833,26 @@ }, "heads_up_message": { "choose_spawn": "Choisissez un emplacement de départ", - "random_spawn": "Le spawn aléatoire est activé. Sélection de l'emplacement de départ pour vous..." + "random_spawn": "Le spawn aléatoire est activé. Sélection de l'emplacement de départ pour vous...", + "singleplayer_game_paused": "Jeu en pause", + "multiplayer_game_paused": "Jeu mis en pause par le créateur du salon" }, "territory_patterns": { "title": "Skins", "colors": "Couleurs", "purchase": "Acheter", "show_only_owned": "Mes skins", + "all_owned": "Vous possédez déjà tous les motifs ! Revenez plus tard pour de nouveau.", + "not_logged_in": "Non connecté", "blocked": { "login": "Vous devez être connecté pour accéder à ce skin.", "purchase": "Achetez ce skin pour le débloquer." }, "pattern": { "default": "Par défaut" - } + }, + "select_skin": "Sélectionnez le motif", + "selected": "sélectionné" }, "flag_input": { "title": "Sélectionner un drapeau", @@ -786,8 +923,9 @@ "mode": "Mode", "mode_ffa": "Chacun pour soi", "mode_team": "Équipe", - "view": "Vue", + "replay": "Revoir", "details": "Détails", + "ranking": "Classé", "started": "Débuté", "map": "Carte", "difficulty": "Difficulté", @@ -796,13 +934,20 @@ "player_stats_tree": { "public": "Public", "private": "Privé", - "singleplayer": "Mode solo", + "singleplayer": "Solo", "mode": "Mode", "stats_wins": "Victoires", "stats_losses": "Défaites", "stats_wlr": "Ratio Victoires:Défaites", "stats_games_played": "Parties jouées", "mode_ffa": "Chacun pour soi", - "mode_team": "En équipe" + "mode_team": "En équipe", + "no_stats": "Aucune statistique enregistrée pour cette sélection." + }, + "matchmaking_button": { + "play_ranked": "Matchmaking 1v1 classé", + "description": "(ALPHA)", + "login_required": "Connectez-vous pour jouer en mode classé", + "must_login": "Vous devez être connecté pour jouer en mode classé." } } diff --git a/resources/lang/id.json b/resources/lang/id.json new file mode 100644 index 000000000..7f7a55fc4 --- /dev/null +++ b/resources/lang/id.json @@ -0,0 +1,953 @@ +{ + "lang": { + "en": "Indonesian", + "native": "Bahasa Indonesia", + "svg": "id", + "lang_code": "id" + }, + "common": { + "close": "Keluar", + "back": "Kembali", + "available": "Tersedia", + "preset_max": "Maks", + "summary_send": "Kirim", + "summary_keep": "Simpan", + "cancel": "Batalkan", + "send": "Kirim", + "cap_label": "Batas maksimal", + "cap_tooltip": "Kapasitas penerima yang tersisa", + "target_dead": "Target dieliminasi", + "target_dead_note": "Anda tidak dapat mengirim sumber daya ke pemain yang telah tereliminasi.", + "none": "Tidak Satupun", + "copied": "Tersalin", + "click_to_copy": "Klik untuk salin" + }, + "main": { + "title": "OpenFront (ALPHA)", + "join_discord": "Discord", + "login_discord": "Masuk dengan Discord", + "sign_in": "Masuk", + "discord_avatar_alt": "Avatar profil Discord", + "user_avatar_alt": "Avatar {username}", + "checking_login": "Memeriksa login...", + "logged_in": "Berhasil masuk!", + "log_out": "Keluar", + "create": "Buat Lobi", + "join": "Bergabung ke Lobi", + "solo": "Sendiri", + "instructions": "Petunjuk", + "game_info": "Informasi Permainan", + "wiki": "Wiki", + "privacy_policy": "Kebijakan Privasi", + "terms_of_service": "Ketentuan Layanan", + "copyright": "© OpenFront™ dan para kontributor", + "reddit": "Reddit", + "play": "Main", + "news": "Berita", + "store": "Toko", + "settings": "Pengaturan", + "keys": "Tombol", + "stats": "Statistik", + "account": "Akun", + "help": "Bantuan", + "menu": "Menu", + "pick_pattern": "Pilih pola!" + }, + "news": { + "github_link": "di GitHub", + "title": "Catatan Rilis" + }, + "help_modal": { + "hotkeys": "Tombol pintas", + "table_key": "Kunci", + "table_action": "Tindakan", + "action_alt_view": "Ganti Tampilan (Medan / Negara)", + "action_attack_altclick": "Serang (saat klik kiri diatur untuk membuka menu)", + "action_build": "Buka menu Pembangunan", + "action_emote": "Buka menu Ekspresi", + "action_center": "Pusatkan kamera pada pemain", + "action_zoom": "Perkecil / Perbesar tampilan", + "action_move_camera": "Pindahkan kamera", + "action_ratio_change": "Kurangi / Tingkatkan rasio serangan", + "action_reset_gfx": "Atur ulang grafis", + "action_auto_upgrade": "Tingkatkan bangunan terdekat secara otomatis", + "ui_section": "UI Permainan", + "ui_leaderboard": "Papan Peringkat", + "ui_your_team": "Tim anda:", + "ui_leaderboard_desc": "Menampilkan pemain teratas dalam permainan beserta nama mereka, persentase wilayah yang dikuasai, jumlah emas, dan pasukan. Opsi Tampilkan Semua akan menampilkan seluruh pemain dalam permainan. Jika tidak ingin melihat papan peringkat, klik Sembunyikan.", + "ui_control": "Panel kendali", + "ui_control_desc": "Panel kontrol berisi elemen berikut:", + "ui_pop": "Populasi - Jumlah unit yang kamu miliki, batas populasi maksimum, serta laju pertambahannya.", + "ui_gold": "Emas - Jumlah emas yang kamu miliki dan laju perolehannya.", + "ui_attack_ratio": "Rasio Serangan - Jumlah pasukan yang akan digunakan saat kamu menyerang. Kamu dapat menyesuaikan rasio serangan menggunakan penggeser. Memiliki pasukan penyerang lebih banyak daripada pasukan bertahan akan mengurangi jumlah pasukan yang hilang saat menyerang, sedangkan jumlah pasukan yang lebih sedikit akan meningkatkan kerusakan yang diterima pasukan penyerang. Efek ini tidak berlaku di atas rasio 2:1.", + "ui_events": "Panel Event", + "ui_events_desc": "Panel Event menampilkan peristiwa, permintaan, dan pesan Obrolan Cepat terbaru. Beberapa contohnya adalah:", + "ui_events_alliance": "Aliansi - Permintaan aliansi dapat diterima atau ditolak. Sekutu dapat berbagi sumber daya dan pasukan, tetapi tidak dapat saling menyerang. Menekan Fokus akan memusatkan tampilan ke pemain yang mengirim permintaan.", + "ui_events_attack": "Serangan - Menampilkan serangan yang masuk dan serangan yang kamu lakukan. Klik pesan untuk memusatkan tampilan ke lokasi serangan, nuklir, atau Kapal (kapal pengangkut). Kamu dapat menarik mundur pasukan dengan menekan tombol X merah. Tindakan ini akan mengorbankan 25% dari pasukan penyerang.\nJika serangan Kapal ditarik kembali, kapal akan kembali ke titik awal dan akan menyerang kembali di sana jika wilayah tersebut telah dikuasai sejak saat itu. Serangan nuklir tidak dapat ditarik kembali setelah diluncurkan.", + "ui_events_quickchat": "Obrolan Cepat - Di sini kamu dapat melihat pesan obrolan yang dikirim dan diterima. Untuk mengirim pesan ke pemain, klik ikon Obrolan Cepat di menu Info pemain tersebut.", + "ui_options": "Pilihan", + "ui_options_desc": "Elemen-elemen berikut dapat ditemukan di dalamnya:", + "ui_playeroverlay": "Overlay Info Pemain", + "ui_playeroverlay_desc": "Saat kamu mengarahkan kursor ke suatu negara, overlay Info Pemain akan ditampilkan di bawah menu Opsi. Overlay ini menampilkan jenis pemain: Manusia, Negara (bot pintar), atau Bot; sikap suatu Negara terhadapmu, mulai dari Bermusuhan hingga Ramah; serta jumlah pasukan bertahan, emas, jumlah Kapal Perang, dan berbagai bangunan yang dimiliki pemain tersebut.", + "ui_wilderness": "Alam Liar", + "option_pause": "Jeda / Lanjutkan permainan – Hanya tersedia dalam mode single-player.", + "option_timer": "Timer – Waktu yang telah berlalu sejak permainan dimulai.", + "option_exit": "Tombol keluar.", + "option_settings": "Pengaturan – Membuka menu pengaturan. Di dalamnya kamu dapat mengaktifkan atau menonaktifkan Tampilan Alternatif, Emoji, Mode Gelap, Ninja (mode anonim / nama acak), serta aksi pada klik kiri.", + "radial_title": "Menu Radial", + "radial_desc": "Klik kanan (atau sentuhan di perangkat seluler) akan membuka Menu Radial. Klik kanan di luar menu untuk menutupnya. Dari menu ini kamu dapat:", + "radial_build": "Buka menu Pembangunan.", + "radial_attack": "Buka menu Serangan.", + "radial_info": "Buka menu informasi.", + "radial_boat": "Kirim Kapal (kapal pengangkut) untuk menyerang lokasi yang dipilih. Hanya tersedia jika kamu memiliki akses ke perairan.", + "radial_donate_troops": "Donasikan pasukan kepada sekutu sesuai dengan persentase pada penggeser rasio serangan yang sedang kamu gunakan pada menu radial tersebut.", + "radial_donate_gold": "Membuka menu penggeser donasi emas sehingga kamu dapat dengan cepat mengirim emas kepada sekutu.", + "radial_close": "Tutup menu.", + "info_title": "Menu Informasi", + "info_enemy_desc": "Berisi informasi seperti nama pemain yang dipilih, jumlah emas, pasukan, status berhenti berdagang dengan kamu, nuklir yang dikirim ke arahmu, serta apakah pemain tersebut adalah pengkhianat.\nStatus Berhenti Berdagang berarti kamu tidak akan menerima emas dari pemain tersebut dan mereka juga tidak akan mengirimkan emas kepadamu melalui kapal dagang. Status ini dapat terjadi secara manual (jika pemain menekan tombol “Hentikan Perdagangan”, yang akan berlangsung sampai kalian berdua menekan “Mulai Perdagangan”) atau secara otomatis (jika kamu mengkhianati aliansi, yang akan berlangsung sampai kalian kembali menjadi sekutu atau setelah 5 menit).\nStatus Pengkhianat akan menampilkan “Ya” selama 30 detik ketika pemain tersebut mengkhianati dan menyerang pemain yang sebelumnya berada dalam aliansi dengannya.\nIkon-ikon di bawah ini mewakili interaksi berikut:", + "info_chat": "Kirim pesan Obrolan Cepat ke pemain. Pilih Kategori, Frasa, dan jika frasa berisi [P1], pilih nama pemain untuk menggantikannya. Lalu tekan Kirim.", + "info_target": "Pasang tanda target pada pemain, sehingga terlihat oleh semua sekutu. Digunakan untuk mengoordinasikan serangan.", + "info_alliance": "Kirim permintaan aliansi ke pemain. Sekutu dapat berbagi sumber daya dan pasukan, tetapi tidak dapat saling menyerang.", + "info_emoji": "Kirim emoji ke pemainnya.", + "info_trade": "Gunakan “Hentikan Perdagangan” untuk berhenti memberikan emas kepada pemain tersebut dan berhenti menerima emas dari mereka melalui kapal dagang. Jika kalian berdua menekan “Mulai\".", + "info_ally_panel": "Panel Info Sekutu", + "info_ally_desc": "Saat kamu beraliansi dengan seorang pemain, ikon-ikon baru berikut akan tersedia:", + "ally_betray": "Mengkhianati sekutumu akan mengakhiri aliansi, menghentikan perdagangan, dan melemahkan pertahananmu. Perdagangan di antara kalian akan dijeda selama 5 menit (atau sampai kalian kembali menjadi sekutu), dan pemain lain juga dapat menghentikan perdagangan. Kecuali jika pemain lain tersebut memang sudah berstatus pengkhianat, kamu akan ditandai sebagai Pengkhianat selama 30 detik.\nSelama waktu ini, sebuah ikon akan muncul di atas namamu dan kamu akan menerima debuff pertahanan sebesar 50%. Bot akan lebih enggan beraliansi denganmu, dan pemain lain akan berpikir dua kali sebelum melakukannya.", + "ally_donate": "Donasikan sebagian pasukanmu kepada sekutu. Digunakan ketika mereka kekurangan pasukan, sedang diserang, atau membutuhkan kekuatan tambahan untuk menghancurkan musuh.", + "ally_donate_gold": "Donasikan sebagian emasmu kepada sekutu. Digunakan saat mereka kekurangan emas dan membutuhkannya untuk membangun, atau ketika anggota tim sedang menabung untuk MIRV.", + "build_menu_title": "Menu Pembangunan", + "build_menu_desc": "Bangun item berikut atau lihat jumlah yang sudah kamu bangun:", + "build_name": "Judul", + "build_icon": "Ikon", + "build_desc": "Deskripsi", + "build_city": "Kota", + "build_city_desc": "Meningkatkan batas populasi maksimum. Berguna saat kamu tidak dapat memperluas wilayah atau hampir mencapai batas populasi.", + "build_factory": "Pabrik", + "build_factory_desc": "Secara otomatis membangun jalur kereta api ke kota, pelabuhan, dan pabrik lain di sekitarnya, serta dapat terhubung dengan negara tetangga yang bersahabat. Kereta akan muncul secara berkala dan memberimu sejumlah emas tetap untuk setiap bangunan yang dikunjungi sepanjang rute, dengan bonus emas tambahan saat mengunjungi bangunan milik tetanggamu.", + "build_defense": "Pos Pertahanan", + "build_defense_desc": "Meningkatkan pertahanan di sekitar perbatasan terdekat, yang ditandai dengan pola kotak-kotak. Serangan musuh menjadi lebih lambat dan menyebabkan lebih banyak korban.", + "build_port": "Pelabuhan", + "build_port_desc": "Hanya dapat dibangun di dekat air. Memungkinkan pembangunan Kapal Perang. Secara otomatis mengirim kapal dagang antara pelabuhan di negaramu dan negara lain (kecuali saat perdagangan dihentikan), yang memberikan emas bagi kedua pihak.\nPerdagangan dengan seorang pemain akan berhenti secara otomatis ketika kamu menyerang atau diserang oleh pemain tersebut. Perdagangan akan dilanjutkan kembali setelah 5 menit atau jika kalian menjadi sekutu. Kamu juga dapat mengatur perdagangan secara manual dengan memilih “Hentikan Perdagangan” atau “Mulai Perdagangan”.", + "build_warship": "Kapal Perang", + "build_warship_desc": "Berpatroli di suatu area, menangkap kapal dagang musuh serta menghancurkan Kapal (kapal pengangkut) dan Kapal Perang mereka. Unit ini muncul dari Pelabuhan terdekat dan akan berpatroli di area yang pertama kali kamu klik saat membangunnya.\nKamu dapat mengendalikan Kapal Perang dengan klik-serang pada unit tersebut (lihat aksi Serang pada menu Hotkeys), lalu klik-serang area baru yang ingin dituju.", + "build_silo": "Silo Peluncur Rudal", + "build_silo_desc": "Memungkinkan peluncuran rudal.", + "build_sam": "Peluncur Rudal SAM", + "build_sam_desc": "Dapat mencegat rudal musuh dalam jangkauan 100 piksel. Peluncur SAM memiliki waktu jeda cooldown 7,5 detik.", + "build_atom": "Bom Atom", + "build_atom_desc": "Bom kecil berdaya ledak tinggi yang menghancurkan wilayah, bangunan, kapal, dan perahu. Muncul dari Silo Rudal terdekat dan mendarat di area yang pertama kali kamu klik saat membangunnya.", + "build_hydrogen": "Bom Hidrogen", + "build_hydrogen_desc": "Bom berdaya ledak besar. Muncul dari Silo Rudal terdekat dan mendarat di area yang pertama kali kamu klik saat membangunnya.", + "build_mirv": "MIRV", + "build_mirv_desc": "Bom paling kuat di dalam permainan. Akan terpecah menjadi bom-bom yang lebih kecil dan mencakup area wilayah yang sangat luas. Hanya memberikan kerusakan kepada pemain yang pertama kali kamu klik saat membangunnya.\nSenjata ini muncul dari Silo Rudal terdekat dan akan mendarat di area yang pertama kali kamu klik saat membangunnya.", + "player_icons": "Ikon Pemain", + "icon_desc": "Berikut beberapa ikon yang akan kamu temui di dalam permainan beserta artinya:", + "icon_crown": "Mahkota – Peringkat 1. Pemain teratas di papan peringkat.", + "icon_traitor": "Perisai Retak – Pengkhianat. Pemain ini menyerang sekutu.", + "icon_ally": "Jabat Tangan – Sekutu. Pemain ini adalah sekutumu.", + "icon_embargo": "Tanda Dolar Dicoret – Embargo. Pemain ini menghentikan perdagangan denganmu, baik secara otomatis maupun manual.", + "icon_request": "Amplop – Permintaan Aliansi. Pemain ini mengirim permintaan aliansi kepadamu.", + "info_enemy_panel": "Panel Info Musuh", + "exit_confirmation": "Apakah yakin keluar dari game?", + "bomb_direction": "Arah busur bom atom/hidrogen" + }, + "single_modal": { + "title": "Sendiri", + "random_spawn": "Kemunculan acak", + "allow_alliances": "Perbolehkan Aliansi", + "toggle_achievements": "Tampilkan / Sembunyikan pencapaian", + "sign_in_for_achievements": "Masuk untuk melihat pencapaian", + "options_title": "Opsi", + "bots": "Bot: ", + "bots_disabled": "Dinonaktifkan", + "nations": "Bangsa-bangsa: ", + "disable_nations": "Nonaktifkan negara", + "instant_build": "Bangun instan", + "infinite_gold": "Emas tak terbatas", + "infinite_troops": "Pasukan tak terbatas", + "compact_map": "Peta Kecil", + "max_timer": "Lama permainan (menit)", + "max_timer_placeholder": "Menit", + "max_timer_invalid": "Silakan masukkan nilai pengatur waktu maksimum yang valid (1-120 menit)", + "disable_nukes": "Nonaktifkan Senjata Nuklir", + "enables_title": "Aktifkan Pengaturan", + "start": "Mulai Permainan" + }, + "token_login_modal": { + "title": "Sedang masuk...", + "logging_in": "Sedang masuk...", + "success": "Berhasil masuk sebagai {email}!" + }, + "account_modal": { + "title": "Akun", + "connected_as": "Terhubung sebagai", + "stats_overview": "Gambaran Umum Statistik", + "link_discord": "Tautkan Akun Discord", + "log_out": "Keluar", + "sign_in_desc": "Masuk untuk menyimpan statistik dan kemajuan Anda", + "or": "ATAU", + "email_placeholder": "Masukkan alamat email Anda", + "get_magic_link": "Dapatkan Tautan Ajaib", + "linked_account": "Masuk sebagai {account_name}", + "fetching_account": "Mengambil informasi akun...", + "recovery_email_sent": "Pemulihan email dikirim ke {email}", + "not_found": "Tidak Ditemukan", + "clear_session": "Hapus Sesi", + "failed_to_send_recovery_email": "Gagal mengirim pemulihan email", + "enter_email_address": "Silahkan masukan alamat email" + }, + "stats_modal": { + "title": "Statistik", + "clan_stats": "Statistik Klan", + "loading": "Loading...", + "error": "Error saat memuat statistik klan", + "no_stats": "Tidak ada klan yang tersedia", + "no_data_yet": "Data belum tersedia", + "clan": "Klan", + "games": "Permainan", + "win_score": "Skor Kemenangan", + "win_score_tooltip": "Kemenangan dihitung berdasarkan bobot partisipasi klan dan tingkat kesulitan pertandingan", + "loss_score": "Skor Kekalahan", + "loss_score_tooltip": "Kerugian dihitung berdasarkan partisipasi klan dan kesulitan pertandingan", + "win_loss_ratio": "Menang/Kalah", + "ratio": "Rasio", + "rank": "Peringkat", + "try_again": "Coba Lagi" + }, + "game_info_modal": { + "title": "Informasi Permainan", + "players": "Pemain", + "atoms": "Atom", + "hydros": "Hidro", + "mirv": "MIRV", + "bombs": "Bom", + "total_gold": "Total", + "all_gold": "Semua emas", + "trade": "Perdagangan", + "conquest_gold": "Emas pemain yang ditaklukan", + "stolen_gold": "Dicuri oleh Kapal Perang", + "num_of_conquests": "Jumlah pemain yang ditaklukan", + "duration": "Durasi", + "survival_time": "Menit Bertahan", + "war": "Perang", + "economy": "Ekonomi", + "conquests": "Penaklukan", + "pirate": "Bajak Laut", + "conquered": "Ditaklukan", + "loading_game_info": "Memuat Statistik Permainan", + "no_winner": "Permainan ini berakhir tanpa pemenang (atau Negara menang)" + }, + "map": { + "map": "Peta", + "world": "Dunia", + "giantworldmap": "Map Dunia Besar", + "europe": "Eropa", + "mena": "MENA", + "northamerica": "Amerika Utara", + "oceania": "Oseania", + "blacksea": "Laut Hitam", + "africa": "Afrika", + "asia": "Asia", + "mars": "Mars", + "southamerica": "Amerika Selatan", + "britanniaclassic": "Britania (klasik)", + "britannia": "Britania", + "gatewaytotheatlantic": "Pintu masuk menuju Atlantik", + "australia": "Australia", + "random": "Random", + "iceland": "Islandia", + "pangaea": "Pangea", + "eastasia": "Asia Timur", + "betweentwoseas": "Diantara Dua Laut", + "faroeislands": "Kepulauan Faroe", + "deglaciatedantarctica": "Antartika yang telah bebas dari gletser", + "europeclassic": "Eropa (klasik)", + "falklandislands": "Kepulauan Falkland", + "baikal": "Baikal", + "halkidiki": "Kalkidiki", + "straitofgibraltar": "Selat Gibraltar", + "italia": "Italia", + "japan": "Jepang", + "yenisei": "Sungai Yenisei", + "pluto": "Pluto", + "montreal": "Montreal", + "newyorkcity": "Kota New York", + "achiran": "Sungai Akheron", + "baikalnukewars": "Baikal (Perang Nuklir)", + "fourislands": "Empat Pulau", + "gulfofstlawrence": "Teluk St. Lawrence", + "lisbon": "Lisboa", + "svalmel": "Svalmel", + "manicouagan": "Manicouagan", + "lemnos": "Lemnos", + "sierpinski": "Sierpinski", + "twolakes": "Dua Danau", + "straitofhormuz": "Selat Hormuz", + "surrounded": "Surrourded", + "didier": "Didier", + "didierfrance": "Didier (Prancis)", + "amazonriver": "Sungai Amazon" + }, + "map_categories": { + "continental": "Kontinental", + "regional": "Regional", + "fantasy": "Lain", + "special": "Spesial", + "arcade": "Arkade" + }, + "map_component": { + "loading": "Loading...", + "error": "Kesalahan" + }, + "private_lobby": { + "title": "Gabung Lobi Privat", + "enter_id": "Masukan ID Lobi", + "player": "Pemain", + "players": "Pemain", + "join_lobby": "Bergabung ke Lobi", + "checking": "Memeriksa Lobi...", + "not_found": "Lobi tidak ditemukan. Mohon periksa ID dan coba lagi.", + "error": "Beberapa kesalahan terjadi. Silakan coba lagi atau hubungi dukungan.", + "joined_waiting": "Berhasil gabung ke lobi! Menunggu untuk penyelenggara untuk memulai...", + "version_mismatch": "Permainan ini dibuat dengan versi yang berbeda. Tidak dapat gabung.", + "disabled_units": "Nonaktfikan Units" + }, + "public_lobby": { + "join": "Gabung ke permainan selanjutnya", + "waiting": "Pemain menunggu", + "teams_Duos": "{team_count} tim berisi 2 pemain (Berdua)", + "teams_Trios": "{team_count} tim berisi 3 pemain (Bertiga)", + "teams_Quads": "{team_count} tim berisi 4 pemain (Berempat)", + "waiting_for_players": "Menunggu pemain", + "starting_game": "Memulai permainan…", + "teams_hvn": "Pemain vs Negara", + "teams_hvn_detailed": "{num} Pemain vs {num} Negara", + "teams": "{num} tim", + "players_per_team": "dari {num}", + "started": "Dimulai" + }, + "matchmaking_modal": { + "title": "Pertandingan 1v1 Ranked (ALPHA)", + "connecting": "Menghubungkan ke server pencarian lawan...", + "searching": "Mencari permainan...", + "waiting_for_game": "Menunggu permainan untuk dimulai...", + "elo": "ELO anda: {elo}" + }, + "username": { + "enter_username": "Masukkan nama pengguna", + "not_string": "Nama pengguna harus berupa string.", + "too_short": "Nama pengguna harus memiliki panjang minimal {min} karakter.", + "too_long": "Nama pengguna tidak boleh melebihi {max} karakter.", + "invalid_chars": "Nama pengguna hanya boleh berupa huruf, angka, spasi dan garis bawah.", + "tag": "Tag", + "tag_too_short": "Nama klan harus terdiri dari 2-5 karakter alfanumerik.", + "tag_invalid_chars": "Tag klan hanya boleh berisi huruf dan angka" + }, + "host_modal": { + "title": "Buat Lobi Tertutup", + "label": "Tertutup", + "mode": "Mode", + "team_count": "Jumlah Tim", + "team_type": "Tipe Tim", + "options_title": "Pilihan", + "bots": "Bot: ", + "bots_disabled": "Nonaktif", + "player_immunity_duration": "Durasi imunitas PVP (menit)", + "nations": "Bangsa-bangsa: ", + "disable_nations": "Nonaktifkan Negara", + "max_timer": "Lama permainan (menit)", + "mins_placeholder": "Menit", + "instant_build": "Bangun instan", + "infinite_gold": "Emas tak terbatas", + "donate_gold": "Donasikan emas", + "infinite_troops": "Pasukan tak terbatas", + "donate_troops": "Donasikan pasukan", + "compact_map": "Peta Kecil", + "enables_title": "Aktifkan Pengaturan", + "player": "Pemain", + "players": "Pemain", + "nation_players": "Bangsa-bangsa", + "nation_player": "Bangsa", + "waiting": "Menunggu pemain...", + "random_spawn": "Kemunculan Acak", + "start": "Mulai Permainan", + "host_badge": "Host", + "assigned_teams": "Tim yang Ditugaskan", + "empty_teams": "Tim Kosong", + "empty_team": "Kosong", + "remove_player": "Hapus {username}", + "teams_Duos": "Berdua (tim yang terdiri dari 2 orang)", + "teams_Trios": "Bertiga (tim yang terdiri dari 3 orang)", + "teams_Quads": "Berempat (tim yang teridri dari 4 orang)", + "teams_Humans Vs Nations": "Pemain vs Negara" + }, + "team_colors": { + "red": "Merah", + "blue": "Biru", + "teal": "Hijau Laut", + "purple": "Ungu", + "yellow": "Kuning", + "orange": "Oranye", + "green": "Hijau", + "bot": "Bot" + }, + "game_starting_modal": { + "title": "Memulai Permainan...", + "credits": "Kredit", + "code_license": "Kode berlisensi AGPL-3.0 (tanpa garansi)" + }, + "difficulty": { + "difficulty": "Kesulitan Negara", + "easy": "Mudah", + "medium": "Sedang", + "hard": "Sulit", + "impossible": "Mustahil" + }, + "game_mode": { + "ffa": "Siapapun bisa bergabung", + "teams": "Tim-tim" + }, + "public_game_modifier": { + "random_spawn": "Kemunculan Acak", + "compact_map": "Peta Kecil" + }, + "select_lang": { + "title": "Pilih Bahasa" + }, + "unit_type": { + "city": "Kota", + "defense_post": "Pos Pertahanan", + "port": "Pelabuhan", + "warship": "Kapal Perang", + "missile_silo": "Silo Peluncur Rudal", + "sam_launcher": "Peluncur Rudal SAM", + "atom_bomb": "Bom Atom", + "hydrogen_bomb": "Bom Hidrogen", + "mirv": "MIRV", + "factory": "Pabrik" + }, + "user_setting": { + "title": "Pengaturan", + "tab_basic": "Pengaturan Dasasr", + "tab_keybinds": "Tombol pintasan", + "dark_mode_label": "Mode Gelap", + "dark_mode_desc": "Beralih tampilan situs antara tema terang dan gelap", + "emojis_label": "Emoji", + "emojis_desc": "Alihkan tampilan emoji di dalam game", + "alert_frame_label": "Bingkai Peringatan", + "alert_frame_desc": "Aktifkan / Nonaktifkan bingkai peringatan. Saat diaktifkan, bingkai akan ditampilkan ketika kamu dikhianati atau diserang melalui darat.", + "special_effects_label": "Efek Spesial", + "special_effects_desc": "Alihkan efek khusus. Nonaktifkan untuk meningkatkan performa", + "structure_sprites_label": "Sprite Bangunan", + "structure_sprites_desc": "Alihkan tampilan sprite bangunan", + "cursor_cost_label_label": "Biaya Pembangunan Kursor", + "cursor_cost_label_desc": "Tampilkan label biaya di bawah ikon kursor pembangunan", + "anonymous_names_label": "Sembunyikan Nama", + "anonymous_names_desc": "Sembunyikan nama asli pemain dengan nama acak di layar Anda.", + "lobby_id_visibility_label": "Sembunyikan ID Lobby", + "lobby_id_visibility_desc": "Sembunyikan ID Lobby saat membuat lobby pribadi", + "toggle_visibility": "Alihkan Visibilitas", + "left_click_label": "Klik Kiri untuk Membuka Menu", + "left_click_desc": "Saat AKTIF, klik kiri membuka menu dan tombol pedang digunakan untuk menyerang. Saat NONAKTIF, klik kiri langsung melakukan serangan.", + "left_click_menu": "Klik Kiri untuk Menu", + "attack_ratio_label": "⚔️ Rasio Serangan", + "attack_ratio_desc": "Persentase pasukan yang dikirim saat menyerang (1–100%)", + "territory_patterns_label": "🏳️ Skin Wilayah", + "territory_patterns_desc": "Pilih apakah ingin menampilkan desain skin wilayah di dalam game", + "performance_overlay_label": "Tampilan Performa", + "performance_overlay_desc": "Aktifkan / Nonaktifkan overlay performa.\nSaat diaktifkan, overlay performa akan ditampilkan. Tekan Shift + D saat permainan berlangsung untuk mengaktifkan atau menonaktifkannya.", + "easter_writing_speed_label": "Multiplier Kecepatan Menulis", + "easter_writing_speed_desc": "Atur seberapa cepat kamu berpura-pura coding (x1–x100)", + "easter_bug_count_label": "Jumlah Bug", + "easter_bug_count_desc": "Seberapa banyak bug yang masih bisa Anda toleransi (0–1000, secara emosional)", + "press_a_key": "Tekan tombol", + "view_options": "Opsi Tampilan", + "toggle_view": "Alihkan Tampilan", + "toggle_view_desc": "Ganti Tampilan (Medan / Negara)", + "build_controls": "Kontrol Pembangunan", + "build_city": "Membangun Kota", + "build_city_desc": "Bangun Kota di bawah kursor Anda.", + "build_factory": "Bangun Pabrik", + "build_factory_desc": "Bangun Pabrik di bawah kursor Anda.", + "build_defense_post": "Bangun Pos Pertahanan", + "build_defense_post_desc": "Bangun Pos Pertahanan di bawah kursor Anda.", + "build_port": "Membangun Pelabuhan", + "build_port_desc": "Bangun Pelabuhan di bawah kursor Anda.", + "build_warship": "Bangun Kapal Perang", + "build_warship_desc": "Bangun Kapal Perang di bawah kursor Anda.", + "build_missile_silo": "Bangun Silo Peluncur Rudal", + "build_missile_silo_desc": "Bangun Silo Peluncur Rudal di bawah kursor Anda.", + "build_sam_launcher": "Bangun Peluncur Rudal SAM", + "build_sam_launcher_desc": "Bangun Peluncur Rudal SAM di bawah kursor Anda.", + "build_atom_bomb": "Bangun Bom Atom", + "build_atom_bomb_desc": "Bangun Bom Atom di bawah kursor Anda.", + "build_hydrogen_bomb": "Bangun Bom Hidrogen", + "build_hydrogen_bomb_desc": "Membangun Bom Hidrogen di bawah kursor Anda.", + "build_mirv": "Membangun MIRV", + "build_mirv_desc": "Bangun MIRV di bawah kursor Anda.", + "menu_shortcuts": "Menu Pintasan", + "build_menu_modifier": "Pengubah Menu Pembangunan", + "build_menu_modifier_desc": "Tahan tombol ini sambil mengklik untuk membuka menu pembuatan.", + "emoji_menu_modifier": "Pengubah Menu Emoji", + "emoji_menu_modifier_desc": "Tahan tombol ini sambil mengklik untuk membuka menu emoji.", + "attack_ratio_controls": "Kontrol Rasio Serangan", + "attack_ratio_up": "Tingkatkan Rasio Serangan", + "attack_ratio_up_desc": "Tingkatkan Rasio Serangan sebesar 10%", + "attack_ratio_down": "Kurangi Rasio Serangan", + "attack_ratio_down_desc": "Kurangi Rasio Serangan sebesar 10%", + "attack_keybinds": "Tombol pintas untuk Serangan", + "boat_attack": "Serangan Kapal Pengangkut", + "boat_attack_desc": "Kirim serangan kapal ke petak di bawah kursor Anda.", + "ground_attack": "Serangan Darat", + "ground_attack_desc": "Kirim serangan darat ke petak di bawah kursor Anda.", + "swap_direction": "Tukar Arah Roket", + "swap_direction_desc": "Ubah arah peluncuran roket (atas/bawah).", + "zoom_controls": "Kontrol Zoom", + "zoom_out": "Perkecil tampilan", + "zoom_out_desc": "Perkecil tampilan peta", + "zoom_in": "Perbesar", + "zoom_in_desc": "Perbesar tampilan peta", + "camera_movement": "Pergerakan Kamera", + "center_camera": "Sorot kamera ke tengah", + "center_camera_desc": "Pusatkan kamera pada pemain", + "move_up": "Pindahkan kamera ke atas", + "move_up_desc": "Memindahkan kamera ke atas", + "move_left": "Pindahkan kamera ke kiri", + "move_left_desc": "Memindahkan kamera ke kiri", + "move_down": "Pindahkan kamera ke bawah", + "move_down_desc": "Memindahkan kamera ke bawah", + "move_right": "Pindahkan kemara ke kanan", + "move_right_desc": "Memindahkan kamera ke kanan", + "reset": "Reset", + "unbind": "Batalkan pengikatan tombol", + "on": "Hidup", + "off": "Mati", + "toggle_terrain": "Tampilkan / Sembunyikan Medan", + "exit_game_label": "Keluar Game", + "exit_game_info": "Kembali ke menu utama", + "background_music_volume": "Volume latar belakang musik", + "sound_effects_volume": "Volume Efek Suara", + "keybind_conflict_error": "Tombol {key} sudah terikat ke aksi lain." + }, + "chat": { + "title": "Obrolan Cepat", + "to": "Dari {user}: {msg}", + "from": "Dari {user}: {msg}", + "category": "Kategori", + "phrase": "Frase", + "player": "Pemain", + "send": "Kirim", + "search": "Cari pemain...", + "build": "Ketik pesanmu...", + "cat": { + "help": "Bantuan", + "attack": "Serang", + "defend": "Bertahan", + "greet": "Salam", + "misc": "Lain-lain", + "warnings": "Peringatan" + }, + "help": { + "troops": "Tolong berikan saya tentara!", + "troops_frontlines": "Kirim pasukan ke garis depan!", + "gold": "Tolong berikan saya emas!", + "no_attack": "Tolong jangan serang saya!", + "sorry_attack": "Maaf, Saya tidak bermaksud untuk menyerang Anda.", + "alliance": "Aliansi?", + "help_defend": "Bantu saya bertahan dari [P1]!", + "trade_partners": "Mari menjadi mitra dagang!" + }, + "attack": { + "attack": "Serang [P1]!", + "mirv": "Luncurkan MIRV ke [P1]!", + "focus": "Fokus serangan pada [P1]!", + "finish": "Mari selesaikan [P1]!", + "build_warships": "Bangun Kapal-Kapal Perang!" + }, + "defend": { + "defend": "Pertahankan [P1]!", + "defend_from": "Bertahan dari [P1]!", + "dont_attack": "Jangan serang [P1]!", + "ally": "[P1] adalah aliansi saya!", + "build_posts": "Bangun Pos Pertahanan!" + }, + "greet": { + "hello": "Halo!", + "good_job": "Kerja bagus!", + "good_luck": "Semoga sukses!", + "have_fun": "Selamat bersenang-senang!", + "gg": "GG!", + "nice_to_meet": "Senang bertemu denganmu!", + "well_played": "Bagus Sekali!", + "hi_again": "Halo lagi!", + "bye": "Da!", + "thanks": "Terima kasih!", + "oops": "Ups, salah tombol!", + "trust_me": "Anda bisa percaya saya. Janji!", + "trust_broken": "Aku percaya padamu...", + "ruining_games": "Kamu bikin permainan kita berdua jadi kacau.", + "dont_do_that": "Jangan!", + "same_team": "Saya di pihak Anda!" + }, + "misc": { + "go": "Ayo!", + "strategy": "Strategi yang mantap!", + "fun": "Permainan ini seru!", + "team_up": "Mari menyerang [P1] bersama-sama!", + "pr": "Kapan PR-ku akhirnya akan digabungkan...?", + "build_closer": "Bangun lebih dekat agar membuat jalur kereta!", + "coastline": "Tolong izinkan saya mendapatkan garis pantai." + }, + "warnings": { + "strong": "[P1] kuat.", + "weak": "[P1] lemah.", + "mirv_soon": "[P1] akan meluncurkan MIRV segera!", + "number1_warning": "Pemain nomor 1 akan segera menang kecuali kita bekerja sama!", + "stalemate": "Mari berdamai. Ini jalan buntu, kita berdua akan kalah.", + "has_allies": "[P1] punya banyak sekutu.", + "no_allies": "[P1] tidak punya sekutu.", + "betrayed": "[P1] menkhianati sekutu dia!", + "betrayed_me": "[P1] menkhianati saya!", + "getting_big": "[P1] berkembang sangat cepat!", + "danger_base": "[P1] tidak terproteksi!", + "saving_for_mirv": "[P1] sedang menabung untuk meluncurkan MIRV.", + "mirv_ready": "[P1] punya cukup emas untuk meluncurkan MIRV!", + "snowballing": "[P1] berkembang terlalu cepat!", + "cheating": "[P1] curang!", + "stop_trading": "Stop berdangan dengan [P1]!" + } + }, + "build_menu": { + "desc": { + "atom_bomb": "Ledakan kecil", + "hydrogen_bomb": "Ledakan dahsyat", + "mirv": "Ledakan Dahsyat, hanya menargetkan pemain yang dipilih", + "missile_silo": "Digunakan untuk meluncurkan nuklir", + "sam_launcher": "Penangkalan nuklir yang mendekat", + "warship": "Menangkap kapal dagang, menghancurkan kapal dan perahu.", + "port": "Mengirim kapal untuk mendapatkan emas", + "defense_post": "Meningkatkan pertahanan perbatasan", + "city": "Meningkatkan jumlah maksimal populasi", + "factory": "Membuat rel dan memunculkan kereta" + }, + "not_enough_money": "Uang tidak cukup" + }, + "win_modal": { + "support_openfront": "Dukung OpenFront!", + "territory_pattern": "Beli skin wilayah untuk bebas iklan!", + "died": "Anda meninggal", + "your_team": "Tim Anda menang!", + "other_team": "tim {team} menang!", + "you_won": "Anda Menang!", + "other_won": "{player} menang!", + "nation_won": "Negara {nation} menang!", + "exit": "Keluar Game", + "keep": "Terus Main", + "spectate": "Menonton", + "wishlist": "Wishlist di Steam!", + "ofm_winter": "Turnamen Musim Dingin OpenFront Masters!", + "ofm_winter_description": "Ikuti turnamen kompetitif dan bersaing melawan pemain terbaik", + "join_tournament": "Ikut Turnamen", + "join_discord": "Gabung Komunitas Discord Kami!", + "discord_description": "Terhubung dengan pemain lain, temukan fitur baru, dan menangkan hadiah!", + "join_server": "Bergabung dengan Server", + "youtube_tutorial": "Butuh bantuan?" + }, + "leaderboard": { + "title": "Papan Peringkat", + "hide": "Sembunyikan", + "rank": "Peringkat", + "player": "Pemain", + "team": "Tim", + "owned": "Dimiliki", + "gold": "Emas", + "maxtroops": "Maksimal pasukan", + "launchers": "Peluncur", + "sams": "SAM-SAM", + "warships": "Kapal Perang", + "cities": "Kota-Kota", + "show_control": "Tampilkan Kontrol", + "show_units": "Tampilkan Unit" + }, + "player_info_overlay": { + "type": "Jenis", + "bot": "Bot", + "nation": "Bangsa", + "player": "Pemain", + "team": "Tim", + "alliance_timeout": "Aliansi berakhir dalam", + "troops": "Pasukan", + "maxtroops": "Maksimal pasukan", + "a_troops": "Pasukan menyerang", + "gold": "Emas", + "ports": "Pelabuhan-Pelabuhan", + "cities": "Kota-kota", + "factories": "Pabrik-pabrik", + "missile_launchers": "Peluncur rudal", + "sams": "SAM", + "warships": "Kapal Perang", + "health": "Kesehatan", + "attitude": "Sikap", + "levels": "Tingkat", + "wilderness_title": "Alam Liar", + "irradiated_wilderness_title": "Hutan Belantara yang Terkena Radiasi" + }, + "events_display": { + "retreating": "mundur", + "retaliate": "Membalas", + "boat": "Perahu", + "alliance_request_status": "{name} {status} permintaan aliansi Anda", + "alliance_accepted": "diterima", + "alliance_rejected": "ditolak", + "duration_second": "1 detik", + "betrayal_description": "Kamu memutus aliansi dengan {name}, menjadikanmu PENGKHIANAT ({malusPercent}% pengurangan pertahanan selama {durationText})", + "duration_seconds_plural": "{seconds} detik", + "betrayed_you": "{name} memutus aliansi dengan Anda", + "about_to_expire": "Aliansi Anda dengan {name} hampir berakhir!", + "alliance_expired": "Aliansi Anda dengan {name} berakhir", + "attack_request": "{name} meminta Anda untuk menyerang {target}", + "sent_emoji": "Dari {name}: {emoji}", + "renew_alliance": "Minta untuk memperpanjang", + "request_alliance": "{name} meminta aliansi!", + "focus": "Fokus", + "accept_alliance": "Setuju", + "reject_alliance": "Tolak", + "alliance_renewed": "Aliansi anda dengan {name} sudah di perpanjang", + "wants_to_renew_alliance": "{name} ingin memperpanjang aliansi", + "ignore": "Abaikan", + "unit_voluntarily_deleted": "Unit dihapus secara sukarela", + "betrayal_debuff_ends": "{time} detik tersisa hingga efek negatif pengkhianatan berakhir", + "attack_cancelled_retreat": "Penyerangan dibatalkan, {troops} pasukan terbunuh saat mundur", + "received_gold_from_captured_ship": "Menerima {gold} emas dari kapal yang di tawan dari {name}", + "received_gold_from_trade": "Menerima {gold} emas dari perdagangan dengan {name}", + "missile_intercepted": "Rudal dicegat {unit}", + "mirv_warheads_intercepted": "{count, plural, one {{count} hulu ledak MIRV berhasil dicegat} other {{count} hulu ledak MIRV berhasil dicegat}}", + "sent_troops_to_player": "Mengirim {troops} pasukan ke {name}", + "received_troops_from_player": "Menerima {troops} pasukan dari {name}", + "sent_gold_to_player": "Mengirim {gold} emas ke {name}", + "received_gold_from_player": "Menerima {gold} emas dari {name}", + "unit_captured_by_enemy": "{unit} Anda ditangkap oleh {name}", + "captured_enemy_unit": "Menangkap {unit} dari {name}", + "unit_destroyed": "{unit} Anda dihancurkan", + "no_boats_available": "Tidak ada kapal yang tersedia, maksmial {max}" + }, + "unit_info_modal": { + "structure_info": "Informasi Struktur", + "unit_type_unknown": "Tidak Diketahui", + "close": "Keluar", + "cooldown": "Cooldown", + "type": "Jenis", + "upgrade": "Tingkatkan", + "level": "Tingkat" + }, + "player_type": { + "player": "Pemain", + "nation": "Bangsa", + "bot": "Bot" + }, + "relation": { + "hostile": "Berseteru", + "distrustful": "Tak dapat dipercaya", + "neutral": "Netral", + "friendly": "Ramah", + "default": "Default" + }, + "control_panel": { + "gold": "Emas", + "troops": "Pasukan", + "attack_ratio": "Rasio Serangan" + }, + "player_panel": { + "gold": "Emas", + "troops": "Pasukan", + "betrayals": "Pengkhianatan", + "traitor": "Pengkhianat", + "trading": "Perdagangan", + "active": "Aktif", + "stopped": "Berhenti", + "alliance_time_remaining": "Aliansi Berakhir Dalam", + "embargo": "Berhenti berdangan dengan Anda", + "nuke": "Nuklir dikirim oleh mereka kepada Anda", + "start_trade": "Mulai Berdagang", + "stop_trade": "Stop Berdagang", + "stop_trade_all": "Stop Berdagang degnan Semuanya", + "start_trade_all": "Mulai Berdagang dengan Semuanya", + "alliances": "Aliansi", + "flag": "Bendera", + "chat": "Chat", + "target": "Sasaran", + "break_alliance": "Rusak Aliansi", + "alliance": "Aliansi", + "send_alliance": "Kirim Proposal Aliansi", + "send_troops": "Kirim Pasukan", + "send_gold": "Kirim Emas", + "emotes": "Emoji", + "arc_up": "Lengkungan ke Atas", + "arc_down": "Lengkungan ke Bawah", + "flip_rocket_trajectory": "Balikkan lintasan roket" + }, + "send_troops_modal": { + "title_with_name": "Kirim Pasukan ke {name}", + "available_tooltip": "Pasukan Anda yang tersedia saat ini", + "min_keep": "Minimal yang ditinggalkan", + "slider_tooltip": "{{percent}}% • {{amount}}", + "aria_slider": "Penggeser pasukan", + "capacity_note": "Penerima hanya dapat menerima {{amount}} saat ini." + }, + "send_gold_modal": { + "title_with_name": "Kirim Emas ke {name}", + "available_tooltip": "Emas yang Anda miliki saat ini", + "aria_slider": "Penggeser jumlah", + "slider_tooltip": "{{percent}}% • {{amount}}" + }, + "replay_panel": { + "replay_speed": "Kecepatan tanyangan ulang", + "game_speed": "Kecepatan Permainan", + "fastest_game_speed": "Maks" + }, + "error_modal": { + "crashed": "Game berhenti / rusak!", + "connection_error": "Kesalahan koneksi!", + "paste_discord": "Silakan tempelkan teks berikut di laporan bug Anda di Discord:", + "copy_clipboard": "Salin ke papan klip", + "copied": "Tersalin!", + "failed_copy": "Gagal menyalin", + "spawn_failed": { + "title": "Kemunculan gagal", + "description": "Pemilihan titik awal otomatis gagal. Anda tidak dapat memainkan game ini." + }, + "desync_notice": "Anda tidak tersinkronisasi dengan pemain lain. Apa yang Anda lihat mungkin berbeda dari pemain lain." + }, + "performance_overlay": { + "reset": "Set ulang", + "copy_json_title": "Salin metrik kinerja saat ini sebagai JSON.", + "copy_clipboard": "Menyalin JSON", + "copied": "Tersalin!", + "failed_copy": "Gagal menyalin", + "fps": "FPS:", + "avg_60s": "Rata-rata (60d):", + "frame": "Bingkai:", + "tick_exec": "Eksekutif Tick:", + "tick_delay": "Penundaan Detik:", + "layers_header": "Lapisan (rata-rata / maksimum, diurutkan berdasarkan total waktu):" + }, + "heads_up_message": { + "choose_spawn": "Pilih lokasi awal", + "random_spawn": "Kemunculan acak diaktifkan. Memilih lokasi awal untuk Anda...", + "singleplayer_game_paused": "Permainan dijeda", + "multiplayer_game_paused": "Permainan di tunda oleh Pembuat Lobi" + }, + "territory_patterns": { + "title": "Tampilan", + "colors": "Warna-Warna", + "purchase": "Beli", + "show_only_owned": "Skin Saya", + "all_owned": "Semua skin sudah dimiliki! Silakan periksa kembali nanti untuk item baru.", + "not_logged_in": "Belum masuk", + "blocked": { + "login": "Anda harus login untuk mengakses skin ini.", + "purchase": "Beli skin ini untuk membukanya." + }, + "pattern": { + "default": "Default" + }, + "select_skin": "Pilih Skin", + "selected": "dipilih" + }, + "flag_input": { + "title": "Pilih Bendera", + "button_title": "Pilih bendera!", + "search_flag": "Cari..." + }, + "spawn_ad": { + "loading": "Memuat iklan..." + }, + "auth": { + "login_required": "Masuk dibutuhkan untuk mengakses website ini.", + "redirecting": "Anda sedang diarahkan...", + "not_authorized": "Anda tidak punya izin untuk mengakses website ini.", + "contact_admin": "Jika Anda yakin melihat pesan ini karena kesalahan, silakan hubungi administrator situs web." + }, + "radial_menu": { + "delete_unit_title": "Hapus Unit", + "delete_unit_description": "Klik untuk menghapus unit terdekat" + }, + "discord_user_header": { + "avatar_alt": "Avatar" + }, + "player_stats_table": { + "building_stats": "Statistik Bangunan", + "ship_arrivals": "Kedatangan Kapal", + "nuke_stats": "Statistik Nuklir", + "player_metrics": "Metrik Pemain", + "building": "Gedung", + "ship_type": "Jenis Kapal", + "weapon": "Senjata", + "built": "Bangun", + "destroyed": "Telah Hancur", + "captured": "Ditangkap", + "lost": "Kalah", + "hits": "Hits", + "launched": "Telah Diluncurkan", + "landed": "Mendarat", + "sent": "Terkirim", + "arrived": "Tiba", + "attack": "Serang", + "received": "Diterima", + "cancelled": "Dibatalkan", + "count": "Hitungan", + "gold": "Emas", + "workers": "Pekerja", + "war": "Perang", + "trade": "Perdagangan", + "steal": "Steal", + "unit": { + "city": "Kota", + "port": "Pelabuhan", + "defp": "Pos Pertahanan", + "saml": "Peluncur Rudal SAM", + "silo": "Silo Peluncur Rudal", + "wshp": "Kapal Perang", + "fact": "Pabrik", + "trade": "Kapal Perdagangan", + "trans": "Kapal Pengangkut", + "abomb": "Bom Atom", + "hbomb": "Bom Hidrogen", + "mirv": "MIRV", + "mirvw": "Hulu ledak MIRV" + } + }, + "game_list": { + "recent_games": "Permainan Terbaru", + "game_id": "ID Permainan", + "mode": "Mode", + "mode_ffa": "Siapapun bisa bergabung", + "mode_team": "Tim", + "replay": "Tayangan ulang", + "details": "Detail", + "ranking": "Peringkat", + "started": "Dimulai", + "map": "Peta", + "difficulty": "Tingkat Kesulitan", + "type": "Jenis" + }, + "player_stats_tree": { + "public": "Publik", + "private": "Tertutup", + "singleplayer": "Sendiri", + "mode": "Mode", + "stats_wins": "Jumlah Kemenangan", + "stats_losses": "Jumlah Kehilangan", + "stats_wlr": "Menang:Kalah Rasio", + "stats_games_played": "Permainan Dimainkan", + "mode_ffa": "Siapapun bisa bergabung", + "mode_team": "Tim", + "no_stats": "Tidak ada statistik yang tercatat untuk pilihan ini." + }, + "matchmaking_button": { + "play_ranked": "Pertandingan 1v1 Ranked", + "description": "(ALPHA)", + "login_required": "Masuk untuk bermain peringkat!", + "must_login": "Anda harus masuk untuk bermain di pertandingan 1v1 Ranked." + } +} diff --git a/resources/lang/metadata.json b/resources/lang/metadata.json index cfb9af301..4c1f989ae 100644 --- a/resources/lang/metadata.json +++ b/resources/lang/metadata.json @@ -101,6 +101,12 @@ "en": "Hungarian", "svg": "hu" }, + { + "code": "id", + "native": "Bahasa Indonesia", + "en": "Indonesian", + "svg": "id" + }, { "code": "it", "native": "Italiano", From 7942990037309f310eee160d2f45b5cf0d22c40b Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 27 Jan 2026 01:29:52 +0100 Subject: [PATCH 093/109] =?UTF-8?q?Crowded=20modifier=20=F0=9F=98=84=20(#3?= =?UTF-8?q?023)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: To increase variety a bit more I present: The "crowded" public game modifier :) It basically simulates a crazy youtuber lobby. Cramp a lot of players on a small map 😄 I think its fun, exciting and you actually need skill to manage the chaos. 5% of public games get this modifier, but because we remove the modifier for big maps its more like 2.5% (should be something special) | Screenshot 2026-01-25 200427 | Screenshot 2026-01-25 200554 | Screenshot 2026-01-25 200521 | |---|---|---| ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- resources/lang/en.json | 2 ++ src/client/PublicLobby.ts | 3 ++ src/core/Schemas.ts | 1 + src/core/game/Game.ts | 1 + src/server/MapPlaylist.ts | 56 +++++++++++++++++++++++++++++----- tests/util/TestServerConfig.ts | 2 +- 6 files changed, 56 insertions(+), 9 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index a9663b2b0..5f76bd3b2 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -166,6 +166,7 @@ "infinite_gold": "Infinite gold", "infinite_troops": "Infinite troops", "compact_map": "Compact Map", + "crowded": "Crowded", "max_timer": "Game length (minutes)", "max_timer_placeholder": "Mins", "max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)", @@ -421,6 +422,7 @@ "public_game_modifier": { "random_spawn": "Random Spawn", "compact_map": "Compact Map", + "crowded": "Crowded", "starting_gold": "5M Starting Gold" }, "select_lang": { diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index 4c895ab8f..e7610672e 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -374,6 +374,9 @@ export class PublicLobby extends LitElement { if (publicGameModifiers.isCompact) { labels.push(translateText("public_game_modifier.compact_map")); } + if (publicGameModifiers.isCrowded) { + labels.push(translateText("public_game_modifier.crowded")); + } if (publicGameModifiers.startingGold) { labels.push(translateText("public_game_modifier.starting_gold")); } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index d225857c5..9255156c1 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -190,6 +190,7 @@ export const GameConfigSchema = z.object({ .object({ isCompact: z.boolean(), isRandomSpawn: z.boolean(), + isCrowded: z.boolean(), startingGold: z.number().int().min(0).optional(), }) .optional(), diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 1c56d5d46..7e613e0c5 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -211,6 +211,7 @@ export enum GameMapSize { export interface PublicGameModifiers { isCompact: boolean; isRandomSpawn: boolean; + isCrowded: boolean; startingGold?: number; } diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 5bbc11b00..b54aa54ff 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -97,7 +97,7 @@ export class MapPlaylist { const modifiers = this.getRandomPublicGameModifiers(); const { startingGold } = modifiers; - let { isCompact, isRandomSpawn } = modifiers; + let { isCompact, isRandomSpawn, isCrowded } = modifiers; // Duos, Trios, and Quads should not get random spawn (as it defeats the purpose) if ( @@ -108,8 +108,8 @@ export class MapPlaylist { isRandomSpawn = false; } - // Maps with smallest player count < 50 don't support compact map in team games - // The smallest player count is the 3rd number in the player counts array + // Maps with smallest player count (third number of calculateMapPlayerCounts) < 50 don't support compact map in team games + // (not enough players after 75% player reduction for compact maps) if ( mode === GameMode.Team && !(await this.supportsCompactMapForTeams(map)) @@ -117,15 +117,34 @@ export class MapPlaylist { isCompact = false; } + // Crowded modifier: if the map's biggest player count (first number of calculateMapPlayerCounts) is 60 or lower (small maps), + // set player count to 125 (or 60 if compact map is also enabled) + let crowdedMaxPlayers: number | undefined; + if (isCrowded) { + crowdedMaxPlayers = await this.getCrowdedMaxPlayers(map, isCompact); + if (crowdedMaxPlayers === undefined) { + isCrowded = false; + } else { + crowdedMaxPlayers = this.adjustForTeams(crowdedMaxPlayers, playerTeams); + } + } + // Create the default public game config (from your GameManager) return { donateGold: mode === GameMode.Team, donateTroops: mode === GameMode.Team, gameMap: map, - maxPlayers: await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact), + maxPlayers: + crowdedMaxPlayers ?? + (await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact)), gameType: GameType.Public, gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal, - publicGameModifiers: { isCompact, isRandomSpawn, startingGold }, + publicGameModifiers: { + isCompact, + isRandomSpawn, + isCrowded, + startingGold, + }, startingGold, difficulty: playerTeams === HumansVsNations ? Difficulty.Medium : Difficulty.Easy, @@ -209,18 +228,31 @@ export class MapPlaylist { return { isRandomSpawn: Math.random() < 0.1, // 10% chance isCompact: Math.random() < 0.05, // 5% chance + isCrowded: Math.random() < 0.05, // 5% chance startingGold: Math.random() < 0.05 ? 5_000_000 : undefined, // 5% chance }; } + // Maps with smallest player count (third number of calculateMapPlayerCounts) < 50 don't support compact map in team games + // (not enough players after 75% player reduction for compact maps) private async supportsCompactMapForTeams(map: GameMapType): Promise { - // Maps with smallest player count < 50 don't support compact map in team games - // The smallest player count is the 3rd number in the player counts array const landTiles = await getMapLandTiles(map); const [, , smallest] = this.calculateMapPlayerCounts(landTiles); return smallest >= 50; } + private async getCrowdedMaxPlayers( + map: GameMapType, + isCompact: boolean, + ): Promise { + const landTiles = await getMapLandTiles(map); + const [firstPlayerCount] = this.calculateMapPlayerCounts(landTiles); + if (firstPlayerCount <= 60) { + return isCompact ? 60 : 125; + } + return undefined; + } + private async lobbyMaxPlayers( map: GameMapType, mode: GameMode, @@ -236,7 +268,15 @@ export class MapPlaylist { if (isCompactMap) { p = Math.max(3, Math.floor(p * 0.25)); } - if (numPlayerTeams === undefined) return p; + return this.adjustForTeams(p, numPlayerTeams); + } + + private adjustForTeams( + playerCount: number, + numPlayerTeams: TeamCountConfig | undefined, + ): number { + if (numPlayerTeams === undefined) return playerCount; + let p = playerCount; switch (numPlayerTeams) { case Duos: p -= p % 2; diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 6a879ccd5..94b625943 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -80,7 +80,7 @@ export class TestServerConfig implements ServerConfig { throw new Error("Method not implemented."); } getRandomPublicGameModifiers(): PublicGameModifiers { - return { isCompact: false, isRandomSpawn: false }; + return { isCompact: false, isRandomSpawn: false, isCrowded: false }; } async supportsCompactMapForTeams(): Promise { throw new Error("Method not implemented."); From 476fa373798ea5ffe129a9f85ccda90443bcb11d Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:10:14 +0100 Subject: [PATCH 094/109] =?UTF-8?q?For=20v29.6:=20More=20team=20games=20?= =?UTF-8?q?=F0=9F=A7=91=E2=80=8D=F0=9F=A4=9D=E2=80=8D=F0=9F=A7=91=20(#3051?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Use ffa:teams ratio of 3:2 ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/server/MapPlaylist.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index b54aa54ff..1a74ecfd3 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -332,6 +332,8 @@ export class MapPlaylist { const ffa1: GameMapType[] = rand.shuffleArray([...maps]); const team1: GameMapType[] = rand.shuffleArray([...maps]); const ffa2: GameMapType[] = rand.shuffleArray([...maps]); + const team2: GameMapType[] = rand.shuffleArray([...maps]); + const ffa3: GameMapType[] = rand.shuffleArray([...maps]); this.mapsPlaylist = []; for (let i = 0; i < maps.length; i++) { @@ -346,6 +348,14 @@ export class MapPlaylist { if (!this.addNextMap(this.mapsPlaylist, ffa2, GameMode.FFA)) { return false; } + if (!this.disableTeams) { + if (!this.addNextMap(this.mapsPlaylist, team2, GameMode.Team)) { + return false; + } + } + if (!this.addNextMap(this.mapsPlaylist, ffa3, GameMode.FFA)) { + return false; + } } return true; } From 4176944639f239489efb5a198694a63088e1b497 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:11:03 +0100 Subject: [PATCH 095/109] =?UTF-8?q?Changelog=20cache=20busting=20?= =?UTF-8?q?=F0=9F=94=A7=20(#3047)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Added cache busting for `changelog.md` ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/client/NewsModal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/NewsModal.ts b/src/client/NewsModal.ts index 4c67f10c8..fe0d622f3 100644 --- a/src/client/NewsModal.ts +++ b/src/client/NewsModal.ts @@ -65,7 +65,7 @@ export class NewsModal extends BaseModal { protected onOpen(): void { if (!this.initialized) { this.initialized = true; - fetch(changelog) + fetch(`${changelog}?v=${encodeURIComponent(version.trim())}`) .then((response) => (response.ok ? response.text() : "Failed to load")) .then((markdown) => markdown From 1dac7bd2e82ce935a2013f2adbc31817acd02499 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Wed, 28 Jan 2026 00:00:18 +0100 Subject: [PATCH 096/109] =?UTF-8?q?Confirm=20alliance=20break=20=E2=9A=A0?= =?UTF-8?q?=EF=B8=8F=20(#3033)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: People accidentally clicked the betray button because it's at the same position as the ally button. So let's add a small confirmation step. https://github.com/user-attachments/assets/754f2d33-7419-42fc-a732-197c3107236e ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- resources/images/CheckmarkIconWhite.svg | 3 ++ src/client/graphics/layers/RadialMenu.ts | 5 ++- .../graphics/layers/RadialMenuElements.ts | 21 ++++++++++ ...nts.spec.ts => radialMenuElements.test.ts} | 42 ++++++++++++++++++- 4 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 resources/images/CheckmarkIconWhite.svg rename tests/{radialMenuElements.spec.ts => radialMenuElements.test.ts} (66%) diff --git a/resources/images/CheckmarkIconWhite.svg b/resources/images/CheckmarkIconWhite.svg new file mode 100644 index 000000000..ef1abfe12 --- /dev/null +++ b/resources/images/CheckmarkIconWhite.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 090df03c1..ab4d0198f 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -907,9 +907,12 @@ export class RadialMenu implements Layer { .select(".center-button-hitbox") .style("cursor", enabled ? "pointer" : "not-allowed"); + // Use default color for back button, otherwise use the current center button color + const buttonColor = + state === "back" ? this.defaultCenterButtonColor : this.centerButtonColor; centerButton .select(".center-button-visible") - .attr("fill", enabled ? this.centerButtonColor : "#999999"); + .attr("fill", enabled ? buttonColor : "#999999"); centerButton .select(".center-button-icon") diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 0cf28cebb..4ee7e5924 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -17,6 +17,7 @@ import allianceIcon from "/images/AllianceIconWhite.svg?url"; import boatIcon from "/images/BoatIconWhite.svg?url"; import buildIcon from "/images/BuildIconWhite.svg?url"; import chatIcon from "/images/ChatIconWhite.svg?url"; +import checkmarkIcon from "/images/CheckmarkIconWhite.svg?url"; import donateGoldIcon from "/images/DonateGoldIconWhite.svg?url"; import donateTroopIcon from "/images/DonateTroopIconWhite.svg?url"; import emojiIcon from "/images/EmojiIconWhite.svg?url"; @@ -218,6 +219,15 @@ const allyBreakElement: MenuElement = { !!params.playerActions?.interaction?.canBreakAlliance, color: COLORS.breakAlly, icon: traitorIcon, + subMenu: () => [allyBreakCancelElement, allyBreakConfirmElement], +}; + +const allyBreakConfirmElement: MenuElement = { + id: "ally_break_confirm", + name: "confirm", + disabled: () => false, + color: COLORS.breakAlly, + icon: checkmarkIcon, action: (params: MenuElementParams) => { params.playerActionHandler.handleBreakAlliance( params.myPlayer, @@ -227,6 +237,17 @@ const allyBreakElement: MenuElement = { }, }; +const allyBreakCancelElement: MenuElement = { + id: "ally_break_cancel", + name: "cancel", + disabled: () => false, + color: COLORS.info, + icon: xIcon, + action: (params: MenuElementParams) => { + params.closeMenu(); + }, +}; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const allyDonateGoldElement: MenuElement = { id: "ally_donate_gold", diff --git a/tests/radialMenuElements.spec.ts b/tests/radialMenuElements.test.ts similarity index 66% rename from tests/radialMenuElements.spec.ts rename to tests/radialMenuElements.test.ts index c24ea9227..a95e6be05 100644 --- a/tests/radialMenuElements.spec.ts +++ b/tests/radialMenuElements.test.ts @@ -75,6 +75,12 @@ const makeParams = (opts?: Partial): MenuElementParams => { const findAllyBreak = (items: any[]) => items.find((i) => i && i.id === "ally_break"); +const findAllyBreakConfirm = (items: any[]) => + items.find((i) => i && i.id === "ally_break_confirm"); + +const findAllyBreakCancel = (items: any[]) => + items.find((i) => i && i.id === "ally_break_cancel"); + describe("RadialMenuElements ally break", () => { test("shows break option with correct color when allied", () => { const params = makeParams(); @@ -85,12 +91,29 @@ describe("RadialMenuElements ally break", () => { expect(ally.color).toBe(COLORS.breakAlly); }); - test("action calls handleBreakAlliance and closes menu", () => { + test("break option opens confirmation submenu", () => { const params = makeParams(); const items = rootMenuElement.subMenu!(params); const ally = findAllyBreak(items)!; - ally.action!(params); + expect(ally.subMenu).toBeDefined(); + const subMenuItems = ally.subMenu!(params); + expect(subMenuItems.length).toBe(2); + + const confirmItem = findAllyBreakConfirm(subMenuItems); + const cancelItem = findAllyBreakCancel(subMenuItems); + expect(confirmItem).toBeTruthy(); + expect(cancelItem).toBeTruthy(); + }); + + test("confirm action calls handleBreakAlliance and closes menu", () => { + const params = makeParams(); + const items = rootMenuElement.subMenu!(params); + const ally = findAllyBreak(items)!; + const subMenuItems = ally.subMenu!(params); + const confirmItem = findAllyBreakConfirm(subMenuItems)!; + + confirmItem.action!(params); expect(params.playerActionHandler.handleBreakAlliance).toHaveBeenCalledWith( params.myPlayer, @@ -98,4 +121,19 @@ describe("RadialMenuElements ally break", () => { ); expect(params.closeMenu).toHaveBeenCalled(); }); + + test("cancel action closes menu without breaking alliance", () => { + const params = makeParams(); + const items = rootMenuElement.subMenu!(params); + const ally = findAllyBreak(items)!; + const subMenuItems = ally.subMenu!(params); + const cancelItem = findAllyBreakCancel(subMenuItems)!; + + cancelItem.action!(params); + + expect( + params.playerActionHandler.handleBreakAlliance, + ).not.toHaveBeenCalled(); + expect(params.closeMenu).toHaveBeenCalled(); + }); }); From da4b8aa5e1254c96e3e5e052950be48601a8f768 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:15:35 +0000 Subject: [PATCH 097/109] Spectate catchup (#3012) ## Description: https://github.com/user-attachments/assets/dc118d5f-3b7f-4ccb-8579-5b0d8c73fe8e Catchup mechanic for live games and changes replays to have a backlog for more "max" speed ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- src/client/LocalServer.ts | 17 +++++++++++++++-- src/core/GameRunner.ts | 14 ++++++++++---- src/core/worker/Worker.worker.ts | 15 +++++++++++++-- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 2514dc695..75121b38a 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -20,7 +20,13 @@ import { import { getPersistentID } from "./Auth"; import { LobbyConfig } from "./ClientGameRunner"; import { ReplaySpeedChangeEvent } from "./InputHandler"; -import { defaultReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier"; +import { + defaultReplaySpeedMultiplier, + ReplaySpeedMultiplier, +} from "./utilities/ReplaySpeedMultiplier"; + +// build a small backlog so MAX can catch up. +const MAX_REPLAY_BACKLOG_TURNS = 60; export class LocalServer { // All turns from the game record on replay. @@ -64,9 +70,16 @@ export class LocalServer { const turnIntervalMs = this.lobbyConfig.serverConfig.turnIntervalMs() * this.replaySpeedMultiplier; + const backlog = Math.max(0, this.turns.length - this.turnsExecuted); + const allowReplayBacklog = + this.replaySpeedMultiplier === ReplaySpeedMultiplier.fastest && + this.lobbyConfig.gameRecord !== undefined; + const maxBacklog = allowReplayBacklog ? MAX_REPLAY_BACKLOG_TURNS : 0; + const canQueueNextTurn = + backlog === 0 || (maxBacklog > 0 && backlog < maxBacklog); if ( - this.turnsExecuted === this.turns.length && + canQueueNextTurn && Date.now() > this.turnStartTime + turnIntervalMs ) { this.turnStartTime = Date.now(); diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 5e45612ea..0f93a94f6 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -112,12 +112,12 @@ export class GameRunner { this.turns.push(turn); } - public executeNextTick() { + public executeNextTick(): boolean { if (this.isExecuting) { - return; + return false; } if (this.currTurn >= this.turns.length) { - return; + return false; } this.isExecuting = true; @@ -144,7 +144,8 @@ export class GameRunner { } else { console.error("Game tick error:", error); } - return; + this.isExecuting = false; + return false; } if (this.game.inSpawnPhase() && this.game.ticks() % 2 === 0) { @@ -177,6 +178,11 @@ export class GameRunner { tickExecutionDuration: tickExecutionDuration, }); this.isExecuting = false; + return true; + } + + public pendingTurns(): number { + return Math.max(0, this.turns.length - this.currTurn); } public playerActions( diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index a60e63e4b..31fd3f136 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -16,6 +16,7 @@ import { const ctx: Worker = self as any; let gameRunner: Promise | null = null; const mapLoader = new FetchGameMapLoader(`/maps`, version); +const MAX_TICKS_PER_HEARTBEAT = 4; function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) { // skip if ErrorUpdate @@ -36,9 +37,19 @@ ctx.addEventListener("message", async (e: MessageEvent) => { const message = e.data; switch (message.type) { - case "heartbeat": - (await gameRunner)?.executeNextTick(); + case "heartbeat": { + const gr = await gameRunner; + if (!gr) { + break; + } + const ticksToRun = Math.min(gr.pendingTurns(), MAX_TICKS_PER_HEARTBEAT); + for (let i = 0; i < ticksToRun; i++) { + if (!gr.executeNextTick()) { + break; + } + } break; + } case "init": try { gameRunner = createGameRunner( From 6cca96b545fd17664134dbcff944c4b056e249a2 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Wed, 28 Jan 2026 08:49:06 +0900 Subject: [PATCH 098/109] Anonymized/hidden names on lobby preview (#2965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #2962 ## Description: Anonymize lobby preview player names when “Hidden names” is enabled, using the same deterministic mapping as in-game. スクリーンショット 2026-01-20 21 13 19 スクリーンショット 2026-01-20 21 13 27 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: aotumuri Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com> --- src/client/HostLobbyModal.ts | 1 + src/client/JoinPrivateLobbyModal.ts | 9 +++- src/client/components/LobbyPlayerView.ts | 52 +++++++++++++++++------- 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index e5bb92f11..5355f9f33 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -653,6 +653,7 @@ export class HostLobbyModal extends BaseModal { .gameMode=${this.gameMode} .clients=${this.clients} .lobbyCreatorClientID=${this.lobbyCreatorClientID} + .currentClientID=${this.lobbyCreatorClientID} .teamCount=${this.teamCount} .nationCount=${this.nationCount} .disableNations=${this.disableNations} diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index c04b8fa93..9287b1730 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -28,6 +28,7 @@ export class JoinPrivateLobbyModal extends BaseModal { @state() private gameConfig: GameConfig | null = null; @state() private lobbyCreatorClientID: string | null = null; @state() private currentLobbyId: string = ""; + @state() private currentClientID: string = ""; @state() private nationCount: number = 0; private playersInterval: NodeJS.Timeout | null = null; @@ -101,6 +102,7 @@ export class JoinPrivateLobbyModal extends BaseModal { .gameMode=${this.gameConfig?.gameMode ?? GameMode.FFA} .clients=${this.players} .lobbyCreatorClientID=${this.lobbyCreatorClientID} + .currentClientID=${this.currentClientID} .teamCount=${this.gameConfig?.playerTeams ?? 2} .nationCount=${this.nationCount} .disableNations=${this.gameConfig?.disableNations ?? false} @@ -290,6 +292,7 @@ export class JoinPrivateLobbyModal extends BaseModal { this.hasJoined = false; this.message = ""; this.currentLobbyId = ""; + this.currentClientID = ""; this.nationCount = 0; this.leaveLobbyOnClose = true; @@ -418,6 +421,7 @@ export class JoinPrivateLobbyModal extends BaseModal { this.showMessage(translateText("private_lobby.joined_waiting")); this.message = ""; this.hasJoined = true; + this.currentClientID = generateID(); // If the modal closes as part of joining the game, do not leave the lobby this.leaveLobbyOnClose = false; @@ -426,7 +430,7 @@ export class JoinPrivateLobbyModal extends BaseModal { new CustomEvent("join-lobby", { detail: { gameID: lobbyId, - clientID: generateID(), + clientID: this.currentClientID, } as JoinLobbyEvent, bubbles: true, composed: true, @@ -477,12 +481,13 @@ export class JoinPrivateLobbyModal extends BaseModal { return "version_mismatch"; } + this.currentClientID = generateID(); this.dispatchEvent( new CustomEvent("join-lobby", { detail: { gameID: lobbyId, gameRecord: parsed.data, - clientID: generateID(), + clientID: this.currentClientID, } as JoinLobbyEvent, bubbles: true, composed: true, diff --git a/src/client/components/LobbyPlayerView.ts b/src/client/components/LobbyPlayerView.ts index 4c72fc21d..cc43e6930 100644 --- a/src/client/components/LobbyPlayerView.ts +++ b/src/client/components/LobbyPlayerView.ts @@ -15,7 +15,9 @@ import { } from "../../core/game/Game"; import { getCompactMapNationCount } from "../../core/game/NationCreation"; import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment"; +import { UserSettings } from "../../core/game/UserSettings"; import { ClientInfo, TeamCountConfig } from "../../core/Schemas"; +import { createRandomName } from "../../core/Util"; import { translateText } from "../Utils"; export interface TeamPreviewData { @@ -30,6 +32,7 @@ export class LobbyTeamView extends LitElement { @state() private teamPreview: TeamPreviewData[] = []; @state() private teamMaxSize: number = 0; @property({ type: String }) lobbyCreatorClientID: string = ""; + @property({ type: String }) currentClientID: string = ""; @property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2; @property({ type: Function }) onKickPlayer?: (clientID: string) => void; @property({ type: Number }) nationCount: number = 0; @@ -38,6 +41,7 @@ export class LobbyTeamView extends LitElement { private theme: PastelTheme = new PastelTheme(); @state() private showTeamColors: boolean = false; + private userSettings: UserSettings = new UserSettings(); willUpdate(changedProperties: Map) { // Recompute team preview when relevant properties change @@ -108,12 +112,14 @@ export class LobbyTeamView extends LitElement { ${repeat( this.clients, (c) => c.clientID ?? c.username, - (client) => - html`
        { + const displayName = this.displayUsername(client); + return html`
        - ${client.username} -
        `, + ${displayName} +
        `; + }, )}
        @@ -151,9 +157,10 @@ export class LobbyTeamView extends LitElement { return html`${repeat( this.clients, (c) => c.clientID ?? c.username, - (client) => - html` - ${client.username} + (client) => { + const displayName = this.displayUsername(client); + return html` + ${displayName} ${client.clientID === this.lobbyCreatorClientID ? html`(${translateText("host_modal.host_badge")}) this.onKickPlayer?.(client.clientID)} aria-label=${translateText("host_modal.remove_player", { - username: client.username, + username: displayName, })} > × ` : html``} - `, + `; + }, )} `; } @@ -207,11 +215,12 @@ export class LobbyTeamView extends LitElement { : repeat( preview.players, (p) => p.clientID ?? p.username, - (p) => - html`
        { + const displayName = this.displayUsername(p); + return html`
        - ${p.username} + ${displayName} ${p.clientID === this.lobbyCreatorClientID ? html`(${translateText("host_modal.host_badge")}) × ` : html``} -
        `, +
        `; + }, )}
        @@ -353,4 +363,18 @@ export class LobbyTeamView extends LitElement { } return getCompactMapNationCount(this.nationCount, this.isCompactMap); } + + private displayUsername(client: ClientInfo): string { + if (!this.userSettings.anonymousNames()) { + return client.username; + } + + if (this.currentClientID && client.clientID === this.currentClientID) { + return client.username; + } + + return ( + createRandomName(client.username, PlayerType.Human) ?? client.username + ); + } } From db745dcf4a9fe765cdb32704a625fb14b5dc1b0a Mon Sep 17 00:00:00 2001 From: Vivacious Box Date: Wed, 28 Jan 2026 00:51:11 +0100 Subject: [PATCH 099/109] Add a troubleshooting panel (#2951) ## Description: Add a troobleshooting panel with the most common problems, and a button to copy the infos for better sharing image image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: Mr. Box --- index.html | 5 + resources/lang/en.json | 33 ++++ src/client/HelpModal.ts | 64 +++++++- src/client/Main.ts | 1 + src/client/TroubleshootingModal.ts | 254 +++++++++++++++++++++++++++++ src/client/utilities/Diagnostic.ts | 141 ++++++++++++++++ 6 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 src/client/TroubleshootingModal.ts create mode 100644 src/client/utilities/Diagnostic.ts diff --git a/index.html b/index.html index cad490b1c..6c90f20e2 100644 --- a/index.html +++ b/index.html @@ -199,6 +199,11 @@ inline class="hidden w-full h-full page-content" > + ${modalHeader({ - title: translateText("main.instructions"), + title: translateText("main.help"), onBack: this.close, ariaLabel: translateText("common.back"), })} @@ -120,6 +121,53 @@ export class HelpModal extends BaseModal { [&_p]:text-gray-300 [&_p]:mb-3 [&_strong]:text-white [&_strong]:font-bold scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent" > + +
        +
        + + + + + +
        +

        + ${translateText("main.troubleshooting")} +

        +
        +
        +
        +
        +

        + ${translateText("help_modal.troubleshooting_desc")} +

        + +
        +
        @@ -1137,6 +1185,20 @@ export class HelpModal extends BaseModal { `; } + openTroubleshooting() { + const troubleshootingModal = document.querySelector( + "troubleshooting-modal", + ) as TroubleshootingModal; + if ( + !troubleshootingModal || + !(troubleshootingModal instanceof TroubleshootingModal) + ) { + console.warn("Troubleshooting modal element not found"); + return; + } + troubleshootingModal.open(); + } + protected onOpen(): void { this.keybinds = this.getKeybinds(); } diff --git a/src/client/Main.ts b/src/client/Main.ts index 8858b2f43..b89c7a3cd 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -813,6 +813,7 @@ class Client { "game-top-bar", "help-modal", "user-setting", + "troubleshooting-modal", "territory-patterns-modal", "language-modal", "news-modal", diff --git a/src/client/TroubleshootingModal.ts b/src/client/TroubleshootingModal.ts new file mode 100644 index 000000000..4a017a4fe --- /dev/null +++ b/src/client/TroubleshootingModal.ts @@ -0,0 +1,254 @@ +import { html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { translateText } from "./Utils"; +import { BaseModal } from "./components/BaseModal"; +import "./components/baseComponents/Modal"; +import { modalHeader } from "./components/ui/ModalHeader"; +import { + collectGraphicsDiagnostics, + GraphicsDiagnostics, +} from "./utilities/Diagnostic"; +import infoIcon from "/images/InfoIcon.svg?url"; + +@customElement("troubleshooting-modal") +export class TroubleshootingModal extends BaseModal { + @property({ type: String }) markdown = "Loading..."; + + @property({ type: Object }) + diagnostics?: GraphicsDiagnostics; + + @property({ type: Boolean }) loading = true; + + private initialized: boolean = false; + + private async loadDiagnostics() { + const canvas = document.createElement("canvas"); + this.diagnostics = await collectGraphicsDiagnostics(canvas); + this.loading = false; + this.initialized = true; + } + + render() { + const content = html` +
        + ${modalHeader({ + titleContent: html`
        + + ${translateText("main.help")} + / ${translateText("troubleshooting.title")} + + +
        `, + onBack: this.close, + ariaLabel: translateText("common.back"), + })} + ${this.loading + ? "" + : html` +
        + ${this.section( + "", + html`${this.infoTip( + translateText("troubleshooting.hardware_acceleration_tip"), + true, + )}`, + )} + ${this.section( + translateText("troubleshooting.environment"), + html` + ${this.row( + translateText("troubleshooting.browser"), + this.diagnostics!.browser.engine, + )} + ${this.row( + translateText("troubleshooting.platform"), + this.diagnostics!.browser.platform, + )} + ${this.row( + translateText("troubleshooting.os"), + this.diagnostics!.browser.os, + )} + ${this.row( + translateText("troubleshooting.device_pixel_ratio"), + this.diagnostics!.browser.dpr, + )} + ${this.infoTip( + translateText("troubleshooting.chromium_tip"), + )} + `, + )} + ${this.section( + translateText("troubleshooting.rendering"), + html` + ${this.row( + translateText("troubleshooting.renderer"), + this.describeRenderer(this.diagnostics!.rendering), + )} + ${this.row( + translateText("troubleshooting.max_texture_size"), + this.diagnostics!.rendering.maxTextureSize ?? + translateText("troubleshooting.unknown"), + )} + ${this.row( + translateText("troubleshooting.high_precision_shaders"), + this.diagnostics!.rendering.shaderHighp === true + ? translateText("troubleshooting.yes") + : translateText("troubleshooting.no"), + )}${this.row( + translateText("troubleshooting.gpu"), + !this.diagnostics!.rendering.gpu || + this.diagnostics!.rendering.gpu.unavailable + ? translateText("troubleshooting.unavailable") + : `${this.diagnostics!.rendering.gpu.vendor} — ${this.diagnostics!.rendering.gpu.renderer}`, + )} + ${this.infoTip(translateText("troubleshooting.gpu_tip"))} + `, + )} + ${this.section( + translateText("troubleshooting.power"), + html` + ${this.diagnostics!.power.unavailable + ? this.row( + translateText("troubleshooting.battery"), + translateText("troubleshooting.unavailable"), + ) + : html` + ${this.row( + translateText("troubleshooting.charging"), + this.diagnostics!.power.charging + ? translateText("troubleshooting.yes") + : translateText("troubleshooting.no"), + )} + ${this.row( + translateText("troubleshooting.battery_level"), + this.diagnostics!.power.level, + )} + `} + ${this.infoTip( + translateText("troubleshooting.power_saving_tip"), + )} + `, + )} +
        + `} +
        + `; + + if (this.inline) { + return content; + } + + return html` + + ${content} + + `; + } + + private infoTip(text: string, warning?: boolean): unknown { + return html` +
        + + ${text} +
        + `; + } + + protected onOpen(): void { + if (!this.initialized) { + this.initialized = true; + this.loadDiagnostics(); + } + } + + private section(title: string, content: unknown) { + return html` +
        +

        + ${title} +

        +
        ${content}
        +
        + `; + } + + private row(label: string, value: unknown) { + return html` +
        + ${label} + ${value} +
        + `; + } + + private async copyDiagnostics() { + if (!this.diagnostics) return; + const formatted = + "```json\n" + JSON.stringify(this.diagnostics, null, 2) + "\n```"; + await navigator.clipboard.writeText(formatted); + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message: html`${translateText("troubleshooting.copied_to_clipboard")}`, + type: "info", + duration: 3000, + }, + }), + ); + } + + private describeRenderer(rendering: any): string { + if (rendering.gpu?.software) { + return translateText("troubleshooting.software_rendering"); + } + if (rendering.type === "Canvas2D") { + return translateText("troubleshooting.canvas_2d_no_gpu"); + } + return `${rendering.type}`; + } + + public close(): void { + this.unregisterEscapeHandler(); + + if (this.inline) { + this.style.pointerEvents = "none"; + if (window.showPage) { + window.showPage?.("page-help"); + } + } else { + this.modalEl?.close(); + } + } +} diff --git a/src/client/utilities/Diagnostic.ts b/src/client/utilities/Diagnostic.ts new file mode 100644 index 000000000..dc6553071 --- /dev/null +++ b/src/client/utilities/Diagnostic.ts @@ -0,0 +1,141 @@ +export type RendererType = "Canvas2D" | "WebGL1" | "WebGL2"; + +export interface BrowserInfo { + engine: string; + platform: string; + os: string; + dpr: number; +} + +export interface GraphicsDiagnostics { + browser: BrowserInfo; + rendering: RenderingInfo; + power: PowerInfo; +} + +export interface GPUInfo { + vendor?: string; + renderer?: string; + software?: boolean; + unavailable?: boolean; +} + +export interface RenderingInfo { + type: RendererType; + antialias?: boolean; + maxTextureSize?: number; + shaderHighp?: boolean; + gpu?: GPUInfo; +} + +export interface PerformanceInfo { + fps: number; + worstFrameMs: number; + jankPercent: number; + throttlingLikely: boolean; +} + +export interface PowerInfo { + charging?: boolean; + level?: string; + unavailable?: boolean; +} + +export async function collectGraphicsDiagnostics( + canvas: HTMLCanvasElement, +): Promise { + /* ---------- Browser / OS ---------- */ + + const uaData = (navigator as any).userAgentData; + + const os = uaData?.platform ?? detectOS(navigator.userAgent); + + const browser: BrowserInfo = { + engine: uaData?.brands + ? uaData.brands.map((b: any) => b.brand).join(", ") + : navigator.userAgent, + platform: navigator.platform, + os, + dpr: window.devicePixelRatio, + }; + + /* ---------- Rendering ---------- */ + + let gl: WebGLRenderingContext | WebGL2RenderingContext | null = null; + let type: RendererType = "Canvas2D"; + + gl = + canvas.getContext("webgl2", { antialias: true }) ?? + canvas.getContext("webgl", { antialias: true }); + + if (gl) { + const isWebGL2 = + typeof WebGL2RenderingContext !== "undefined" && + gl instanceof WebGL2RenderingContext; + type = isWebGL2 ? "WebGL2" : "WebGL1"; + } + + const rendering: RenderingInfo = { type }; + + if (gl) { + rendering.antialias = gl.getContextAttributes()?.antialias ?? false; + rendering.maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE); + + const precision = gl.getShaderPrecisionFormat( + gl.FRAGMENT_SHADER, + gl.HIGH_FLOAT, + ); + rendering.shaderHighp = precision !== null && precision.precision > 0; + + const debugInfo = gl.getExtension("WEBGL_debug_renderer_info"); + + if (debugInfo) { + const renderer = gl.getParameter( + (debugInfo as any).UNMASKED_RENDERER_WEBGL, + ) as string; + + const vendor = gl.getParameter( + (debugInfo as any).UNMASKED_VENDOR_WEBGL, + ) as string; + rendering.gpu = { + vendor, + renderer, + software: /swiftshader|llvmpipe|software/i.test(renderer), + }; + } else { + rendering.gpu = { unavailable: true }; + } + } + + /* ---------- Power ---------- */ + + let power: PowerInfo = {}; + + if ("getBattery" in navigator) { + try { + const battery = await (navigator as any).getBattery(); + power = { + charging: battery.charging, + level: Math.round(battery.level * 100) + "%", + }; + } catch { + power = { unavailable: true }; + } + } else { + power = { unavailable: true }; + } + return { + browser, + rendering, + power, + }; +} + +function detectOS(ua: string): string { + if (/windows nt/i.test(ua)) return "Windows"; + if (/mac os x/i.test(ua)) return "macOS"; + if (/android/i.test(ua)) return "Android"; + if (/iphone|ipad|ipod/i.test(ua)) return "iOS"; + if (/linux/i.test(ua)) return "Linux"; + return "Unknown"; +} From 1314115d3f8ff989a5911d4e66c339c672ac28fa Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Wed, 28 Jan 2026 08:52:48 +0900 Subject: [PATCH 100/109] Add map picker with Featured/All tabs (#3005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #2996 ## Description: Replace map selection UI in src/client/SinglePlayerModal.ts and src/client/HostLobbyModal.ts with the picker (Featured/All tabs + random map card). Also, since the html was getting quite long, I extracted the shared parts into a separate component. スクリーンショット 2026-01-23 21 57 03 スクリーンショット 2026-01-23 21 57 12 I separated Map.ts because the display logic looked reusable in other places, but I’m also open to merging it back if that makes more sense. If the review prefers it integrated, I can combine them again. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: aotumuri --- resources/lang/en.json | 3 + src/client/HelpModal.ts | 1 - src/client/HostLobbyModal.ts | 86 +------- src/client/SinglePlayerModal.ts | 91 +-------- .../components/{Maps.ts => map/MapDisplay.ts} | 6 +- src/client/components/map/MapPicker.ts | 183 ++++++++++++++++++ 6 files changed, 208 insertions(+), 162 deletions(-) rename src/client/components/{Maps.ts => map/MapDisplay.ts} (96%) create mode 100644 src/client/components/map/MapPicker.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index d9b6c0d9b..56c8941cd 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -280,6 +280,8 @@ }, "map": { "map": "Map", + "featured": "Featured", + "all": "All", "world": "World", "giantworldmap": "Giant World Map", "europe": "Europe", @@ -330,6 +332,7 @@ "amazonriver": "Amazon River" }, "map_categories": { + "featured": "Featured", "continental": "Continental", "regional": "Regional", "fantasy": "Other", diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index a5d150f3f..e696db76f 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; -import "./components/Maps"; import { modalHeader } from "./components/ui/ModalHeader"; import { TroubleshootingModal } from "./TroubleshootingModal"; diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 5355f9f33..eff76894b 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -12,7 +12,6 @@ import { Quads, Trios, UnitType, - mapCategories, } from "../core/game/Game"; import { ClientInfo, @@ -28,7 +27,7 @@ import "./components/CopyButton"; import "./components/Difficulties"; import "./components/FluentSlider"; import "./components/LobbyPlayerView"; -import "./components/Maps"; +import "./components/map/MapPicker"; import { modalHeader } from "./components/ui/ModalHeader"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import { JoinLobbyEvent } from "./Main"; @@ -38,7 +37,6 @@ import { renderToggleInputCardInput, } from "./utilities/RenderToggleInputCard"; import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; -import randomMap from "/images/RandomMap.webp?url"; @customElement("host-lobby-modal") export class HostLobbyModal extends BaseModal { @state() private selectedMap: GameMapType = GameMapType.World; @@ -209,80 +207,14 @@ export class HostLobbyModal extends BaseModal { ${translateText("map.map")}
        -
        - - ${Object.entries(mapCategories).map( - ([categoryKey, maps]) => html` -
        -

        - ${translateText(`map_categories.${categoryKey}`)} -

        -
        - ${maps.map((mapValue) => { - const mapKey = Object.entries(GameMapType).find( - ([, v]) => v === mapValue, - )?.[0]; - return html` -
        this.handleMapSelection(mapValue)} - class="cursor-pointer transition-transform duration-200 active:scale-95" - > - -
        - `; - })} -
        -
        - `, - )} - -
        -

        - ${translateText("map_categories.special")} -

        -
        - -
        -
        -
        + + this.handleMapSelection(mapValue)} + .onSelectRandom=${() => this.handleSelectRandomMap()} + >
        diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index dece359e4..e423d7596 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -13,7 +13,6 @@ import { Quads, Trios, UnitType, - mapCategories, } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; import { TeamCountConfig } from "../core/Schemas"; @@ -24,7 +23,7 @@ import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; import "./components/FluentSlider"; -import "./components/Maps"; +import "./components/map/MapPicker"; import { modalHeader } from "./components/ui/ModalHeader"; import { fetchCosmetics } from "./Cosmetics"; import { FlagInput } from "./FlagInput"; @@ -35,7 +34,6 @@ import { renderToggleInputCardInput, } from "./utilities/RenderToggleInputCard"; import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; -import randomMap from "/images/RandomMap.webp?url"; @customElement("single-player-modal") export class SinglePlayerModal extends BaseModal { @@ -197,84 +195,15 @@ export class SinglePlayerModal extends BaseModal { -
        - ${Object.entries(mapCategories).map( - ([categoryKey, maps]) => html` -
        -

        - ${translateText(`map_categories.${categoryKey}`)} -

        -
        - ${maps.map((mapValue) => { - const mapKey = Object.keys(GameMapType).find( - (key) => - GameMapType[key as keyof typeof GameMapType] === - mapValue, - ); - return html` -
        this.handleMapSelection(mapValue)} - class="cursor-pointer transition-transform duration-200 active:scale-95" - > - -
        - `; - })} -
        -
        - `, - )} - - -
        -

        - ${translateText("map_categories.special")} -

        -
        - -
        -
        -
        + + this.handleMapSelection(mapValue)} + .onSelectRandom=${() => this.handleSelectRandomMap()} + > diff --git a/src/client/components/Maps.ts b/src/client/components/map/MapDisplay.ts similarity index 96% rename from src/client/components/Maps.ts rename to src/client/components/map/MapDisplay.ts index e46e4691b..b7fc1364c 100644 --- a/src/client/components/Maps.ts +++ b/src/client/components/map/MapDisplay.ts @@ -1,8 +1,8 @@ import { LitElement, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; -import { Difficulty, GameMapType } from "../../core/game/Game"; -import { terrainMapFileLoader } from "../TerrainMapFileLoader"; -import { translateText } from "../Utils"; +import { Difficulty, GameMapType } from "../../../core/game/Game"; +import { terrainMapFileLoader } from "../../TerrainMapFileLoader"; +import { translateText } from "../../Utils"; @customElement("map-display") export class MapDisplay extends LitElement { diff --git a/src/client/components/map/MapPicker.ts b/src/client/components/map/MapPicker.ts new file mode 100644 index 000000000..52607622e --- /dev/null +++ b/src/client/components/map/MapPicker.ts @@ -0,0 +1,183 @@ +import { LitElement, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { + Difficulty, + GameMapType, + mapCategories, +} from "../../../core/game/Game"; +import { translateText } from "../../Utils"; +import "./MapDisplay"; +import randomMap from "/images/RandomMap.webp?url"; + +const featuredMaps: GameMapType[] = [ + GameMapType.World, + GameMapType.Europe, + GameMapType.NorthAmerica, + GameMapType.SouthAmerica, + GameMapType.Asia, + GameMapType.Africa, + GameMapType.Japan, +]; + +@customElement("map-picker") +export class MapPicker extends LitElement { + @property({ type: String }) selectedMap: GameMapType = GameMapType.World; + @property({ type: Boolean }) useRandomMap = false; + @property({ type: Boolean }) showMedals = false; + @property({ type: Boolean }) randomMapDivider = false; + @property({ attribute: false }) mapWins: Map> = + new Map(); + @property({ attribute: false }) onSelectMap?: (map: GameMapType) => void; + @property({ attribute: false }) onSelectRandom?: () => void; + @state() private showAllMaps = false; + + createRenderRoot() { + return this; + } + + private handleMapSelection(mapValue: GameMapType) { + this.onSelectMap?.(mapValue); + } + + private handleSelectRandomMap = () => { + this.onSelectRandom?.(); + }; + + private getWins(mapValue: GameMapType): Set { + return this.mapWins?.get(mapValue) ?? new Set(); + } + + private renderMapCard(mapValue: GameMapType) { + const mapKey = Object.entries(GameMapType).find( + ([_, value]) => value === mapValue, + )?.[0]; + return html` +
        this.handleMapSelection(mapValue)} + class="cursor-pointer transition-transform duration-200 active:scale-95" + > + +
        + `; + } + + private renderAllMaps() { + const mapCategoryEntries = Object.entries(mapCategories); + return html`
        + ${mapCategoryEntries.map( + ([categoryKey, maps]) => html` +
        +

        + ${translateText(`map_categories.${categoryKey}`)} +

        +
        + ${maps.map((mapValue) => this.renderMapCard(mapValue))} +
        +
        + `, + )} +
        `; + } + + private renderFeaturedMaps() { + let featuredMapList = featuredMaps; + if (!featuredMapList.includes(this.selectedMap)) { + featuredMapList = [this.selectedMap, ...featuredMaps]; + } + return html`
        +

        + ${translateText("map_categories.featured")} +

        +
        + ${featuredMapList.map((mapValue) => this.renderMapCard(mapValue))} +
        +
        `; + } + + render() { + return html` +
        +
        +
        + + +
        +
        + ${this.showAllMaps ? this.renderAllMaps() : this.renderFeaturedMaps()} +
        +

        + ${translateText("map_categories.special")} +

        +
        + +
        +
        +
        + `; + } +} From 0cc58a8f5a13f62a36f91eea2ed9d2c39419a442 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Wed, 28 Jan 2026 08:54:01 +0900 Subject: [PATCH 101/109] fix: add validation for unknown flags in manifest.json (#3044) Resolves #3041 ## Description: - Add a test to ensure an error is thrown when manifest.json specifies a non-existent flag. - Fix the underlying issue by removing the invalid flag specification (see error below). ``` resources/maps/straitofgibraltar/manifest.json -> nations[0].flag "Rif" does not exist in resources/flags resources/maps/straitofgibraltar/manifest.json -> nations[5].flag "Shilha" does not exist in resources/flags resources/maps/straitofgibraltar/manifest.json -> nations[6].flag "Andalusia" does not exist in resources/flags resources/maps/italia/manifest.json -> nations[0].flag "custom:Kingdom of the Two Sicilies" does not exist in resources/flags resources/maps/italia/manifest.json -> nations[3].flag "custom:Tuscany" does not exist in resources/flags resources/maps/italia/manifest.json -> nations[5].flag "custom:Modena" does not exist in resources/flags resources/maps/italia/manifest.json -> nations[6].flag "custom:Parma" does not exist in resources/flags resources/maps/italia/manifest.json -> nations[8].flag "custom:Kingdom of Sardinia" does not exist in resources/flags resources/maps/italia/manifest.json -> nations[11].flag "custom:Ottoman Empire2" does not exist in resources/flags resources/maps/britannia/manifest.json -> nations[19].flag "gb-nir" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[0].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[1].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[2].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[4].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[5].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[6].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[7].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[8].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[9].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[10].flag "quebec" does not exist in resources/flags resources/maps/montreal/manifest.json -> nations[11].flag "quebec" does not exist in resources/flags ``` ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: aotumuri --- map-generator/assets/maps/britannia/info.json | 3 +- map-generator/assets/maps/italia/info.json | 18 ++---- map-generator/assets/maps/montreal/info.json | 22 +++---- .../assets/maps/straitofgibraltar/info.json | 9 +-- resources/maps/britannia/manifest.json | 1 - resources/maps/italia/manifest.json | 6 -- resources/maps/montreal/manifest.json | 22 +++---- .../maps/straitofgibraltar/manifest.json | 3 - tests/MapManifestFlags.test.ts | 61 +++++++++++++++++++ 9 files changed, 93 insertions(+), 52 deletions(-) create mode 100644 tests/MapManifestFlags.test.ts diff --git a/map-generator/assets/maps/britannia/info.json b/map-generator/assets/maps/britannia/info.json index b72c54cbb..5d6ddd2b4 100644 --- a/map-generator/assets/maps/britannia/info.json +++ b/map-generator/assets/maps/britannia/info.json @@ -98,8 +98,7 @@ }, { "coordinates": [404, 1146], - "name": "Fermanagh", - "flag": "gb-nir" + "name": "Fermanagh" } ] } diff --git a/map-generator/assets/maps/italia/info.json b/map-generator/assets/maps/italia/info.json index 99465ddda..ea87c2146 100644 --- a/map-generator/assets/maps/italia/info.json +++ b/map-generator/assets/maps/italia/info.json @@ -3,8 +3,7 @@ "nations": [ { "coordinates": [1038, 993], - "name": "Kingdom of the Two Sicilies", - "flag": "custom:Kingdom of the Two Sicilies" + "name": "Kingdom of the Two Sicilies" }, { "coordinates": [370, 1137], @@ -18,8 +17,7 @@ }, { "coordinates": [625, 534], - "name": "Tuscany", - "flag": "custom:Tuscany" + "name": "Tuscany" }, { "coordinates": [595, 190], @@ -28,13 +26,11 @@ }, { "coordinates": [469, 386], - "name": "Modena", - "flag": "custom:Modena" + "name": "Modena" }, { "coordinates": [391, 254], - "name": "Parma", - "flag": "custom:Parma" + "name": "Parma" }, { "coordinates": [361, 68], @@ -43,8 +39,7 @@ }, { "coordinates": [278, 774], - "name": "Kingdom of Sardinia", - "flag": "custom:Kingdom of Sardinia" + "name": "Kingdom of Sardinia" }, { "coordinates": [29, 266], @@ -58,8 +53,7 @@ }, { "coordinates": [1238, 349], - "name": "Ottoman Empire", - "flag": "custom:Ottoman Empire2" + "name": "Ottoman Empire" } ] } diff --git a/map-generator/assets/maps/montreal/info.json b/map-generator/assets/maps/montreal/info.json index 0cb6c3e75..85d3756a6 100644 --- a/map-generator/assets/maps/montreal/info.json +++ b/map-generator/assets/maps/montreal/info.json @@ -3,17 +3,17 @@ "nations": [ { "coordinates": [800, 430], - "flag": "quebec", + "flag": "Quebec", "name": "Laval" }, { "coordinates": [1110, 930], - "flag": "quebec", + "flag": "Quebec", "name": "Royal Mount park" }, { "coordinates": [1220, 1360], - "flag": "quebec", + "flag": "Quebec", "name": "Hochelaga Archipelago" }, { @@ -23,42 +23,42 @@ }, { "coordinates": [1400, 1000], - "flag": "quebec", + "flag": "Quebec", "name": "Saint-Lambert" }, { "coordinates": [500, 130], - "flag": "quebec", + "flag": "Quebec", "name": "Blainville" }, { "coordinates": [350, 650], - "flag": "quebec", + "flag": "Quebec", "name": "Saint-Eustache" }, { "coordinates": [200, 1350], - "flag": "quebec", + "flag": "Quebec", "name": "Perrot Island" }, { "coordinates": [25, 950], - "flag": "quebec", + "flag": "Quebec", "name": "Kanesatake Lands" }, { "coordinates": [50, 450], - "flag": "quebec", + "flag": "Quebec", "name": "Mirabel" }, { "coordinates": [650, 1450], - "flag": "quebec", + "flag": "Quebec", "name": "Chateauguay" }, { "coordinates": [1330, 300], - "flag": "quebec", + "flag": "Quebec", "name": "Pointe-aux-Trembles" } ] diff --git a/map-generator/assets/maps/straitofgibraltar/info.json b/map-generator/assets/maps/straitofgibraltar/info.json index edc797671..1a826d7e7 100644 --- a/map-generator/assets/maps/straitofgibraltar/info.json +++ b/map-generator/assets/maps/straitofgibraltar/info.json @@ -3,8 +3,7 @@ "nations": [ { "coordinates": [1941, 1031], - "name": "Rif", - "flag": "Rif" + "name": "Rif" }, { "coordinates": [2733, 1190], @@ -28,13 +27,11 @@ }, { "coordinates": [1271, 1393], - "name": "Shilha", - "flag": "Shilha" + "name": "Shilha" }, { "coordinates": [1555, 258], - "name": "Andalusia", - "flag": "Andalusia" + "name": "Andalusia" } ] } diff --git a/resources/maps/britannia/manifest.json b/resources/maps/britannia/manifest.json index 7a0db2a33..415dac416 100644 --- a/resources/maps/britannia/manifest.json +++ b/resources/maps/britannia/manifest.json @@ -113,7 +113,6 @@ }, { "coordinates": [404, 1146], - "flag": "gb-nir", "name": "Fermanagh" } ] diff --git a/resources/maps/italia/manifest.json b/resources/maps/italia/manifest.json index d8242ba2b..ed721cb36 100644 --- a/resources/maps/italia/manifest.json +++ b/resources/maps/italia/manifest.json @@ -18,7 +18,6 @@ "nations": [ { "coordinates": [1038, 993], - "flag": "custom:Kingdom of the Two Sicilies", "name": "Kingdom of the Two Sicilies" }, { @@ -33,7 +32,6 @@ }, { "coordinates": [625, 534], - "flag": "custom:Tuscany", "name": "Tuscany" }, { @@ -43,12 +41,10 @@ }, { "coordinates": [469, 386], - "flag": "custom:Modena", "name": "Modena" }, { "coordinates": [391, 254], - "flag": "custom:Parma", "name": "Parma" }, { @@ -58,7 +54,6 @@ }, { "coordinates": [278, 774], - "flag": "custom:Kingdom of Sardinia", "name": "Kingdom of Sardinia" }, { @@ -73,7 +68,6 @@ }, { "coordinates": [1238, 349], - "flag": "custom:Ottoman Empire2", "name": "Ottoman Empire" } ] diff --git a/resources/maps/montreal/manifest.json b/resources/maps/montreal/manifest.json index 78ce3f637..4c17fa2f8 100644 --- a/resources/maps/montreal/manifest.json +++ b/resources/maps/montreal/manifest.json @@ -18,17 +18,17 @@ "nations": [ { "coordinates": [800, 430], - "flag": "quebec", + "flag": "Quebec", "name": "Laval" }, { "coordinates": [1110, 930], - "flag": "quebec", + "flag": "Quebec", "name": "Royal Mount park" }, { "coordinates": [1220, 1360], - "flag": "quebec", + "flag": "Quebec", "name": "Hochelaga Archipelago" }, { @@ -38,42 +38,42 @@ }, { "coordinates": [1400, 1000], - "flag": "quebec", + "flag": "Quebec", "name": "Saint-Lambert" }, { "coordinates": [500, 130], - "flag": "quebec", + "flag": "Quebec", "name": "Blainville" }, { "coordinates": [350, 650], - "flag": "quebec", + "flag": "Quebec", "name": "Saint-Eustache" }, { "coordinates": [200, 1350], - "flag": "quebec", + "flag": "Quebec", "name": "Perrot Island" }, { "coordinates": [25, 950], - "flag": "quebec", + "flag": "Quebec", "name": "Kanesatake Lands" }, { "coordinates": [50, 450], - "flag": "quebec", + "flag": "Quebec", "name": "Mirabel" }, { "coordinates": [650, 1450], - "flag": "quebec", + "flag": "Quebec", "name": "Chateauguay" }, { "coordinates": [1330, 300], - "flag": "quebec", + "flag": "Quebec", "name": "Pointe-aux-Trembles" } ] diff --git a/resources/maps/straitofgibraltar/manifest.json b/resources/maps/straitofgibraltar/manifest.json index 930ef92a5..790ecf015 100644 --- a/resources/maps/straitofgibraltar/manifest.json +++ b/resources/maps/straitofgibraltar/manifest.json @@ -18,7 +18,6 @@ "nations": [ { "coordinates": [1941, 1031], - "flag": "Rif", "name": "Rif" }, { @@ -43,12 +42,10 @@ }, { "coordinates": [1271, 1393], - "flag": "Shilha", "name": "Shilha" }, { "coordinates": [1555, 258], - "flag": "Andalusia", "name": "Andalusia" } ] diff --git a/tests/MapManifestFlags.test.ts b/tests/MapManifestFlags.test.ts new file mode 100644 index 000000000..dee46bf7d --- /dev/null +++ b/tests/MapManifestFlags.test.ts @@ -0,0 +1,61 @@ +import fs from "fs"; +import { globSync } from "glob"; +import path from "path"; + +type Nation = { + flag?: string; +}; + +type Manifest = { + nations?: Nation[]; +}; + +describe("Map manifests: nation flags exist", () => { + test("All nations' flags reference existing SVG files", () => { + const manifestPaths = globSync("resources/maps/**/manifest.json"); + + expect(manifestPaths.length).toBeGreaterThan(0); + + const flagDir = path.join(__dirname, "../resources/flags"); + const errors: string[] = []; + + for (const manifestPath of manifestPaths) { + try { + const raw = fs.readFileSync(manifestPath, "utf8"); + const manifest = JSON.parse(raw) as Manifest; + + (manifest.nations ?? []).forEach((nation, idx) => { + const flag = nation?.flag; + if (flag === undefined || flag === null) return; + if (typeof flag !== "string") { + errors.push( + `${manifestPath} -> nations[${idx}].flag is not a string`, + ); + return; + } + + if (flag.trim().length === 0) return; + if (flag.startsWith("!")) return; + + const svgFile = flag.endsWith(".svg") ? flag : `${flag}.svg`; + const flagPath = path.join(flagDir, svgFile); + if (!fs.existsSync(flagPath)) { + errors.push( + `${manifestPath} -> nations[${idx}].flag "${flag}" does not exist in resources/flags`, + ); + } + }); + } catch (err) { + errors.push( + `Failed to parse ${manifestPath}: ${(err as Error).message}`, + ); + } + } + + if (errors.length > 0) { + throw new Error( + "Map manifest flag file violations:\n" + errors.join("\n"), + ); + } + }); +}); From cb3128f390998a0ed095ebfd63be25aafa7e7847 Mon Sep 17 00:00:00 2001 From: Evan Date: Wed, 28 Jan 2026 11:29:27 -0800 Subject: [PATCH 102/109] Better CrazyGames integration (#3055) ## Description: Better integration with CrazyGames: * Don't show login because accounts have not been integrated with CrazyGames yet * Integrate CG invite links & usernames * Refactor match making logic to Matchmaking.ts * Allow periods to support crazy game usernames * Create a no-crazygames class that disabled elements when on crazygames ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/client/CrazyGamesSDK.ts | 161 +++++++++++++++++- src/client/HostLobbyModal.ts | 10 +- src/client/LangSelector.ts | 1 + src/client/Main.ts | 111 ++++++------ src/client/Matchmaking.ts | 73 +++++++- src/client/PatternInput.ts | 5 + src/client/SinglePlayerModal.ts | 6 + src/client/UsernameInput.ts | 35 ++-- src/client/components/CopyButton.ts | 11 +- src/client/components/DesktopNavBar.ts | 6 +- src/client/components/MobileNavBar.ts | 4 +- src/client/components/PatternButton.ts | 2 +- src/client/components/PlayPage.ts | 27 +-- .../graphics/layers/GameRightSidebar.ts | 15 +- src/client/graphics/layers/SettingsModal.ts | 11 +- src/client/graphics/layers/UnitDisplay.ts | 2 +- src/client/graphics/layers/WinModal.ts | 1 + src/core/Schemas.ts | 2 +- 18 files changed, 358 insertions(+), 125 deletions(-) diff --git a/src/client/CrazyGamesSDK.ts b/src/client/CrazyGamesSDK.ts index b00cfe7de..1933074ec 100644 --- a/src/client/CrazyGamesSDK.ts +++ b/src/client/CrazyGamesSDK.ts @@ -3,6 +3,28 @@ declare global { CrazyGames?: { SDK: { init: () => Promise; + user: { + getUser(): Promise<{ + username: string; + } | null>; + addAuthListener: ( + listener: ( + user: { + username: string; + } | null, + ) => void, + ) => void; + }; + ad: { + requestAd: ( + adType: string, + callbacks: { + adStarted: () => void; + adFinished: () => void; + adError: (error: any) => void; + }, + ) => void; + }; game: { gameplayStart: () => Promise; gameplayStop: () => Promise; @@ -14,7 +36,9 @@ declare global { [key: string]: string | number; }) => string; hideInviteButton: () => void; + inviteLink: (params: { [key: string]: string | number }) => string; getInviteParam: (paramName: string) => string | null; + isInstantMultiplayer?: boolean; }; }; }; @@ -24,6 +48,24 @@ declare global { export class CrazyGamesSDK { private initialized = false; private isGameplayActive = false; + private readyPromise: Promise; + private resolveReady!: () => void; + + constructor() { + this.readyPromise = new Promise((resolve) => { + this.resolveReady = resolve; + }); + } + + async ready(): Promise { + const timeout = new Promise((resolve) => { + setTimeout(() => resolve(false), 3000); + }); + + const ready = this.readyPromise.then(() => true); + + return Promise.race([ready, timeout]); + } isOnCrazyGames(): boolean { try { @@ -34,9 +76,17 @@ export class CrazyGamesSDK { } return false; } catch (e) { + console.log("[CrazyGames]: ", e); // If we get a cross-origin error, we're definitely iframed // Check our own referrer as fallback - return document.referrer.includes("crazygames"); + const isCrazyGames = document.referrer.includes("crazygames"); + console.log("[CrazyGames], contains referrer: ", isCrazyGames); + if (isCrazyGames) { + return true; + } + + // Fallback: on safari private we can't get referrer, so just assume we are in crazygames if in iframe + return window.self !== window.top; } } @@ -70,12 +120,63 @@ export class CrazyGamesSDK { try { await window.CrazyGames.SDK.init(); this.initialized = true; + this.resolveReady(); console.log("CrazyGames SDK initialized"); } catch (error) { console.error("Failed to initialize CrazyGames SDK:", error); } } + async getUsername(): Promise { + const isReady = await this.ready(); + if (!isReady) { + return null; + } + try { + return (await window.CrazyGames!.SDK.user.getUser())?.username ?? null; + } catch (e) { + console.log("error getting CrazyGames username: ", e); + return null; + } + } + + async addAuthListener( + listener: ( + user: { + username: string; + } | null, + ) => void, + ): Promise { + if (!(await this.ready())) { + return; + } + + try { + console.log("registering CrazyGames auth listener"); + window.CrazyGames!.SDK.user.addAuthListener(listener); + } catch (error) { + console.error("Failed to add auth listener:", error); + } + } + + async isInstantMultiplayer(): Promise { + const isReady = await this.ready(); + if (!isReady) { + return false; + } + const gameId = await this.getInviteGameId(); + if (gameId !== null) { + // Game id exists, meaning we are joining the game, not hosting it. + return false; + } + try { + return window.CrazyGames!.SDK.game.isInstantMultiplayer ?? false; + } catch (e) { + console.log("Error getting instant multiplayer: ", e); + return false; + } + } + async gameplayStart(): Promise { if (!this.isReady()) { return; @@ -156,7 +257,6 @@ export class CrazyGamesSDK { if (!this.isReady()) { return null; } - try { const options: { gameId: string | number; @@ -165,6 +265,9 @@ export class CrazyGamesSDK { gameId, }; const link = window.CrazyGames!.SDK.game.showInviteButton(options); + // Store the game so we know that we are host. This way when player refreshes page, + // It won't show up as "joining" a game we created. + localStorage.setItem(gameId, "true"); console.log("CrazyGames: invite button shown, link:", link); return link; } catch (error) { @@ -186,20 +289,66 @@ export class CrazyGamesSDK { } } - getInviteGameId(): string | null { + createInviteLink(gameId: string): string | null { if (!this.isReady()) { + console.warn("CrazyGames SDK not ready, cannot create invite link"); return null; } try { - const value = window.CrazyGames!.SDK.game.getInviteParam("gameId"); - console.log(`CrazyGames: got invite gameId:`, value); - return value; + const link = window.CrazyGames!.SDK.game.inviteLink({ gameId }); + console.log("CrazyGames: created invite link:", link); + return link; + } catch (error) { + console.error("Failed to create invite link:", error); + return null; + } + } + + async getInviteGameId(): Promise { + if (!(await this.ready())) { + return null; + } + try { + const gameId = window.CrazyGames!.SDK.game.getInviteParam("gameId"); + if (gameId) { + console.log("[CrazyGames] found invite link", gameId); + // We already created this game, can't join a game we created. + return localStorage.getItem(gameId) === "true" ? null : gameId; + } + return null; } catch (error) { console.error(`Failed to get invite gameId:`, error); return null; } } + + requestMidgameAd(): Promise { + return new Promise((resolve) => { + if (!this.isReady()) { + resolve(); + return; + } + + try { + const callbacks = { + adFinished: () => { + console.log("End midgame ad"); + resolve(); + }, + adError: (error: any) => { + console.log("Error midgame ad", error); + resolve(); + }, + adStarted: () => console.log("Start midgame ad"), + }; + window.CrazyGames!.SDK.ad.requestAd("midgame", callbacks); + } catch (error) { + console.error("Failed to request midgame ad:", error); + resolve(); + } + }); + } } export const crazyGamesSDK = new CrazyGamesSDK(); diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 03e0a6ed0..cd49edf11 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -113,6 +113,12 @@ export class HostLobbyModal extends BaseModal { } private async buildLobbyUrl(): Promise { + if (crazyGamesSDK.isOnCrazyGames()) { + const link = crazyGamesSDK.createInviteLink(this.lobbyId); + if (link !== null) { + return link; + } + } const config = await getServerConfigFromClient(); return `${window.location.origin}/${config.workerPath(this.lobbyId)}/game/${this.lobbyId}?lobby&s=${encodeURIComponent(this.lobbyUrlSuffix)}`; } @@ -123,7 +129,9 @@ export class HostLobbyModal extends BaseModal { } private updateHistory(url: string): void { - history.replaceState(null, "", url); + if (!crazyGamesSDK.isOnCrazyGames()) { + history.replaceState(null, "", url); + } } render() { diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index 3097ea7a8..f80415d06 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -228,6 +228,7 @@ export class LangSelector extends LitElement { "stats-modal", "flag-input-modal", "flag-input", + "matchmaking-button", "token-login", ]; diff --git a/src/client/Main.ts b/src/client/Main.ts index 57644d6b8..2cfe82ad3 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -7,7 +7,7 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; import "./AccountModal"; -import { getUserMe, hasLinkedAccount } from "./Api"; +import { getUserMe } from "./Api"; import { userAuth } from "./Auth"; import { joinLobby } from "./ClientGameRunner"; import { fetchCosmetics } from "./Cosmetics"; @@ -43,7 +43,7 @@ import { } from "./Transport"; import { UserSettingModal } from "./UserSettingModal"; import "./UsernameInput"; -import { UsernameInput } from "./UsernameInput"; +import { genAnonUsername, UsernameInput } from "./UsernameInput"; import { getDiscordAvatarUrl, incrementGamesPlayed, @@ -209,6 +209,7 @@ class Client { private usernameInput: UsernameInput | null = null; private flagInput: FlagInput | null = null; + private hostModal: HostPrivateLobbyModal; private joinModal: JoinPrivateLobbyModal; private publicLobby: PublicLobby; private userSettings: UserSettings = new UserSettings(); @@ -426,56 +427,14 @@ class Client { ) { console.warn("Matchmaking modal element not found"); } - const matchmakingButton = document.getElementById("matchmaking-button"); - const matchmakingButtonLoggedOut = document.getElementById( - "matchmaking-button-logged-out", - ); - - const updateMatchmakingButton = (loggedIn: boolean) => { - if (!loggedIn) { - matchmakingButton?.classList.add("hidden"); - matchmakingButtonLoggedOut?.classList.remove("hidden"); - } else { - matchmakingButton?.classList.remove("hidden"); - matchmakingButtonLoggedOut?.classList.add("hidden"); - } - }; - - if (matchmakingButton) { - matchmakingButton.addEventListener("click", () => { - if (this.usernameInput?.isValid()) { - window.showPage?.("page-matchmaking"); - this.publicLobby.leaveLobby(); - } else { - window.dispatchEvent( - new CustomEvent("show-message", { - detail: { - message: this.usernameInput?.validationError, - color: "red", - duration: 3000, - }, - }), - ); - } - }); - } - - if (matchmakingButtonLoggedOut) { - matchmakingButtonLoggedOut.addEventListener("click", () => { - window.showPage?.("page-account"); - }); - } const onUserMe = async (userMeResponse: UserMeResponse | false) => { - // Check if user has actual authentication (discord or email), not just a publicId - const isLinked: boolean = hasLinkedAccount(userMeResponse); - updateMatchmakingButton(isLinked); updateAccountNavButton(userMeResponse); - const adsEnabled = + const hasLinkedAccount = !crazyGamesSDK.isOnCrazyGames() && - ((userMeResponse || null)?.player?.flares?.length ?? 0) === 0; - console.log("ads enabled: ", adsEnabled); - window.adsEnabled = adsEnabled; + ((userMeResponse || null)?.player?.flares?.length ?? 0) > 0; + console.log("ads enabled: ", hasLinkedAccount); + window.adsEnabled = !hasLinkedAccount && !crazyGamesSDK.isOnCrazyGames(); document.dispatchEvent( new CustomEvent("userMeResponse", { detail: userMeResponse, @@ -516,10 +475,10 @@ class Client { } }); - const hostModal = document.querySelector( + this.hostModal = document.querySelector( "host-lobby-modal", ) as HostPrivateLobbyModal; - if (!hostModal || !(hostModal instanceof HostPrivateLobbyModal)) { + if (!this.hostModal || !(this.hostModal instanceof HostPrivateLobbyModal)) { console.warn("Host private lobby modal element not found"); } const hostLobbyButton = document.getElementById("host-lobby-button"); @@ -575,7 +534,11 @@ class Client { } // Attempt to join lobby - this.handleUrl(); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => this.handleUrl()); + } else { + this.handleUrl(); + } const onHashUpdate = () => { // Reset the UI to its initial state @@ -647,17 +610,36 @@ class Client { }); } - private handleUrl() { + private async handleUrl() { + // Wait for modal custom elements to be defined + await Promise.all([ + customElements.whenDefined("join-private-lobby-modal"), + customElements.whenDefined("host-lobby-modal"), + ]); + // Check if CrazyGames SDK is enabled first (no hash needed in CrazyGames) if (crazyGamesSDK.isOnCrazyGames()) { - const lobbyId = crazyGamesSDK.getInviteGameId(); + const lobbyId = await crazyGamesSDK.getInviteGameId(); + console.log("got game id", lobbyId); if (lobbyId && GAME_ID_REGEX.test(lobbyId)) { + console.log("game parsed successfully"); + // Wait 2 seconds to ensure all elements are actually loaded, + // On low end-chromebooks the join modal was not registered in time. + await new Promise((resolve) => setTimeout(resolve, 2000)); window.showPage?.("page-join-private-lobby"); this.joinModal?.open(lobbyId); console.log(`CrazyGames: joining lobby ${lobbyId} from invite param`); return; } } + crazyGamesSDK.isInstantMultiplayer().then((isInstant) => { + if (isInstant) { + console.log( + `CrazyGames: joining instant multiplayer lobby from CrazyGames`, + ); + this.hostModal.open(); + } + }); const strip = () => history.replaceState( @@ -780,7 +762,8 @@ class Client { : this.flagInput.getCurrentFlag(), }, turnstileToken: await this.getTurnstileToken(lobby), - playerName: this.usernameInput?.getCurrentUsername() ?? "", + playerName: + this.usernameInput?.getCurrentUsername() ?? genAnonUsername(), clientID: lobby.clientID, gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, gameRecord: lobby.gameRecord, @@ -926,7 +909,8 @@ class Client { return null; } - if (this.turnstileTokenPromise === null) { + // Always request a new token on crazygames. + if (this.turnstileTokenPromise === null || crazyGamesSDK.isOnCrazyGames()) { console.log("No prefetched turnstile token, getting new token"); return (await getTurnstileToken())?.token ?? null; } @@ -942,6 +926,7 @@ class Client { const tokenTTL = 3 * 60 * 1000; if (Date.now() < token.createdAt + tokenTTL) { console.log("Prefetched turnstile token is valid"); + return token.token; } else { console.log("Turnstile token expired, getting new token"); @@ -950,11 +935,27 @@ class Client { } } +// Hide elements with no-crazygames class if on CrazyGames +const hideCrazyGamesElements = () => { + if (crazyGamesSDK.isOnCrazyGames()) { + document.querySelectorAll(".no-crazygames").forEach((el) => { + (el as HTMLElement).style.display = "none"; + }); + } +}; + // Initialize the client when the DOM is loaded const bootstrap = () => { initLayout(); new Client().initialize(); initNavigation(); + + // Hide elements immediately + hideCrazyGamesElements(); + + // Also hide elements after a short delay to catch late-rendered components + setTimeout(hideCrazyGamesElements, 100); + setTimeout(hideCrazyGamesElements, 500); }; if (document.readyState === "loading") { diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index 73307d15d..0c4b634f0 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -3,7 +3,7 @@ import { customElement, query, state } from "lit/decorators.js"; import { UserMeResponse } from "../core/ApiSchemas"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { generateID } from "../core/Util"; -import { getUserMe } from "./Api"; +import { getUserMe, hasLinkedAccount } from "./Api"; import { getPlayToken } from "./Auth"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; @@ -240,6 +240,7 @@ export class MatchmakingModal extends BaseModal { @customElement("matchmaking-button") export class MatchmakingButton extends LitElement { @query("matchmaking-modal") private matchmakingModal?: MatchmakingModal; + @state() private isLoggedIn = false; constructor() { super(); @@ -247,6 +248,14 @@ export class MatchmakingButton extends LitElement { async connectedCallback() { super.connectedCallback(); + // Listen for user authentication changes + document.addEventListener("userMeResponse", (event: Event) => { + const customEvent = event as CustomEvent; + if (customEvent.detail) { + const userMeResponse = customEvent.detail as UserMeResponse | false; + this.isLoggedIn = hasLinkedAccount(userMeResponse); + } + }); } createRenderRoot() { @@ -254,19 +263,65 @@ export class MatchmakingButton extends LitElement { } render() { + if (this.isLoggedIn) { + return html` + + + + `; + } + return html` -
        - -
        + + `; } + private handleLoggedInClick() { + const usernameInput = document.querySelector("username-input") as any; + const publicLobby = document.querySelector("public-lobby") as any; + + if (usernameInput?.isValid()) { + this.open(); + publicLobby?.leaveLobby(); + } else { + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message: usernameInput?.validationError, + color: "red", + duration: 3000, + }, + }), + ); + } + } + + private handleLoggedOutClick() { + window.showPage?.("page-account"); + } + private open() { this.matchmakingModal?.open(); } diff --git a/src/client/PatternInput.ts b/src/client/PatternInput.ts index 755c7c834..483bef75c 100644 --- a/src/client/PatternInput.ts +++ b/src/client/PatternInput.ts @@ -5,6 +5,7 @@ import { UserSettings } from "../core/game/UserSettings"; import { PlayerPattern } from "../core/Schemas"; import { renderPatternPreview } from "./components/PatternButton"; import { fetchCosmetics } from "./Cosmetics"; +import { crazyGamesSDK } from "./CrazyGamesSDK"; import { translateText } from "./Utils"; @customElement("pattern-input") @@ -73,6 +74,10 @@ export class PatternInput extends LitElement { } render() { + if (crazyGamesSDK.isOnCrazyGames()) { + return html``; + } + const isDefault = this.pattern === null && this.selectedColor === null; const showSelect = this.showSelectLabel && isDefault; const buttonTitle = translateText("territory_patterns.title"); diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index c34dfe268..440d1948a 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -27,6 +27,7 @@ import "./components/FluentSlider"; import "./components/Maps"; import { modalHeader } from "./components/ui/ModalHeader"; import { fetchCosmetics } from "./Cosmetics"; +import { crazyGamesSDK } from "./CrazyGamesSDK"; import { FlagInput } from "./FlagInput"; import { JoinLobbyEvent } from "./Main"; import { UsernameInput } from "./UsernameInput"; @@ -89,6 +90,9 @@ export class SinglePlayerModal extends BaseModal { }; private renderNotLoggedInBanner(): TemplateResult { + if (crazyGamesSDK.isOnCrazyGames()) { + return html``; + } return html`
        @@ -1057,6 +1061,8 @@ export class SinglePlayerModal extends BaseModal { const selectedColor = this.userSettings.getSelectedColor(); + await crazyGamesSDK.requestMidgameAd(); + this.dispatchEvent( new CustomEvent("join-lobby", { detail: { diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts index 20b2bb372..0434f4674 100644 --- a/src/client/UsernameInput.ts +++ b/src/client/UsernameInput.ts @@ -8,6 +8,7 @@ import { MIN_USERNAME_LENGTH, validateUsername, } from "../core/validations/username"; +import { crazyGamesSDK } from "./CrazyGamesSDK"; const usernameKey: string = "username"; @@ -39,8 +40,18 @@ export class UsernameInput extends LitElement { connectedCallback() { super.connectedCallback(); - const stored = this.getStoredUsername(); + const stored = this.getUsername(); this.parseAndSetUsername(stored); + crazyGamesSDK.getUsername().then((username) => { + this.parseAndSetUsername(username ?? genAnonUsername()); + this.requestUpdate(); + }); + crazyGamesSDK.addAuthListener((user) => { + if (user) { + this.parseAndSetUsername(user?.username); + } + this.requestUpdate(); + }); } private parseAndSetUsername(fullUsername: string) { @@ -52,6 +63,8 @@ export class UsernameInput extends LitElement { this.clanTag = ""; this.baseUsername = fullUsername; } + + this.validateAndStore(); } render() { @@ -161,7 +174,7 @@ export class UsernameInput extends LitElement { } } - private getStoredUsername(): string { + private getUsername(): string { const storedUsername = localStorage.getItem(usernameKey); if (storedUsername) { return storedUsername; @@ -176,20 +189,20 @@ export class UsernameInput extends LitElement { } private generateNewUsername(): string { - const newUsername = "Anon" + this.uuidToThreeDigits(); + const newUsername = genAnonUsername(); this.storeUsername(newUsername); return newUsername; } - private uuidToThreeDigits(): string { - const uuid = uuidv4(); - const cleanUuid = uuid.replace(/-/g, "").toLowerCase(); - const decimal = BigInt(`0x${cleanUuid}`); - const threeDigits = decimal % 1000n; - return threeDigits.toString().padStart(3, "0"); - } - public isValid(): boolean { return this._isValid; } } + +export function genAnonUsername(): string { + const uuid = uuidv4(); + const cleanUuid = uuid.replace(/-/g, "").toLowerCase(); + const decimal = BigInt(`0x${cleanUuid}`); + const threeDigits = decimal % 1000n; + return "Anon" + threeDigits.toString().padStart(3, "0"); +} diff --git a/src/client/components/CopyButton.ts b/src/client/components/CopyButton.ts index 13742cc58..5a7b7fbec 100644 --- a/src/client/components/CopyButton.ts +++ b/src/client/components/CopyButton.ts @@ -2,6 +2,7 @@ import { LitElement, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { getServerConfigFromClient } from "../../core/configuration/ConfigLoader"; import { UserSettings } from "../../core/game/UserSettings"; +import { crazyGamesSDK } from "../CrazyGamesSDK"; import { copyToClipboard, translateText } from "../Utils"; @customElement("copy-button") @@ -73,15 +74,21 @@ export class CopyButton extends LitElement { return url; } - private async resolveCopyText(): Promise { + private async resolveCopyText(): Promise { if (this.copyText) return this.copyText; + if (crazyGamesSDK.isOnCrazyGames()) { + return crazyGamesSDK.createInviteLink(this.lobbyId); + } if (!this.lobbyId) return ""; return await this.buildCopyUrl(); } private async handleCopy() { const text = await this.resolveCopyText(); - if (!text) return; + if (!text) { + alert("Error copying game id"); + return; + } await copyToClipboard( text, () => (this.copySuccess = true), diff --git a/src/client/components/DesktopNavBar.ts b/src/client/components/DesktopNavBar.ts index 74ea1b498..b6b67b435 100644 --- a/src/client/components/DesktopNavBar.ts +++ b/src/client/components/DesktopNavBar.ts @@ -105,7 +105,7 @@ export class DesktopNavBar extends LitElement { data-i18n="main.news" > @@ -127,14 +127,14 @@ export class DesktopNavBar extends LitElement { @@ -135,7 +135,7 @@ export class MobileNavBar extends LitElement { data-i18n="main.settings" > diff --git a/src/client/components/PatternButton.ts b/src/client/components/PatternButton.ts index 037dc4743..6d6b94b18 100644 --- a/src/client/components/PatternButton.ts +++ b/src/client/components/PatternButton.ts @@ -72,7 +72,7 @@ export class PatternButton extends LitElement { return html`
        - - - - - +
        diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index e86317a7c..2ee9ee276 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -105,10 +105,15 @@ export class GameRightSidebar extends LitElement implements Layer { private onPauseButtonClick() { this.isPaused = !this.isPaused; + if (this.isPaused) { + crazyGamesSDK.gameplayStop(); + } else { + crazyGamesSDK.gameplayStart(); + } this.eventBus.emit(new PauseGameIntentEvent(this.isPaused)); } - private onExitButtonClick() { + private async onExitButtonClick() { const isAlive = this.game.myPlayer()?.isAlive(); if (isAlive) { const isConfirmed = confirm( @@ -116,10 +121,10 @@ export class GameRightSidebar extends LitElement implements Layer { ); if (!isConfirmed) return; } - crazyGamesSDK.gameplayStop().then(() => { - // redirect to the home page - window.location.href = "/"; - }); + await crazyGamesSDK.requestMidgameAd(); + await crazyGamesSDK.gameplayStop(); + // redirect to the home page + window.location.href = "/"; } private onSettingsButtonClick() { diff --git a/src/client/graphics/layers/SettingsModal.ts b/src/client/graphics/layers/SettingsModal.ts index 92d8f1fe4..0a5b184bf 100644 --- a/src/client/graphics/layers/SettingsModal.ts +++ b/src/client/graphics/layers/SettingsModal.ts @@ -1,9 +1,10 @@ import { html, LitElement } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; +import { crazyGamesSDK } from "src/client/CrazyGamesSDK"; +import { PauseGameIntentEvent } from "src/client/Transport"; import { EventBus } from "../../../core/EventBus"; import { UserSettings } from "../../../core/game/UserSettings"; import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler"; -import { PauseGameIntentEvent } from "../../Transport"; import { translateText } from "../../Utils"; import SoundManager from "../../sound/SoundManager"; import { Layer } from "./Layer"; @@ -105,8 +106,14 @@ export class SettingsModal extends LitElement implements Layer { } private pauseGame(pause: boolean) { - if (this.shouldPause && !this.wasPausedWhenOpened) + if (this.shouldPause && !this.wasPausedWhenOpened) { + if (pause) { + crazyGamesSDK.gameplayStop(); + } else { + crazyGamesSDK.gameplayStart(); + } this.eventBus.emit(new PauseGameIntentEvent(pause)); + } } private onTerrainButtonClick() { diff --git a/src/client/graphics/layers/UnitDisplay.ts b/src/client/graphics/layers/UnitDisplay.ts index 4b9a5d60c..c08bcb183 100644 --- a/src/client/graphics/layers/UnitDisplay.ts +++ b/src/client/graphics/layers/UnitDisplay.ts @@ -130,7 +130,7 @@ export class UnitDisplay extends LitElement implements Layer { return html` `; diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 0b93aa9fc..fd02d3b84 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -114,7 +114,7 @@ export class TransportShipExecution implements Execution { mg.displayIncomingUnit( this.boat.id(), // TODO TranslateText - `Naval invasion incoming from ${this.attacker.displayName()}`, + `Naval invasion incoming from ${this.attacker.displayName()} (${renderTroops(this.boat.troops())})`, MessageType.NAVAL_INVASION_INBOUND, this.target.id(), ); From 936b689769c58421416fc9ab730717fc0552f1ba Mon Sep 17 00:00:00 2001 From: evanpelle Date: Wed, 28 Jan 2026 13:06:43 -0800 Subject: [PATCH 108/109] bugfix: duplicate matchmaking modals causing elo to display unknown --- index.html | 5 ---- src/client/Matchmaking.ts | 58 ++++++++++++++++++--------------------- 2 files changed, 27 insertions(+), 36 deletions(-) diff --git a/index.html b/index.html index b1f825ae2..f5861d94e 100644 --- a/index.html +++ b/index.html @@ -189,11 +189,6 @@ inline class="hidden w-full h-full page-content" > - - - ${translateText("matchmaking_button.play_ranked")} - - - ${translateText("matchmaking_button.description")} - - + + ${translateText("matchmaking_button.play_ranked")} + + + ${translateText("matchmaking_button.description")} + + + ` + : html` + + `; - - `; - } - - return html` - - - - `; + return html` ${button} `; } private handleLoggedInClick() { From 6d3d2738893158cc1cf2a017d1662c4a71758b6b Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 28 Jan 2026 23:56:53 +0000 Subject: [PATCH 109/109] Shop new tag (#3057) ## Description: adds a "tag" of NEW to the shop which pulses image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- resources/lang/en.json | 1 + src/client/components/DesktopNavBar.ts | 16 +++++++++++----- src/client/components/MobileNavBar.ts | 16 +++++++++++----- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 56c8941cd..80ce52fcd 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -46,6 +46,7 @@ "play": "Play", "news": "News", "store": "Store", + "store_new_badge": "NEW", "settings": "Settings", "keys": "Keys", "stats": "Stats", diff --git a/src/client/components/DesktopNavBar.ts b/src/client/components/DesktopNavBar.ts index 74ea1b498..18f1e439d 100644 --- a/src/client/components/DesktopNavBar.ts +++ b/src/client/components/DesktopNavBar.ts @@ -104,11 +104,17 @@ export class DesktopNavBar extends LitElement { data-page="page-news" data-i18n="main.news" > - +
        + + +
        - +
        + + +
    Commits

    m2SK>Po%X)D)F`sF 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 literal 81586 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%0DC@ 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 diff --git a/resources/images/helpModal/leaderboard2.webp b/resources/images/helpModal/leaderboard2.webp index 2afde336939ea8896a63c6bbe3e3818c90e73ad0..64599293bbe070ea67586fa93b5cfd7be64a22ac 100644 GIT binary patch literal 7628 zcmV;-9W&xmNk&G*9RL7VMM6+kP&gpC9RL6jiU6GfDu@9z0X~sNol7O7qM@ZTnV7H= z31@Ene3=^6VGn2n-%sn%JI?RACB46Wd*iR|e~$h1|5W^y{^#hw<9{ao=lf6Wuc~H2 z`Vjt?&`;#QTz^6R_xDfJpYWgFKO{XJ_yhh2s2Aja$Unt@YW$7$57>jJf2i~Y_21?{ z>Hn|(OZnOVHRuQA|J47{{mlJ=~C;Z>PKe9jFPyhb!U%0>j!uhTs93h(dSx(cA;V6vG zg1ooN%66P@N)EFrJ@Pk%+mAlb$f*d3iO}R{n0Oxkf-_w(bjVlx&=i!>VE^dk)R=Dm zsF$i?Vr|!O=8FgZQv~?c0sk}9Z5~=Qnv)IR)e`kgEKJT{zIxLjmQ_woz{~nEI9a#3 zr{U>Jf%*VAjlFwlL|v!?;0x(Hr)}fo-0jCl0o3V+i-9Gc#^0pEI`J#+_jRXqdXAu| zPgIg_-SY8F7()z_+GcQ_IdKQxF8)Vqr6LRJK+vDJ{inm}k}yZtyV4yw9Z{((++efSPwlAS614i+t3T(aP>04juB?v zz~}H~EC$(BL!?N_@EZH|ft6K*}JBn)` z#VSWc6(vN)jPS$dXg?>b?IHph$lSAj`k`?&{yd^N-T`|pt*P(d8QQeCQEk}Db)#l> z6#jmB-Yb=379gd8;MxEg}hp6&#D3N^(@_&%* zL+e3oSdu41g24$Q0(^*?VpB$k>B01BhXl&EaF&QI_G8HgN?cb-{y`PKC;d)7a3U7uU z^#ER;ff^M@z|kZmL8={I2Vfvsj*g^1fw!|CRWFKEpg)Z8poSA6obj*4RtxK->WSXb z<7*hT(#OHAV+#|f_MG7a-pM)f{(f`Kvz+?6-a&_SGEq)aOTq4R`-oj~LSMN9mT_Uq z0ydJx61FLqA!6X7H5h<;7OL?YxXj9nlQ@CwOAE#KmYLXA5Hu2Y`T2UUjdxqo^L_+y6LO`t)!0mIJE{i*p`=buu4<9~oc^NlXcEcHfNL_jdGDeScE0U85IWmdau16Wspjqw1j zqfHU+x|e^JgadlcF;UF7hZbHqQjqI`4qlZ+5oxm(DBxDDf{A409GM+$R}cBYIW}9L=YMlF4I&Q>1bu%BvK)a_^4U8(+fZ+-Eyf<%92%8i zI=0y0Bgkl2T!eCL$>Ib{FRtUFclf`co*zBD-0+k?iZ|9>V%X^7PqeLX_eulfA7gvB z`?5EJV#;2DB2^k)R$_{PRp#6k__}e+ew&!7R1C}j2L|y(HD=3p#-6q)l z0w5jbu9#U;_Ol4D-i}YGc3_4htLVE4+s7PC`5Ba0)6S7^W+j3uqRevjt|^7#r`6cC z_lD&7_<=A=^JiJFk)c<0=i|uIX#~(;w2<|a%BMf{TPc)k$Wq;rr;e&#u|K?GkmvZW z;2F%LU9oXBTb!5mkWyncTvT-5+Vtu0n!zhZ(0_xj#6(qow6@|KbM?^;+LmX)FG{7r z68fBRLUKWERG9sewMO~NLlTgwAkFe@jTm|af+VjS$tLMO&dwSP`k-~Am?Tc&@gxK3 zDVtoZ)t|NP6X=L$T^8NN$bcYqMN(x{aFRe|1l(0yP z#gMm9f#p`xG5GK2T-Bfb`g15Ovc-AFGO5K=?Sc~3XnTCr8{V_g zqNpv_y`Rx4Pqq~?G3R~t!ZZw90xI{E{2LjB*y)qSo0|Qhus7nDCb9L+J3hoH)b#Ks zEB{OPG_|N8xQnGA9YHF8F1#J6AQLjQt4QlWpqN*#eba>- z)b)9Nuk#|+Id1@2q-(_?(6YmRNTVxG8y5ad8YNc#LcK z_dKTTBR^n|)hv=;S3m0+{d+S{xu@O^Q#vGR)@j#ik;0pO-Jl@lNhfu*$755YA%M6C zQo<;l(#moYR{qAfCfowm4n;XO%$B_N?no|K9;%CqWZ9lD>+a(>8bFr5{xMVRxOSPH zDXqUG2V+U?08|U}u{Vs_9#o~0KTfj~fItK%wSnwJ{Ff~|;N2ZnD}t+QTMd9VUNpzw z-rq%NBnV>(&)@hnx7}3q6zTeW2-w>+f0N!XH+Dkn)?gC;EtR20&?bU(QY%pn6CK|9gw z)N7M^be|OYA|l&b0Qv^wBasjAakYKb{;-`{eLWxH_R7*J3z6>1@w!_z@$Ue3yM_2u z*Xy+?2!c;@l?BKlo(aeofeaz|&RWy6cS)3hKLxCo(X{J6h(%n{OoW(1ps zSU zBXxnFkrlHDmvGw2NZLyEj_ZnoZXSZsC7Hj5E8GJtj~X3gnkw|!)#ZX9@D5M$dyZC0 z_y?{XT_@sKj3r%4=3HPD=yIWi36dPM4EDFTjY;H^`@s7TGsWcIpx&~aerurcLlq*( zl7B@k@uJ^ZTFKKFzj_Z2Ckcc;LzDVa23ND;)AO>zOmufbxRz=PI9x*%WUwEVE4d6# zO0E{3P!-fGk0Y@7%hR|D(UbYkC-bs^p(+(o87N3!qnOK{0UEfBDzTezcY`%74`4No z&BafW;{CYA+=Uovf}1BkNjWBHPNgnb`F})jf=#|nzrt)}K8N#efAGy>;y*f7q; z0Q}sf+QA3K`2e{+p9of^?l3YVbr4#&T);9}c=8DB-yu@L_;cC1}Zc!;3Ry1@3 z9_<|NE;P0t-?rtPeEwNQ`V_zfPL;Uxtv46rA3kOYWy=*_!Gia6bGm554~UOB`^s~! zKu*nDq_1ZiR%HM6ECqL6bJ~D#ZF|*U@xOofT-*2UF=v3Qo`24WyR|n1nHnk^5JE?_ z?(iUc*-0Z8b+7C2r>>6y(Z`ZJwA~Hcc00pm*Y)>32c=6UOq!=1@k~TLHOi|MeS^lf zdQ|J&Ct~00ULa6KQZd}XTHhY1O(umLD3SQZ#vhk%4#0R+W(Eh%4}F?gwL&?yvKKX@ z6QP$)dBks+MU@4W9Wqiv)*Zq;fxP^6#^XMs%C-cG%MUy z+I-n6>M?>0nB&-Qd78_V&$GMJtUU!y8!$npDHA z(pv@F>W&QBIm=)T8QUDQ;krQfy$<^xDUYtqw75`G`fujg8QFfNbDP+8+&yI@SRmI1V6FD zWD(le6owaYV|Vf0;uNHfc%h@&0<=U^y2Lp6@LiD6Wt+Z-s=Kf{8o{7d3&y)Z@4Z2$j2YNrjf^Twpb;1nK*%vWK?bnS5zo?p)81ci89 z$yVHLgg(_zw!|akU$u6(voosx2#Nv^jX~Pe^=*zxx zlswQKy??N9%8$WM8e3LG{_tTHW+?M~F9M%sTTZtB689Lx%=-#tr?|^L>KBPn1*;2k zA_$8{Ce~!7Csf(KjOy<%o{#4PwKHK?W|o|_XI&!NQQm7(d8BSmhJ%`Y_N>&%0 zFoO-K#x;*9C5zOef(^`;`=Uz`3;iNI+)*=|oquqZZOS{jB%+UnQr75>8^*IP#Q;AQ z3BWR1bQ=0Pd+?!M^!C5!U1uOdx|7fhbxB@y)Vu5==@c4S8#es9ig~pSFdNCSKgQ_X z>2`Y7BH>=cCg)epQ^kjE3m(^F!Udi(?sY~{VI>)F0nxY-avgf8o$cE(iD2Cdb7`e_ z>KX50KF7?5x7!_R%Vm*z>{L$A6|BX1$}*!!NTg?(+;|JX0000hsyV3^+5u|9YZst`;iYJde&hy6cvHU!tcW0xE z_1#H-@70tywRbL#yR>SgFWzx=LLyWr6{kvLPq=!dv$-S`dp;1QuIypP!+vzI^zMyb z`XVP2w*9myG^>0l8Ixl*h})*X+oyaJbfLl%`9Wlypt*DwT@(*eNQB;}7zNb=p8%Us zUvOmya1>i3z)=+1&s;XY{EZ8zb~(b~&1#9c;$e5p*|Iq(mLkWx#Tpm{t>QGT9T&FK zT3{B(1T*oXZ)EmGXnRy3;*f=v9M($!85jkO%PZsgB( zmD59tM#$#K)EG+%I@0jMX0NygB#tEj?jRQQZ^{65$ZPRzS(50*3rAbw4As@@vccOF1TY#>BSW$C8Q2)zq1Zh>Bg28G2{~IqegVr zRf5^+pu$f{PZmJSH9UR4n`{9LN7o|*T8iaOrfC#>aR7cP)O)w=8j(@#`pi>jwiYS{ zsf*bwnsfeu@-AVa4KWXS5kiZk)iATRA8+(^?fD=itUBwv^Q<*%(XF=sDP$Yl7aR{G z$+O$2q&lchd1@H*E^0Z%O|Uh;QU^QPRTTy^P{+NBZaJm$rshZtDi%STtI9Cv<_&!% zu?b_tE&dSaI2%WZhgKNIOMN3}a*lWy!V zl9h&{slxqL`hKvs?x4mzO;1}~TU?^<97!%Ai9zcR#5U%T0000005cVspXFWBt~@?= zP!Z6~tt#HGdT9nWM5%FX$TSz>1#`!wN~LG9W@N;~j8p-tw@}cz<7)xQc9q!mnx(eF zJs==1mDCNH2k0#C;9tpG8 z9LU~g{aP!}6D-b7-aC7tr?_YH9x1?0T28QNN`C@H>o(8%aqP03vzl0AK+D>0`Du#% zThD$QDj1v^?)S|w7Aht^pGwOCKjdg?^KalMqP$rht8phZB) zGOIu4fluQs8m>|?qIhSnpmdP9BPBog{SA-2sz}27z1x|trqVvC_NjER#(|d9$<*^3 z>wvgXAoHkwrU`LeMFQX`?r`bzoX&|w70ln+9qF;gPd}H=f4a_k-6`?MKgqMYZA~Se zCrT4;M%Gj5DwVLz#K{_h*O7sZNCVm|wC1mY*gjp5hGkL$x+Q6yRKX+rDnFUC67rQ1 zwd31UE%KlE=(nQ_+bkpM?y4nlk>EPFlhykcMjfF&C|T`V z<4e<$pNG-flFSw>J1mQ%_f0}z&O_i(ig*0n=Ww&OU;y=`0-o2U-9l2(^AD>^5+2-& zc*QT+tpCna^<+eRH0hIzf(ne{bLZJn} zVKN9xP!`BogJ%vQ4b*zba1>poyF@MldMO81Qw?$clYTO=(%}NVm6}!?>*JXAvY{YV zOX(GGra^xYYb^-$iVi-NS*M=SfQ(3PX~cpJVIMQG5Pm`6%I;!Z&Lgi+)J-7@+R8NM zjp;>Jw>wDA^PC2keWuGM`sVw-5^k&()t+ER*{5cu07xKFdEWlexy9&cB9JFJ5g>r_ zS1dkw<_UW5Ve(_=%6RG@ljtmrf>P@?P~}&@XHZ>0cY}_Uawl2rJ0PJatK*dlnZT)E zfoR(dei7?oPBasPFZG0pb=h7#cjgeO`+MOiiKguFBv2ZfCrck>rH`%*yD&cNr?3Zv zPSn#o=D$j`Ywl7yYR%4kMv`^Ec_}qbNdibmc|6VG)Ck~^h3Rp@p^>YgG=v$Nw6MX0(2N)4wDh( ze?mtI_UcZ=ohY?&V$O;piEH4-F?3?ZkRr)(RJn_hrY1%-y_}LqIvN0)#QqEPrTlJG zAG4$C0b%kSR%3J?Kl4wNL%Xr>D8OpNk$`~}4cLfR^TtTNUe10rF?PG#v0BgX34agf z3N}Av{6_b!*IawdUWOJUAPSJ{6BQYmh9#;@rq@PvSziOpNEi=_WQq2FT-)cw?@H&Q zu6B%0S^pGp$V^p^`jlZ_bMOD@64VEh;-185jEep*fhmvBo;PNEVXSOw3mg|DrJU~} z_rW0Mw((;oRnN;q1pdM;A=$$=T88C)>+vfJm6L_DcEZtV^+BAv z!Qh8}<&&Eh$J=KH#bbP;g3SS7BtMJan^dZ(=IUdBe^`9fca_({@IH?ig?fE8-AtO4 z1#B|g8JPvP(dToepNANDf!Z403csY)Hq#sj#BlES>G@h95IU)HdiHr4CrRO<(d5-% z|2ycB@S0`6ZOcp&tAIj1R?;Ru)RSfkZ;C(A_qQsSX23FP4!)cSi|LxQ?_^9d%urTK7IYMmsAfEEC*wRzq@xg{j6Qf#BrHPFWKo}YE z%Iq~4XfEDtx?swY;Ue8?%c|#mM+5%9?C^O`Mf72~Ja^NO>Q!m;dLj`)rT{eRiuk$m2LC2>Kl(pc~929#&tFK>m0B1P@Bm8BSI>eNpw zL=9~hUrh+4-+QA#Azyak5Yt*NW#s}u>73i`Bo7?-tzxmaMTWWm&&`N-30A(zF?`F# zZH6$!hzI{}wdwKJ1Oxxi4Vlx8Sdy!4HX|6$1O_Sq01&}0j!82cWy981s@#6j$gX3s zF{s4$I7YFaXtnG|f>11!NTFjI>4F%+Z-de~%IO`e6-@LV0001&$q!-x literal 48572 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 Y!C(AYz|Eh90_XMmxOn0+Hngn+0Kl?wIsgCw diff --git a/resources/images/helpModal/playerInfoOverlay.webp b/resources/images/helpModal/playerInfoOverlay.webp index 0500feb9cefb2087b6cb7d65e87832fe8bef84cd..51a548f03a7b3eaef245fe6c1b49c8b75da2a254 100644 GIT binary patch literal 5496 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 literal 39490 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 q!WqDV7IHx#NCCpA%<|ly>{{R3a{*;yg diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index 2c06f2c07..cfc2a611b 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -110,7 +110,7 @@ export class HelpModal extends BaseModal { })}