From 5136f8edc273441b96bdf16bd1f9bd9a2b7e70c3 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Wed, 28 May 2025 22:59:20 +0200 Subject: [PATCH 01/84] Meta Adjustments from [UN] clan test (#932) ## Description: The changes seem necessary to reduce snowballing in v23 and strengthen ports. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: 1brucben --- src/core/configuration/DefaultConfig.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index cdd5d7205..ee31983d6 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -66,9 +66,9 @@ const numPlayersConfig = { } as const satisfies Record; const TERRAIN_EFFECTS = { - [TerrainType.Plains]: { mag: 0.85, speed: 0.8 }, - [TerrainType.Highland]: { mag: 1, speed: 1 }, - [TerrainType.Mountain]: { mag: 1.2, speed: 1.3 }, + [TerrainType.Plains]: { mag: 0.9, speed: 0.8 }, + [TerrainType.Highland]: { mag: 1.1, speed: 1 }, + [TerrainType.Mountain]: { mag: 1.3, speed: 1.25 }, } as const; export abstract class DefaultServerConfig implements ServerConfig { @@ -276,7 +276,7 @@ export class DefaultConfig implements Config { return 10000 + 150 * Math.pow(dist, 1.1); } tradeShipSpawnRate(numberOfPorts: number): number { - return Math.round(10 * Math.pow(numberOfPorts, 0.6)); + return Math.round(10 * Math.pow(numberOfPorts, 0.5)); } unitInfo(type: UnitType): UnitInfo { @@ -647,7 +647,7 @@ export class DefaultConfig implements Config { // smaller countries recieve a boost to pop growth to speed up early game const baseAdditionRate = 10; const basePopGrowthRate = 1300 / max + 1 / 140; - const reproductionPop = 0.8 * player.troops() + 1.2 * player.workers(); + const reproductionPop = 0.85 * player.troops() + 1.15 * player.workers(); let toAdd = baseAdditionRate + basePopGrowthRate * reproductionPop; const totalPop = player.totalPopulation(); const ratio = 1 - totalPop / max; From 0576a70479d96e0364a92eb753647c635c2fc506 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Wed, 28 May 2025 15:38:51 -0700 Subject: [PATCH 02/84] fix alternate view regression (#937) ## Description: when pressing space for alternate view, the ships did not change color. This was cased by incorrect sprite caching. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: --- src/client/graphics/SpriteLoader.ts | 2 +- src/client/graphics/layers/UnitLayer.ts | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/client/graphics/SpriteLoader.ts b/src/client/graphics/SpriteLoader.ts index 7fb1ab852..4bc552bc1 100644 --- a/src/client/graphics/SpriteLoader.ts +++ b/src/client/graphics/SpriteLoader.ts @@ -128,7 +128,7 @@ export const getColoredSprite = ( const territoryColor = customTerritoryColor ?? theme.territoryColor(owner); const borderColor = customBorderColor ?? theme.borderColor(owner); const spawnHighlightColor = theme.spawnHighlightColor(); - const key = `${unit.type()}-${owner.id()}`; + const key = `${unit.type()}-${owner.id()}-${customTerritoryColor}-${customBorderColor}`; if (coloredSpriteCache.has(key)) { return coloredSpriteCache.get(key)!; diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 5ec98850c..8501610f8 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -4,7 +4,6 @@ import { ClientID } from "../../../core/Schemas"; import { Theme } from "../../../core/configuration/Config"; import { UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; import { BezenhamLine } from "../../../core/utilities/Line"; import { @@ -16,6 +15,7 @@ import { MoveWarshipIntentEvent } from "../../Transport"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; +import { GameUpdateType } from "../../../core/game/GameUpdates"; import { getColoredSprite, isSpriteReady, @@ -70,8 +70,11 @@ export class UnitLayer implements Layer { if (this.myPlayer === null) { this.myPlayer = this.game.playerByClientID(this.clientID); } + const unitIds = this.game + .updatesSinceLastTick() + ?.[GameUpdateType.Unit]?.map((unit) => unit.id); - this.updateUnitsSprites(); + this.updateUnitsSprites(unitIds ?? []); } init() { @@ -202,7 +205,7 @@ export class UnitLayer implements Layer { this.transportShipTrailCanvas.width = this.game.width(); this.transportShipTrailCanvas.height = this.game.height(); - this.updateUnitsSprites(); + this.updateUnitsSprites(this.game.units().map((unit) => unit.id())); this.unitToTrail.forEach((trail, unit) => { for (const t of trail) { @@ -218,10 +221,9 @@ export class UnitLayer implements Layer { }); } - private updateUnitsSprites() { - const unitsToUpdate = this.game - .updatesSinceLastTick() - ?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id)) + private updateUnitsSprites(unitIds: number[]) { + const unitsToUpdate = unitIds + ?.map((id) => this.game.unit(id)) .filter((unit) => unit !== undefined); if (unitsToUpdate) { From 15519b95c8fc79a8d9b166c034ab02f5be0c72cd Mon Sep 17 00:00:00 2001 From: evanpelle Date: Wed, 28 May 2025 17:26:13 -0700 Subject: [PATCH 03/84] fix warship targetting range (#938) ## Description: A warship refactor caused a regressions where warships could attack at any distance. Also refactored & simplified the trade ship logic. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: --- src/core/execution/WarshipExecution.ts | 59 +++++++++++++++++--------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index f5400c74d..43233bd91 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -79,28 +79,45 @@ export class WarshipExecution implements Execution { const hasPort = this.warship.owner().units(UnitType.Port).length > 0; const patrolRangeSquared = this.mg.config().warshipPatrolRange() ** 2; - const ships = this.mg - .nearbyUnits( - this.warship.patrolTile()!, - this.mg.config().warshipTargettingRange(), - [UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip], - ) - .filter( - ({ unit }) => - unit.owner() !== this.warship.owner() && - unit !== this.warship && - !unit.owner().isFriendly(this.warship.owner()) && - !this.alreadySentShell.has(unit) && - (unit.type() !== UnitType.TradeShip || - (hasPort && - this.mg.euclideanDistSquared(this.warship.tile(), unit.tile()) <= - patrolRangeSquared && - unit.targetUnit()?.owner() !== this.warship.owner() && - !unit.targetUnit()?.owner().isFriendly(this.warship.owner()) && - unit.isSafeFromPirates() !== true)), - ); + const ships = this.mg.nearbyUnits( + this.warship.tile()!, + this.mg.config().warshipTargettingRange(), + [UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip], + ); + const potentialTargets: { unit: Unit; distSquared: number }[] = []; + for (const { unit, distSquared } of ships) { + if ( + unit.owner() === this.warship.owner() || + unit === this.warship || + unit.owner().isFriendly(this.warship.owner()) || + this.alreadySentShell.has(unit) + ) { + continue; + } + if (unit.type() === UnitType.TradeShip) { + if ( + !hasPort || + unit.isSafeFromPirates() || + unit.targetUnit()?.owner() === this.warship.owner() || // trade ship is coming to my port + unit.targetUnit()?.owner().isFriendly(this.warship.owner()) // trade ship is coming to my ally + ) { + continue; + } + if ( + this.mg.euclideanDistSquared( + this.warship.patrolTile()!, + unit.tile(), + ) > patrolRangeSquared + ) { + // Prevent warship from chasing trade ship that is too far away from + // the patrol tile to prevent warships from wandering around the map. + continue; + } + } + potentialTargets.push({ unit: unit, distSquared }); + } - return ships.sort((a, b) => { + return potentialTargets.sort((a, b) => { const { unit: unitA, distSquared: distA } = a; const { unit: unitB, distSquared: distB } = b; From 3511bb0eb488d24ce3ec7898dc922196534261a4 Mon Sep 17 00:00:00 2001 From: Mason Schmidgall <13247733+spicydll@users.noreply.github.com> Date: Wed, 28 May 2025 22:00:31 -0500 Subject: [PATCH 04/84] Add instructional overlay message during spawn phase (#934) ## Description: My first game, I was embarrassingly confused about the spawn phase. I looked for where my nation spawned for something like 3 minutes before I realized I needed to actually click a location at the beginning. Therefore, my first contribution is to add a simple UI message during the spawn phase that will hopefully prevent anyone else from making the same mistake. I have implemented this as an overlay layer that displays at the top and center of the screen during spawn phase. ## UI Screenshots Spawn phase message ![image](https://github.com/user-attachments/assets/1d07bc51-e7eb-47d4-9ad6-8ef06404b40a) ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: spicydll --- src/client/graphics/GameRenderer.ts | 10 +++++ src/client/graphics/layers/HeadsUpMessage.ts | 46 ++++++++++++++++++++ src/client/index.html | 5 +++ 3 files changed, 61 insertions(+) create mode 100644 src/client/graphics/layers/HeadsUpMessage.ts diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 901ff166e..6fef871c0 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -13,6 +13,7 @@ import { ControlPanel } from "./layers/ControlPanel"; import { EmojiTable } from "./layers/EmojiTable"; import { EventsDisplay } from "./layers/EventsDisplay"; import { FxLayer } from "./layers/FxLayer"; +import { HeadsUpMessage } from "./layers/HeadsUpMessage"; import { Layer } from "./layers/Layer"; import { Leaderboard } from "./layers/Leaderboard"; import { MultiTabModal } from "./layers/MultiTabModal"; @@ -172,6 +173,14 @@ export function createRenderer( } playerTeamLabel.game = game; + const headsUpMessage = document.querySelector( + "heads-up-message", + ) as HeadsUpMessage; + if (!(headsUpMessage instanceof HeadsUpMessage)) { + console.error("heads-up message not found"); + } + headsUpMessage.game = game; + const unitInfoModal = document.querySelector( "unit-info-modal", ) as UnitInfoModal; @@ -220,6 +229,7 @@ export function createRenderer( topBar, playerPanel, playerTeamLabel, + headsUpMessage, unitInfoModal, multiTabModal, ]; diff --git a/src/client/graphics/layers/HeadsUpMessage.ts b/src/client/graphics/layers/HeadsUpMessage.ts new file mode 100644 index 000000000..8dd68d59a --- /dev/null +++ b/src/client/graphics/layers/HeadsUpMessage.ts @@ -0,0 +1,46 @@ +import { LitElement, html } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { GameView } from "../../../core/game/GameView"; +import { Layer } from "./Layer"; + +@customElement("heads-up-message") +export class HeadsUpMessage extends LitElement implements Layer { + public game: GameView; + + @state() + private isVisible = false; + + createRenderRoot() { + return this; + } + + init() { + this.isVisible = true; + this.requestUpdate(); + } + + tick() { + if (!this.game.inSpawnPhase()) { + this.isVisible = false; + this.requestUpdate(); + } + } + + render() { + if (!this.isVisible) { + return html``; + } + + return html` +
e.preventDefault()} + > + Choose a starting location +
+ `; + } +} diff --git a/src/client/index.html b/src/client/index.html index 3e15ba515..dc635b838 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -299,6 +299,11 @@ +
+ +
From 1167ac80d74bb6fd963b28a38d8d844448917e23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9odore=20L=C3=A9on?= Date: Thu, 29 May 2025 05:01:17 +0200 Subject: [PATCH 05/84] Add test coverage script (#929) ## Description: Added a command "npm run test:coverage" This could be added to the CI/CD to check if a pull request induced regressions, and if it added propers tests. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: theodoreleon.aetarax --- .gitignore | 1 + jest.config.ts | 10 ++++++++++ package.json | 1 + 3 files changed, 12 insertions(+) diff --git a/.gitignore b/.gitignore index 90ce6e054..0cbd366d8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ build/ node_modules/ out/ static/ +coverage/ TODO.txt resources/images/.DS_Store resources/.DS_Store diff --git a/jest.config.ts b/jest.config.ts index 02f05ddd8..bb15e1770 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -17,4 +17,14 @@ export default { }, transformIgnorePatterns: ["node_modules/(?!(node:)/)"], preset: "ts-jest/presets/default-esm", + collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts"], + coverageThreshold: { + global: { + branches: 0, + functions: 0, + lines: 0, + statements: 0, + }, + }, + coverageReporters: ["text", "lcov", "html"], }; diff --git a/package.json b/package.json index 2e2d02381..eedac982d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dev": "cross-env GAME_ENV=dev concurrently \"npm run start:client\" \"npm run start:server-dev\"", "tunnel": "npm run build-prod && npm run start:server", "test": "jest", + "test:coverage": "jest --coverage", "format": "prettier --ignore-unknown --write .", "lint": "eslint", "lint:fix": "eslint --fix", From 4443459bc520278ecb06830b25981a48c3ef6a6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9odore=20L=C3=A9on?= Date: Thu, 29 May 2025 05:01:51 +0200 Subject: [PATCH 06/84] Added two checkboxes to the default pull request template (#930) ## Description: Add the following checks to the pull request template : - [ ] I have added relevant tests to the test directory - [ ] I process any text displayed to the user through translateText() and i've added it to the en.json file/i ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I have added relevant tests to the test directory - [x] I process any text displayed to the user through translateText() and i've added it to the en.json file - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: theodoreleon.aetarax --- .github/PULL_REQUEST_TEMPLATE.md | 2 ++ .github/workflows/pr-description.yml | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ce91d5ba5..c7c8c0124 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,6 +3,8 @@ ## Please complete the following: - [ ] I have added screenshots for all UI updates +- [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file +- [ ] I have added relevant tests to the test directory - [ ] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [ ] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors diff --git a/.github/workflows/pr-description.yml b/.github/workflows/pr-description.yml index 864bca7a8..e38ba5037 100644 --- a/.github/workflows/pr-description.yml +++ b/.github/workflows/pr-description.yml @@ -24,9 +24,11 @@ jobs: errors.push('❌ Missing or short `## Description:` section.'); } - // Check all three boxes are checked + // Check all five boxes are checked const requiredBoxes = [ /- \[x\] I have added screenshots for all UI updates/i, + /- \[x\] I process any text displayed to the user through translateText\(\) and I\'ve added it to the en\.json file/i, + /- \[x\] I have added relevant tests to the test directory/i, /- \[x\] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced/i, /- \[x\] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors/i ]; From 46252176d81ada9fbb10a42c5dd9a6909b2d520e Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 29 May 2025 09:34:27 -0700 Subject: [PATCH 07/84] update privacy policy for playwire ads --- resources/privacy-policy.html | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/resources/privacy-policy.html b/resources/privacy-policy.html index 121539400..efb7b2f78 100644 --- a/resources/privacy-policy.html +++ b/resources/privacy-policy.html @@ -59,7 +59,7 @@

Privacy Policy

-

Last Updated: April 29, 2025

+

Last Updated: May 29, 2025

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

  • By email: openfrontio@gmail.com
  • +

    Advertising

    +

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

    + From 535df61ffd594505cca8a11e08dc3e42e3457b44 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 29 May 2025 11:07:04 -0700 Subject: [PATCH 09/84] add robots.txt --- resources/robots.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 resources/robots.txt diff --git a/resources/robots.txt b/resources/robots.txt new file mode 100644 index 000000000..14267e903 --- /dev/null +++ b/resources/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / \ No newline at end of file From 5e7bfb2708e267127565278130ec8b7851eddaad Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 29 May 2025 11:33:42 -0700 Subject: [PATCH 10/84] add steam wishlist link on win modal --- resources/lang/en.json | 3 ++- src/client/graphics/layers/WinModal.ts | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 9d492f0a4..7f4bd92d0 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -359,7 +359,8 @@ "you_won": "You Won!", "other_won": "{player} has won!", "exit": "Exit Game", - "keep": "Keep Playing" + "keep": "Keep Playing", + "wishlist": "Wishlist on Steam!" }, "leaderboard": { "title": "Leaderboard", diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index ce0739e50..ce705513d 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -148,7 +148,24 @@ export class WinModal extends LitElement implements Layer { } innerHtml() { - return html``; + return html`

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

    `; } show() { From 32d746768e942230a65001ca34ad9dc6b7a2a535 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 29 May 2025 14:57:58 -0700 Subject: [PATCH 11/84] Fix slow singleplayer timer (#943) ## Description: The LocalServer was counting 100ms between turns, causing the timer to run slow (100ms + turn execution time), it now checks 100ms from the start of the previous turn. I've noticed it still runs a tad slow (1-2 seconds slow after 1 minute), but it's much better than before. ## Please complete the following: - [ ] I have added screenshots for all UI updates - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] I have added relevant tests to the test directory - [ ] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [ ] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: --- src/client/LocalServer.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 4d7be1a1c..c3982510f 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -30,7 +30,7 @@ export class LocalServer { private allPlayersStats: AllPlayersStats = {}; private turnsExecuted = 0; - private lastTurnCompletedTime = 0; + private turnStartTime = 0; private turnCheckInterval: NodeJS.Timeout; @@ -47,9 +47,10 @@ export class LocalServer { if ( this.isReplay || Date.now() > - this.lastTurnCompletedTime + - this.lobbyConfig.serverConfig.turnIntervalMs() + this.turnStartTime + this.lobbyConfig.serverConfig.turnIntervalMs() ) { + this.turnStartTime = Date.now(); + // End turn on the server means the client will start processing the turn. this.endTurn(); } } @@ -140,11 +141,13 @@ export class LocalServer { } } + // This is so the client can tell us when it finished processing the turn. public turnComplete() { this.turnsExecuted++; - this.lastTurnCompletedTime = Date.now(); } + // endTurn in this context means the server has collected all the intents + // and will send the turn to the client. private endTurn() { if (this.paused) { return; From 9471fdaf1f60ad14b601a5b80e4ba8d4f16938e0 Mon Sep 17 00:00:00 2001 From: falc <76709589+falcolnic@users.noreply.github.com> Date: Fri, 30 May 2025 00:16:35 +0200 Subject: [PATCH 12/84] improved perfomance of PseudoRandom (#933) ## Description: Added XorShift algo for better random number generation ## Please complete the following: - [x] I have added screenshots for all UI updates - [ ] I have added relevant tests to the test directory - [x] I process any text displayed to the user through translateText() and i've added it to the en.json file - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: @qqkedsi ![Screenshot from 2025-05-29 00-25-20](https://github.com/user-attachments/assets/7e748c7f-3bc2-4275-8ffd-9adf3a224064) --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/core/PseudoRandom.ts | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/core/PseudoRandom.ts b/src/core/PseudoRandom.ts index daf27497f..406cfb243 100644 --- a/src/core/PseudoRandom.ts +++ b/src/core/PseudoRandom.ts @@ -9,6 +9,9 @@ export class PseudoRandom { private c: number = 12345; private state: number; + private static readonly POW36_8 = Math.pow(36, 8); // Pre-compute 36^8 + private static readonly INV_2_32 = 1 / 4294967296; // 1 / 2^32 for float conversion + constructor(seed: number) { // Initialize the XorShift state with seed this.state0 = seed | 0; // Force to 32-bit integer with bitwise OR @@ -48,6 +51,13 @@ export class PseudoRandom { return (this.state0 + this.state1) | 0; } + /** + * Optimized version that directly returns unsigned 32-bit integer + */ + private _nextUInt32(): number { + return this._nextIntInternal() >>> 0; + } + /** * Generates the next pseudorandom number. * @returns A number between 0 (inclusive) and 1 (exclusive). @@ -55,7 +65,7 @@ export class PseudoRandom { next(): number { // Get a 32-bit integer and convert to [0,1) range // Using >>> 0 to get unsigned interpretation (positive number) - const int = this._nextIntInternal() >>> 0; + const int = this._nextUInt32(); // Update the state variable to maintain compatibility with original interface this.state = int % this.m; @@ -64,25 +74,33 @@ export class PseudoRandom { return this.state / this.m; } + /** + * Optimized version for internal use - directly converts to [0,1) without state update + */ + private _nextFloat(): number { + return this._nextUInt32() * PseudoRandom.INV_2_32; + } + /** * Generates a random integer between min (inclusive) and max (exclusive). */ nextInt(min: number, max: number): number { - return Math.floor(this.next() * (max - min) + min); + // keep max exclusive, min inclusive – round down to get an int + return Math.floor(this._nextFloat() * (max - min)) + min; } /** * Generates a random float between min (inclusive) and max (exclusive). */ nextFloat(min: number, max: number): number { - return this.next() * (max - min) + min; + return this._nextFloat() * (max - min) + min; } /** * Generates a random ID (8 characters, alphanumeric). */ nextID(): string { - return this.nextInt(0, Math.pow(36, 8)) // 36^8 possibilities + return Math.floor(this._nextFloat() * PseudoRandom.POW36_8) // 36^8 possibilities .toString(36) // Convert to base36 (0-9 and a-z) .padStart(8, "0"); // Ensure 8 chars by padding with zeros } @@ -94,25 +112,25 @@ export class PseudoRandom { if (arr.length === 0) { throw new Error("array must not be empty"); } - return arr[this.nextInt(0, arr.length)]; + return arr[Math.floor(this._nextFloat() * arr.length)]; } /** * Returns true with probability 1/odds. */ chance(odds: number): boolean { - return this.nextInt(0, odds) === 0; + return Math.floor(this._nextFloat() * odds) === 0; } /** * Returns a shuffled copy of the array using Fisher-Yates algorithm. */ shuffleArray(array: T[]): T[] { - for (let i = array.length - 1; i >= 0; i--) { - const j = this.nextInt(0, i + 1); - [array[i], array[j]] = [array[j], array[i]]; + const result = [...array]; + for (let i = result.length - 1; i >= 0; i--) { + const j = Math.floor(this._nextFloat() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; } - - return array; + return result; } } From fff0f00e226dfe02f183b84a64762c68975761af Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Thu, 29 May 2025 19:13:35 -0400 Subject: [PATCH 13/84] Change deploy concurrency group (#944) ## Description: Change deploy concurrency group ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3d32eb87a..58fd0819a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -37,7 +37,7 @@ on: permissions: {} concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.event_name == 'workflow_dispatch' && inputs.target_host || 'staging' }} cancel-in-progress: false jobs: From a1a4b22351197d0377b198da9261073784089f6e Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Thu, 29 May 2025 19:47:28 -0400 Subject: [PATCH 14/84] Set singleplayer gitCommit in the client (#945) ## Description: Set singleplayer gitCommit in the client ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/core/Schemas.ts | 4 +++- src/core/Util.ts | 2 +- src/server/Worker.ts | 20 ++++++++++++++------ webpack.config.js | 7 +++++-- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index fb67b0bad..c7030515d 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -446,10 +446,12 @@ export const GameEndInfoSchema = GameStartInfoSchema.extend({ }); export type GameEndInfo = z.infer; +const GitCommitSchema = z.string().regex(/^[0-9a-fA-F]{40}$/); + export const AnalyticsRecordSchema = z.object({ info: GameEndInfoSchema, version: z.literal("v0.0.2"), - gitCommit: z.string(), + gitCommit: GitCommitSchema, }); export type AnalyticsRecord = z.infer; diff --git a/src/core/Util.ts b/src/core/Util.ts index b518f5f1a..d78f7091d 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -195,7 +195,7 @@ export function createGameRecord( ): GameRecord { const duration = Math.floor((end - start) / 1000); const version = "v0.0.2"; - const gitCommit = ""; + const gitCommit = process.env.GIT_COMMIT ?? "unknown"; const num_turns = allTurns.length; const turns = allTurns.filter( (t) => t.intents.length !== 0 || t.hash !== undefined, diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 5ff087242..e9fb2131a 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -5,10 +5,16 @@ import ipAnonymize from "ip-anonymize"; import path from "path"; import { fileURLToPath } from "url"; import { WebSocket, WebSocketServer } from "ws"; +import { z } from "zod/v4"; import { GameEnv } from "../core/configuration/Config"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; -import { ClientMessageSchema, GameConfig, GameRecord } from "../core/Schemas"; +import { + ClientMessageSchema, + GameConfig, + GameRecord, + GameRecordSchema, +} from "../core/Schemas"; import { archive, readGameRecord } from "./Archive"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; @@ -241,13 +247,15 @@ export function startWorker() { app.post( "/api/archive_singleplayer_game", gatekeeper.httpHandler(LimiterType.Post, async (req, res) => { - const gameRecord: GameRecord = req.body; - - if (!gameRecord) { - log.info("game record not found in request"); - res.status(404).json({ error: "Game record not found" }); + const result = GameRecordSchema.safeParse(req.body); + if (!result.success) { + const error = z.prettifyError(result.error); + log.info(error); + res.status(400).json({ error }); return; } + + const gameRecord: GameRecord = result.data; archive(gameRecord); res.json({ success: true, diff --git a/webpack.config.js b/webpack.config.js index 4b4096141..3f6d28936 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,3 +1,4 @@ +import { execSync } from "child_process"; import CopyPlugin from "copy-webpack-plugin"; import ESLintPlugin from "eslint-webpack-plugin"; import HtmlWebpackPlugin from "html-webpack-plugin"; @@ -8,6 +9,9 @@ import webpack from "webpack"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const gitCommit = + process.env.GIT_COMMIT ?? execSync("git rev-parse HEAD").toString().trim(); + export default async (env, argv) => { const isProduction = argv.mode === "production"; @@ -116,9 +120,8 @@ export default async (env, argv) => { "process.env.WEBSOCKET_URL": JSON.stringify( isProduction ? "" : "localhost:3000", ), - }), - new webpack.DefinePlugin({ "process.env.GAME_ENV": JSON.stringify(isProduction ? "prod" : "dev"), + "process.env.GIT_COMMIT": JSON.stringify(gitCommit), }), new CopyPlugin({ patterns: [ From 5b42e746a06c55fd227a7bafa71ccdafa45ef578 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 29 May 2025 17:14:49 -0700 Subject: [PATCH 15/84] generate unique env file for each deployment to prevent conflicts --- deploy.sh | 10 +++++++--- update.sh | 28 +++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/deploy.sh b/deploy.sh index 48c9cc5f5..dd8210778 100755 --- a/deploy.sh +++ b/deploy.sh @@ -171,8 +171,12 @@ if [ $? -ne 0 ]; then exit 1 fi +# Generate a random filename for the environment file to prevent conflicts +# when multiple deployments are happening at the same time. +ENV_FILE="${REMOTE_UPDATE_PATH}/${SUBDOMAIN}-${RANDOM}.env" + ssh -i $SSH_KEY $REMOTE_USER@$SERVER_HOST "chmod +x $REMOTE_UPDATE_SCRIPT && \ -cat > $REMOTE_UPDATE_PATH/.env << 'EOL' +cat > $ENV_FILE << 'EOL' GAME_ENV=$ENV ENV=$ENV HOST=$HOST @@ -192,8 +196,8 @@ OTEL_ENDPOINT=$OTEL_ENDPOINT BASIC_AUTH_USER=$BASIC_AUTH_USER BASIC_AUTH_PASS=$BASIC_AUTH_PASS EOL -chmod 600 $REMOTE_UPDATE_PATH/.env && \ -$REMOTE_UPDATE_SCRIPT" +chmod 600 $ENV_FILE && \ +$REMOTE_UPDATE_SCRIPT $ENV_FILE" if [ $? -ne 0 ]; then echo "❌ Failed to execute update script on server." diff --git a/update.sh b/update.sh index 91c516346..761c353f9 100755 --- a/update.sh +++ b/update.sh @@ -2,12 +2,25 @@ # update.sh - Script to update Docker container on Hetzner server # Called by deploy.sh after uploading Docker image to Docker Hub -# Load environment variables if .env exists -if [ -f /home/openfront/.env ]; then - echo "Loading environment variables from .env file..." - export $(grep -v '^#' /home/openfront/.env | xargs) +# Check if environment file is provided +if [ $# -ne 1 ]; then + echo "Error: Environment file path is required" + echo "Usage: $0 " + exit 1 fi +ENV_FILE="$1" + +# Check if environment file exists +if [ ! -f "$ENV_FILE" ]; then + echo "Error: Environment file '$ENV_FILE' not found" + exit 1 +fi + +# Load environment variables from the provided file +echo "Loading environment variables from $ENV_FILE..." +export $(grep -v '^#' "$ENV_FILE" | xargs) + echo "======================================================" echo "🔄 UPDATING SERVER: ${HOST} ENVIRONMENT" echo "======================================================" @@ -47,7 +60,7 @@ fi echo "Starting new container for ${HOST} environment..." docker run -d \ --restart="${RESTART}" \ - --env-file /home/openfront/.env \ + --env-file "$ENV_FILE" \ --name "${CONTAINER_NAME}" \ "${DOCKER_IMAGE}" @@ -60,6 +73,11 @@ if [ $? -eq 0 ]; then docker image prune -a -f docker container prune -f echo "Cleanup complete." + + # Remove the environment file + echo "Removing environment file ${ENV_FILE}..." + rm -f "$ENV_FILE" + echo "Environment file removed." else echo "Failed to start container" exit 1 From 682918732a7d178ecc47ca7ba64a59be9729955a Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 29 May 2025 17:19:55 -0700 Subject: [PATCH 16/84] remove ads.txt --- resources/ads.txt | 999 ---------------------------------------------- 1 file changed, 999 deletions(-) delete mode 100644 resources/ads.txt diff --git a/resources/ads.txt b/resources/ads.txt deleted file mode 100644 index a5b191562..000000000 --- a/resources/ads.txt +++ /dev/null @@ -1,999 +0,0 @@ -ownerdomain=openfront.io -managerdomain=adinplay.com -#V 01.04.2025 VH -#V - - -#----------------------------------------------------------------------------# -# . # -# .o8 # -# oooo ooo .ooooo. ooo. .oo. .oooo. .o888oo oooo oooo .oooo.o # -# `88. .8' d88' `88b `888P"Y88b `P )88b 888 `888 `888 d88( "8 # -# `88..8' 888ooo888 888 888 .oP"888 888 888 888 `"Y88b. # -# `888' 888 . 888 888 d8( 888 888 . 888 888 o. )88b # -# `8' `Y8bod8P' o888o o888o `Y888""8o "888" `V88V"V8P' 8""888P' # -# # -# The leading advertising solution for gaming and entertainment # -# # -# To become a publisher or advertise please contact info@venatus.com # -# # -#----------------------------------------------------------------------------# -adagio.io, 1090, DIRECT -rubiconproject.com, 19116, RESELLER, 0bfd66d529a55807 -pubmatic.com, 159110, RESELLER, 5d62403b186f2ace -lijit.com, 367236, RESELLER, fafdf38b16bf6b2b -improvedigital.com, 1790, RESELLER -triplelift.com, 13482, RESELLER, 6c33edb13117fd86 -rubiconproject.com, 12186, RESELLER, 0bfd66d529a55807 -video.unrulymedia.com, 5672421953199218469, RESELLER -amxrtb.com, 105199358, DIRECT -amxrtb.com, 105199778, DIRECT -sharethrough.com, a6a34444, RESELLER, d53b998a7bd4ecd2 -appnexus.com, 12290, RESELLER -pubmatic.com, 158355, RESELLER, 5d62403b186f2ace -rubiconproject.com, 23844, RESELLER, 0bfd66d529a55807 -openx.com, 559680764, RESELLER, 6a698e2ec38604c6 -adform.com, 2767, RESELLER -adyoulike.com, c1314a52de718f3c214c00173d2994f9, DIRECT -pubmatic.com, 160925, RESELLER, 5d62403b186f2ace -aps.amazon.com,70247b00-ff8f-4016-b3ab-8344daf96e09,DIRECT -aniview.com, 5f2063121d82c82557194737, RESELLER, 78b21b97965ec3f8 -aniview.com, 643f8e74688b10f72307cc24, DIRECT, 78b21b97965ec3f8 -google.com, pub-6346866704322274, RESELLER, f08c47fec0942fa0 -pubmatic.com, 160993, RESELLER, 5d62403b186f2ace -rubiconproject.com, 13918, RESELLER, 0bfd66d529a55807 -google.com, pub-5717092533913515, RESELLER, f08c47fec0942fa0 -gannett.com, 22652678936, RESELLER -richaudience.com, 1ru8dKmJJV, RESELLER -sharethrough.com, zLsEa05k, RESELLER, d53b998a7bd4ecd2 -aps.amazon.com, 1ad7261b-91ea-4b6f-b9e9-b83522205b75, RESELLER -pubmatic.com, 161335, RESELLER, 5d62403b186f2ace -openx.com, 556532676, RESELLER, 6a698e2ec38604c6 -mediago.io, 045ac24b888bcf59a09731e7f0f2084f, RESELLER -blockthrough.com, 5643766199222272, DIRECT -criteo.com, B-062405, DIRECT, 9fac4a4a87c2a44f -themediagrid.com, CVQXOH, DIRECT, 35d5010d7789b49d -freewheel.tv, 211121, DIRECT -freewheel.tv, 211129-524565, DIRECT -freewheel.tv, 211129-169843, DIRECT -google.com, pub-5781531207509232, DIRECT, f08c47fec0942fa0 -google.com, pub-5781531207509232, RESELLER, f08c47fec0942fa0 -google.com, pub-2553634189837243, RESELLER, f08c47fec0942fa0 -gumgum.com, 13385, RESELLER, ffdef49475d318a9 -gumgum.com, 14302, RESELLER, ffdef49475d318a9 -rubiconproject.com, 23434, RESELLER, 0bfd66d529a55807 -pubmatic.com, 157897, RESELLER, 5d62403b186f2ace -indexexchange.com, 183921, DIRECT, 50b1c356f2c5c8fc -indexexchange.com, 193067, DIRECT, 50b1c356f2c5c8fc -indexexchange.com, 194127, DIRECT, 50b1c356f2c5c8fc -indexexchange.com, 205972, RESELLER, 50b1c356f2c5c8fc -Blis.com,33,RESELLER,61453ae19a4b73f4 -conversantmedia.com,40881,RESELLER,03113cd04947736d -insticator.com,843c9a44-60ea-4342-8ad4-68f894283b3e,DIRECT,b3511ffcafb23a32 -sharethrough.com,Q9IzHdvp,DIRECT,d53b998a7bd4ecd2 -rubiconproject.com,17062,RESELLER,0bfd66d529a55807 -risecodes.com,6124caed9c7adb0001c028d8,DIRECT -pubmatic.com,95054,DIRECT,5d62403b186f2ace -openx.com,558230700,RESELLER,6a698e2ec38604c6 -video.unrulymedia.com,136898039,RESELLER -lijit.com,257618,RESELLER,fafdf38b16bf6b2b -minutemedia.com,01garg96c88b,RESELLER -appnexus.com,3695,RESELLER,f5ab79cb980f11d1 -kargo.com, 8688, DIRECT -kueez.com,e5b6208bc94ed2d5788e1e4c1cf5452e, DIRECT -rubiconproject.com, 16920, RESELLER, 0bfd66d529a55807 -openx.com, 557564833, RESELLER, 6a698e2ec38604c6 -lijit.com, 407406, RESELLER, fafdf38b16bf6b2b #SOVRN -appnexus.com, 8826,RESELLER, f5ab79cb980f11d1 -Media.net,8CU4JTRF9, RESELLER -rubiconproject.com, 13762, RESELLER, 0bfd66d529a55807 -media.net, 8CU8ARTF8, DIRECT -Media.net, 8CU198XI2, DIRECT -themediagrid.com, LTW57M, DIRECT, 35d5010d7789b49d -ogury.com, 086233d2-e8a8-44fc-907b-f0752e1c85de, DIRECT -appnexus.com, 11470, RESELLER -openx.com, 542378302, RESELLER, 6a698e2ec38604c6 -openx.com, 540134228, RESELLER, 6a698e2ec38604c6 -openx.com, 537144009, RESELLER, 6a698e2ec38604c6 -openx.com, 560557013, RESELLER, 6a698e2ec38604c6 -optidigital.com,p230,DIRECT -pubmatic.com,158939,RESELLER,5d62403b186f2ace -rubiconproject.com,20336,RESELLER,0bfd66d529a55807 -smartadserver.com,3379,RESELLER,060d053dcf45cbf3 -triplelift.com,8183,RESELLER,6c33edb13117fd86 -the-ozone-project.com, ozoneven0005, DIRECT -openx.com, 540731760, RESELLER, 6a698e2ec38604c6 -pubmatic.com, 160557, RESELLER, 5d62403b186f2ace -themediagrid.com, WF71T3, DIRECT, 35d5010d7789b49d -Yahoo.com, 60170, DIRECT, e1a5b5b6e3255540 -pubmatic.com, 159234, RESELLER, 5d62403b186f2ace -pubmatic.com, 160552, RESELLER, 5d62403b186f2ace -pubmatic.com, 159401, RESELLER, 5d62403b186f2ace -pubmatic.com, 165533, RESELLER, 5d62403b186f2ace -richaudience.com, 1XvIoD5o0S, DIRECT -pubmatic.com, 81564, DIRECT, 5d62403b186f2ace -pubmatic.com, 156538, DIRECT, 5d62403b186f2ace -appnexus.com, 8233, DIRECT -rubiconproject.com, 13510, DIRECT -risecodes.com, 5fa94677b2db6a00015b22a9, DIRECT -pubmatic.com, 160295, RESELLER, 5d62403b186f2ace -xandr.com, 14082, RESELLER -rubiconproject.com, 23876, RESELLER, 0bfd66d529a55807 -sharethrough.com, 5926d422, RESELLER, d53b998a7bd4ecd2 -yieldmo.com, 2754490424016969782, RESELLER -media.net, 8CUQ6928Q, RESELLER -onetag.com, 69f48c2160c8113, RESELLER -amxrtb.com, 105199691, RESELLER -openx.com, 537140488, RESELLER, 6a698e2ec38604c6 -video.unrulymedia.com, 335119963, RESELLER -seedtag.com, 5aa6c80640c9e209009721e0, DIRECT -xandr.com, 4009, DIRECT, f5ab79cb980f11d1 -rubiconproject.com, 17280, DIRECT, 0bfd66d529a55807 -smartadserver.com, 3050, DIRECT -lijit.com, 397546, DIRECT, fafdf38b16bf6b2b -sharethrough.com, 31c129df, DIRECT, d53b998a7bd4ecd2 -sharethrough.com, awx1H4AI, RESELLER, d53b998a7bd4ecd2 -smaato.com, 1100055690, DIRECT, 07bcf65f187117b4 -smaato.com, 1100049216, DIRECT, 07bcf65f187117b5 -rubiconproject.com, 24600, RESELLER, 0bfd66d529a55807 -pubmatic.com, 156177, RESELLER, 5d62403b186f2ace -smartadserver.com, 3490, DIRECT -smartadserver.com, 4016, DIRECT -smartadserver.com, 4074, DIRECT -sovrn.com, 237754, DIRECT, fafdf38b16bf6b2b -lijit.com, 237754, DIRECT, fafdf38b16bf6b2b -lijit.com, 506352, DIRECT, fafdf38b16bf6b2b -teads.tv, 23348, DIRECT, 15a9c44f6d26cbe1 -triplelift.com, 6059, RESELLER, 6c33edb13117fd86 -video.unrulymedia.com, 985572675, DIRECT -video.unrulymedia.com, 985572675, RESELLER -sharethrough.com, 6qlnf8SY, RESELLER, d53b998a7bd4ecd2 -appnexus.com, 12986, RESELLER, f5ab79cb980f11d1 -improvedigital.com, 1069, RESELLER -pubmatic.com, 158056, RESELLER -Weborama.nl, 10714, DIRECT -adwmg.com, 101261, DIRECT, c9688a22012618e7 -google.com, pub-8622186303703569, DIRECT, f08c47fec0942fa0 -freewheel.tv, 1604590, DIRECT -freewheel.tv, 1604595, DIRECT -pubmatic.com, 156512, DIRECT -indexexchange.com, 183753, DIRECT -wunderkind.co, 6438, DIRECT -wunderkind.co, 6449, DIRECT -criteo.com, B-068503, DIRECT -appnexus.com, 806, DIRECT, f5ab79cb980f11d1 -appnexus.com,1908,RESELLER,f5ab79cb980f11d1 -adinplay.com, FTB, DIRECT -venatus.com, 67f90df66f43edab7e84d165, DIRECT - -################################## -# AdinPlay.com ads.txt - 2025-04-16 -################################## - -adinplay.com, OFI, DIRECT - -#Google -google.com, pub-3282547114800347, RESELLER, f08c47fec0942fa0 - -#Appnexus -appnexus.com, 8631, RESELLER, f5ab79cb980f11d1 - -#Index -indexexchange.com, 186547, RESELLER, 50b1c356f2c5c8fc -indexexchange.com, 187218, RESELLER, 50b1c356f2c5c8fc -indexexchange.com, 177754, RESELLER, 50b1c356f2c5c8fc -indexexchange.com, 196862, RESELLER, 50b1c356f2c5c8fc -indexexchange.com, 207014, RESELLER, 50b1c356f2c5c8fc - - -#Pulsepoint -contextweb.com, 561767, RESELLER, 89ff185a4c4e857c - -#Pubmatic -pubmatic.com, 156975, RESELLER, 5d62403b186f2ace -pubmatic.com, 156857, RESELLER, 5d62403b186f2ace -pubmatic.com, 162231, RESELLER, 5d62403b186f2ace - -#OpenX -openx.com, 540164985, RESELLER, 6a698e2ec38604c6 -openx.com, 540010967, RESELLER, 6a698e2ec38604c6 -openx.com, 540182293, RESELLER, 6a698e2ec38604c6 -openx.com, 556894440, RESELLER, 6a698e2ec38604c6 - - -#Sovrn -sovrn.com, 268781, RESELLER, fafdf38b16bf6b2b -lijit.com, 268781, RESELLER, fafdf38b16bf6b2b -lijit.com, 268781-eb, DIRECT, fafdf38b16bf6b2b -appnexus.com, 1360, RESELLER, f5ab79cb980f11d1 -openx.com, 538959099, RESELLER, 6a698e2ec38604c6 -openx.com, 539924617, RESELLER, 6a698e2ec38604c6 -pubmatic.com, 137711, RESELLER, 5d62403b186f2ace -pubmatic.com, 156212, RESELLER, 5d62403b186f2ace -rubiconproject.com, 17960, RESELLER, 0bfd66d529a55807 -sovrn.com, 264160, RESELLER, fafdf38b16bf6b2b -lijit.com, 264160, RESELLER, fafdf38b16bf6b2b -lijit.com, 264160-eb, DIRECT, fafdf38b16bf6b2b -smartadserver.com, 4125, RESELLER -sharethrough.com,7144eb80,RESELLER - - -#Oath -coxmt.com, 2000067907202, RESELLER -pubmatic.com, 156377, RESELLER, 5d62403b186f2ace #banner -pubmatic.com, 156078, RESELLER, 5d62403b186f2ace #banner -pubmatic.com, 155967, RESELLER, 5d62403b186f2ace #banner -openx.com, 537143344, RESELLER, 6a698e2ec38604c6 -indexexchange.com, 175407, RESELLER, 50b1c356f2c5c8fc - -#Rhythmone -rhythmone.com, 1432377581,DIRECT, a670c89d4a324e47 -rhythmone.com, 665259327, DIRECT, a670c89d4a324e47 -rhythmone.com, 2451244104, RESELLER, a670c89d4a324e47 -video.unrulymedia.com, 2451244104, RESELLER -video.unrulymedia.com, 1432377581, DIRECT - - -#Gumgum -aolcloud.net,9904,RESELLER -appnexus.com,1001,DIRECT,f5ab79cb980f11d1 -appnexus.com,2758,RESELLER,f5ab79cb980f11d1 -appnexus.com,3135,DIRECT,f5ab79cb980f11d1 -bidtellect.com,1407,RESELLER,1c34aa2d85d45e93 -contextweb.com,558355,RESELLER,89ff185a4c4e857c -openx.com,537120563,DIRECT,6a698e2ec38604c6 -openx.com,537149485,RESELLER,6a698e2ec38604c6 -google.com,pub-9557089510405422,DIRECT,f08c47fec0942fa0 -google.com,pub-3848273848634341,RESELLER,f08c47fec0942fa0 -google.com, pub-7861278482560604, RESELLER, f08c47fec0942fa0 -rhythmone.com,78519861,RESELLER, a670c89d4a324e47 -outbrain.com,01a755b08c8c22b15d46a8b753ab6955d4,RESELLER -appnexus.com,7597,RESELLER,f5ab79cb980f11d1 -openx.com,540003333,RESELLER,6a698e2ec38604c6 -33across.com,0013300001r0t9mAAA,RESELLER - - -#Amazon -aps.amazon.com,53b902f9-cf9c-4605-aec3-2c8ce65042b8,DIRECT -gumgum.com,13543,DIRECT,ffdef49475d318a9 -appnexus.com,8631,DIRECT,f5ab79cb980f11d1 -indexexchange.com,196862,DIRECT,50b1c356f2c5c8fc -pubmatic.com,160006,RESELLER,5d62403b186f2ace -pubmatic.com,160096,RESELLER,5d62403b186f2ace -rubiconproject.com,18020,RESELLER,0bfd66d529a55807 -pubmatic.com,162231,DIRECT,5d62403b186f2ace -appnexus.com,1908,RESELLER,f5ab79cb980f11d1 -smaato.com,1100044650,RESELLER,07bcf65f187117b4 -ad-generation.jp,12474,RESELLER,7f4ea9029ac04e53 -districtm.io,100962,RESELLER,3fd707be9c4527c3 -yieldmo.com,2719019867620450718,RESELLER -appnexus.com,3663,RESELLER,f5ab79cb980f11d1 -rhythmone.com,1654642120,RESELLER,a670c89d4a324e47 -yahoo.com,55029,RESELLER,e1a5b5b6e3255540 -gumgum.com,14141,RESELLER,ffdef49475d318a9 -admanmedia.com,726,RESELLER -emxdgt.com,2009,RESELLER,1e1d41537f7cad7f -appnexus.com,1356,RESELLER,f5ab79cb980f11d1 -contextweb.com,562541,RESELLER,89ff185a4c4e857c -themediagrid.com,JTQKMP,RESELLER,35d5010d7789b49d -sovrn.com,375328,RESELLER,fafdf38b16bf6b2b -lijit.com,375328,RESELLER,fafdf38b16bf6b2b -beachfront.com,14804,RESELLER,e2541279e8e2ca4d -improvedigital.com,2050,RESELLER -mintegral.com,10043,RESELLER,0aeed750c80d6423 -sonobi.com,7f5fa520f8,RESELLER,d1a215d9eb5aee9e -openx.com,556894440,DIRECT,6a698e2ec38604c6 -onetag.com,7683ebe7bee7969,DIRECT -media.net,8CUZ1MK22,RESELLER -sharethrough.com,buaxQzOE,DIRECT,d53b998a7bd4ecd2 -smartadserver.com,4571,DIRECT,060d053dcf45cbf3 -mediago.io,045ac24b888bcf59a09731e7f0f2084f,RESELLER -adyoulike.com,7463c359225e043c111036d7a29affa5,RESELLER -minutemedia.com,01gya4708ddm,RESELLER -visiblemeasures.com,1052,RESELLER -undertone.com,4205,RESELLER,d954590d0cb265b9 -admedia.com,AM1601,RESELLER,ae6c32151e71f19d -triplelift.com,8472,DIRECT,6c33edb13117fd86 -kargo.com,8824,RESELLER -start.io,123111883,RESELLER -connectad.io,455,RESELLER,85ac85a30c93b3e5 - -# 33Across -rubiconproject.com, 16414, RESELLER, 0bfd66d529a55807 #33Across #hb #tag -rubiconproject.com, 21642, RESELLER, 0bfd66d529a55807 #33Across #hb #tag #viewable -rubiconproject.com, 21434, RESELLER, 0bfd66d529a55807 #33Across #tag #ebda -rubiconproject.com, 21720, RESELLER, 0bfd66d529a55807 #33Across EU #hb #tag -pubmatic.com, 156423, RESELLER, 5d62403b186f2ace #33Across #hb #tag -pubmatic.com, 158136, RESELLER, 5d62403b186f2ace #33Across EU #hb #tag -pubmatic.com, 158569, RESELLER, 5d62403b186f2ace #33Across #tag #ebda -appnexus.com, 10239, RESELLER, f5ab79cb980f11d1 #33Across #hb #tag #viewable -appnexus.com, 1001, RESELLER, f5ab79cb980f11d1 #33Across #tag -appnexus.com, 3135, RESELLER, f5ab79cb980f11d1 #33Across #tag -openx.com, 537120563, RESELLER, 6a698e2ec38604c6 #33Across #hb #tag -openx.com, 539392223, RESELLER, 6a698e2ec38604c6 #33Across #tag #ebda -openx.com, 540995201, RESELLER, 6a698e2ec38604c6 #33Across #hb #tag #viewable -adtech.com, 12094, RESELLER #33Across #hb #tag -adtech.com, 9993, RESELLER #33Across #tag -aol.com, 47594, RESELLER, e1a5b5b6e3255540 #33Across #hb #tag #viewable -yahoo.com, 55188, DIRECT, e1a5b5b6e3255540 #33Across #tag #ebda -advangelists.com, 8d3bba7425e7c98c50f52ca1b52d3735, RESELLER, 60d26397ec060f98 #33Across #hb #tag -sonobi.com, a416546bb7, RESELLER, d1a215d9eb5aee9e #33Across #tag #ebda -indexexchange.com, 190966, RESELLER, 50b1c356f2c5c8fc #33Across #tag #ebda -indexexchange.com, 183635, RESELLER, 50b1c356f2c5c8fc #33Across #hb #tag #viewable -google.com, pub-9557089510405422, RESELLER, f08c47fec0942fa0 #33Across #tag - -#Rubiconproject -rubiconproject.com, 15636, RESELLER, 0bfd66d529a55807 - -#LockerDome -lockerdome.com, 11908041977355520, DIRECT - -#Yield Nexus -yieldnexus.com, 1, DIRECT -ssp.ynxs.io, 185, DIRECT -appnexus.com, 10617, RESELLER, f5ab79cb980f11d1 -appnexus.com, 9393, RESELLER, f5ab79cb980f11d1 -advertising.com, 25034, RESELLER -sonobi.com, 783272317b, RESELLER, d1a215d9eb5aee9e -indexexchange.com, 186684,RESELLER, 50b1c356f2c5c8fc - -#CPM -appnexus.com, 9624, RESELLER, f5ab79cb980f11d1 -adtech.com, 11506, RESELLER -yahoo.com, 56896, RESELLER -pubmatic.com, 156078, RESELLER, 5d62403b186f2ace -advertising.com, 25218, RESELLER #video, US -beachfront.com, 9065, RESELLER -contextweb.com, 559969, RESELLER, 89ff185a4c4e857c -indexexchange.com, 189455, RESELLER, 50b1c356f2c5c8fc -advertising.com, 28320, RESELLER -richaudience.com, NtMZGaQQTT, RESELLER -adform.com, 1942, RESELLER -adform.com, 1941, RESELLER -adtech.com, 4687, RESELLER -aerserv.com, 2750, RESELLER, 2ce496b9f80eb9fa -aol.com, 27093, RESELLER -aol.com, 46658, RESELLER -aolcloud.net, 4687, RESELLER -appnexus.com, 2928, RESELLER, f5ab79cb980f11d1 -contextweb.com, 560520, RESELLER, 89ff185a4c4e857c -google.com, pub-9115524111147081, RESELLER, f08c47fec0942fa0 -google.com, pub-4673227357197067, RESELLER, f08c47fec0942fa0 -indexexchange.com, 179394, RESELLER, 50b1c356f2c5c8fc -lijit.com, 249425, RESELLER, fafdf38b16bf6b2b -cpmstar.com, 49818, RESELLER -mobfox.com, 74240, RESELLER -mobfox.com, 45499, RESELLER -openx.com, 539625136, RESELLER, 6a698e2ec38604c6 -smaato.com, 1100037086, RESELLER -smaato.com, 1100000579, RESELLER -sovrn.com, 249425, RESELLER, fafdf38b16bf6b2b -openx.com, 541079309, RESELLER, 6a698e2ec38604c6 -openx.com, 541166421, RESELLER, 6a698e2ec38604c6 -contextweb.com, 562263, RESELLER, 89ff185a4c4e857c -districtm.io, 102015, RESELLER, 3fd707be9c4527c3 -lkqd.net, 304, RESELLER, 59c49fa9598a0117 -lkqd.com, 304, RESELLER, 59c49fa9598a0117 -advertising.com, 2694, RESELLER -google.com, pub-5781531207509232, RESELLER, f08c47fec0942fa0 -appnexus.com, 806, RESELLER, f5ab79cb980f11d1 -freewheel.tv, 211121, RESELLER -freewheel.tv, 211129, RESELLER -indexexchange.com, 183921, RESELLER, 50b1c356f2c5c8fc -openx.com, 540134228, RESELLER, 6a698e2ec38604c6 -openx.com, 540634629, RESELLER, 6a698e2ec38604c6 -pubmatic.com, 156715, RESELLER, 5d62403b186f2ace -rubiconproject.com, 13762, RESELLER, 0bfd66d529a55807 -smartadserver.com, 3490, RESELLER -springserve.com, 550, RESELLER, a24eb641fc82e93d -beachfront.com, 4969, RESELLER, e2541279e8e2ca4d -advertising.com, 26282, RESELLER -pubmatic.com, 157310, RESELLER, 5d62403b186f2ace -rhythmone.com, 2968119028, RESELLER, a670c89d4a324e47 -contextweb.com, 561910, RESELLER, 89ff185a4c4e857c -openx.com, 540226160, RESELLER, 6a698e2ec38604c6 -openx.com, 540255318, RESELLER, 6a698e2ec38604c6 -ssp.ynxs.io, 185, RESELLER -tremorhub.com, hpwve, RESELLER, 1a4e959a1b50034a -telaria.com, hpwve, RESELLER, 1a4e959a1b50034a -video.unrulymedia.com, UNRX-PUB-29dad46b-9bec-43c7-b950-c59d09cc8c71, RESELLER -video.unrulymedia.com, 985572675, RESELLER -rhythmone.com, 2864567592, RESELLER, a670c89d4a324e47 -vidoomy.com, 51019, RESELLER -aol.com, 22762, RESELLER -freewheel.tv, 872257, RESELLER -openx.com, 540804929, RESELLER, 6a698e2ec38604c6 -emxdgt.com, 1495, RESELLER, 1e1d41537f7cad7f - -#Rubicon -rubiconproject.com, 23042, RESELLER, 0bfd66d529a55807 -rubiconproject.com, 23044, RESELLER, 0bfd66d529a55807 - - -#AMX - -amxrtb.com, 105199469, RESELLER -appnexus.com, 12290, RESELLER, f5ab79cb980f11d1 -appnexus.com, 11786, RESELLER, f5ab79cb980f11d1 -indexexchange.com, 191503, RESELLER, 50b1c356f2c5c8fc -lijit.com, 260380, RESELLER, fafdf38b16bf6b2b -sovrn.com, 260380, RESELLER, fafdf38b16bf6b2b -pubmatic.com, 158355, RESELLER, 5d62403b186f2ace -appnexus.com, 9393, RESELLER, f5ab79cb980f11d1 #Video #Display -appnexus.com, 11924, RESELLER, f5ab79cb980f11d1 - -#Kueez -kueez.com, fe46d13305ce1b89f18a84c52275b7fe, DIRECT -appnexus.com, 8826, RESELLER -rubiconproject.com, 16920, RESELLER -openx.com, 557564833, RESELLER -lijit.com, 407406, RESELLER -media.net, 8cu4jtrf9, RESELLER -pubmatic.com, 162110, RESELLER -sharethrough.com, n98xdzel, RESELLER -33across.com, 0010b00002odu4haax, RESELLER -yieldmo.com, 3133660606033240149, RESELLER -onetag.com, 6e053d779444c00, RESELLER -video.unrulymedia.com, 3486482593, RESELLER -sonobi.com, 4c4fba1717, RESELLER -smartadserver.com, 4288, RESELLER -zetaglobal.com, 108, RESELLER -improvedigital.com, 2106, RESELLER -loopme.com, 11576, RESELLER -themediagrid.com, uot45z, RESELLER - - -#Aniview - -aniview.com, 606c5af8b82e996ca965f498, RESELLER, 78b21b97965ec3f8 -advertising.com, 23089, RESELLER -appnexus.com, 12637, RESELLER, f5ab79cb980f11d1 -appnexus.com, 9382, RESELLER, f5ab79cb980f11d1 -synacor.com, 82171, RESELLER, e108f11b2cdf7d5b -pubmatic.com, 156344, RESELLER, 5d62403b186f2ace -rubiconproject.com, 13344, RESELLER, 0bfd66d529a55807 -indexexchange.com, 191740, RESELLER, 50b1c356f2c5c8fc -conversantmedia.com, 100195, DIRECT, 03113cd04947736d -appnexus.com, 4052, RESELLER, f5ab79cb980f11d1 -contextweb.com, 561998, RESELLER, 89ff185a4c4e857c -pubmatic.com, 158100, RESELLER, 5d62403b186f2ace -yahoo.com, 55771, RESELLER, e1a5b5b6e3255540 -onetag.com, 57e618150c70d90, DIRECT -google.com, pub-3769010358500643, RESELLER, f08c47fec0942fa0 -video.unrulymedia.com, 3350674472, DIRECT -rhythmone.com, 3350674472, DIRECT, a670c89d4a324e47 -google.com, pub-4586415728471297, RESELLER, f08c47fec0942fa0 -google.com, pub-3565385483761681, DIRECT, f08c47fec0942fa0 -google.com, pub-5717092533913515, RESELLER, f08c47fec0942fa0 -smartadserver.com, 2786, DIRECT -improvedigital.com, 1147, DIRECT -google.com, pub-2930805104418204, RESELLER, f08c47fec0942fa0 -google.com, pub-4903453974745530, RESELLER, f08c47fec0942fa0 -richaudience.com, 1ru8dKmJJV, DIRECT -advertising.com, 7574, RESELLER -appnexus.com, 8233, RESELLER, f5ab79cb980f11d1 -pubmatic.com, 81564, RESELLER, 5d62403b186f2ace -pubmatic.com, 156538, RESELLER, 5d62403b186f2ace -rubiconproject.com, 13510, RESELLER, 0bfd66d529a55807 -smartadserver.com, 2640, RESELLER -smartadserver.com, 2441, RESELLER -yahoo.com, 57857, RESELLER, e1a5b5b6e3255540 -undertone.com, 4077, DIRECT -appnexus.com, 2234, RESELLER, f5ab79cb980f11d1 -rubiconproject.com, 22412, RESELLER, 0bfd66d529a55807 -advertising.com, 28650, RESELLER -pubmatic.com, 160318, RESELLER, 5d62403b186f2ace -pubmatic.com, 160319, RESELLER, 5d62403b186f2ace -appnexus.com, 10112, RESELLER, f5ab79cb980f11d1 -google.com, pub-0679975395820445, RESELLER, f08c47fec0942fa0 -google.com, pub-9936969251765866, RESELLER, f08c47fec0942fa0 - -#Fluct -adingo.jp, 25262, RESELLER -pubmatic.com, 156313, RESELLER, 5d62403b186f2ace -appnexus.com, 7044, RESELLER, f5ab79cb980f11d1 -pubmatic.com, 158060, RESELLER, 5d62403b186f2ace - -#Conversant -conversantmedia.com, 100106, RESELLER, 03113cd04947736d -lijit.com, 411121, RESELLER, fafdf38b16bf6b2b #SOVRN -admanmedia.com, 2050, RESELLER -Appnerve.com, 187287, RESELLER -rubiconproject.com, 23644, RESELLER, 0bfd66d529a55807 - - -#OneTag -onetag.com, 7683ebe7bee7969, RESELLER -onetag.com, 7683ebe7bee7969-OB, RESELLER -appnexus.com, 13099, RESELLER, f5ab79cb980f11d1 -yahoo.com, 58905, RESELLER, e1a5b5b6e3255540 -rubiconproject.com, 11006, RESELLER, 0bfd66d529a55807 -smartadserver.com, 4111, RESELLER - -#Media.net -media.net, 8CUEHU9Y5, RESELLER -openx.com, 537100188, RESELLER, 6a698e2ec38604c6 -pubmatic.com, 159463, RESELLER, 5d62403b186f2ace -emxdgt.com, 1759, RESELLER, 1e1d41537f7cad7f -google.com, pub-7439041255533808, RESELLER, f08c47fec0942fa0 -rubiconproject.com, 19396, RESELLER, 0bfd66d529a55807 -onetag.com, 5d49f482552c9b6, RESELLER -sonobi.com, 83729e979b, RESELLER -33across.com, 0010b00002cGp2AAAS, RESELLER, bbea06d9c4d2853c -rhythmone.com, 3611299104, RESELLER, a670c89d4a324e47 -districtm.io, 100600, RESELLER -lemmatechnologies.com, 399, RESELLER, 7829010c5bebd1fb #LEMMA -e-planning.net,ec771b05828a67fa,RESELLER,c1ba615865ed87b2 -google.com, pub-9685734445476814, RESELLER, f08c47fec0942fa0 - -#EMX Digital -emxdgt.com, 2345, RESELLER, 1e1d41537f7cad7f - - -#The MediaGrid -themediagrid.com, B8ZEVT, RESELLER, 35d5010d7789b49d -themediagrid.com, 3W8S2K, RESELLER, 35d5010d7789b49d - -#triplelift -triplelift.com, 12900, RESELLER, 6c33edb13117fd86 -triplelift.com, 12900-EB, DIRECT, 6c33edb13117fd86 -triplelift.com, 13897, DIRECT, 6c33edb13117fd86 - -#Sharethrough - -sharethrough.com, buaxQzOE, RESELLER, d53b998a7bd4ecd2 -sharethrough.com, jvyAFD6e, DIRECT, d53b998a7bd4ecd2 -pubmatic.com, 156557, RESELLER, 5d62403b186f2ace -rubiconproject.com, 18694, RESELLER, 0bfd66d529a55807 -openx.com, 540274407, RESELLER, 6a698e2ec38604c6 -33across.com, 0013300001kQj2HAAS, RESELLER, bbea06d9c4d2853c -smaato.com, 1100047713, RESELLER, 07bcf65f187117b4 -yahoo.com, 59531, RESELLER, e1a5b5b6e3255540 -smartadserver.com, 4342, RESELLER -smartadserver.com, 4012, RESELLER - - -#V 15.01.2024 PH - -#------------------------------------------------------------------------------------------------------ -adagio.io, 1090, DIRECT # Adagio_0_6 -rubiconproject.com, 19116, RESELLER, 0bfd66d529a55807 # Adagio_0_6 -pubmatic.com, 159110, RESELLER, 5d62403b186f2ace # Adagio_0_6 -improvedigital.com, 1790, RESELLER # Adagio_0_6 -indexexchange.com, 194558, RESELLER # Adagio_0_6 -richaudience.com, 1BTOoaD22a, DIRECT # Adagio_0_6 -33across.com, 0015a00002oUk4aAAC, DIRECT, bbea06d9c4d2853c # Adagio_0_6 -appnexus.com, 10239, RESELLER, f5ab79cb980f11d1 # Adagio_0_6 -rubiconproject.com, 16414, RESELLER, 0bfd66d529a55807 # Adagio_0_6 -lijit.com, 367236, RESELLER, fafdf38b16bf6b2b # Adagio_0_6 -e-planning.net, 83c06e81531537f4, RESELLER, c1ba615865ed87b2 # Adagio_0_6 -amxrtb.com, 105199358, DIRECT # AdaptMX_1_6&7 -indexexchange.com, 191503, RESELLER # AdaptMX_1_6&7 -appnexus.com, 11786, RESELLER # AdaptMX_1_6&7 -appnexus.com, 12290, RESELLER # AdaptMX_1_6&7 -pubmatic.com, 158355, RESELLER, 5d62403b186f2ace # AdaptMX_1_6&7 -advertising.com, 28305, RESELLER # AdaptMX_1_6&7 -rubiconproject.com, 23844, RESELLER, 0bfd66d529a55807 # AdaptMX_1_6&7 -openx.com, 559680764, RESELLER, 6a698e2ec38604c6 # AdaptMX_1_6&7 -adform.com, 2767, RESELLER # Adform_0_6&7 -adyoulike.com, c1314a52de718f3c214c00173d2994f9, DIRECT # AdYouLike_0_6 -pubmatic.com, 160925, RESELLER, 5d62403b186f2ace # AdYouLike_0_6 -rubiconproject.com, 20736, RESELLER, 0bfd66d529a55807 # AdYouLike_0_6 -appnexus.com, 7664, RESELLER # AdYouLike_0_6 -aps.amazon.com,70247b00-ff8f-4016-b3ab-8344daf96e09,DIRECT # Amazon_3_6&7 -ad-generation.jp,12474,RESELLER # Amazon_3_6&7 -aniview.com, 5f2063121d82c82557194737, RESELLER, 78b21b97965ec3f8 # Aniview -aniview.com, 643f8e74688b10f72307cc24, DIRECT, 78b21b97965ec3f8 # Aniview -google.com, pub-6346866704322274, RESELLER, f08c47fec0942fa0 # Aniview -pubmatic.com, 160993, RESELLER, 5d62403b186f2ace # Aniview -rubiconproject.com, 13918, RESELLER, 0bfd66d529a55807 # Aniview -google.com, pub-5717092533913515, RESELLER, f08c47fec0942fa0 # Aniview -gannett.com, 22652678936, RESELLER # Aniview -richaudience.com, 1ru8dKmJJV, RESELLER # Aniview -appnexus.com, 12637, RESELLER, f5ab79cb980f11d1 # Aniview -google.com, pub-3565385483761681, RESELLER, f08c47fec0942fa0 # Aniview -sharethrough.com, zLsEa05k, RESELLER, d53b998a7bd4ecd2 # Aniview -aps.amazon.com, 1ad7261b-91ea-4b6f-b9e9-b83522205b75, RESELLER # Aniview -pubmatic.com, 161335, RESELLER, 5d62403b186f2ace # Aniview -google.com, pub-7734005103835923, RESELLER, f08c47fec0942fa0 # Aniview -openx.com, 559611024, RESELLER, 6a698e2ec38604c6 # Aniview -yieldlab.net, 495507, DIRECT # Aniview -blockthrough.com, 5643766199222272, DIRECT # Blockthrough -appnexus.com, 6979, RESELLER # Blockthrough -indexexchange.com, 194341, RESELLER, 50b1c356f2c5c8fc # Blockthrough -pubmatic.com, 160377, RESELLER, 5d62403b186f2ace # Blockthrough -rubiconproject.com, 23718, RESELLER, 0bfd66d529a55807 # Blockthrough -onetag.com, 75804861b76a852, DIRECT # Blockthrough -amxrtb.com, 105199664, DIRECT # Blockthrough -criteo.com, B-062405, DIRECT, 9fac4a4a87c2a44f # Criteo_0_6&7 -themediagrid.com, CVQXOH, DIRECT, 35d5010d7789b49d # Criteo_0_6&7 -cpmstar.com, 53615, DIRECT # CPMSTAR -rhythmone.com,1838093862,DIRECT,a670c89d4a324e47 # CPMSTAR -video.unrulymedia.com, 1838093862, DIRECT # CPMSTAR -pubmatic.com, 160251, DIRECT, 5d62403b186f2ace # CPMSTAR -pubmatic.com, 161595, DIRECT, 5d62403b186f2ace # CPMSTAR -rubiconproject.com, 23330, DIRECT, 0bfd66d529a55807 # CPMSTAR -conversantmedia.com, 41150, DIRECT, 03113cd04947736d # Epsilon -adingo.jp, 24379, DIRECT # Fluct_1_6&7 -freewheel.tv, 211121, DIRECT # Freewheel_0_7 -freewheel.tv, 211129, RESELLER # Freewheel_0_7 -google.com, pub-5781531207509232, RESELLER, f08c47fec0942fa0 # Google_AdX_6&7 -google.com, pub-2553634189837243, RESELLER, f08c47fec0942fa0 # Google_AdX_6&7 -gumgum.com, 13385, RESELLER, ffdef49475d318a9 # GumGum_JP_0_9_6 -gumgum.com, 14302, RESELLER, ffdef49475d318a9 # GumGum_JP_0_9_6 -improvedigital.com, 1012, DIRECT # Improve_0_6&7 -improvedigital.com, 1640, RESELLER # Improve_1_6 -improvedigital.com, 2114, RESELLER # Improve_kids_1_6&7 -indexexchange.com, 183921, DIRECT, 50b1c356f2c5c8fc # Index Exchange_0_6&7 -indexexchange.com, 188416, DIRECT, 50b1c356f2c5c8fc # Index Exchange_1_6&7 -indexexchange.com, 193067, DIRECT, 50b1c356f2c5c8fc # Index Exchange_2_6&7 -indexexchange.com, 194127, DIRECT, 50b1c356f2c5c8fc # Index Exchange_7&4_6&7 -indexexchange.com, 205972, RESELLER, 50b1c356f2c5c8fc # Index Exchange_Oz -indexexchange.com, 206870, RESELLER, 50b1c356f2c5c8fc # Index_EasyConnect -iion.io, 10133, DIRECT # iion -kargo.com, 8688, DIRECT # Kargo_0_6 -rubiconproject.com, 17902, RESELLER, 0bfd66d529a55807 # Magnite_1_6&7 -rubiconproject.com, 13762, RESELLER, 0bfd66d529a55807 # Magnite_0&2_6&7 -telaria.com,hpwve,RESELLER,1a4e959a1b50034a # Magnite_Streaming -tremorhub.com,hpwve,RESELLER,1a4e959a1b50034a # Magnite_Streaming -media.net, 8CU8ARTF8, DIRECT # Media.net -Media.net, 8CU5786QK, DIRECT # Media.net -themediagrid.com, LTW57M, DIRECT, 35d5010d7789b49d # MediaGrid_2_6&7 -minutemedia.com, 01gerz6y43ck, RESELLER # MinuteMedia_0_6 -pubmatic.com, 161683, RESELLER, 5d62403b186f2ace # MinuteMedia_0_6 -appnexus.com, 8381, RESELLER # MinuteMedia_0_6 -triplelift.com, 6030, RESELLER, 6c33edb13117fd86 # MinuteMedia_0_6 -33across.com, 0013300001jlr99AAA, RESELLER, bbea06d9c4d2853c # MinuteMedia_0_6 -nobid.io, 22629800915, DIRECT # Nobid_0_6 -sonobi.com, 7ad1b9f952, RESELLER, d1a215d9eb5aee9e # Nobid_0_6 -xandr.com, 12701, RESELLER, f5ab79cb980f11d1 # Nobid_0_6 -lijit.com, 273657, DIRECT, fafdf38b16bf6b2b # Nobid_0_6 -onetag.com, 694e68b73971b58, DIRECT # Nobid_0_6 -yahoo.com, 57872, RESELLER # Nobid_0_6 -sharethrough.com, UvcAx8IL, DIRECT, d53b998a7bd4ecd2 # Nobid_0_6 -ogury.com, 086233d2-e8a8-44fc-907b-f0752e1c85de, DIRECT # Ogury_0_6 -appnexus.com, 11470, RESELLER # Ogury_0_6 -openx.com, 537144009, RESELLER, 6a698e2ec38604c6 # OpenX_0_6 -openx.com, 540134228, RESELLER, 6a698e2ec38604c6 # OpenX_0_7 -openx.com, 540368327, RESELLER, 6a698e2ec38604c6 # OpenX_1_6&7 -openx.com, 542378302, RESELLER, 6a698e2ec38604c6 # OpenX_2_6&7 -the-ozone-project.com, ozoneven0005, DIRECT # Ozone_0_6 -appnexus.com, 9979, RESELLER # Ozone_0_6 -openx.com, 540731760, RESELLER, 6a698e2ec38604c6 # Ozone_0_6 -adform.com, 2657, RESELLER, 9f5210a2f0999e32 # Ozone_0_6 -pubmatic.com, 160557, RESELLER, 5d62403b186f2ace # Ozone_0_6 -themediagrid.com, WF71T3, DIRECT, 35d5010d7789b49d # Ozone_0_6 -pgamssp.com, 634dc90283fff00f005151f2, DIRECT # PGAM_0_7 -freewheel.tv, 1489202, RESELLER # PGAM_0_7 -freewheel.tv, 1488706, RESELLER # PGAM_0_7 -video.unrulymedia.com, 5921144960123684292, RESELLER # PGAM_0_7 -appnexus.com, 9291, RESELLER # PGAM_0_7 -pubmatic.com, 162623, RESELLER, 5d62403b186f2ace # PGAM_0_7 -primis.tech, 31136, DIRECT, b6b21d256ef43532 # Primis -pubmatic.com, 156595, RESELLER, 5d62403b186f2ace # Primis -google.com, pub-1320774679920841, RESELLER, f08c47fec0942fa0 # Primis -openx.com, 540258065, RESELLER, 6a698e2ec38604c6 # Primis -rubiconproject.com, 20130, RESELLER, 0bfd66d529a55807 # Primis -freewheel.tv, 19133, RESELLER, 74e8e47458f74754 # Primis -smartadserver.com, 3436, RESELLER, 060d053dcf45cbf3 # Primis -indexexchange.com, 191923, RESELLER, 50b1c356f2c5c8fc # Primis -adform.com, 2078, RESELLER # Primis -Media.net, 8CU695QH7, RESELLER # Primis -video.unrulymedia.com, 2338962694, RESELLER # Primis -sharethrough.com, flUyJowI, RESELLER, d53b998a7bd4ecd2 # Primis -triplelift.com, 8210, RESELLER, 6c33edb13117fd86 # Primis -yahoo.com, 59260, RESELLER # Primis -pubmatic.com, 159234, RESELLER, 5d62403b186f2ace # PubMatic_0_6&7 -pubmatic.com, 158940, RESELLER, 5d62403b186f2ace # PubMatic_1_6&7 -pubmatic.com, 160552, RESELLER, 5d62403b186f2ace # PubMatic_4_7 -pubmatic.com, 159401, RESELLER, 5d62403b186f2ace # PubMatic_2_6&7 -pubmatic.com, 163598, RESELLER, 5d62403b186f2ace # Pubmatic_OW -richaudience.com, 1XvIoD5o0S, DIRECT # Rich Audience_0_6&7 -risecodes.com, 5fa94677b2db6a00015b22a9, DIRECT # Rise -pubmatic.com, 160295, RESELLER, 5d62403b186f2ace # Rise -xandr.com, 14082, RESELLER # Rise -rubiconproject.com, 23876, RESELLER, 0bfd66d529a55807 # Rise -media.net, 8CUQ6928Q, RESELLER # Rise_Temp -sharethrough.com, 5926d422, RESELLER, d53b998a7bd4ecd2 # Rise_Temp -sharethrough.com, 31c129df, DIRECT, d53b998a7bd4ecd2 # Sharethrough_0_6&7 -sharethrough.com, Ip2TfKpa, DIRECT, d53b998a7bd4ecd2 # Sharethrough_1_6&7 -smartadserver.com, 2161, RESELLER # Showheroes_7_8 -appnexus.com, 8833, RESELLER, f5ab79cb980f11d1 # Showheroes_7_8 -smartadserver.com, 3668, RESELLER # Showheroes_7_8 -freewheel.tv, 1003361, DIRECT # Showheroes_7_8 -pubmatic.com, 156695, DIRECT, 5d62403b186f2ace # Showheroes_7_8 -showheroes.com, 6829, RESELLER # Showheroes_7_8 -smartadserver.com, 3490, DIRECT # Smart AdServer_0&1&2_6&7 -smartadserver.com, 3490-OB, DIRECT, 060d053dcf45cbf3 # Smart AdServer_0&1&2_6&7 -smartadserver.com, 4016, DIRECT # Smart AdServer_0&1&2_6&7 -smartadserver.com, 4074, DIRECT # Smart AdServer_0&1&2_6&7 -smaato.com, 1100055690, DIRECT, 07bcf65f187117b4 # Smaato -smaato.com, 1100004890, DIRECT, 07bcf65f187117b4 # Smaato -sonobi.com, 116da9d98c, DIRECT, d1a215d9eb5aee9e # Sonobi_0_6&7 -sonobi.com, e017850301, DIRECT, d1a215d9eb5aee9e # Sonobi_4_7 -sovrn.com, 237754, DIRECT, fafdf38b16bf6b2b # Sovrn_0&1&2_6&7 -lijit.com, 237754, DIRECT, fafdf38b16bf6b2b # Sovrn_0&1&2_6&7 -lijit.com, 237754-eb, DIRECT, fafdf38b16bf6b2b # Sovrn_1_6&7 -taboola.com,1422403,DIRECT,c228e6794e811952 # Taboola_6_8 -triplelift.com, 6059, DIRECT, 6c33edb13117fd86 # Triplelift_0&2_6&7 -triplelift.com, 6059-EB, DIRECT, 6c33edb13117fd86 # Triplelift_0&2_6&7 -video.unrulymedia.com, 985572675, DIRECT # Unruly_0&2_7 -rhythmone.com, 2864567592, DIRECT, a670c89d4a324e47 # Unruly_0&2_7 -xandr.com, 13799, RESELLER # Unruly -sharethrough.com, 6qlnf8SY, RESELLER, d53b998a7bd4ecd2 # Unruly -vidazoo.com, 655c85dc63ceeb606a0f365f, DIRECT, b6ada874b4d7d0b2 # Vidazoo -pubmatic.com, 159988, RESELLER, 5d62403b186f2ace # Vidazoo -rubiconproject.com, 17130, RESELLER, 0bfd66d529a55807 # Vidazoo -pubmatic.com, 156512, DIRECT # Wunderkind -indexexchange.com, 183753, DIRECT # Wunderkind -wunderkind.co, 6438, DIRECT # Wunderkind -wunderkind.co, 6449, DIRECT # Wunderkind -criteo.com, B-068503, DIRECT # Wunderkind -appnexus.com, 806, DIRECT, f5ab79cb980f11d1 # Xandr_0&2_6&7 -appnexus.com,1908,RESELLER,f5ab79cb980f11d1 # Xandr_0&2_6&7 - - -#Equativ - -smartadserver.com, 4571, RESELLER, 060d053dcf45cbf3 -smartadserver.com, 4571-OB, RESELLER, 060d053dcf45cbf3 -smartadserver.com, 4016, RESELLER, 060d053dcf45cbf3 #Global -smartadserver.com, 4012, RESELLER, 060d053dcf45cbf3 #EUR -smartadserver.com, 4071, RESELLER, 060d053dcf45cbf3 #USD -smartadserver.com, 4073, RESELLER, 060d053dcf45cbf3 #BRL -smartadserver.com, 4074, RESELLER, 060d053dcf45cbf3 #MXN -smartadserver.com, 4247, RESELLER, 060d053dcf45cbf3 #CAD -smartadserver.com, 4228, RESELLER, 060d053dcf45cbf3 #USD_CTV -pubmatic.com, 156439, RESELLER, 5d62403b186f2ace -pubmatic.com, 154037, RESELLER, 5d62403b186f2ace -rubiconproject.com, 16114, RESELLER, 0bfd66d529a55807 -openx.com, 537149888, RESELLER, 6a698e2ec38604c6 -appnexus.com, 3703, RESELLER, f5ab79cb980f11d1 -loopme.com, 5679, RESELLER, 6c8d5f95897a5a3b -xad.com, 958, RESELLER, 81cbf0a75a5e0e9a -video.unrulymedia.com, 2564526802, RESELLER -smaato.com, 1100044045, RESELLER, 07bcf65f187117b4 -pubnative.net, 1006576, RESELLER, d641df8625486a7b -verve.com, 15503, RESELLER, 0c8f5958fc2d6270 -adyoulike.com, b4bf4fdd9b0b915f746f6747ff432bde, RESELLER, 4ad745ead2958bf7 -axonix.com, 57264, RESELLER -admanmedia.com, 43, RESELLER -sharethrough.com, OAW69Fon, RESELLER, d53b998a7bd4ecd2 -contextweb.com, 560288, RESELLER, 89ff185a4c4e857c - -#nobid - -nobid.io, 22931676975, DIRECT -xandr.com, 11429, RESELLER, f5ab79cb980f11d1 -sharethrough.com, aRE1degH, RESELLER, d53b998a7bd4ecd2 -sonobi.com, 7ad1b9f952, RESELLER, d1a215d9eb5aee9e -sharethrough.com, UvcAx8IL, RESELLER, d53b998a7bd4ecd2 -amxrtb.com, 105199579, RESELLER -yahoo.com,49648,RESELLER -rubiconproject.com, 24434, RESELLER, 0bfd66d529a55807 -minutemedia.com, 01gerz67grgj, RESELLER -pubmatic.com, 161683, RESELLER, 5d62403b186f2ace -appnexus.com, 8381, RESELLER, f5ab79cb980f11d1 -triplelift.com, 6030, RESELLER, 6c33edb13117fd86 -sonobi.com, 37fbaf262c, RESELLER, d1a215d9eb5aee9e -openx.com, 540780517, RESELLER, 6a698e2ec38604c6 -rubiconproject.com, 17598, RESELLER, 0bfd66d529a55807 -indexexchange.com, 196326, RESELLER, 50b1c356f2c5c8fc -yahoo.com, 59407, RESELLER, e1a5b5b6e3255540 -sharethrough.com, xz7QjFBY, RESELLER, d53b998a7bd4ecd2 -inmobi.com,8f261ace12c3486ba2e0d2011cd97976,RESELLER,83e75a7ae333ca9d -risecodes.com, 63ea59eef828de0001cf1773, RESELLER -inmobi.com, 9e311c7a68e94888aac7fbb4272381e2, RESELLER, 83e75a7ae333ca9d -video.unrulymedia.com, 1352466146, RESELLER -yahoo.com, 59261, RESELLER, e1a5b5b6e3255540 -gumgum.com, 13926, RESELLER, ffdef49475d318a9 -onetag.com, 694e68b73971b58, RESELLER -lijit.com, 273657, RESELLER, fafdf38b16bf6b2b -sovrn.com, 273657, RESELLER, fafdf38b16bf6b2b -mediafuse.com, 389, RESELLER -appnexus.com, 9538, RESELLER, f5ab79cb980f11d1 -yahoo.com, 57872, RESELLER -video.unrulymedia.com, 2997140015, RESELLER -indexexchange.com, 182257, RESELLER, 50b1c356f2c5c8fc -152media.info,152M374,RESELLER -appnexus.com, 3153, RESELLER, f5ab79cb980f11d1 -#media.net_serverside_displayvideo -media.net, 8CUV34PJ4, DIRECT -sharethrough.com, koRtppYA, RESELLER, d53b998a7bd4ecd2 -video.unrulymedia.com, 699546687, RESELLER -lijit.com, 264726, RESELLER, fafdf38b16bf6b2b -onetag.com, 765b4e6bb9c8438, RESELLER -amxrtb.com, 105199663, RESELLER -yieldmo.com, 2954622693783052507, RESELLER -loopme.com, 11556, RESELLER, 6c8d5f95897a5a3b -Contextweb.com, 562963, RESELLER, 89ff185a4c4e857c -zeta.com, 591, RESELLER -disqus.com, 591, RESELLER -admanmedia.com, 953, RESELLER -smartadserver.com, 4106, RESELLER, 060d053dcf45cbf3 -imds.tv, 82302, RESELLER, ae6c32151e71f19d -improvedigital.com, 2073, RESELLER -betweendigital.com, 44808, RESELLER -adyoulike.com, 53264963677efeda057eef7db2cb305f, RESELLER -freewheel.tv,1577878,RESELLER -freewheel.tv,1577888,RESELLER -dxkulture.com, 9533, DIRECT, 259726033fc4df0c -dxkulture.com, 0098, DIRECT, 259726033fc4df0c -adswizz.com,dxkulture,DIRECT -adswizz.com,651,DIRECT -pubmatic.com,164751,RESELLER,5d62403b186f2ace -rubiconproject.com,26094,DIRECT,0bfd66d529a55807 -zetaglobal.net,790,DIRECT -ssp.disqus.com,790,DIRECT -video.unrulymedia.com,946176315,RESELLER -video.unrulymedia.com, 347774562, RESELLER -rubiconproject.com, 15268, RESELLER, 0bfd66d529a55807 -pubmatic.com, 159277, RESELLER - -#AdaptMX - -amxrtb.com, 105199723, DIRECT -appnexus.com, 12290, RESELLER -pubmatic.com, 161527, RESELLER -rubiconproject.com, 23844, RESELLER - - - -# Adagio -adagio.io, 1361, RESELLER -# Adagio - Magnite -rubiconproject.com, 19116, RESELLER, 0bfd66d529a55807 -# Adagio - Pubmatic -pubmatic.com, 159110, RESELLER, 5d62403b186f2ace -# Adagio - Improve Digital -improvedigital.com, 1790, RESELLER -# Adagio - Onetag -onetag.com, 6b859b96c564fbe, RESELLER -appnexus.com, 13099, RESELLER -pubmatic.com, 161593, RESELLER, 5d62403b186f2ace -# Adagio - Index Exchange -indexexchange.com, 194558, RESELLER -# Adagio - 33Across -33across.com, 0015a00002oUk4aAAC, RESELLER, bbea06d9c4d2853c -yahoo.com, 57289, RESELLER, e1a5b5b6e3255540 -appnexus.com, 10239, RESELLER, f5ab79cb980f11d1 -rubiconproject.com, 16414, RESELLER, 0bfd66d529a55807 -pubmatic.com, 156423, RESELLER, 5d62403b186f2ace -rubiconproject.com, 21642, RESELLER, 0bfd66d529a55807 -conversantmedia.com, 100141, RESELLER -indexexchange.com, 191973, RESELLER, 50b1c356f2c5c8fc -triplelift.com, 12503, RESELLER, 6c33edb13117fd86 -insticator.com, 4ec3ed85-2830-4174-9f7f-f545620598b9, RESELLER -sharethrough.com, Q9IzHdvp, RESELLER, d53b998a7bd4ecd2 -admanmedia.com, 2216, RESELLER -connectad.io, 456, RESELLER, 85ac85a30c93b3e5 -# Adagio - Equativ -smartadserver.com, 3554, RESELLER -# Adagio - Sovrn -lijit.com, 367236, RESELLER, fafdf38b16bf6b2b -# Adagio - Freewheel -freewheel.tv, 1568036, RESELLER -freewheel.tv, 1568041, RESELLER -# Adagio - OpenX -openx.com, 558899373, RESELLER, 6a698e2ec38604c6 -# Adagio - Triplelift -triplelift.com, 13482, RESELLER, 6c33edb13117fd86 -# Adagio - E-Planning -e-planning.net, 83c06e81531537f4, RESELLER, c1ba615865ed87b2 -pubmatic.com, 156631, RESELLER, 5d62403b186f2ace -openx.com, 541031350, RESELLER, 6a698e2ec38604c6 -rubiconproject.com, 12186, RESELLER, 0bfd66d529a55807 -# Adagio - Nexxen -video.unrulymedia.com, 5672421953199218469, RESELLER - -#Freewheel - -freewheel.tv, 1598995, RESELLER -freewheel.tv, 1599004, RESELLER - - -#Pgam - -pgamssp.com, 64661fa49d522e327b0a8b84, DIRECT -freewheel.tv, 1489202, RESELLER -freewheel.tv, 1488706, RESELLER -rubiconproject.com, 24852, RESELLER, 0bfd66d529a55807 -pubmatic.com, 162623, RESELLER, 5d62403b186f2ace -video.unrulymedia.com, 5921144960123684292, RESELLER -appnexus.com, 9291, RESELLER, f5ab79cb980f11d1 - - -#Sonobi - -sonobi.com, 3ee2ca3952, RESELLER, d1a215d9eb5aee9e - - -#Rich Audience - -richaudience.com, kWVs0vbyki, RESELLER -appnexus.com, 2928, DIRECT, f5ab79cb980f11d1 -smartadserver.com, 1999, RESELLER, 060d053dcf45cbf3 - -#Ozone - -the-ozone-project.com, OZONEAIP0001, DIRECT -appnexus.com, 9979, RESELLER, f5ab79cb980f11d1 -openx.com, 540731760, RESELLER, 6a698e2ec38604c6 -adform.com, 2657, RESELLER, 9f5210a2f0999e32 -pubmatic.com, 160557, RESELLER, 5d62403b186f2ace -indexexchange.com, 206233, RESELLER, 50b1c356f2c5c8fc -themediagrid.com, 1J3ZI6, DIRECT, 35d5010d7789b49d -themediagrid.com, WF71T3, DIRECT, 35d5010d7789b49d - -# OptiDigital -optidigital.com,p345,RESELLER -pubmatic.com,158939,RESELLER,5d62403b186f2ace -rubiconproject.com,20336,RESELLER,0bfd66d529a55807 -smartadserver.com,3379,RESELLER,060d053dcf45cbf3 -criteo.com,B-060926,RESELLER,9fac4a4a87c2a44f -themediagrid.com,3ETIX5,RESELLER,35d5010d7789b49d -triplelift.com,8183,RESELLER,6c33edb13117fd86 -appnexus.com,12190,RESELLER,f5ab79cb980f11d1 -onetag.com,806eabb849d0326,RESELLER -rtbhouse.com,mSu1piUSmB9TF4AQDGk4,RESELLER -33across.com,001Pg00000HMy0YIAT,RESELLER,bbea06d9c4d2853c -e-planning.net,a76893b96338e7e9,RESELLER,c1ba615865ed87b2 -appnexus.com,15941,RESELLER,f5ab79cb980f11d1 -video.unrulymedia.com,731539260,RESELLER - -#Rise -risecodes.com, 643813aab7212c00011c3f28, DIRECT -pubmatic.com, 160295, RESELLER, 5d62403b186f2ace -xandr.com, 14082, RESELLER -rubiconproject.com, 23876, RESELLER, 0bfd66d529a55807 -sharethrough.com, 5926d422, RESELLER, d53b998a7bd4ecd2 -media.net, 8CUQ6928Q, RESELLER -sonobi.com, 4a289cdd79, RESELLER, d1a215d9eb5aee9e -video.unrulymedia.com, 335119963, RESELLER -contextweb.com,562615,RESELLER,89ff185a4c4e857c -onetag.com, 69f48c2160c8113, RESELLER -33across.com, 0010b00002Xbn7QAAR, RESELLER, bbea06d9c4d2853c -yieldmo.com, 2754490424016969782, RESELLER -openx.com, 537140488, RESELLER, 6a698e2ec38604c6 -lijit.com, 405318, RESELLER, fafdf38b16bf6b2b -themediagrid.com, 4DQHAP, RESELLER, 35d5010d7789b49d -loopme.com, 11362, RESELLER, 6c8d5f95897a5a3b -amxrtb.com, 105199691, RESELLER -smartadserver.com, 4284, RESELLER -adform.com, 3119, RESELLER, 9f5210a2f0999e32 -smaato.com, 1100057444, RESELLER, 07bcf65f187117b4 -adyoulike.com, 78afbc34fac571736717317117dfa247, RESELLER - -#Block -blockthrough.com, 5130683165442048, DIRECT -pubmatic.com, 160377, RESELLER, 5d62403b186f2ace -rubiconproject.com, 23718, RESELLER, 0bfd66d529a55807 -appnexus.com, 6979, RESELLER -lijit.com, 251666, RESELLER, fafdf38b16bf6b2b -lijit.com, 251666-eb, RESELLER, fafdf38b16bf6b2b -video.unrulymedia.com, 2444764291, RESELLER -contextweb.com, 558511, RESELLER -krushmedia.com, AJxF6R572a9M6CaTvK, RESELLER -criteo.com, 8990, RESELLER -smartadserver.com, 4485, RESELLER, 060d053dcf45cbf3 -smartadserver.com, 4485-OB, RESELLER, 060d053dcf45cbf3 -Contextweb.com, 562926, RESELLER, 89ff185a4c4e857c - -# VT Amazon TAM - -aps.amazon.com,70247b00-ff8f-4016-b3ab-8344daf96e09,DIRECT -indexexchange.com, 193067, DIRECT, 50b1c356f2c5c8fc -triplelift.com, 6059, DIRECT, 6c33edb13117fd86 -sharethrough.com, 31c129df, DIRECT, d53b998a7bd4ecd2 -appnexus.com, 806, DIRECT, f5ab79cb980f11d1 -risecodes.com, 5fa94677b2db6a00015b22a9, DIRECT -minutemedia.com, 01gerz6y43ck, RESELLER -themediagrid.com, LTW57M, DIRECT, 35d5010d7789b49d -vidazoo.com, 655c85dc63ceeb606a0f365f, DIRECT, b6ada874b4d7d0b2 -smartadserver.com, 3490, DIRECT - -################################## -# AdinPlay.com ads.txt - 2025-04-16 -################################## - -venatus.com, OFI, DIRECT \ No newline at end of file From 32f5723c72213c92d19eb31877d7023a317e1ca6 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Thu, 29 May 2025 20:53:18 -0400 Subject: [PATCH 17/84] Simplify bots retaliation logic (#946) ## Description: Simplify bots retaliation logic. Do not counter-attack before reaching the trigger ratio. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/core/execution/BotExecution.ts | 1 - src/core/execution/FakeHumanExecution.ts | 1 - src/core/execution/utils/BotBehavior.ts | 16 ++++++++++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts index 3b0354810..84d46768c 100644 --- a/src/core/execution/BotExecution.ts +++ b/src/core/execution/BotExecution.ts @@ -79,7 +79,6 @@ export class BotExecution implements Execution { } this.behavior.forgetOldEnemies(); - this.behavior.checkIncomingAttacks(); const enemy = this.behavior.selectRandomEnemy(); if (!enemy) return; if (!this.bot.sharesBorderWith(enemy)) return; diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index af71e77c2..4fa3ec7cd 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -262,7 +262,6 @@ export class FakeHumanExecution implements Execution { throw new Error("not initialized"); } this.behavior.forgetOldEnemies(); - this.behavior.checkIncomingAttacks(); this.behavior.assistAllies(); const enemy = this.behavior.selectEnemy(); if (!enemy) return; diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts index afbb53331..e68a0c775 100644 --- a/src/core/execution/utils/BotBehavior.ts +++ b/src/core/execution/utils/BotBehavior.ts @@ -52,7 +52,7 @@ export class BotBehavior { } } - checkIncomingAttacks() { + private checkIncomingAttacks() { // Switch enemies if we're under attack const incomingAttacks = this.player.incomingAttacks(); if (incomingAttacks.length > 0) { @@ -109,6 +109,11 @@ export class BotBehavior { } } + // Retaliate against incoming attacks + if (this.enemy === null) { + this.checkIncomingAttacks(); + } + // Select the most hated player if (this.enemy === null) { const mostHated = this.player.allRelationsSorted()[0]; @@ -145,8 +150,15 @@ export class BotBehavior { this.enemy = neighbor; this.enemyUpdated = this.game.ticks(); } + } - // Select a traitor as an enemy + // Retaliate against incoming attacks + if (this.enemy === null) { + this.checkIncomingAttacks(); + } + + // Select a traitor as an enemy + if (this.enemy === null) { const traitors = this.player .neighbors() .filter((n) => n.isPlayer() && n.isTraitor()) as Player[]; From be2e64c85c08e42fb2934bf8444cf8d7c96d7dbb Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 29 May 2025 19:18:34 -0700 Subject: [PATCH 18/84] put meta changes in news modal --- resources/lang/en.json | 3 +++ src/client/NewsModal.ts | 39 +++++++++++++++++++++++++++++++++------ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 7f4bd92d0..886b68fd6 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -20,6 +20,9 @@ "advertise": "Advertise", "wiki": "Wiki" }, + "news": { + "title": "Version 23 released!" + }, "help_modal": { "hotkeys": "Hotkeys", "table_key": "Key", diff --git a/src/client/NewsModal.ts b/src/client/NewsModal.ts index 94c8116bf..13ddb67bb 100644 --- a/src/client/NewsModal.ts +++ b/src/client/NewsModal.ts @@ -12,6 +12,10 @@ export class NewsModal extends LitElement { }; static styles = css` + :host { + display: block; + } + .news-container { max-height: 60vh; overflow-y: auto; @@ -24,10 +28,20 @@ export class NewsModal extends LitElement { .news-content { color: #ddd; line-height: 1.5; - background: rgba(255, 255, 255, 0.05); + background: rgba(0, 0, 0, 0.6); border-radius: 8px; padding: 1rem; } + + .news-content a { + color: #4a9eff !important; + text-decoration: underline !important; + transition: color 0.2s ease; + } + + .news-content a:hover { + color: #6fb3ff !important; + } `; render() { @@ -36,7 +50,24 @@ export class NewsModal extends LitElement {
    -
    INSERT NEWS HERE
    +
    +

    Main things to note:

    +
    +
      +
    • Workers reproduce faster than troops.
    • +
    • Defense = troops divided how much land you have.
    • +
    • Attacking troops count toward your population limit.
    • +
    +
    +
    + See full changelog + here. +
    @@ -58,8 +89,4 @@ export class NewsModal extends LitElement { private close() { this.modalEl?.close(); } - - createRenderRoot() { - return this; // light DOM - } } From ca0a848f9fe2a8b55871b12753b40111b9805000 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 29 May 2025 19:44:53 -0700 Subject: [PATCH 19/84] show news button --- src/client/Main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/Main.ts b/src/client/Main.ts index 22bd98b65..8fdc0cfdb 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -75,7 +75,7 @@ class Client { } // Comment out to show news button. - newsButton.hidden = true; + // newsButton.hidden = true; const langSelector = document.querySelector( "lang-selector", From 91b19c1f097c976b01913931d95279dbbc0dee21 Mon Sep 17 00:00:00 2001 From: Drills Kibo <59177241+drillskibo@users.noreply.github.com> Date: Fri, 30 May 2025 05:55:03 +0200 Subject: [PATCH 20/84] Add close label (#949) ## Description: Add a label to the close button on News modal. Closes [#947](https://github.com/openfrontio/OpenFrontIO/issues/947) ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: `drillskibo` ![image](https://github.com/user-attachments/assets/35500890-a30a-4481-aa73-802152325eef) --- resources/lang/en.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/resources/lang/en.json b/resources/lang/en.json index 886b68fd6..e75ecdb6e 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -5,6 +5,9 @@ "svg": "uk_us_flag", "lang_code": "en" }, + "common": { + "close": "Close" + }, "main": { "title": "OpenFront (ALPHA)", "join_discord": "Join the Discord!", From 44e7b4990d57b1d9e578d41071259c17031dc77b Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Fri, 30 May 2025 02:12:03 -0400 Subject: [PATCH 21/84] Remove ClientID from GameRenderer (#878) ## Description: GameView provides a `myPlayer()` implementation. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors --- src/client/ClientGameRunner.ts | 7 +----- src/client/graphics/GameRenderer.ts | 15 +++--------- src/client/graphics/layers/ChatDisplay.ts | 6 ++--- src/client/graphics/layers/ControlPanel.ts | 2 -- src/client/graphics/layers/EventsDisplay.ts | 24 +++++++++---------- src/client/graphics/layers/Leaderboard.ts | 9 +------ src/client/graphics/layers/NameLayer.ts | 15 +----------- .../graphics/layers/PlayerInfoOverlay.ts | 17 +++---------- src/client/graphics/layers/RadialMenu.ts | 4 +--- src/client/graphics/layers/TeamStats.ts | 2 -- src/client/graphics/layers/UILayer.ts | 2 -- src/client/graphics/layers/UnitLayer.ts | 23 +++++------------- 12 files changed, 29 insertions(+), 97 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index df11c56c8..67d899f22 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -142,12 +142,7 @@ export async function createClientGame( consolex.log("going to init path finder"); consolex.log("inited path finder"); const canvas = createCanvas(); - const gameRenderer = createRenderer( - canvas, - gameView, - eventBus, - lobbyConfig.clientID, - ); + const gameRenderer = createRenderer(canvas, gameView, eventBus); consolex.log( `creating private game got difficulty: ${lobbyConfig.gameStartInfo.config.difficulty}`, diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 6fef871c0..ad114234c 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -1,6 +1,5 @@ import { consolex } from "../../core/Consolex"; import { EventBus } from "../../core/EventBus"; -import { ClientID } from "../../core/Schemas"; import { GameView } from "../../core/game/GameView"; import { GameStartingModal } from "../GameStartingModal"; import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler"; @@ -38,7 +37,6 @@ export function createRenderer( canvas: HTMLCanvasElement, game: GameView, eventBus: EventBus, - clientID: ClientID, ): GameRenderer { const transformHandler = new TransformHandler(game, eventBus, canvas); @@ -71,7 +69,6 @@ export function createRenderer( if (!emojiTable || !(leaderboard instanceof Leaderboard)) { consolex.error("EmojiTable element not found in the DOM"); } - leaderboard.clientID = clientID; leaderboard.eventBus = eventBus; leaderboard.game = game; @@ -79,7 +76,6 @@ export function createRenderer( if (!emojiTable || !(teamStats instanceof TeamStats)) { consolex.error("EmojiTable element not found in the DOM"); } - teamStats.clientID = clientID; teamStats.eventBus = eventBus; teamStats.game = game; @@ -87,7 +83,6 @@ export function createRenderer( if (!(controlPanel instanceof ControlPanel)) { consolex.error("ControlPanel element not found in the DOM"); } - controlPanel.clientID = clientID; controlPanel.eventBus = eventBus; controlPanel.uiState = uiState; controlPanel.game = game; @@ -100,7 +95,6 @@ export function createRenderer( } eventsDisplay.eventBus = eventBus; eventsDisplay.game = game; - eventsDisplay.clientID = clientID; const chatDisplay = document.querySelector("chat-display") as ChatDisplay; if (!(chatDisplay instanceof ChatDisplay)) { @@ -108,7 +102,6 @@ export function createRenderer( } chatDisplay.eventBus = eventBus; chatDisplay.game = game; - chatDisplay.clientID = clientID; const playerInfo = document.querySelector( "player-info-overlay", @@ -117,7 +110,6 @@ export function createRenderer( consolex.error("player info overlay not found"); } playerInfo.eventBus = eventBus; - playerInfo.clientID = clientID; playerInfo.transform = transformHandler; playerInfo.game = game; @@ -201,10 +193,10 @@ export function createRenderer( new TerrainLayer(game, transformHandler), new TerritoryLayer(game, eventBus), structureLayer, - new UnitLayer(game, eventBus, clientID, transformHandler), + new UnitLayer(game, eventBus, transformHandler), new FxLayer(game), - new UILayer(game, eventBus, clientID, transformHandler), - new NameLayer(game, transformHandler, clientID), + new UILayer(game, eventBus, transformHandler), + new NameLayer(game, transformHandler), eventsDisplay, chatDisplay, buildMenu, @@ -212,7 +204,6 @@ export function createRenderer( eventBus, game, transformHandler, - clientID, emojiTable as EmojiTable, buildMenu, uiState, diff --git a/src/client/graphics/layers/ChatDisplay.ts b/src/client/graphics/layers/ChatDisplay.ts index e4f376e18..f6940ae0a 100644 --- a/src/client/graphics/layers/ChatDisplay.ts +++ b/src/client/graphics/layers/ChatDisplay.ts @@ -9,7 +9,6 @@ import { GameUpdateType, } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; -import { ClientID } from "../../../core/Schemas"; import { onlyImages } from "../../../core/Util"; import { Layer } from "./Layer"; @@ -24,7 +23,6 @@ interface ChatEvent { export class ChatDisplay extends LitElement implements Layer { public eventBus: EventBus; public game: GameView; - public clientID: ClientID; private active: boolean = false; @@ -61,7 +59,7 @@ export class ChatDisplay extends LitElement implements Layer { onDisplayMessageEvent(event: DisplayMessageUpdate) { if (event.messageType !== MessageType.CHAT) return; - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if ( event.playerID !== null && (!myPlayer || myPlayer.smallID() !== event.playerID) @@ -90,7 +88,7 @@ export class ChatDisplay extends LitElement implements Layer { if (messages) { for (const msg of messages) { if (msg.messageType === MessageType.CHAT) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if ( msg.playerID !== null && (!myPlayer || myPlayer.smallID() !== msg.playerID) diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts index 0ec35f9af..19d2bd02c 100644 --- a/src/client/graphics/layers/ControlPanel.ts +++ b/src/client/graphics/layers/ControlPanel.ts @@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators.js"; import { translateText } from "../../../client/Utils"; import { EventBus } from "../../../core/EventBus"; import { GameView } from "../../../core/game/GameView"; -import { ClientID } from "../../../core/Schemas"; import { AttackRatioEvent } from "../../InputHandler"; import { SendSetTargetTroopRatioEvent } from "../../Transport"; import { renderNumber, renderTroops } from "../../Utils"; @@ -13,7 +12,6 @@ import { Layer } from "./Layer"; @customElement("control-panel") export class ControlPanel extends LitElement implements Layer { public game: GameView; - public clientID: ClientID; public eventBus: EventBus; public uiState: UIState; diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index f95f4d84b..0ccba3800 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -23,7 +23,6 @@ import { TargetPlayerUpdate, UnitIncomingUpdate, } from "../../../core/game/GameUpdates"; -import { ClientID } from "../../../core/Schemas"; import { CancelAttackIntentEvent, CancelBoatIntentEvent, @@ -66,7 +65,6 @@ interface Event { export class EventsDisplay extends LitElement implements Layer { public eventBus: EventBus; public game: GameView; - public clientID: ClientID; private active: boolean = false; private events: Event[] = []; @@ -184,7 +182,7 @@ export class EventsDisplay extends LitElement implements Layer { renderLayer(): void {} onDisplayMessageEvent(event: DisplayMessageUpdate) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if ( event.playerID !== null && (!myPlayer || myPlayer.smallID() !== event.playerID) @@ -202,7 +200,7 @@ export class EventsDisplay extends LitElement implements Layer { } onDisplayChatEvent(event: DisplayChatMessageUpdate) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if ( event.playerID === null || !myPlayer || @@ -230,7 +228,7 @@ export class EventsDisplay extends LitElement implements Layer { } onAllianceRequestEvent(update: AllianceRequestUpdate) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if (!myPlayer || update.recipientID !== myPlayer.smallID()) { return; } @@ -282,7 +280,7 @@ export class EventsDisplay extends LitElement implements Layer { } onAllianceRequestReplyEvent(update: AllianceRequestReplyUpdate) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if (!myPlayer || update.request.requestorID !== myPlayer.smallID()) { return; } @@ -303,7 +301,7 @@ export class EventsDisplay extends LitElement implements Layer { } onBrokeAllianceEvent(update: BrokeAllianceUpdate) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if (!myPlayer) return; const betrayed = this.game.playerBySmallID(update.betrayedID) as PlayerView; @@ -341,7 +339,7 @@ export class EventsDisplay extends LitElement implements Layer { } onAllianceExpiredEvent(update: AllianceExpiredUpdate) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if (!myPlayer) return; const otherID = @@ -365,7 +363,7 @@ export class EventsDisplay extends LitElement implements Layer { onTargetPlayerEvent(event: TargetPlayerUpdate) { const other = this.game.playerBySmallID(event.playerID) as PlayerView; - const myPlayer = this.game.playerByClientID(this.clientID) as PlayerView; + const myPlayer = this.game.myPlayer() as PlayerView; if (!myPlayer || !myPlayer.isFriendly(other)) return; const target = this.game.playerBySmallID(event.targetID) as PlayerView; @@ -380,13 +378,13 @@ export class EventsDisplay extends LitElement implements Layer { } emitCancelAttackIntent(id: string) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if (!myPlayer) return; this.eventBus.emit(new CancelAttackIntentEvent(myPlayer.id(), id)); } emitBoatCancelIntent(id: number) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if (!myPlayer) return; this.eventBus.emit(new CancelBoatIntentEvent(id)); } @@ -406,7 +404,7 @@ export class EventsDisplay extends LitElement implements Layer { } onEmojiMessageEvent(update: EmojiUpdate) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if (!myPlayer) return; const recipient = @@ -441,7 +439,7 @@ export class EventsDisplay extends LitElement implements Layer { } onUnitIncomingEvent(event: UnitIncomingUpdate) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if (!myPlayer || myPlayer.smallID() !== event.playerID) { return; diff --git a/src/client/graphics/layers/Leaderboard.ts b/src/client/graphics/layers/Leaderboard.ts index fb22697b4..2bcca0e8d 100644 --- a/src/client/graphics/layers/Leaderboard.ts +++ b/src/client/graphics/layers/Leaderboard.ts @@ -4,7 +4,6 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { translateText } from "../../../client/Utils"; import { EventBus, GameEvent } from "../../../core/EventBus"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; -import { ClientID } from "../../../core/Schemas"; import { renderNumber } from "../../Utils"; import { Layer } from "./Layer"; @@ -36,7 +35,6 @@ export class GoToUnitEvent implements GameEvent { @customElement("leader-board") export class Leaderboard extends LitElement implements Layer { public game: GameView | null = null; - public clientID: ClientID | null = null; public eventBus: EventBus | null = null; players: Entry[] = []; @@ -66,12 +64,7 @@ export class Leaderboard extends LitElement implements Layer { private updateLeaderboard() { if (this.game === null) throw new Error("Not initialized"); - if (this.clientID === null) { - return; - } - const myPlayer = - this.game.playerViews().find((p) => p.clientID() === this.clientID) ?? - null; + const myPlayer = this.game.myPlayer(); const sorted = this.game .playerViews() diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 32d924fcc..2f8c36a3d 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -8,7 +8,6 @@ import shieldIcon from "../../../../resources/images/ShieldIconBlack.svg"; import targetIcon from "../../../../resources/images/TargetIcon.svg"; import traitorIcon from "../../../../resources/images/TraitorIcon.svg"; import { PseudoRandom } from "../../../core/PseudoRandom"; -import { ClientID } from "../../../core/Schemas"; import { Theme } from "../../../core/configuration/Config"; import { AllPlayers, Cell, nukeTypes, UnitType } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; @@ -47,14 +46,12 @@ export class NameLayer implements Layer { private nukeRedIconImage: HTMLImageElement; private shieldIconImage: HTMLImageElement; private container: HTMLDivElement; - private myPlayer: PlayerView | null = null; private firstPlace: PlayerView | null = null; private theme: Theme = this.game.config().theme(); constructor( private game: GameView, private transformHandler: TransformHandler, - private clientID: ClientID, ) { this.traitorIconImage = new Image(); this.traitorIconImage.src = traitorIcon; @@ -314,7 +311,7 @@ export class NameLayer implements Layer { ".player-icons", ) as HTMLDivElement; const iconSize = Math.min(render.fontSize * 1.5, 48); - const myPlayer = this.getPlayer(); + const myPlayer = this.game.myPlayer(); // Crown icon const existingCrown = iconsDiv.querySelector('[data-icon="crown"]'); @@ -520,14 +517,4 @@ export class NameLayer implements Layer { } return icon; } - - private getPlayer(): PlayerView | null { - if (this.myPlayer !== null) { - return this.myPlayer; - } - this.myPlayer = - this.game.playerViews().find((p) => p.clientID() === this.clientID) ?? - null; - return this.myPlayer; - } } diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index 394669f81..4ee411891 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -11,7 +11,6 @@ import { } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; -import { ClientID } from "../../../core/Schemas"; import { MouseMoveEvent } from "../../InputHandler"; import { renderNumber, renderTroops } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; @@ -42,9 +41,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer { @property({ type: Object }) public game!: GameView; - @property({ type: String }) - public clientID!: ClientID; - @property({ type: Object }) public eventBus!: EventBus; @@ -137,13 +133,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer { this.requestUpdate(); } - private myPlayer(): PlayerView | null { - if (!this.game) { - return null; - } - return this.game.playerByClientID(this.clientID); - } - private getRelationClass(relation: Relation): string { switch (relation) { case Relation.Hostile: @@ -175,7 +164,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { } private renderPlayerInfo(player: PlayerView) { - const myPlayer = this.myPlayer(); + const myPlayer = this.game.myPlayer(); const isFriendly = myPlayer?.isFriendly(player); let relationHtml: TemplateResult | null = null; const attackingTroops = player @@ -275,8 +264,8 @@ export class PlayerInfoOverlay extends LitElement implements Layer { private renderUnitInfo(unit: UnitView) { const isAlly = - (unit.owner() === this.myPlayer() || - this.myPlayer()?.isFriendly(unit.owner())) ?? + (unit.owner() === this.game.myPlayer() || + this.game.myPlayer()?.isFriendly(unit.owner())) ?? false; return html` diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index db79c507e..4df72f104 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -16,7 +16,6 @@ import { } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; -import { ClientID } from "../../../core/Schemas"; import { CloseViewEvent, ContextMenuEvent, @@ -98,7 +97,6 @@ export class RadialMenu implements Layer { private eventBus: EventBus, private g: GameView, private transformHandler: TransformHandler, - private clientID: ClientID, private emojiTable: EmojiTable, private buildMenu: BuildMenu, private uiState: UIState, @@ -121,7 +119,7 @@ export class RadialMenu implements Layer { return; } const tile = this.g.ref(clickedCell.x, clickedCell.y); - const p = this.g.playerByClientID(this.clientID); + const p = this.g.myPlayer(); if (p === null) { return; } diff --git a/src/client/graphics/layers/TeamStats.ts b/src/client/graphics/layers/TeamStats.ts index b671becb9..e62152f13 100644 --- a/src/client/graphics/layers/TeamStats.ts +++ b/src/client/graphics/layers/TeamStats.ts @@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; import { GameMode } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; -import { ClientID } from "../../../core/Schemas"; import { renderNumber } from "../../Utils"; import { Layer } from "./Layer"; @@ -18,7 +17,6 @@ interface TeamEntry { @customElement("team-stats") export class TeamStats extends LitElement implements Layer { public game: GameView; - public clientID: ClientID; public eventBus: EventBus; teams: TeamEntry[] = []; diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts index ce91ea7be..e8b8a54a0 100644 --- a/src/client/graphics/layers/UILayer.ts +++ b/src/client/graphics/layers/UILayer.ts @@ -1,6 +1,5 @@ import { Colord } from "colord"; import { EventBus } from "../../../core/EventBus"; -import { ClientID } from "../../../core/Schemas"; import { Theme } from "../../../core/configuration/Config"; import { UnitType } from "../../../core/game/Game"; import { GameView, UnitView } from "../../../core/game/GameView"; @@ -35,7 +34,6 @@ export class UILayer implements Layer { constructor( private game: GameView, private eventBus: EventBus, - private clientID: ClientID, private transformHandler: TransformHandler, ) { this.theme = game.config().theme(); diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 8501610f8..03afa99c9 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -1,10 +1,9 @@ import { colord, Colord } from "colord"; import { EventBus } from "../../../core/EventBus"; -import { ClientID } from "../../../core/Schemas"; import { Theme } from "../../../core/configuration/Config"; import { UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; -import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; +import { GameView, UnitView } from "../../../core/game/GameView"; import { BezenhamLine } from "../../../core/utilities/Line"; import { AlternateViewEvent, @@ -40,8 +39,6 @@ export class UnitLayer implements Layer { private alternateView = false; - private myPlayer: PlayerView | null = null; - private oldShellTile = new Map(); private transformHandler: TransformHandler; @@ -55,7 +52,6 @@ export class UnitLayer implements Layer { constructor( private game: GameView, private eventBus: EventBus, - private clientID: ClientID, transformHandler: TransformHandler, ) { this.theme = game.config().theme(); @@ -67,9 +63,6 @@ export class UnitLayer implements Layer { } tick() { - if (this.myPlayer === null) { - this.myPlayer = this.game.playerByClientID(this.clientID); - } const unitIds = this.game .updatesSinceLastTick() ?.[GameUpdateType.Unit]?.map((unit) => unit.id); @@ -98,18 +91,13 @@ export class UnitLayer implements Layer { } const clickRef = this.game.ref(cell.x, cell.y); - // Make sure we have the current player - if (this.myPlayer === null) { - this.myPlayer = this.game.playerByClientID(this.clientID); - } - // Only select warships owned by the player return this.game .units(UnitType.Warship) .filter( (unit) => unit.isActive() && - unit.owner() === this.myPlayer && // Only allow selecting own warships + unit.owner() === this.game.myPlayer() && // Only allow selecting own warships this.game.manhattanDist(unit.tile(), clickRef) <= this.WARSHIP_SELECTION_RADIUS, ) @@ -256,13 +244,14 @@ export class UnitLayer implements Layer { } private relationship(unit: UnitView): Relationship { - if (this.myPlayer === null) { + const myPlayer = this.game.myPlayer(); + if (myPlayer === null) { return Relationship.Enemy; } - if (this.myPlayer === unit.owner()) { + if (myPlayer === unit.owner()) { return Relationship.Self; } - if (this.myPlayer.isFriendly(unit.owner())) { + if (myPlayer.isFriendly(unit.owner())) { return Relationship.Ally; } return Relationship.Enemy; From 74cad570d157af1a98e32958f40879d56347aadc Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Fri, 30 May 2025 12:02:07 -0400 Subject: [PATCH 22/84] Resolve code scanning warning about HTML injection (#953) ## Description: Resolve code scanning warning about HTML injection with copilot suggested fix. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/client/graphics/layers/NameLayer.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 2f8c36a3d..0d56c76e4 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -218,10 +218,19 @@ export class NameLayer implements Layer { shieldDiv.style.display = "flex"; shieldDiv.style.alignItems = "center"; shieldDiv.style.gap = "0px"; - shieldDiv.innerHTML = ` - - 0 - `; + const shieldImg = document.createElement("img"); + shieldImg.src = this.shieldIconImage.src; + shieldImg.style.width = "16px"; + shieldImg.style.height = "16px"; + + const shieldSpan = document.createElement("span"); + shieldSpan.textContent = "0"; + shieldSpan.style.color = "black"; + shieldSpan.style.fontSize = "10px"; + shieldSpan.style.marginTop = "-2px"; + + shieldDiv.appendChild(shieldImg); + shieldDiv.appendChild(shieldSpan); element.appendChild(shieldDiv); // Start off invisible so it doesn't flash at 0,0 From 838973a11b82a3ef07fdb20d0fb6b818f26ceb9f Mon Sep 17 00:00:00 2001 From: Demonessica <37988730+Demonessica@users.noreply.github.com> Date: Fri, 30 May 2025 09:03:04 -0700 Subject: [PATCH 23/84] Fix invalid username popup being behind public game button (#951) ## Description: one hour of fucking around with git later: fixes the invalid username popup being behind the public game button and accepts the close label fix from #949 ![image](https://github.com/user-attachments/assets/ab65ab56-79fc-4c72-8c99-a50521c2faaf) ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: demonessica --- src/client/UsernameInput.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts index 1f699da06..c0cd900c9 100644 --- a/src/client/UsernameInput.ts +++ b/src/client/UsernameInput.ts @@ -47,7 +47,7 @@ export class UsernameInput extends LitElement { /> ${this.validationError ? html`
    ${this.validationError}
    ` From 2a3240da1c70213b73c83120cd8912b6c2167201 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Fri, 30 May 2025 12:10:00 -0400 Subject: [PATCH 24/84] Server role lookup (#954) ## Description: - Validate that user tokens are accepted by the API server, in case of token revoked / remote logout. - Lookup user roles by their token. - Sets the groundwork for validating custom flag codes, patterns, etc. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/core/ApiSchemas.ts | 4 ---- src/server/Client.ts | 1 + src/server/Worker.ts | 18 ++++++++++++++++-- src/server/jwt.ts | 35 ++++++++++++++++++++++++++++++++++- 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 1c594e1a7..112ddc8b9 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -28,10 +28,6 @@ export const TokenPayloadSchema = z.object({ iss: z.string(), aud: z.string(), exp: z.number(), - rol: z - .string() - .optional() - .transform((val) => (val ?? "").split(",")), }); export type TokenPayload = z.infer; diff --git a/src/server/Client.ts b/src/server/Client.ts index 216150dbf..6eff8b1b8 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -12,6 +12,7 @@ export class Client { public readonly clientID: ClientID, public readonly persistentID: string, public readonly claims: TokenPayload | null, + public readonly roles: string[] | null, public readonly ip: string, public readonly username: string, public readonly ws: WebSocket, diff --git a/src/server/Worker.ts b/src/server/Worker.ts index e9fb2131a..2ec20a3fd 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -19,7 +19,7 @@ import { archive, readGameRecord } from "./Archive"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; import { gatekeeper, LimiterType } from "./Gatekeeper"; -import { verifyClientToken } from "./jwt"; +import { getUserMe, verifyClientToken } from "./jwt"; import { logger } from "./Logger"; import { initWorkerMetrics } from "./WorkerMetrics"; @@ -316,11 +316,25 @@ export function startWorker() { config, ); + const roles: string[] | null = null; + + // Check user roles + if (claims !== null) { + const result = await getUserMe(clientMsg.token, config); + if (result === false) { + log.warn("Token is not valid", claims); + return; + } + } + + // TODO: Validate client settings based on roles + // Create client and add to game const client = new Client( clientMsg.clientID, persistentId, - claims ?? null, + claims, + roles, ip, clientMsg.username, ws, diff --git a/src/server/jwt.ts b/src/server/jwt.ts index 150402a5f..c7896bd91 100644 --- a/src/server/jwt.ts +++ b/src/server/jwt.ts @@ -1,5 +1,10 @@ import { jwtVerify } from "jose"; -import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas"; +import { + TokenPayload, + TokenPayloadSchema, + UserMeResponse, + UserMeResponseSchema, +} from "../core/ApiSchemas"; import { ServerConfig } from "../core/configuration/Config"; type TokenVerificationResult = { @@ -27,3 +32,31 @@ export async function verifyClientToken( const persistentId = claims.sub; return { persistentId, claims }; } + +export async function getUserMe( + token: string, + config: ServerConfig, +): Promise { + try { + // Get the user object + const response = await fetch(config.jwtIssuer() + "/users/@me", { + headers: { + authorization: `Bearer ${token}`, + }, + }); + if (response.status !== 200) return false; + const body = await response.json(); + const result = UserMeResponseSchema.safeParse(body); + if (!result.success) { + console.error( + "Invalid response", + JSON.stringify(body), + JSON.stringify(result.error), + ); + return false; + } + return result.data; + } catch (e) { + return false; + } +} From d61efdc1de25c15d61fbfd216adb90996d0f1214 Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Fri, 30 May 2025 18:52:40 +0200 Subject: [PATCH 25/84] Flag fixes in several maps (#957) ## Description: Thanks to J.Dimlight for reporting this. Flag images were not displayed on maps. Or the wrong flag was displayed. Because of no flag code, or pointing to non-existant or incorrect flag file names. There are currently no flags to display for these Nations, as there is no flag file present in resources: Gateway to the Atlantic - Jesuit Monks - Wine Japan and Neighbors: - Sakhalin Mars: - European Space Agency South America: - The Biggest Snakes - Normal Capybaras - Just Otters ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: tryout33 --- resources/maps/Europe.json | 8 ++++---- resources/maps/GatewayToTheAtlantic.json | 12 ++++++------ resources/maps/Mars.json | 2 +- resources/maps/NorthAmerica.json | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/resources/maps/Europe.json b/resources/maps/Europe.json index 2fdc5694f..d0029e0f0 100644 --- a/resources/maps/Europe.json +++ b/resources/maps/Europe.json @@ -187,7 +187,7 @@ "coordinates": [1254, 899], "name": "Slovak Republic", "strength": 3, - "flag": "SK" + "flag": "sk" }, { "coordinates": [1002, 1061], @@ -259,7 +259,7 @@ "coordinates": [1522, 48], "name": "Polar Bears", "strength": 2, - "flag": "polar_bear" + "flag": "polar_bears" }, { "coordinates": [821, 628], @@ -289,13 +289,13 @@ "coordinates": [1919, 1608], "name": "Hashemite Kingdom of Jordan", "strength": 1, - "flag": "hu" + "flag": "jo" }, { "coordinates": [1898, 1535], "name": "Lebanese Republic", "strength": 1, - "flag": "hu" + "flag": "lb" } ] } diff --git a/resources/maps/GatewayToTheAtlantic.json b/resources/maps/GatewayToTheAtlantic.json index c36454b2a..9f3846102 100644 --- a/resources/maps/GatewayToTheAtlantic.json +++ b/resources/maps/GatewayToTheAtlantic.json @@ -19,7 +19,7 @@ "coordinates": [1334, 537], "name": "Duchy of Aquitaine", "strength": 2, - "flag": "aquitane" + "flag": "aquitaine" }, { "coordinates": [2115, 684], @@ -31,7 +31,7 @@ "coordinates": [1207, 763], "name": "The Basque", "strength": 3, - "flag": "" + "flag": "es-pv" }, { "coordinates": [1281, 1142], @@ -49,7 +49,7 @@ "coordinates": [561, 764], "name": "Kingdom of Galicia", "strength": 2, - "flag": "galicia" + "flag": "es-ga" }, { "coordinates": [1004, 1436], @@ -115,7 +115,7 @@ "coordinates": [1755, 1130], "name": "The Old Ones", "strength": 3, - "flag": "nuragic" + "flag": "neuragic_empire" }, { "coordinates": [2097, 1670], @@ -151,7 +151,7 @@ "coordinates": [1017, 180], "name": "Kingdom of Brittany", "strength": 2, - "flag": "britanny" + "flag": "brittany" }, { "coordinates": [2072, 567], @@ -175,7 +175,7 @@ "coordinates": [1475, 1657], "name": "French Foreign Legion", "strength": 3, - "flag": "french_foreign_legion" + "flag": "French foreign legion" }, { "coordinates": [1685, 417], diff --git a/resources/maps/Mars.json b/resources/maps/Mars.json index 342c707ec..c5d0a9f2d 100644 --- a/resources/maps/Mars.json +++ b/resources/maps/Mars.json @@ -13,7 +13,7 @@ "coordinates": [122, 750], "name": "USSR", "strength": 2, - "flag": "" + "flag": "ussr" }, { "coordinates": [1232, 735], diff --git a/resources/maps/NorthAmerica.json b/resources/maps/NorthAmerica.json index ac55c5ef6..9422284f9 100644 --- a/resources/maps/NorthAmerica.json +++ b/resources/maps/NorthAmerica.json @@ -283,7 +283,7 @@ "coordinates": [1189, 240], "name": "Polar Bears", "strength": 3, - "flag": "polar_bear" + "flag": "polar_bears" }, { "coordinates": [1480, 343], From f6687302c654fdcf5144f0471baf760a8033bd90 Mon Sep 17 00:00:00 2001 From: Duwibi <86431918+Duwibi@users.noreply.github.com> Date: Fri, 30 May 2025 22:50:25 +0300 Subject: [PATCH 26/84] Fix map jsons (#960) ## Description: Quite a bit of maps have problems with their jsons. In this PR, I'll try to fix some of them. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: Nikola123 --- resources/maps/Europe.json | 4 ++-- resources/maps/Japan.json | 4 ++-- resources/maps/NorthAmerica.json | 18 +++++++++--------- resources/maps/SouthAmerica.json | 4 ++-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/resources/maps/Europe.json b/resources/maps/Europe.json index d0029e0f0..33ff48987 100644 --- a/resources/maps/Europe.json +++ b/resources/maps/Europe.json @@ -244,7 +244,7 @@ "flag": "gb-sct" }, { - "coordinates": [2239, 3215], + "coordinates": [2300, 510], "name": "USSR", "strength": 3, "flag": "ussr" @@ -280,7 +280,7 @@ "flag": "eg" }, { - "coordinates": [1188, 1612], + "coordinates": [1115, 1650], "name": "State of Libya", "strength": 1, "flag": "ly" diff --git a/resources/maps/Japan.json b/resources/maps/Japan.json index 3f9ab541d..7996d5724 100644 --- a/resources/maps/Japan.json +++ b/resources/maps/Japan.json @@ -4,7 +4,7 @@ "height": 1646, "nations": [ { - "coordinates": [1151, 709], + "coordinates": [1150, 660], "name": "Hokkaido", "strength": 1, "flag": "jp" @@ -40,7 +40,7 @@ "flag": "jp" }, { - "coordinates": [8612, 1183], + "coordinates": [595, 1190], "name": "Shikoku", "strength": 2, "flag": "jp" diff --git a/resources/maps/NorthAmerica.json b/resources/maps/NorthAmerica.json index 9422284f9..ea8ee8494 100644 --- a/resources/maps/NorthAmerica.json +++ b/resources/maps/NorthAmerica.json @@ -4,37 +4,37 @@ "height": 1448, "nations": [ { - "coordinates": [1693, 1045], + "coordinates": [1625, 1040], "name": "Florida", "strength": 3, "flag": "Florida" }, { - "coordinates": [1001, 427], + "coordinates": [1010, 435], "name": "Canada", "strength": 2, "flag": "ca" }, { - "coordinates": [1364, 1179], + "coordinates": [1250, 1130], "name": "Mexico", "strength": 2, "flag": "mx" }, { - "coordinates": [1556, 1295], + "coordinates": [1460, 1275], "name": "Guatemala", "strength": 1, "flag": "gt" }, { - "coordinates": [1612, 1289], + "coordinates": [1530, 1290], "name": "Honduras", "strength": 1, "flag": "hn" }, { - "coordinates": [1642, 1348], + "coordinates": [1570, 1350], "name": "Nicaragua", "strength": 1, "flag": "ni" @@ -58,7 +58,7 @@ "flag": "ve" }, { - "coordinates": [1775, 1183], + "coordinates": [1725, 1180], "name": "Cuba", "strength": 1, "flag": "cu" @@ -94,7 +94,7 @@ "flag": "Georgia_US" }, { - "coordinates": [420, 1209], + "coordinates": [250, 1200], "name": "Hawaii", "strength": 1, "flag": "Hawaii" @@ -286,7 +286,7 @@ "flag": "polar_bears" }, { - "coordinates": [1480, 343], + "coordinates": [1480, 350], "name": "Frost Giants", "strength": 3, "flag": "frost_giant" diff --git a/resources/maps/SouthAmerica.json b/resources/maps/SouthAmerica.json index 36807cf01..fc31ad2a8 100644 --- a/resources/maps/SouthAmerica.json +++ b/resources/maps/SouthAmerica.json @@ -1,7 +1,7 @@ { "name": "Americas", "width": 1746, - "height": 2380, + "height": 2378, "nations": [ { "coordinates": [438, 58], @@ -94,7 +94,7 @@ "flag": "gf" }, { - "coordinates": [801, 242], + "coordinates": [800, 410], "name": "Guyana", "strength": 1, "flag": "gy" From 9ad2cfaffd66ed1e816dbc9b94995e18bf3f6361 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Fri, 30 May 2025 22:22:50 +0200 Subject: [PATCH 27/84] change defaults to reflect meta (#942) ## Description: Changes default target troop ratio and attack size to reflect better early game strategies in v23 ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: 1brucben --- src/client/graphics/layers/ControlPanel.ts | 10 +++++----- src/core/game/PlayerImpl.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts index 19d2bd02c..9ce3cc7cf 100644 --- a/src/client/graphics/layers/ControlPanel.ts +++ b/src/client/graphics/layers/ControlPanel.ts @@ -16,13 +16,13 @@ export class ControlPanel extends LitElement implements Layer { public uiState: UIState; @state() - private attackRatio: number = 0.2; + private attackRatio: number = 0.3; @state() - private targetTroopRatio = 0.95; + private targetTroopRatio = 0.6; @state() - private currentTroopRatio = 0.95; + private currentTroopRatio = 0.6; @state() private _population: number; @@ -59,10 +59,10 @@ export class ControlPanel extends LitElement implements Layer { init() { this.attackRatio = Number( - localStorage.getItem("settings.attackRatio") ?? "0.2", + localStorage.getItem("settings.attackRatio") ?? "0.3", ); this.targetTroopRatio = Number( - localStorage.getItem("settings.troopRatio") ?? "0.95", + localStorage.getItem("settings.troopRatio") ?? "0.6", ); this.init_ = true; this.uiState.attackRatio = this.attackRatio; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 49aa91777..46e4fe0a9 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -109,7 +109,7 @@ export class PlayerImpl implements Player { ) { this._flag = playerInfo.flag; this._name = sanitizeUsername(playerInfo.name); - this._targetTroopRatio = 95n; + this._targetTroopRatio = 60n; this._troops = toInt(startTroops); this._workers = 0n; this._gold = 0n; From a0e00e1fb03ac67c6e8fa4632394bd62918b05a0 Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Fri, 30 May 2025 22:25:07 +0200 Subject: [PATCH 28/84] Even more flag flair (#959) ## Description: Add Sakhalin flag for nation on Japan map. Use Aztec flag for Biggest Snakes nation. Change 'Jesuit Monks' nation into 'Tamazgha' with corresponding flag. Thanks @ N0ur and J. Dimlight ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: tryout33 --- resources/flags/Sakhalin.svg | 3 +++ resources/maps/GatewayToTheAtlantic.json | 4 ++-- resources/maps/Japan.json | 2 +- resources/maps/SouthAmerica.json | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 resources/flags/Sakhalin.svg diff --git a/resources/flags/Sakhalin.svg b/resources/flags/Sakhalin.svg new file mode 100644 index 000000000..ff85f0646 --- /dev/null +++ b/resources/flags/Sakhalin.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/resources/maps/GatewayToTheAtlantic.json b/resources/maps/GatewayToTheAtlantic.json index 9f3846102..0f90f7fb3 100644 --- a/resources/maps/GatewayToTheAtlantic.json +++ b/resources/maps/GatewayToTheAtlantic.json @@ -119,9 +119,9 @@ }, { "coordinates": [2097, 1670], - "name": "Jesuit Monks", + "name": "Tamazgha", "strength": 2, - "flag": "" + "flag": "Amazigh flag" }, { "coordinates": [979, 1013], diff --git a/resources/maps/Japan.json b/resources/maps/Japan.json index 7996d5724..9f3c53bd9 100644 --- a/resources/maps/Japan.json +++ b/resources/maps/Japan.json @@ -31,7 +31,7 @@ "coordinates": [1162, 154], "name": "Sakhalin", "strength": 2, - "flag": "" + "flag": "Sakhalin" }, { "coordinates": [571, 1116], diff --git a/resources/maps/SouthAmerica.json b/resources/maps/SouthAmerica.json index fc31ad2a8..ed56a9f8d 100644 --- a/resources/maps/SouthAmerica.json +++ b/resources/maps/SouthAmerica.json @@ -133,7 +133,7 @@ "coordinates": [1270, 1035], "name": "The Biggest Snakes", "strength": 3, - "flag": "" + "flag": "Aztec Empire" }, { "coordinates": [894, 693], From 851526ba4eab43e6c3ee848057f1ee7b062fb248 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Fri, 30 May 2025 23:19:39 +0200 Subject: [PATCH 29/84] Only load tiles when viewed by player (#887) ## Description: Tiles are only run through putImageData() if they are currently viewed by the player. This significantly (usually between 20-80%) reduces the computation time of putImageData() on large maps. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: 1brucben --------- Co-authored-by: evanpelle --- src/client/graphics/GameRenderer.ts | 2 +- src/client/graphics/layers/TerritoryLayer.ts | 90 ++++++++++---------- 2 files changed, 44 insertions(+), 48 deletions(-) diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index ad114234c..149706c8f 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -191,7 +191,7 @@ export function createRenderer( const layers: Layer[] = [ new TerrainLayer(game, transformHandler), - new TerritoryLayer(game, eventBus), + new TerritoryLayer(game, eventBus, transformHandler), structureLayer, new UnitLayer(game, eventBus, transformHandler), new FxLayer(game), diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index deb2cfd0a..550bab478 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -8,6 +8,7 @@ import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { PseudoRandom } from "../../../core/PseudoRandom"; import { AlternateViewEvent, DragEvent } from "../../InputHandler"; +import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; export class TerritoryLayer implements Layer { @@ -32,7 +33,7 @@ export class TerritoryLayer implements Layer { private lastDragTime = 0; private nodrawDragDuration = 200; - private refreshRate = 10; + private refreshRate = 10; //refresh every 10ms private lastRefresh = 0; private lastFocusedPlayer: PlayerView | null = null; @@ -40,6 +41,7 @@ export class TerritoryLayer implements Layer { constructor( private game: GameView, private eventBus: EventBus, + private transformHandler: TransformHandler, ) { this.theme = game.config().theme(); } @@ -48,11 +50,10 @@ export class TerritoryLayer implements Layer { return true; } - paintPlayerBorder(player: PlayerView) { - player.borderTiles().then((playerBorderTiles) => { - playerBorderTiles.borderTiles.forEach((tile: TileRef) => { - this.paintTerritory(tile, true); // Immediately paint the tile instead of enqueueing - }); + async paintPlayerBorder(player: PlayerView) { + const tiles = await player.borderTiles(); + tiles.borderTiles.forEach((tile: TileRef) => { + this.paintTerritory(tile, true); // Immediately paint the tile instead of enqueueing }); } @@ -128,11 +129,7 @@ export class TerritoryLayer implements Layer { euclDistFN(centerTile, 9, true), )) { if (!this.game.hasOwner(tile)) { - this.paintHighlightCell( - new Cell(this.game.x(tile), this.game.y(tile)), - color, - 255, - ); + this.paintHighlightTile(tile, color, 255); } } } @@ -155,16 +152,16 @@ export class TerritoryLayer implements Layer { const context = this.canvas.getContext("2d"); if (context === null) throw new Error("2d context not supported"); this.context = context; + this.canvas.width = this.game.width(); + this.canvas.height = this.game.height(); this.imageData = this.context.getImageData( 0, 0, - this.game.width(), - this.game.height(), + this.canvas.width, + this.canvas.height, ); this.initImageData(); - this.canvas.width = this.game.width(); - this.canvas.height = this.game.height(); this.context.putImageData(this.imageData, 0, 0); // Add a second canvas for highlights @@ -199,7 +196,19 @@ export class TerritoryLayer implements Layer { ) { this.lastRefresh = now; this.renderTerritory(); - this.context.putImageData(this.imageData, 0, 0); + + const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect(); + const vx0 = Math.max(0, topLeft.x); + const vy0 = Math.max(0, topLeft.y); + const vx1 = Math.min(this.game.width() - 1, bottomRight.x); + const vy1 = Math.min(this.game.height() - 1, bottomRight.y); + + const w = vx1 - vx0 + 1; + const h = vy1 - vy0 + 1; + + if (w > 0 && h > 0) { + this.context.putImageData(this.imageData, 0, 0, vx0, vy0, w, h); + } } if (this.alternativeView) { return; @@ -245,15 +254,10 @@ export class TerritoryLayer implements Layer { } if (!this.game.hasOwner(tile)) { if (this.game.hasFallout(tile)) { - this.paintCell( - this.game.x(tile), - this.game.y(tile), - this.theme.falloutColor(), - 150, - ); + this.paintTile(tile, this.theme.falloutColor(), 150); return; } - this.clearCell(new Cell(this.game.x(tile), this.game.y(tile))); + this.clearTile(tile); return; } const owner = this.game.owner(tile) as PlayerView; @@ -273,40 +277,28 @@ export class TerritoryLayer implements Layer { const lightTile = (x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1); const borderColor = lightTile ? borderColors.light : borderColors.dark; - this.paintCell(x, y, borderColor, 255); + this.paintTile(tile, borderColor, 255); } else { const useBorderColor = playerIsFocused ? this.theme.focusedBorderColor() : this.theme.borderColor(owner); - this.paintCell( - this.game.x(tile), - this.game.y(tile), - useBorderColor, - 255, - ); + this.paintTile(tile, useBorderColor, 255); } } else { - this.paintCell( - this.game.x(tile), - this.game.y(tile), - this.theme.territoryColor(owner), - 150, - ); + this.paintTile(tile, this.theme.territoryColor(owner), 150); } } - paintCell(x: number, y: number, color: Colord, alpha: number) { - const index = y * this.game.width() + x; - const offset = index * 4; + paintTile(tile: TileRef, color: Colord, alpha: number) { + const offset = tile * 4; this.imageData.data[offset] = color.rgba.r; this.imageData.data[offset + 1] = color.rgba.g; this.imageData.data[offset + 2] = color.rgba.b; this.imageData.data[offset + 3] = alpha; } - clearCell(cell: Cell) { - const index = cell.y * this.game.width() + cell.x; - const offset = index * 4; + clearTile(tile: TileRef) { + const offset = tile * 4; this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) } @@ -324,13 +316,17 @@ export class TerritoryLayer implements Layer { }); } - paintHighlightCell(cell: Cell, color: Colord, alpha: number) { - this.clearCell(cell); + paintHighlightTile(tile: TileRef, color: Colord, alpha: number) { + this.clearTile(tile); + const x = this.game.x(tile); + const y = this.game.y(tile); this.highlightContext.fillStyle = color.alpha(alpha / 255).toRgbString(); - this.highlightContext.fillRect(cell.x, cell.y, 1, 1); + this.highlightContext.fillRect(x, y, 1, 1); } - clearHighlightCell(cell: Cell) { - this.highlightContext.clearRect(cell.x, cell.y, 1, 1); + clearHighlightTile(tile: TileRef) { + const x = this.game.x(tile); + const y = this.game.y(tile); + this.highlightContext.clearRect(x, y, 1, 1); } } From b24814cb7d15a0b48faaf505e337789e10ad77ca Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Fri, 30 May 2025 23:05:35 -0400 Subject: [PATCH 30/84] Hide login button (#965) ## Description: Hide login button when logged in. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/client/Main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/Main.ts b/src/client/Main.ts index 8fdc0cfdb..c4b877eaa 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -188,8 +188,8 @@ class Client { logoutDiscordButton.hidden = true; return; } - // TODO: Update the page for logged in user loginDiscordButton.translationKey = "main.logged_in"; + loginDiscordButton.hidden = true; const { user, player } = userMeResponse; }); } From 5c750f13d175563ae17b13bd0ae25aa62a6720c5 Mon Sep 17 00:00:00 2001 From: Demonessica <37988730+Demonessica@users.noreply.github.com> Date: Thu, 29 May 2025 15:17:15 -0700 Subject: [PATCH 31/84] Fix CSS performance issues from #857 while preserving functionality (#928) ## Description: Replaces the `all` selector with a specific class to reduce CSS computation. Maintains functionality of hiding the news button. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: `demonessica` --- src/client/index.html | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/client/index.html b/src/client/index.html index 025003b45..ae94e0ae7 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -99,6 +99,11 @@ display: none; } } + + /* display:none if child has class parent-hidden since we can't use shadow DOM in Lit due to Tailwind */ + .component-hideable:has(> .parent-hidden) { + display: none; + } @@ -224,7 +229,9 @@
    - +
    From 15cd1e9ae929f8c516c4e051c282a4db2b52c203 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Sat, 31 May 2025 14:09:17 -0400 Subject: [PATCH 32/84] Fix discord user schema (#969) ## Description: Update the discord user zod schema to address login failures for users without avatars. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/core/ApiSchemas.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 112ddc8b9..ebb8dc99b 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -34,11 +34,11 @@ export type TokenPayload = z.infer; export const UserMeResponseSchema = z.object({ user: z.object({ id: z.string(), - avatar: z.string(), + avatar: z.string().nullable(), username: z.string(), - global_name: z.string(), + global_name: z.string().nullable(), discriminator: z.string(), - locale: z.string(), + locale: z.string().optional(), }), player: z.object({ publicId: z.string(), From 46ac079491040f6bb6ce9341038d744ddf1df1d6 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Sat, 31 May 2025 20:11:43 +0200 Subject: [PATCH 33/84] Balance Adjustment for Attack Mechanism (#973) ## Description: Original playtests mainly focused on balancing speed. This PR adjusts attack losses further upwards for all terrain types by around 10%. Pop growth is decreased by around 10% to slightly slow game tempo. Worker growth boost is also weakened a bit to nerf super low troop meta beginning. Slight 10% speed nerf at higher attack sizes. Port spawn rate boosted. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: 1brucben --- src/core/configuration/DefaultConfig.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index ee31983d6..268b2dfee 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -66,8 +66,8 @@ const numPlayersConfig = { } as const satisfies Record; const TERRAIN_EFFECTS = { - [TerrainType.Plains]: { mag: 0.9, speed: 0.8 }, - [TerrainType.Highland]: { mag: 1.1, speed: 1 }, + [TerrainType.Plains]: { mag: 1, speed: 0.8 }, // higher speed, lower damage + [TerrainType.Highland]: { mag: 1.15, speed: 1 }, [TerrainType.Mountain]: { mag: 1.3, speed: 1.25 }, } as const; @@ -276,7 +276,7 @@ export class DefaultConfig implements Config { return 10000 + 150 * Math.pow(dist, 1.1); } tradeShipSpawnRate(numberOfPorts: number): number { - return Math.round(10 * Math.pow(numberOfPorts, 0.5)); + return Math.round(10 * Math.pow(numberOfPorts, 0.4)); } unitInfo(type: UnitType): UnitInfo { @@ -545,7 +545,7 @@ export class DefaultConfig implements Config { tilesPerTickUsed: baseTileCost * within(defenderDensity, 3, 100) ** 0.2 * - (attackStandardSize / attackTroops) ** 0.1 * + (attackStandardSize / attackTroops) ** 0.075 * speed * within(attackRatio, 0.1, 20) ** 0.4, }; @@ -646,8 +646,8 @@ export class DefaultConfig implements Config { //population grows proportional to current population with growth decreasing as it approaches max // smaller countries recieve a boost to pop growth to speed up early game const baseAdditionRate = 10; - const basePopGrowthRate = 1300 / max + 1 / 140; - const reproductionPop = 0.85 * player.troops() + 1.15 * player.workers(); + const basePopGrowthRate = 1200 / max + 1 / 150; + const reproductionPop = 0.9 * player.troops() + 1.1 * player.workers(); let toAdd = baseAdditionRate + basePopGrowthRate * reproductionPop; const totalPop = player.totalPopulation(); const ratio = 1 - totalPop / max; From 9c4317a444ca04171c50dbceabadd77ea4b66567 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Sat, 31 May 2025 23:53:45 +0200 Subject: [PATCH 34/84] Prevent Attack Spam (#977) ## Description: This PR assigns a small cost (1% of population) to creating each new attack. This nerfs the strategy of spamming 1% size attacks against an enemy to max pop growth and encourages more normal >10% attack sizes. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: 1brucben --- src/core/execution/AttackExecution.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 42d361736..daf17d759 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -120,6 +120,9 @@ export class AttackExecution implements Execution { new Set(), ); + const penalty = Math.floor(this._owner.population() * 0.01); + this._owner.removeTroops(penalty); + if (this.sourceTile !== null) { this.addNeighbors(this.sourceTile); } else { From 5b1577ea37f4f3a334829aa02155c791dd34ce1b Mon Sep 17 00:00:00 2001 From: Mason Schmidgall <13247733+spicydll@users.noreply.github.com> Date: Sat, 31 May 2025 16:55:45 -0500 Subject: [PATCH 35/84] Update HeadsUpMessage.ts to support translations (#981) ## Description: This change fixes the spawn message to use `translateText()`. I'll ensure I don't miss this again. ![image](https://github.com/user-attachments/assets/76868b1c-701d-4035-bc55-34f3635ecf06) ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: .spicydll --- resources/lang/debug.json | 3 +++ resources/lang/en.json | 3 +++ src/client/graphics/layers/HeadsUpMessage.ts | 3 ++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/resources/lang/debug.json b/resources/lang/debug.json index 0e6eaf3ca..5b7c27515 100644 --- a/resources/lang/debug.json +++ b/resources/lang/debug.json @@ -164,5 +164,8 @@ "Balanced": "difficulty.Balanced", "Intense": "difficulty.Intense", "Impossible": "difficulty.Impossible" + }, + "heads_up_message": { + "choose_spawn": "heads_up_message.choose_spawn" } } diff --git a/resources/lang/en.json b/resources/lang/en.json index e75ecdb6e..d65dd52c0 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -428,5 +428,8 @@ "copied": "Copied!", "failed_copy": "Failed to copy", "desync_notice": "You are desynced from other players. What you see might differ from other players." + }, + "heads_up_message": { + "choose_spawn": "Choose a starting location" } } diff --git a/src/client/graphics/layers/HeadsUpMessage.ts b/src/client/graphics/layers/HeadsUpMessage.ts index 8dd68d59a..5c1e206d9 100644 --- a/src/client/graphics/layers/HeadsUpMessage.ts +++ b/src/client/graphics/layers/HeadsUpMessage.ts @@ -1,6 +1,7 @@ import { LitElement, html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { GameView } from "../../../core/game/GameView"; +import { translateText } from "../../Utils"; import { Layer } from "./Layer"; @customElement("heads-up-message") @@ -39,7 +40,7 @@ export class HeadsUpMessage extends LitElement implements Layer { backdrop-blur-md text-white text-md lg:text-xl p-1 lg:p-2" @contextmenu=${(e) => e.preventDefault()} > - Choose a starting location + ${translateText("heads_up_message.choose_spawn")}
    `; } From 03d11eb941d5ed27d9a9d276593ded36d4c332e3 Mon Sep 17 00:00:00 2001 From: Duwibi <86431918+Duwibi@users.noreply.github.com> Date: Sun, 1 Jun 2025 01:44:38 +0300 Subject: [PATCH 36/84] Cap lobby sizes at 150 (#984) ## Description: Currenly, it's possible for the World map to reach 225 players. This PR caps it at just 150. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: Nikola123 --- src/core/configuration/DefaultConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 268b2dfee..8c2cfd898 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -147,7 +147,7 @@ export abstract class DefaultServerConfig implements ServerConfig { const [l, m, s] = numPlayersConfig[map] ?? [50, 30, 20]; const r = Math.random(); const base = r < 0.3 ? l : r < 0.6 ? m : s; - return mode === GameMode.Team ? Math.ceil(base * 1.5) : base; + return Math.min(mode === GameMode.Team ? Math.ceil(base * 1.5) : base, 150); } workerIndex(gameID: GameID): number { From 24d134049abe50e5947aa3c3b4b299e07ee7ee30 Mon Sep 17 00:00:00 2001 From: Duwibi <86431918+Duwibi@users.noreply.github.com> Date: Sun, 1 Jun 2025 01:53:52 +0300 Subject: [PATCH 37/84] Fix Translations showing as untranslated keys (#983) ## Description: Fixes translations showing as untranslated keys ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: Nikola123 --- src/client/LangSelector.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index adee86edf..27b7f9399 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -78,10 +78,18 @@ export class LangSelector extends LitElement { }); } + private getClosestSupportedLang(lang: string): string { + if (!lang) return "en"; + if (lang in this.languageMap) return lang; + const base = lang.split("-")[0]; + if (base in this.languageMap) return base; + return "en"; + } + private async initializeLanguage() { - const locale = new Intl.Locale(navigator.language); - const defaultLang = locale.language; - const userLang = localStorage.getItem("lang") || defaultLang; + const browserLocale = navigator.language; + const savedLang = localStorage.getItem("lang"); + const userLang = this.getClosestSupportedLang(savedLang || browserLocale); this.defaultTranslations = await this.loadLanguage("en"); this.translations = await this.loadLanguage(userLang); From 05a2338fead3174eaa120f6b7600c94727e389c7 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Sun, 1 Jun 2025 03:06:27 +0200 Subject: [PATCH 38/84] Another Balance Change (#987) ## Description: Reduces attack speed for large attacks. A further 10% increase in attacker losses. minor 5% decrease in pop growth. Attacks against Terra Nullius fade away as in v22. This addresses the community poll finding that many players consider current troop movements to be too fast. The latter two changes help nerf attacking and slowdown snowballing. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: 1brucben --- src/core/configuration/DefaultConfig.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 8c2cfd898..5e31ef4d6 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -66,8 +66,8 @@ const numPlayersConfig = { } as const satisfies Record; const TERRAIN_EFFECTS = { - [TerrainType.Plains]: { mag: 1, speed: 0.8 }, // higher speed, lower damage - [TerrainType.Highland]: { mag: 1.15, speed: 1 }, + [TerrainType.Plains]: { mag: 1.1, speed: 0.8 }, // higher speed, lower damage + [TerrainType.Highland]: { mag: 1.2, speed: 1 }, [TerrainType.Mountain]: { mag: 1.3, speed: 1.25 }, } as const; @@ -230,7 +230,7 @@ export class DefaultConfig implements Config { falloutDefenseModifier(falloutRatio: number): number { // falloutRatio is between 0 and 1 // So defense modifier is between [3, 1] - return 3 - falloutRatio * 2; + return 2 - falloutRatio; } SAMCooldown(): number { return 75; @@ -536,7 +536,7 @@ export class DefaultConfig implements Config { ? this.traitorDefenseDebuff() : 1; const baseTroopLoss = 16; - const baseTileCost = 23; + const baseTileCost = 30; const attackStandardSize = 10_000; return { attackerTroopLoss: @@ -545,15 +545,15 @@ export class DefaultConfig implements Config { tilesPerTickUsed: baseTileCost * within(defenderDensity, 3, 100) ** 0.2 * - (attackStandardSize / attackTroops) ** 0.075 * + (attackStandardSize / attackTroops) ** 0.1 * speed * - within(attackRatio, 0.1, 20) ** 0.4, + within(attackRatio, 0.1, 20) ** 0.35, }; } else { return { attackerTroopLoss: 16 * mag, defenderTroopLoss: 0, - tilesPerTickUsed: 31 * speed, + tilesPerTickUsed: 492 * speed * within(attackTroops, 1, 10000) ** -0.3, }; } } @@ -646,7 +646,7 @@ export class DefaultConfig implements Config { //population grows proportional to current population with growth decreasing as it approaches max // smaller countries recieve a boost to pop growth to speed up early game const baseAdditionRate = 10; - const basePopGrowthRate = 1200 / max + 1 / 150; + const basePopGrowthRate = 1100 / max + 1 / 160; const reproductionPop = 0.9 * player.troops() + 1.1 * player.workers(); let toAdd = baseAdditionRate + basePopGrowthRate * reproductionPop; const totalPop = player.totalPopulation(); From dfea816bff4b47a1f9283434bf5b0e1d2c69d3d8 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sat, 31 May 2025 18:11:02 -0700 Subject: [PATCH 39/84] make bots weaker (#985) ## Description: I believe many of the complains of the game being too slow in the beginning is due to bots being stronger in v23 than v22. This PR weakens bots significants and it feels much closer to v22. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: --- src/core/configuration/DefaultConfig.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 5e31ef4d6..9fb503cfe 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -512,16 +512,12 @@ export class DefaultConfig implements Config { if (attacker.isPlayer() && defenderIsPlayer) { if ( - attackerType === PlayerType.Human && + (attackerType === PlayerType.Human || + attackerType === PlayerType.FakeHuman) && defenderType === PlayerType.Bot ) { - mag *= 0.8; - } - if ( - attackerType === PlayerType.FakeHuman && - defenderType === PlayerType.Bot - ) { - mag *= 0.8; + mag *= 0.6; + speed *= 0.6; } } if (attackerType === PlayerType.Bot) { From 6ea430fc58de517a854fb339e40afa2212e9af10 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sat, 31 May 2025 18:11:11 -0700 Subject: [PATCH 40/84] Remove shield icon from bots (#986) ## Description: Bots have much different meta than nations or humans, so their defense number is misleading. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: --- src/client/graphics/layers/NameLayer.ts | 57 ++++++++++++++----------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 0d56c76e4..c2a1fea45 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -9,7 +9,13 @@ import targetIcon from "../../../../resources/images/TargetIcon.svg"; import traitorIcon from "../../../../resources/images/TraitorIcon.svg"; import { PseudoRandom } from "../../../core/PseudoRandom"; import { Theme } from "../../../core/configuration/Config"; -import { AllPlayers, Cell, nukeTypes, UnitType } from "../../../core/game/Game"; +import { + AllPlayers, + Cell, + nukeTypes, + PlayerType, + UnitType, +} from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { createCanvas, renderNumber, renderTroops } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; @@ -211,27 +217,29 @@ export class NameLayer implements Layer { troopsDiv.style.marginTop = "-5%"; element.appendChild(troopsDiv); - const shieldDiv = document.createElement("div"); - shieldDiv.classList.add("player-shield"); - shieldDiv.style.zIndex = "3"; - shieldDiv.style.marginTop = "-5%"; - shieldDiv.style.display = "flex"; - shieldDiv.style.alignItems = "center"; - shieldDiv.style.gap = "0px"; - const shieldImg = document.createElement("img"); - shieldImg.src = this.shieldIconImage.src; - shieldImg.style.width = "16px"; - shieldImg.style.height = "16px"; + if (player.type() !== PlayerType.Bot) { + const shieldDiv = document.createElement("div"); + shieldDiv.classList.add("player-shield"); + shieldDiv.style.zIndex = "3"; + shieldDiv.style.marginTop = "-5%"; + shieldDiv.style.display = "flex"; + shieldDiv.style.alignItems = "center"; + shieldDiv.style.gap = "0px"; + const shieldImg = document.createElement("img"); + shieldImg.src = this.shieldIconImage.src; + shieldImg.style.width = "16px"; + shieldImg.style.height = "16px"; - const shieldSpan = document.createElement("span"); - shieldSpan.textContent = "0"; - shieldSpan.style.color = "black"; - shieldSpan.style.fontSize = "10px"; - shieldSpan.style.marginTop = "-2px"; + const shieldSpan = document.createElement("span"); + shieldSpan.textContent = "0"; + shieldSpan.style.color = "black"; + shieldSpan.style.fontSize = "10px"; + shieldSpan.style.marginTop = "-2px"; - shieldDiv.appendChild(shieldImg); - shieldDiv.appendChild(shieldSpan); - element.appendChild(shieldDiv); + shieldDiv.appendChild(shieldImg); + shieldDiv.appendChild(shieldSpan); + element.appendChild(shieldDiv); + } // Start off invisible so it doesn't flash at 0,0 element.style.display = "none"; @@ -300,11 +308,10 @@ export class NameLayer implements Layer { const density = renderNumber( render.player.troops() / render.player.numTilesOwned(), ); - const shieldDiv = render.element.querySelector( - ".player-shield", - ) as HTMLDivElement; - const shieldImg = shieldDiv.querySelector("img"); - const shieldNumber = shieldDiv.querySelector("span"); + const shieldDiv: HTMLDivElement | null = + render.element.querySelector(".player-shield"); + const shieldImg = shieldDiv?.querySelector("img"); + const shieldNumber = shieldDiv?.querySelector("span"); if (shieldImg) { shieldImg.style.width = `${render.fontSize * 0.8}px`; shieldImg.style.height = `${render.fontSize * 0.8}px`; From 2e0d8242e25eb2dc08db906a0003263ee3fd6a97 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Sun, 1 Jun 2025 18:25:03 +0200 Subject: [PATCH 41/84] Balance Update (#996) ## Description: This update does the following: slight nerf to gold production by large players Speed boost for very large attacks 5% speed buff for mountains Defense post boost small trade ship spawn rate increase attack losses up 20% for all attacks small speed effect boost for defense posts 20% population growth decrease for large players nerfed worker growth boost slightly nerfed troop/worker conversion rate ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: 1brucben --- src/core/configuration/DefaultConfig.ts | 29 ++++++++++++++----------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 9fb503cfe..d135ec0cc 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -66,9 +66,9 @@ const numPlayersConfig = { } as const satisfies Record; const TERRAIN_EFFECTS = { - [TerrainType.Plains]: { mag: 1.1, speed: 0.8 }, // higher speed, lower damage - [TerrainType.Highland]: { mag: 1.2, speed: 1 }, - [TerrainType.Mountain]: { mag: 1.3, speed: 1.25 }, + [TerrainType.Plains]: { mag: 1, speed: 0.8 }, // higher speed, lower damage + [TerrainType.Highland]: { mag: 1.1, speed: 1 }, + [TerrainType.Mountain]: { mag: 1.2, speed: 1.2 }, } as const; export abstract class DefaultServerConfig implements ServerConfig { @@ -243,10 +243,10 @@ export class DefaultConfig implements Config { return 40; } defensePostLossMultiplier(): number { - return 6; + return 8; } defensePostSpeedMultiplier(): number { - return 3; + return 4; } playerTeams(): number | typeof Duos { return this._gameConfig.playerTeams ?? 0; @@ -276,7 +276,7 @@ export class DefaultConfig implements Config { return 10000 + 150 * Math.pow(dist, 1.1); } tradeShipSpawnRate(numberOfPorts: number): number { - return Math.round(10 * Math.pow(numberOfPorts, 0.4)); + return Math.round(10 * Math.pow(numberOfPorts, 0.3)); } unitInfo(type: UnitType): UnitInfo { @@ -532,16 +532,19 @@ export class DefaultConfig implements Config { ? this.traitorDefenseDebuff() : 1; const baseTroopLoss = 16; - const baseTileCost = 30; + const attackLossModifier = 1.3; + const baseTileCost = 44; const attackStandardSize = 10_000; return { attackerTroopLoss: - mag * (baseTroopLoss + defenderDensity * traitorDebuff), + mag * + (baseTroopLoss + + attackLossModifier * defenderDensity * traitorDebuff), defenderTroopLoss: defenderDensity, tilesPerTickUsed: baseTileCost * within(defenderDensity, 3, 100) ** 0.2 * - (attackStandardSize / attackTroops) ** 0.1 * + (attackStandardSize / attackTroops) ** 0.2 * speed * within(attackRatio, 0.1, 20) ** 0.35, }; @@ -642,8 +645,8 @@ export class DefaultConfig implements Config { //population grows proportional to current population with growth decreasing as it approaches max // smaller countries recieve a boost to pop growth to speed up early game const baseAdditionRate = 10; - const basePopGrowthRate = 1100 / max + 1 / 160; - const reproductionPop = 0.9 * player.troops() + 1.1 * player.workers(); + const basePopGrowthRate = 1200 / max + 1 / 200; + const reproductionPop = player.troops() + 1.15 * player.workers(); let toAdd = baseAdditionRate + basePopGrowthRate * reproductionPop; const totalPop = player.totalPopulation(); const ratio = 1 - totalPop / max; @@ -674,11 +677,11 @@ export class DefaultConfig implements Config { } goldAdditionRate(player: Player): number { - return 0.045 * player.workers() ** 0.7; + return 0.08 * player.workers() ** 0.65; } troopAdjustmentRate(player: Player): number { - const maxDiff = this.maxPopulation(player) / 500; + const maxDiff = this.maxPopulation(player) / 600; const target = player.population() * player.targetTroopRatio(); const diff = target - player.troops(); if (Math.abs(diff) < maxDiff) { From 0aac91e56ac6592fb3292251416c5808b5b9a1cd Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sun, 1 Jun 2025 19:02:26 -0700 Subject: [PATCH 42/84] Revert meta (#1002) ## Description: ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: --------- Co-authored-by: 1brucben <1benjbruce@gmail.com> --- src/client/graphics/layers/ControlPanel.ts | 10 +- src/client/graphics/layers/NameLayer.ts | 13 +- src/core/configuration/Config.ts | 3 +- src/core/configuration/DefaultConfig.ts | 166 +++++++++++---------- src/core/execution/AttackExecution.ts | 3 - src/core/game/Game.ts | 1 - src/core/game/GameUpdates.ts | 1 - src/core/game/GameView.ts | 3 - src/core/game/PlayerImpl.ts | 18 +-- 9 files changed, 97 insertions(+), 121 deletions(-) diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts index 9ce3cc7cf..19d2bd02c 100644 --- a/src/client/graphics/layers/ControlPanel.ts +++ b/src/client/graphics/layers/ControlPanel.ts @@ -16,13 +16,13 @@ export class ControlPanel extends LitElement implements Layer { public uiState: UIState; @state() - private attackRatio: number = 0.3; + private attackRatio: number = 0.2; @state() - private targetTroopRatio = 0.6; + private targetTroopRatio = 0.95; @state() - private currentTroopRatio = 0.6; + private currentTroopRatio = 0.95; @state() private _population: number; @@ -59,10 +59,10 @@ export class ControlPanel extends LitElement implements Layer { init() { this.attackRatio = Number( - localStorage.getItem("settings.attackRatio") ?? "0.3", + localStorage.getItem("settings.attackRatio") ?? "0.2", ); this.targetTroopRatio = Number( - localStorage.getItem("settings.troopRatio") ?? "0.6", + localStorage.getItem("settings.troopRatio") ?? "0.95", ); this.init_ = true; this.uiState.attackRatio = this.attackRatio; diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index c2a1fea45..1146c1d67 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -9,13 +9,7 @@ import targetIcon from "../../../../resources/images/TargetIcon.svg"; import traitorIcon from "../../../../resources/images/TraitorIcon.svg"; import { PseudoRandom } from "../../../core/PseudoRandom"; import { Theme } from "../../../core/configuration/Config"; -import { - AllPlayers, - Cell, - nukeTypes, - PlayerType, - UnitType, -} from "../../../core/game/Game"; +import { AllPlayers, Cell, nukeTypes, UnitType } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { createCanvas, renderNumber, renderTroops } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; @@ -217,7 +211,9 @@ export class NameLayer implements Layer { troopsDiv.style.marginTop = "-5%"; element.appendChild(troopsDiv); - if (player.type() !== PlayerType.Bot) { + // TODO: Remove the shield icon. + /* eslint-disable no-constant-condition */ + if (false) { const shieldDiv = document.createElement("div"); shieldDiv.classList.add("player-shield"); shieldDiv.style.zIndex = "3"; @@ -240,6 +236,7 @@ export class NameLayer implements Layer { shieldDiv.appendChild(shieldSpan); element.appendChild(shieldDiv); } + /* eslint-enable no-constant-condition */ // Start off invisible so it doesn't flash at 0,0 element.style.display = "none"; diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 7da156e3b..cc8627bb2 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -125,8 +125,7 @@ export interface Config { defensePostRange(): number; SAMCooldown(): number; SiloCooldown(): number; - defensePostLossMultiplier(): number; - defensePostSpeedMultiplier(): number; + defensePostDefenseBonus(): number; falloutDefenseModifier(percentOfFallout: number): number; difficultyModifier(difficulty: Difficulty): number; warshipPatrolRange(): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index d135ec0cc..c7d95030b 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -65,12 +65,6 @@ const numPlayersConfig = { [GameMapType.Halkidiki]: [50, 40, 30], } as const satisfies Record; -const TERRAIN_EFFECTS = { - [TerrainType.Plains]: { mag: 1, speed: 0.8 }, // higher speed, lower damage - [TerrainType.Highland]: { mag: 1.1, speed: 1 }, - [TerrainType.Mountain]: { mag: 1.2, speed: 1.2 }, -} as const; - export abstract class DefaultServerConfig implements ServerConfig { private publicKey: JWK; abstract jwtAudience(): string; @@ -229,8 +223,8 @@ export class DefaultConfig implements Config { falloutDefenseModifier(falloutRatio: number): number { // falloutRatio is between 0 and 1 - // So defense modifier is between [3, 1] - return 2 - falloutRatio; + // So defense modifier is between [5, 2.5] + return 5 - falloutRatio * 2; } SAMCooldown(): number { return 75; @@ -240,13 +234,10 @@ export class DefaultConfig implements Config { } defensePostRange(): number { - return 40; + return 30; } - defensePostLossMultiplier(): number { - return 8; - } - defensePostSpeedMultiplier(): number { - return 4; + defensePostDefenseBonus(): number { + return 5; } playerTeams(): number | typeof Duos { return this._gameConfig.playerTeams ?? 0; @@ -276,7 +267,7 @@ export class DefaultConfig implements Config { return 10000 + 150 * Math.pow(dist, 1.1); } tradeShipSpawnRate(numberOfPorts: number): number { - return Math.round(10 * Math.pow(numberOfPorts, 0.3)); + return Math.round(10 * Math.pow(numberOfPorts, 0.6)); } unitInfo(type: UnitType): UnitInfo { @@ -478,27 +469,34 @@ export class DefaultConfig implements Config { defenderTroopLoss: number; tilesPerTickUsed: number; } { + let mag = 0; + let speed = 0; const type = gm.terrainType(tileToConquer); - const mod = TERRAIN_EFFECTS[type]; - if (!mod) { - throw new Error(`terrain type ${type} not supported`); + switch (type) { + case TerrainType.Plains: + mag = 85; + speed = 16.5; + break; + case TerrainType.Highland: + mag = 100; + speed = 20; + break; + case TerrainType.Mountain: + mag = 120; + speed = 25; + break; + default: + throw new Error(`terrain type ${type} not supported`); } - let mag = mod.mag; - let speed = mod.speed; - - const attackerType = attacker.type(); - const defenderIsPlayer = defender.isPlayer(); - const defenderType = defenderIsPlayer ? defender.type() : null; - - if (defenderIsPlayer) { + if (defender.isPlayer()) { for (const dp of gm.nearbyUnits( tileToConquer, gm.config().defensePostRange(), UnitType.DefensePost, )) { if (dp.unit.owner() === defender) { - mag *= this.defensePostLossMultiplier(); - speed *= this.defensePostSpeedMultiplier(); + mag *= this.defensePostDefenseBonus(); + speed *= this.defensePostDefenseBonus(); break; } } @@ -510,49 +508,55 @@ export class DefaultConfig implements Config { speed *= this.falloutDefenseModifier(falloutRatio); } - if (attacker.isPlayer() && defenderIsPlayer) { + if (attacker.isPlayer() && defender.isPlayer()) { if ( - (attackerType === PlayerType.Human || - attackerType === PlayerType.FakeHuman) && - defenderType === PlayerType.Bot + attacker.type() === PlayerType.Human && + defender.type() === PlayerType.Bot ) { - mag *= 0.6; - speed *= 0.6; + mag *= 0.8; + } + if ( + attacker.type() === PlayerType.FakeHuman && + defender.type() === PlayerType.Bot + ) { + mag *= 0.8; } } - if (attackerType === PlayerType.Bot) { - speed *= 4; // slow bot attacks + + let largeLossModifier = 1; + if (attacker.numTilesOwned() > 100_000) { + largeLossModifier = Math.sqrt(100_000 / attacker.numTilesOwned()); } - if (defenderIsPlayer) { - const defenderTroops = defender.troops(); - const defenderTiles = defender.numTilesOwned(); - const defenderDensity = defenderTroops / defenderTiles; - const attackRatio = defenderTroops / attackTroops; - const traitorDebuff = defender.isTraitor() - ? this.traitorDefenseDebuff() - : 1; - const baseTroopLoss = 16; - const attackLossModifier = 1.3; - const baseTileCost = 44; - const attackStandardSize = 10_000; + let largeSpeedMalus = 1; + if (attacker.numTilesOwned() > 75_000) { + // sqrt is only exponent 1/2 which doesn't slow enough huge players + largeSpeedMalus = (75_000 / attacker.numTilesOwned()) ** 0.6; + } + + if (defender.isPlayer()) { return { attackerTroopLoss: + within(defender.troops() / attackTroops, 0.6, 2) * mag * - (baseTroopLoss + - attackLossModifier * defenderDensity * traitorDebuff), - defenderTroopLoss: defenderDensity, + 0.8 * + largeLossModifier * + (defender.isTraitor() ? this.traitorDefenseDebuff() : 1), + defenderTroopLoss: defender.troops() / defender.numTilesOwned(), tilesPerTickUsed: - baseTileCost * - within(defenderDensity, 3, 100) ** 0.2 * - (attackStandardSize / attackTroops) ** 0.2 * + within(defender.troops() / (5 * attackTroops), 0.2, 1.5) * speed * - within(attackRatio, 0.1, 20) ** 0.35, + largeSpeedMalus, }; } else { return { - attackerTroopLoss: 16 * mag, + attackerTroopLoss: + attacker.type() === PlayerType.Bot ? mag / 10 : mag / 5, defenderTroopLoss: 0, - tilesPerTickUsed: 492 * speed * within(attackTroops, 1, 10000) ** -0.3, + tilesPerTickUsed: within( + (2000 * Math.max(10, speed)) / attackTroops, + 5, + 100, + ), }; } } @@ -564,9 +568,13 @@ export class DefaultConfig implements Config { numAdjacentTilesWithEnemy: number, ): number { if (defender.isPlayer()) { - return 10 * numAdjacentTilesWithEnemy; + return ( + within(((5 * attackTroops) / defender.troops()) * 2, 0.01, 0.5) * + numAdjacentTilesWithEnemy * + 3 + ); } else { - return 12 * numAdjacentTilesWithEnemy; + return numAdjacentTilesWithEnemy * 2; } } @@ -596,28 +604,28 @@ export class DefaultConfig implements Config { startManpower(playerInfo: PlayerInfo): number { if (playerInfo.playerType === PlayerType.Bot) { - return 6_000; + return 10_000; } if (playerInfo.playerType === PlayerType.FakeHuman) { switch (this._gameConfig.difficulty) { case Difficulty.Easy: - return 2_500 + 1000 * (playerInfo?.nation?.strength ?? 1); + return 2_500 * (playerInfo?.nation?.strength ?? 1); case Difficulty.Medium: - return 6_000 + 2000 * (playerInfo?.nation?.strength ?? 1); + return 5_000 * (playerInfo?.nation?.strength ?? 1); case Difficulty.Hard: - return 20_000 + 4000 * (playerInfo?.nation?.strength ?? 1); + return 20_000 * (playerInfo?.nation?.strength ?? 1); case Difficulty.Impossible: - return 50_000 + 8000 * (playerInfo?.nation?.strength ?? 1); + return 50_000 * (playerInfo?.nation?.strength ?? 1); } } - return this.infiniteTroops() ? 1_000_000 : 20_000; + return this.infiniteTroops() ? 1_000_000 : 25_000; } maxPopulation(player: Player | PlayerView): number { const maxPop = player.type() === PlayerType.Human && this.infiniteTroops() ? 1_000_000_000 - : 1 * (player.numTilesOwned() * 30 + 50000) + + : 2 * (Math.pow(player.numTilesOwned(), 0.6) * 1000 + 50000) + player.units(UnitType.City).length * this.cityPopulationIncrease(); if (player.type() === PlayerType.Bot) { @@ -630,26 +638,22 @@ export class DefaultConfig implements Config { switch (this._gameConfig.difficulty) { case Difficulty.Easy: - return maxPop * 0.4; + return maxPop * 0.5; case Difficulty.Medium: - return maxPop * 0.8; + return maxPop * 1; case Difficulty.Hard: - return maxPop * 1.4; + return maxPop * 1.5; case Difficulty.Impossible: - return maxPop * 1.8; + return maxPop * 2; } } populationIncreaseRate(player: Player): number { const max = this.maxPopulation(player); - //population grows proportional to current population with growth decreasing as it approaches max - // smaller countries recieve a boost to pop growth to speed up early game - const baseAdditionRate = 10; - const basePopGrowthRate = 1200 / max + 1 / 200; - const reproductionPop = player.troops() + 1.15 * player.workers(); - let toAdd = baseAdditionRate + basePopGrowthRate * reproductionPop; - const totalPop = player.totalPopulation(); - const ratio = 1 - totalPop / max; + + let toAdd = 10 + Math.pow(player.population(), 0.73) / 4; + + const ratio = 1 - player.population() / max; toAdd *= ratio; if (player.type() === PlayerType.Bot) { @@ -673,15 +677,15 @@ export class DefaultConfig implements Config { } } - return Math.min(totalPop + toAdd, max) - totalPop; + return Math.min(player.population() + toAdd, max) - player.population(); } goldAdditionRate(player: Player): number { - return 0.08 * player.workers() ** 0.65; + return 0.045 * player.workers() ** 0.7; } troopAdjustmentRate(player: Player): number { - const maxDiff = this.maxPopulation(player) / 600; + const maxDiff = this.maxPopulation(player) / 1000; const target = player.population() * player.targetTroopRatio(); const diff = target - player.troops(); if (Math.abs(diff) < maxDiff) { diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index daf17d759..42d361736 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -120,9 +120,6 @@ export class AttackExecution implements Execution { new Set(), ); - const penalty = Math.floor(this._owner.population() * 0.01); - this._owner.removeTroops(penalty); - if (this.sourceTile !== null) { this.addNeighbors(this.sourceTile); } else { diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index eb52a6b10..f27821f50 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -446,7 +446,6 @@ export interface Player { // Resources & Population gold(): Gold; population(): number; - totalPopulation(): number; workers(): number; troops(): number; targetTroopRatio(): number; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 7f3acd1cd..385739d8a 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -105,7 +105,6 @@ export interface PlayerUpdate { tilesOwned: number; gold: number; population: number; - totalPopulation: number; workers: number; troops: number; targetTroopRatio: number; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index b32170dc7..5358bc568 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -229,9 +229,6 @@ export class PlayerView { population(): number { return this.data.population; } - totalPopulation(): number { - return this.data.totalPopulation; - } workers(): number { return this.data.workers; } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 46e4fe0a9..ccb35498d 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -109,7 +109,7 @@ export class PlayerImpl implements Player { ) { this._flag = playerInfo.flag; this._name = sanitizeUsername(playerInfo.name); - this._targetTroopRatio = 60n; + this._targetTroopRatio = 95n; this._troops = toInt(startTroops); this._workers = 0n; this._gold = 0n; @@ -139,7 +139,6 @@ export class PlayerImpl implements Player { tilesOwned: this.numTilesOwned(), gold: Number(this._gold), population: this.population(), - totalPopulation: this.totalPopulation(), workers: this.workers(), troops: this.troops(), targetTroopRatio: this.targetTroopRatio(), @@ -652,21 +651,6 @@ export class PlayerImpl implements Player { population(): number { return Number(this._troops + this._workers); } - totalPopulation(): number { - return this.population() + this.attackingTroops(); - } - private attackingTroops(): number { - const landAttackTroops = this._outgoingAttacks - .filter((a) => a.isActive()) - .reduce((sum, a) => sum + a.troops(), 0); - - const boatTroops = this.units(UnitType.TransportShip) - .map((u) => u.troops()) - .reduce((sum, n) => sum + n, 0); - - return landAttackTroops + boatTroops; - } - workers(): number { return Math.max(1, Number(this._workers)); } From 2c31d99521a6ad6f53c3bb797e5e6c32585c8928 Mon Sep 17 00:00:00 2001 From: Erwan Huon <67637529+ERHash@users.noreply.github.com> Date: Mon, 2 Jun 2025 19:43:57 +0200 Subject: [PATCH 43/84] Fix text overflow in instructions for longer translations (#971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: This pull request addresses a layout issue where text in instructions (player icon) would overflow or be cut off, especially when translated into languages with longer word structures (e.g., Dutch, German, French, ...) ![image](https://github.com/user-attachments/assets/8fe77f1c-91e1-44ad-91b4-2dbec720fee3) _Before_ (web app view) ![image](https://github.com/user-attachments/assets/5c867453-4b3c-49e2-b45d-a3ec5af8c2ab) _After_ (mobile layout) ### 🔧 Changes made: Removed fixed height classes h-8 and md:h-10 from description containers. Allowed natural text wrapping and height growth by relying on min-h only where needed. Added text-center for better alignment and break-words to handle long words gracefully. ### 🧪 Tested in: Dutch German French English Responsive layouts (mobile and desktop) ## 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 -> UI only - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: RTHOne --- src/client/HelpModal.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index 6209d50ea..bd1807f59 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -473,7 +473,7 @@ export class HelpModal extends LitElement { class="flex flex-col items-center w-full md:w-1/3 mb-2 md:mb-0" >
    ${translateText("help_modal.icon_crown")}
    @@ -489,7 +489,7 @@ export class HelpModal extends LitElement { class="flex flex-col items-center w-full md:w-1/3 mb-2 md:mb-0" >
    ${translateText("help_modal.icon_traitor")}
    @@ -505,7 +505,7 @@ export class HelpModal extends LitElement { class="flex flex-col items-center w-full md:w-1/3 mb-2 md:mb-0" >
    ${translateText("help_modal.icon_ally")}
    @@ -523,7 +523,7 @@ export class HelpModal extends LitElement { class="flex flex-col items-center w-full md:w-1/3 mb-2 md:mb-0" >
    ${translateText("help_modal.icon_embargo")}
    @@ -539,7 +539,7 @@ export class HelpModal extends LitElement { class="flex flex-col items-center w-full md:w-1/3 mb-2 md:mb-0" >
    ${translateText("help_modal.icon_request")}
    From 19bf71e02439826de21534d306bc10b0a4d29726 Mon Sep 17 00:00:00 2001 From: Erwan Huon <67637529+ERHash@users.noreply.github.com> Date: Mon, 2 Jun 2025 20:20:48 +0200 Subject: [PATCH 44/84] Add dynamic sorting to leaderboard by tiles, gold, and troops (#961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Description: This PR adds dynamic sorting to the in-game leaderboard. Players can now sort the leaderboard by tiles owned (default), gold, or troops by clicking the respective column headers. The currently selected sort is indicated with a downward arrow (⬇️). This improves usability and makes it easier to compare player stats mid-game. ### Please complete the following: [✅] I have added screenshots for all UI updates https://github.com/user-attachments/assets/d0238698-a187-4fc7-9407-04d11cccbc32 [✅] I process any text displayed to the user through translateText() and I've added it to the en.json file [❌] I have added relevant tests to the test directory [✅] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced [✅] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors Closes #923 --------- Co-authored-by: evanpelle Co-authored-by: evanpelle --- src/client/graphics/layers/Leaderboard.ts | 65 ++++++++++++++++++++--- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/src/client/graphics/layers/Leaderboard.ts b/src/client/graphics/layers/Leaderboard.ts index 2bcca0e8d..0749a90aa 100644 --- a/src/client/graphics/layers/Leaderboard.ts +++ b/src/client/graphics/layers/Leaderboard.ts @@ -44,6 +44,12 @@ export class Leaderboard extends LitElement implements Layer { private _shownOnInit = false; private showTopFive = true; + @state() + private _sortKey: "tiles" | "gold" | "troops" = "tiles"; + + @state() + private _sortOrder: "asc" | "desc" = "desc"; + init() {} tick() { @@ -62,13 +68,37 @@ export class Leaderboard extends LitElement implements Layer { } } + private setSort(key: "tiles" | "gold" | "troops") { + if (this._sortKey === key) { + this._sortOrder = this._sortOrder === "asc" ? "desc" : "asc"; + } else { + this._sortKey = key; + this._sortOrder = "desc"; + } + this.updateLeaderboard(); + } + private updateLeaderboard() { if (this.game === null) throw new Error("Not initialized"); const myPlayer = this.game.myPlayer(); - const sorted = this.game - .playerViews() - .sort((a, b) => b.numTilesOwned() - a.numTilesOwned()); + let sorted = this.game.playerViews(); + + const compare = (a: number, b: number) => + this._sortOrder === "asc" ? a - b : b - a; + + switch (this._sortKey) { + case "gold": + sorted = sorted.sort((a, b) => compare(a.gold(), b.gold())); + break; + case "troops": + sorted = sorted.sort((a, b) => compare(a.troops(), b.troops())); + break; + default: + sorted = sorted.sort((a, b) => + compare(a.numTilesOwned(), b.numTilesOwned()), + ); + } const numTilesWithoutFallout = this.game.numLandTiles() - this.game.numTilesWithFallout(); @@ -174,6 +204,8 @@ export class Leaderboard extends LitElement implements Layer { th { background-color: rgb(31 41 55 / 0.5); color: white; + cursor: pointer; + user-select: none; } .myPlayer { font-weight: bold; @@ -275,9 +307,30 @@ export class Leaderboard extends LitElement implements Layer { ${translateText("leaderboard.rank")} ${translateText("leaderboard.player")} - ${translateText("leaderboard.owned")} - ${translateText("leaderboard.gold")} - ${translateText("leaderboard.troops")} + this.setSort("tiles")}> + ${translateText("leaderboard.owned")} + ${this._sortKey === "tiles" + ? this._sortOrder === "asc" + ? "⬆️" + : "⬇️" + : ""} + + this.setSort("gold")}> + ${translateText("leaderboard.gold")} + ${this._sortKey === "gold" + ? this._sortOrder === "asc" + ? "⬆️" + : "⬇️" + : ""} + + this.setSort("troops")}> + ${translateText("leaderboard.troops")} + ${this._sortKey === "troops" + ? this._sortOrder === "asc" + ? "⬆️" + : "⬇️" + : ""} + From 3d2525c38857ff40879773347852ecfdd3c72c4f Mon Sep 17 00:00:00 2001 From: Erwan Huon <67637529+ERHash@users.noreply.github.com> Date: Mon, 2 Jun 2025 20:26:27 +0200 Subject: [PATCH 45/84] Fix Player Name Monospaced Text Overflow on PlayerInfo (#975) ## Description: This PR addresses the issue of long continuous strings (without spaces) overflowing their container in the user interface. By adding the CSS property word-break: break-all (via the Tailwind class break-all) to the text container, we ensure that lengthy words will wrap properly and maintain the layout integrity across different screen sizes. ![image](https://github.com/user-attachments/assets/358c0871-a9b8-4828-8401-610acd7eb242) _Before_ ![image](https://github.com/user-attachments/assets/d6c820de-f2bf-479a-90c9-54eec51b4425) _After_ ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: RTHOne Co-authored-by: evanpelle --- src/client/graphics/layers/PlayerInfoOverlay.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index 4ee411891..099a865e2 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -201,7 +201,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { return html`
    From f7fd11eaa74f40012ba6e6d34fc29bc06bc5a769 Mon Sep 17 00:00:00 2001 From: Andrew Niziolek Date: Mon, 2 Jun 2025 14:26:50 -0400 Subject: [PATCH 46/84] Scroll bar Behavior on Chromium Browsers, c-modal_content (#976) _Related to Issue #972_ ## Description: Updating the modal_content overflow behavior from "scroll" to "auto" presents the scroll-bar when content overflows on the Y axis but hides it when not necessary to view all content. **New behavior** ![image](https://github.com/user-attachments/assets/0a2365ed-f676-4267-8bd7-22d77ea43f90) **Previous behavior** ![image2](https://github.com/user-attachments/assets/b2175f4a-e044-4262-b751-22c917bf519a) This maintains the functionality of the Language dialog but hides the scrollbar when necessary in the Join Lobby dialog. This automatic behavior should further apply to any dialogs making use of the `c-modal_content` 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: ajaxburger --- src/client/components/baseComponents/Modal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/components/baseComponents/Modal.ts b/src/client/components/baseComponents/Modal.ts index 87d0e9431..16d420c6e 100644 --- a/src/client/components/baseComponents/Modal.ts +++ b/src/client/components/baseComponents/Modal.ts @@ -54,7 +54,7 @@ export class OModal extends LitElement { color: #fff; padding: 1.4rem; max-height: 60dvh; - overflow-y: scroll; + overflow-y: auto; backdrop-filter: blur(8px); } `; From 9089de959b80341ba3a1cd2393beba88253f5567 Mon Sep 17 00:00:00 2001 From: Shaan <39768960+shaan150@users.noreply.github.com> Date: Mon, 2 Jun 2025 19:30:18 +0100 Subject: [PATCH 47/84] =?UTF-8?q?Synced=20the=20single=20player=20and=20ho?= =?UTF-8?q?st=20files=20together,=20and=20fix=20issue=20withc=E2=80=A6=20(?= =?UTF-8?q?#991)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: This is a UI fix that addresses the issue where the nuke related options were not able to be deselected in private lobby's, these are now able to done. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors - [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: Shaan160 Fixes #989 ![image](https://github.com/user-attachments/assets/78ae73ed-b73e-49f7-a2dd-bade3bcfa8bc) --- src/client/HostLobbyModal.ts | 67 ++++--------------- src/client/SinglePlayerModal.ts | 56 ++++------------ src/client/utilities/RenderUnitTypeOptions.ts | 48 +++++++++++++ 3 files changed, 75 insertions(+), 96 deletions(-) create mode 100644 src/client/utilities/RenderUnitTypeOptions.ts diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index d532ef3a4..6e12a4ad3 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -19,6 +19,7 @@ import "./components/Difficulties"; import { DifficultyDescription } from "./components/Difficulties"; import "./components/Maps"; import { JoinLobbyEvent } from "./Main"; +import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; @customElement("host-lobby-modal") export class HostLobbyModal extends LitElement { @@ -314,59 +315,10 @@ export class HostLobbyModal extends LitElement {
    - ${[ - [UnitType.City, "unit_type.city"], - [UnitType.DefensePost, "unit_type.defense_post"], - [UnitType.Port, "unit_type.port"], - [UnitType.Warship, "unit_type.warship"], - [UnitType.MissileSilo, "unit_type.missile_silo"], - [UnitType.SAMLauncher, "unit_type.sam_launcher"], - [UnitType.AtomBomb, "unit_type.atom_bomb"], - [UnitType.HydrogenBomb, "unit_type.hydrogen_bomb"], - [UnitType.MIRV, "unit_type.mirv"], - ].map( - ([unitType, translationKey]: [UnitType, string]) => html` - - `, - )} + ${renderUnitTypeOptions({ + disabledUnits: this.disabledUnits, + toggleUnit: this.toggleUnit.bind(this), + })}
    @@ -545,6 +497,15 @@ export class HostLobbyModal extends LitElement { return response; } + private toggleUnit(unit: UnitType, checked: boolean): void { + consolex.log(`Toggling unit type: ${unit} to ${checked}`); + this.disabledUnits = checked + ? [...this.disabledUnits, unit] + : this.disabledUnits.filter((u) => u !== unit); + + this.putGameConfig(); + } + private getRandomMap(): GameMapType { const maps = Object.values(GameMapType); const randIdx = Math.floor(Math.random() * maps.length); diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 09a6d4334..509f564f6 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -21,6 +21,7 @@ import "./components/Maps"; import { FlagInput } from "./FlagInput"; import { JoinLobbyEvent } from "./Main"; import { UsernameInput } from "./UsernameInput"; +import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; @customElement("single-player-modal") export class SinglePlayerModal extends LitElement { @@ -39,7 +40,7 @@ export class SinglePlayerModal extends LitElement { @state() private gameMode: GameMode = GameMode.FFA; @state() private teamCount: number | typeof Duos = 2; - @state() private disabledUnits: string[] = []; + @state() private disabledUnits: UnitType[] = []; render() { return html` @@ -284,48 +285,10 @@ export class SinglePlayerModal extends LitElement {
    - ${[ - [UnitType.City, "unit_type.city"], - [UnitType.DefensePost, "unit_type.defense_post"], - [UnitType.Port, "unit_type.port"], - [UnitType.Warship, "unit_type.warship"], - [UnitType.MissileSilo, "unit_type.missile_silo"], - [UnitType.SAMLauncher, "unit_type.sam_launcher"], - [UnitType.AtomBomb, "unit_type.atom_bomb"], - [UnitType.HydrogenBomb, "unit_type.hydrogen_bomb"], - [UnitType.MIRV, "unit_type.mirv"], - ].map( - ([unitType, translationKey]) => html` - - `, - )} + ${renderUnitTypeOptions({ + disabledUnits: this.disabledUnits, + toggleUnit: this.toggleUnit.bind(this), + })}
    @@ -403,6 +366,13 @@ export class SinglePlayerModal extends LitElement { return maps[randIdx] as GameMapType; } + private toggleUnit(unit: UnitType, checked: boolean): void { + consolex.log(`Toggling unit type: ${unit} to ${checked}`); + this.disabledUnits = checked + ? [...this.disabledUnits, unit] + : this.disabledUnits.filter((u) => u !== unit); + } + private startGame() { // If random map is selected, choose a random map now if (this.useRandomMap) { diff --git a/src/client/utilities/RenderUnitTypeOptions.ts b/src/client/utilities/RenderUnitTypeOptions.ts new file mode 100644 index 000000000..c74aaf7ef --- /dev/null +++ b/src/client/utilities/RenderUnitTypeOptions.ts @@ -0,0 +1,48 @@ +// renderUnitTypeOptions.ts +import { html, TemplateResult } from "lit"; +import { UnitType } from "../../core/game/Game"; +import { translateText } from "../Utils"; + +export interface UnitTypeRenderContext { + disabledUnits: UnitType[]; + toggleUnit: (unit: UnitType, checked: boolean) => void; +} + +const unitOptions: { type: UnitType; translationKey: string }[] = [ + { type: UnitType.City, translationKey: "unit_type.city" }, + { type: UnitType.DefensePost, translationKey: "unit_type.defense_post" }, + { type: UnitType.Port, translationKey: "unit_type.port" }, + { type: UnitType.Warship, translationKey: "unit_type.warship" }, + { type: UnitType.MissileSilo, translationKey: "unit_type.missile_silo" }, + { type: UnitType.SAMLauncher, translationKey: "unit_type.sam_launcher" }, + { type: UnitType.AtomBomb, translationKey: "unit_type.atom_bomb" }, + { type: UnitType.HydrogenBomb, translationKey: "unit_type.hydrogen_bomb" }, + { type: UnitType.MIRV, translationKey: "unit_type.mirv" }, +]; + +export function renderUnitTypeOptions({ + disabledUnits, + toggleUnit, +}: UnitTypeRenderContext): TemplateResult[] { + return unitOptions.map( + ({ type, translationKey }) => html` + + `, + ); +} From 983146160469ed9bca0c1c9a82992c54140f22d2 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Mon, 2 Jun 2025 14:31:10 -0400 Subject: [PATCH 48/84] Equalize team sizes (#992) ## Description: Ensure that team sizes are equal. Closes #841 ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/core/configuration/Config.ts | 6 +++++- src/core/configuration/DefaultConfig.ts | 12 ++++++++++-- src/server/MapPlaylist.ts | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index cc8627bb2..9ca741873 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -29,7 +29,11 @@ export enum GameEnv { export interface ServerConfig { turnIntervalMs(): number; gameCreationRate(): number; - lobbyMaxPlayers(map: GameMapType, mode: GameMode): number; + lobbyMaxPlayers( + map: GameMapType, + mode: GameMode, + numPlayerTeams: number | undefined, + ): number; numWorkers(): number; workerIndex(gameID: GameID): number; workerPath(gameID: GameID): string; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index c7d95030b..4661e594c 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -137,11 +137,19 @@ export abstract class DefaultServerConfig implements ServerConfig { return 60 * 1000; } - lobbyMaxPlayers(map: GameMapType, mode: GameMode): number { + lobbyMaxPlayers( + map: GameMapType, + mode: GameMode, + numPlayerTeams: number | undefined, + ): number { const [l, m, s] = numPlayersConfig[map] ?? [50, 30, 20]; const r = Math.random(); const base = r < 0.3 ? l : r < 0.6 ? m : s; - return Math.min(mode === GameMode.Team ? Math.ceil(base * 1.5) : base, 150); + let p = Math.min(mode === GameMode.Team ? Math.ceil(base * 1.5) : base, l); + if (numPlayerTeams !== undefined) { + p -= p % numPlayerTeams; + } + return p; } workerIndex(gameID: GameID): number { diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index dfec2aa3c..cebf4a0b8 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -51,7 +51,7 @@ export class MapPlaylist { // Create the default public game config (from your GameManager) return { gameMap: map, - maxPlayers: config.lobbyMaxPlayers(map, mode), + maxPlayers: config.lobbyMaxPlayers(map, mode, numPlayerTeams), gameType: GameType.Public, difficulty: Difficulty.Medium, infiniteGold: false, From 461177b2be48b71909f7706fb31c1b14c1dac41c Mon Sep 17 00:00:00 2001 From: Guillaume Quarmeau Date: Mon, 2 Jun 2025 20:39:16 +0200 Subject: [PATCH 49/84] Added support for dark mode icons for Alliance Request Icon and Embargo Icon (#993) ## Description: Alliance Request and Embargo icons now switch from white to black according to whether dark mode is enabled. ![Screenshot 2025-06-01 120058](https://github.com/user-attachments/assets/ccc97b7b-088c-4294-953b-63fdfb0c354e) *Dark mode off* ![Screenshot 2025-06-01 120011](https://github.com/user-attachments/assets/1ff3f9bb-c01e-4c49-b03b-97851914af1b) *Dark mode on* ## Potential issue: The only caveat is that upon changing the theme when both icons are displayed, the display order is not conserved, so they may appear in a different order than before. This seems however to be a very small use case, and rather insignificant as icons don't have an order to begin with. Do let me know if this is a problem though! ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: @vermylion --- ...tIcon.svg => AllianceRequestBlackIcon.svg} | 26 +++---- resources/images/AllianceRequestWhiteIcon.svg | 14 ++++ .../{EmbargoIcon.svg => EmbargoBlackIcon.svg} | 0 resources/images/EmbargoWhiteIcon.svg | 29 ++++++++ src/client/graphics/layers/NameLayer.ts | 69 ++++++++++++++----- 5 files changed, 108 insertions(+), 30 deletions(-) rename resources/images/{AllianceRequestIcon.svg => AllianceRequestBlackIcon.svg} (98%) mode change 100755 => 100644 create mode 100644 resources/images/AllianceRequestWhiteIcon.svg rename resources/images/{EmbargoIcon.svg => EmbargoBlackIcon.svg} (100%) mode change 100755 => 100644 create mode 100644 resources/images/EmbargoWhiteIcon.svg diff --git a/resources/images/AllianceRequestIcon.svg b/resources/images/AllianceRequestBlackIcon.svg old mode 100755 new mode 100644 similarity index 98% rename from resources/images/AllianceRequestIcon.svg rename to resources/images/AllianceRequestBlackIcon.svg index f5981e241..5fa1e9e31 --- a/resources/images/AllianceRequestIcon.svg +++ b/resources/images/AllianceRequestBlackIcon.svg @@ -1,14 +1,14 @@ - - - - - - + + + + + + \ No newline at end of file diff --git a/resources/images/AllianceRequestWhiteIcon.svg b/resources/images/AllianceRequestWhiteIcon.svg new file mode 100644 index 000000000..c34774dd4 --- /dev/null +++ b/resources/images/AllianceRequestWhiteIcon.svg @@ -0,0 +1,14 @@ + + + + + + + \ No newline at end of file diff --git a/resources/images/EmbargoIcon.svg b/resources/images/EmbargoBlackIcon.svg old mode 100755 new mode 100644 similarity index 100% rename from resources/images/EmbargoIcon.svg rename to resources/images/EmbargoBlackIcon.svg diff --git a/resources/images/EmbargoWhiteIcon.svg b/resources/images/EmbargoWhiteIcon.svg new file mode 100644 index 000000000..c5a966b73 --- /dev/null +++ b/resources/images/EmbargoWhiteIcon.svg @@ -0,0 +1,29 @@ + + + + + + + + + diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 1146c1d67..db7993f90 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -1,7 +1,9 @@ import allianceIcon from "../../../../resources/images/AllianceIcon.svg"; -import allianceRequestIcon from "../../../../resources/images/AllianceRequestIcon.svg"; +import allianceRequestBlackIcon from "../../../../resources/images/AllianceRequestBlackIcon.svg"; +import allianceRequestWhiteIcon from "../../../../resources/images/AllianceRequestWhiteIcon.svg"; import crownIcon from "../../../../resources/images/CrownIcon.svg"; -import embargoIcon from "../../../../resources/images/EmbargoIcon.svg"; +import embargoBlackIcon from "../../../../resources/images/EmbargoBlackIcon.svg"; +import embargoWhiteIcon from "../../../../resources/images/EmbargoWhiteIcon.svg"; import nukeRedIcon from "../../../../resources/images/NukeIconRed.svg"; import nukeWhiteIcon from "../../../../resources/images/NukeIconWhite.svg"; import shieldIcon from "../../../../resources/images/ShieldIconBlack.svg"; @@ -11,6 +13,7 @@ import { PseudoRandom } from "../../../core/PseudoRandom"; import { Theme } from "../../../core/configuration/Config"; import { AllPlayers, Cell, nukeTypes, UnitType } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; +import { UserSettings } from "../../../core/game/UserSettings"; import { createCanvas, renderNumber, renderTroops } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; @@ -37,17 +40,20 @@ export class NameLayer implements Layer { private renders: RenderInfo[] = []; private seenPlayers: Set = new Set(); private traitorIconImage: HTMLImageElement; - private allianceRequestIconImage: HTMLImageElement; + private allianceRequestBlackIconImage: HTMLImageElement; + private allianceRequestWhiteIconImage: HTMLImageElement; private allianceIconImage: HTMLImageElement; private targetIconImage: HTMLImageElement; private crownIconImage: HTMLImageElement; - private embargoIconImage: HTMLImageElement; + private embargoBlackIconImage: HTMLImageElement; + private embargoWhiteIconImage: HTMLImageElement; private nukeWhiteIconImage: HTMLImageElement; private nukeRedIconImage: HTMLImageElement; private shieldIconImage: HTMLImageElement; private container: HTMLDivElement; private firstPlace: PlayerView | null = null; private theme: Theme = this.game.config().theme(); + private userSettings: UserSettings = new UserSettings(); constructor( private game: GameView, @@ -57,14 +63,18 @@ export class NameLayer implements Layer { this.traitorIconImage.src = traitorIcon; this.allianceIconImage = new Image(); this.allianceIconImage.src = allianceIcon; - this.allianceRequestIconImage = new Image(); - this.allianceRequestIconImage.src = allianceRequestIcon; + this.allianceRequestBlackIconImage = new Image(); + this.allianceRequestBlackIconImage.src = allianceRequestBlackIcon; + this.allianceRequestWhiteIconImage = new Image(); + this.allianceRequestWhiteIconImage.src = allianceRequestWhiteIcon; this.crownIconImage = new Image(); this.crownIconImage.src = crownIcon; this.targetIconImage = new Image(); this.targetIconImage.src = targetIcon; - this.embargoIconImage = new Image(); - this.embargoIconImage.src = embargoIcon; + this.embargoBlackIconImage = new Image(); + this.embargoBlackIconImage.src = embargoBlackIcon; + this.embargoWhiteIconImage = new Image(); + this.embargoWhiteIconImage.src = embargoWhiteIcon; this.nukeWhiteIconImage = new Image(); this.nukeWhiteIconImage.src = nukeWhiteIcon; this.nukeRedIconImage = new Image(); @@ -325,6 +335,7 @@ export class NameLayer implements Layer { ) as HTMLDivElement; const iconSize = Math.min(render.fontSize * 1.5, 48); const myPlayer = this.game.myPlayer(); + const isDarkMode = this.userSettings.darkMode(); // Crown icon const existingCrown = iconsDiv.querySelector('[data-icon="crown"]'); @@ -376,13 +387,27 @@ export class NameLayer implements Layer { } // Alliance request icon - const data = '[data-icon="alliance-request"]'; - const existingRequestAlliance = iconsDiv.querySelector(data); + let existingRequestAlliance = iconsDiv.querySelector( + '[data-icon="alliance-request"]', + ); + const isThemeAllianceRequestIcon = + existingRequestAlliance?.getAttribute("dark-mode") === + isDarkMode.toString(); + const AllianceRequestIconImageSrc = isDarkMode + ? this.allianceRequestWhiteIconImage.src + : this.allianceRequestBlackIconImage.src; + if (myPlayer !== null && render.player.isRequestingAllianceWith(myPlayer)) { + // Create new icon to match theme + if (existingRequestAlliance && !isThemeAllianceRequestIcon) { + existingRequestAlliance.remove(); + existingRequestAlliance = null; + } + if (!existingRequestAlliance) { iconsDiv.appendChild( this.createIconElement( - this.allianceRequestIconImage.src, + AllianceRequestIconImageSrc, iconSize, "alliance-request", ), @@ -437,19 +462,28 @@ export class NameLayer implements Layer { existingEmoji.remove(); } - const existingEmbargo = iconsDiv.querySelector('[data-icon="embargo"]'); + // Embargo icon + let existingEmbargo = iconsDiv.querySelector('[data-icon="embargo"]'); const hasEmbargo = myPlayer && (render.player.hasEmbargoAgainst(myPlayer) || myPlayer.hasEmbargoAgainst(render.player)); + const isThemeEmbargoIcon = + existingEmbargo?.getAttribute("dark-mode") === isDarkMode.toString(); + const embargoIconImageSrc = isDarkMode + ? this.embargoWhiteIconImage.src + : this.embargoBlackIconImage.src; + if (myPlayer && hasEmbargo) { + // Create new icon to match theme + if (existingEmbargo && !isThemeEmbargoIcon) { + existingEmbargo.remove(); + existingEmbargo = null; + } + if (!existingEmbargo) { iconsDiv.appendChild( - this.createIconElement( - this.embargoIconImage.src, - iconSize, - "embargo", - ), + this.createIconElement(embargoIconImageSrc, iconSize, "embargo"), ); } } else if (existingEmbargo) { @@ -523,6 +557,7 @@ export class NameLayer implements Layer { icon.style.width = `${size}px`; icon.style.height = `${size}px`; icon.setAttribute("data-icon", id); + icon.setAttribute("dark-mode", this.userSettings.darkMode().toString()); if (center) { icon.style.position = "absolute"; icon.style.top = "50%"; From df0d99e212be913d84575115f47ea27cbc845f9d Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 2 Jun 2025 12:39:37 -0700 Subject: [PATCH 50/84] cap tradeShipSpawnRate at 50, 7740909 removed cap, reducing trade too much --- src/core/configuration/DefaultConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 4661e594c..fe51b043c 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -275,7 +275,7 @@ export class DefaultConfig implements Config { return 10000 + 150 * Math.pow(dist, 1.1); } tradeShipSpawnRate(numberOfPorts: number): number { - return Math.round(10 * Math.pow(numberOfPorts, 0.6)); + return Math.min(50, Math.round(10 * Math.pow(numberOfPorts, 0.6))); } unitInfo(type: UnitType): UnitInfo { From 73a6853fd71daccc774545ac560c87c9d304654e Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Mon, 2 Jun 2025 15:48:24 -0400 Subject: [PATCH 51/84] Use bigint for gold (#1000) ## Description: - Switch gold to bigint. - Remove unused or untrusted values from event payloads. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/client/Transport.ts | 12 +-- src/client/Utils.ts | 3 +- src/client/graphics/layers/BuildMenu.ts | 6 +- src/client/graphics/layers/ChatModal.ts | 1 - src/client/graphics/layers/ControlPanel.ts | 7 +- src/client/graphics/layers/EventsDisplay.ts | 2 +- src/client/graphics/layers/PlayerPanel.ts | 7 +- src/client/graphics/layers/TeamStats.ts | 2 +- src/client/graphics/layers/TopBar.ts | 2 +- src/core/Schemas.ts | 2 +- src/core/configuration/Config.ts | 2 +- src/core/configuration/DefaultConfig.ts | 107 +++++++++++--------- src/core/execution/ConstructionExecution.ts | 3 +- src/core/execution/DonateGoldExecution.ts | 12 ++- src/core/execution/DonateTroopExecution.ts | 6 +- src/core/execution/FakeHumanExecution.ts | 3 +- src/core/game/Game.ts | 10 +- src/core/game/GameUpdates.ts | 3 +- src/core/game/PlayerImpl.ts | 35 ++++--- 19 files changed, 125 insertions(+), 100 deletions(-) diff --git a/src/client/Transport.ts b/src/client/Transport.ts index cbef4bbba..7d85ae931 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -4,6 +4,7 @@ import { AllPlayers, Cell, GameType, + Gold, PlayerID, PlayerType, Tick, @@ -94,15 +95,13 @@ export class SendEmojiIntentEvent implements GameEvent { export class SendDonateGoldIntentEvent implements GameEvent { constructor( - public readonly sender: PlayerView, public readonly recipient: PlayerView, - public readonly gold: number | null, + public readonly gold: Gold | null, ) {} } export class SendDonateTroopsIntentEvent implements GameEvent { constructor( - public readonly sender: PlayerView, public readonly recipient: PlayerView, public readonly troops: number | null, ) {} @@ -110,7 +109,6 @@ export class SendDonateTroopsIntentEvent implements GameEvent { export class SendQuickChatEvent implements GameEvent { constructor( - public readonly sender: PlayerView, public readonly recipient: PlayerView, public readonly quickChatKey: string, public readonly variables: { [key: string]: string }, @@ -119,17 +117,13 @@ export class SendQuickChatEvent implements GameEvent { export class SendEmbargoIntentEvent implements GameEvent { constructor( - public readonly sender: PlayerView, public readonly target: PlayerView, public readonly action: "start" | "stop", ) {} } export class CancelAttackIntentEvent implements GameEvent { - constructor( - public readonly playerID: PlayerID, - public readonly attackID: string, - ) {} + constructor(public readonly attackID: string) {} } export class CancelBoatIntentEvent implements GameEvent { diff --git a/src/client/Utils.ts b/src/client/Utils.ts index a6c90191f..62fc2e80e 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -4,7 +4,8 @@ export function renderTroops(troops: number): string { return renderNumber(troops / 10); } -export function renderNumber(num: number): string { +export function renderNumber(num: number | bigint): string { + num = Number(num); num = Math.max(num, 0); if (num >= 10_000_000) { diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index f048c2271..70f28ea65 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -12,7 +12,7 @@ import samlauncherIcon from "../../../../resources/images/SamLauncherIconWhite.s import shieldIcon from "../../../../resources/images/ShieldIconWhite.svg"; import { translateText } from "../../../client/Utils"; import { EventBus } from "../../../core/EventBus"; -import { Cell, PlayerActions, UnitType } from "../../../core/game/Game"; +import { Cell, Gold, PlayerActions, UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView } from "../../../core/game/GameView"; import { BuildUnitIntentEvent } from "../../Transport"; @@ -314,13 +314,13 @@ export class BuildMenu extends LitElement implements Layer { return unit[0].canBuild !== false; } - private cost(item: BuildItemDisplay): number { + private cost(item: BuildItemDisplay): Gold { for (const bu of this.playerActions?.buildableUnits ?? []) { if (bu.type === item.unitType) { return bu.cost; } } - return 0; + return 0n; } private count(item: BuildItemDisplay): string { diff --git a/src/client/graphics/layers/ChatModal.ts b/src/client/graphics/layers/ChatModal.ts index 40cb4e4a4..7bec6d3b4 100644 --- a/src/client/graphics/layers/ChatModal.ts +++ b/src/client/graphics/layers/ChatModal.ts @@ -236,7 +236,6 @@ export class ChatModal extends LitElement { this.eventBus.emit( new SendQuickChatEvent( - this.sender, this.recipient, this.selectedQuickChatKey, variables, diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts index 19d2bd02c..c01ae4bc9 100644 --- a/src/client/graphics/layers/ControlPanel.ts +++ b/src/client/graphics/layers/ControlPanel.ts @@ -2,6 +2,7 @@ import { LitElement, html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { translateText } from "../../../client/Utils"; import { EventBus } from "../../../core/EventBus"; +import { Gold } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { AttackRatioEvent } from "../../InputHandler"; import { SendSetTargetTroopRatioEvent } from "../../Transport"; @@ -46,10 +47,10 @@ export class ControlPanel extends LitElement implements Layer { private _manpower: number = 0; @state() - private _gold: number; + private _gold: Gold; @state() - private _goldPerSecond: number; + private _goldPerSecond: Gold; private _lastPopulationIncreaseRate: number; @@ -124,7 +125,7 @@ export class ControlPanel extends LitElement implements Layer { this._troops = player.troops(); this._workers = player.workers(); this.popRate = this.game.config().populationIncreaseRate(player) * 10; - this._goldPerSecond = this.game.config().goldAdditionRate(player) * 10; + this._goldPerSecond = this.game.config().goldAdditionRate(player) * 10n; this.currentTroopRatio = player.troops() / player.population(); this.requestUpdate(); diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index 0ccba3800..ef236985e 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -380,7 +380,7 @@ export class EventsDisplay extends LitElement implements Layer { emitCancelAttackIntent(id: string) { const myPlayer = this.game.myPlayer(); if (!myPlayer) return; - this.eventBus.emit(new CancelAttackIntentEvent(myPlayer.id(), id)); + this.eventBus.emit(new CancelAttackIntentEvent(id)); } emitBoatCancelIntent(id: number) { diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 4e82be5bb..859803e7c 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -90,7 +90,6 @@ export class PlayerPanel extends LitElement implements Layer { e.stopPropagation(); this.eventBus.emit( new SendDonateTroopsIntentEvent( - myPlayer, other, myPlayer.troops() * this.uiState.attackRatio, ), @@ -104,7 +103,7 @@ export class PlayerPanel extends LitElement implements Layer { other: PlayerView, ) { e.stopPropagation(); - this.eventBus.emit(new SendDonateGoldIntentEvent(myPlayer, other, null)); + this.eventBus.emit(new SendDonateGoldIntentEvent(other, null)); this.hide(); } @@ -114,7 +113,7 @@ export class PlayerPanel extends LitElement implements Layer { other: PlayerView, ) { e.stopPropagation(); - this.eventBus.emit(new SendEmbargoIntentEvent(myPlayer, other, "start")); + this.eventBus.emit(new SendEmbargoIntentEvent(other, "start")); this.hide(); } @@ -124,7 +123,7 @@ export class PlayerPanel extends LitElement implements Layer { other: PlayerView, ) { e.stopPropagation(); - this.eventBus.emit(new SendEmbargoIntentEvent(myPlayer, other, "stop")); + this.eventBus.emit(new SendEmbargoIntentEvent(other, "stop")); this.hide(); } diff --git a/src/client/graphics/layers/TeamStats.ts b/src/client/graphics/layers/TeamStats.ts index e62152f13..dcbbd10b0 100644 --- a/src/client/graphics/layers/TeamStats.ts +++ b/src/client/graphics/layers/TeamStats.ts @@ -58,7 +58,7 @@ export class TeamStats extends LitElement implements Layer { this.teams = Object.entries(grouped) .map(([teamStr, teamPlayers]) => { - let totalGold = 0; + let totalGold = 0n; let totalTroops = 0; let totalScoreSort = 0; diff --git a/src/client/graphics/layers/TopBar.ts b/src/client/graphics/layers/TopBar.ts index 76e218ba5..991fda739 100644 --- a/src/client/graphics/layers/TopBar.ts +++ b/src/client/graphics/layers/TopBar.ts @@ -50,7 +50,7 @@ export class TopBar extends LitElement implements Layer { const popRate = this.game.config().populationIncreaseRate(myPlayer) * 10; const maxPop = this.game.config().maxPopulation(myPlayer); - const goldPerSecond = this.game.config().goldAdditionRate(myPlayer) * 10; + const goldPerSecond = this.game.config().goldAdditionRate(myPlayer) * 10n; return html`
    0, + cost: () => 0n, territoryBound: false, }; case UnitType.Warship: return { cost: (p: Player) => p.type() === PlayerType.Human && this.infiniteGold() - ? 0 - : Math.min( - 1_000_000, - (p.unitsIncludingConstruction(UnitType.Warship).length + 1) * - 250_000, + ? 0n + : BigInt( + Math.min( + 1_000_000, + (p.unitsIncludingConstruction(UnitType.Warship).length + + 1) * + 250_000, + ), ), territoryBound: false, maxHealth: 1000, }; case UnitType.Shell: return { - cost: () => 0, + cost: () => 0n, territoryBound: false, damage: 250, }; case UnitType.SAMMissile: return { - cost: () => 0, + cost: () => 0n, territoryBound: false, }; case UnitType.Port: return { cost: (p: Player) => p.type() === PlayerType.Human && this.infiniteGold() - ? 0 - : Math.min( - 1_000_000, - Math.pow( - 2, - p.unitsIncludingConstruction(UnitType.Port).length, - ) * 125_000, + ? 0n + : BigInt( + Math.min( + 1_000_000, + Math.pow( + 2, + p.unitsIncludingConstruction(UnitType.Port).length, + ) * 125_000, + ), ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 2 * 10, @@ -327,41 +332,43 @@ export class DefaultConfig implements Config { case UnitType.AtomBomb: return { cost: (p: Player) => - p.type() === PlayerType.Human && this.infiniteGold() ? 0 : 750_000, + p.type() === PlayerType.Human && this.infiniteGold() + ? 0n + : 750_000n, territoryBound: false, }; case UnitType.HydrogenBomb: return { cost: (p: Player) => p.type() === PlayerType.Human && this.infiniteGold() - ? 0 - : 5_000_000, + ? 0n + : 5_000_000n, territoryBound: false, }; case UnitType.MIRV: return { cost: (p: Player) => p.type() === PlayerType.Human && this.infiniteGold() - ? 0 - : 25_000_000, + ? 0n + : 25_000_000n, territoryBound: false, }; case UnitType.MIRVWarhead: return { - cost: () => 0, + cost: () => 0n, territoryBound: false, }; case UnitType.TradeShip: return { - cost: () => 0, + cost: () => 0n, territoryBound: false, }; case UnitType.MissileSilo: return { cost: (p: Player) => p.type() === PlayerType.Human && this.infiniteGold() - ? 0 - : 1_000_000, + ? 0n + : 1_000_000n, territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 10 * 10, }; @@ -369,12 +376,14 @@ export class DefaultConfig implements Config { return { cost: (p: Player) => p.type() === PlayerType.Human && this.infiniteGold() - ? 0 - : Math.min( - 250_000, - (p.unitsIncludingConstruction(UnitType.DefensePost).length + - 1) * - 50_000, + ? 0n + : BigInt( + Math.min( + 250_000, + (p.unitsIncludingConstruction(UnitType.DefensePost).length + + 1) * + 50_000, + ), ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 5 * 10, @@ -383,12 +392,14 @@ export class DefaultConfig implements Config { return { cost: (p: Player) => p.type() === PlayerType.Human && this.infiniteGold() - ? 0 - : Math.min( - 3_000_000, - (p.unitsIncludingConstruction(UnitType.SAMLauncher).length + - 1) * - 1_500_000, + ? 0n + : BigInt( + Math.min( + 3_000_000, + (p.unitsIncludingConstruction(UnitType.SAMLauncher).length + + 1) * + 1_500_000, + ), ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 30 * 10, @@ -397,20 +408,22 @@ export class DefaultConfig implements Config { return { cost: (p: Player) => p.type() === PlayerType.Human && this.infiniteGold() - ? 0 - : Math.min( - 1_000_000, - Math.pow( - 2, - p.unitsIncludingConstruction(UnitType.City).length, - ) * 125_000, + ? 0n + : BigInt( + Math.min( + 1_000_000, + Math.pow( + 2, + p.unitsIncludingConstruction(UnitType.City).length, + ) * 125_000, + ), ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 2 * 10, }; case UnitType.Construction: return { - cost: () => 0, + cost: () => 0n, territoryBound: true, }; default: @@ -688,8 +701,8 @@ export class DefaultConfig implements Config { return Math.min(player.population() + toAdd, max) - player.population(); } - goldAdditionRate(player: Player): number { - return 0.045 * player.workers() ** 0.7; + goldAdditionRate(player: Player): Gold { + return BigInt(Math.floor(0.045 * player.workers() ** 0.7)); } troopAdjustmentRate(player: Player): number { diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts index 49ed2e89f..ba7a7c9c0 100644 --- a/src/core/execution/ConstructionExecution.ts +++ b/src/core/execution/ConstructionExecution.ts @@ -2,6 +2,7 @@ import { consolex } from "../Consolex"; import { Execution, Game, + Gold, Player, PlayerID, Tick, @@ -26,7 +27,7 @@ export class ConstructionExecution implements Execution { private ticksUntilComplete: Tick; - private cost: number; + private cost: Gold; constructor( private ownerId: PlayerID, diff --git a/src/core/execution/DonateGoldExecution.ts b/src/core/execution/DonateGoldExecution.ts index 166f34cde..20837d66e 100644 --- a/src/core/execution/DonateGoldExecution.ts +++ b/src/core/execution/DonateGoldExecution.ts @@ -1,5 +1,5 @@ import { consolex } from "../Consolex"; -import { Execution, Game, Player, PlayerID } from "../game/Game"; +import { Execution, Game, Gold, Player, PlayerID } from "../game/Game"; export class DonateGoldExecution implements Execution { private sender: Player; @@ -10,7 +10,7 @@ export class DonateGoldExecution implements Execution { constructor( private senderID: PlayerID, private recipientID: PlayerID, - private gold: number | null, + private gold: Gold | null, ) {} init(mg: Game, ticks: number): void { @@ -28,14 +28,16 @@ export class DonateGoldExecution implements Execution { this.sender = mg.player(this.senderID); this.recipient = mg.player(this.recipientID); if (this.gold === null) { - this.gold = Math.round(this.sender.gold() / 3); + this.gold = this.sender.gold() / 3n; } } tick(ticks: number): void { if (this.gold === null) throw new Error("not initialized"); - if (this.sender.canDonate(this.recipient)) { - this.sender.donateGold(this.recipient, this.gold); + if ( + this.sender.canDonate(this.recipient) && + this.sender.donateGold(this.recipient, this.gold) + ) { this.recipient.updateRelation(this.sender, 50); } else { consolex.warn( diff --git a/src/core/execution/DonateTroopExecution.ts b/src/core/execution/DonateTroopExecution.ts index 99dc4ca66..40fde1781 100644 --- a/src/core/execution/DonateTroopExecution.ts +++ b/src/core/execution/DonateTroopExecution.ts @@ -34,8 +34,10 @@ export class DonateTroopsExecution implements Execution { tick(ticks: number): void { if (this.troops === null) throw new Error("not initialized"); - if (this.sender.canDonate(this.recipient)) { - this.sender.donateTroops(this.recipient, this.troops); + if ( + this.sender.canDonate(this.recipient) && + this.sender.donateTroops(this.recipient, this.troops) + ) { this.recipient.updateRelation(this.sender, 50); } else { consolex.warn( diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index 4fa3ec7cd..7a657c176 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -4,6 +4,7 @@ import { Difficulty, Execution, Game, + Gold, Nation, Player, PlayerID, @@ -543,7 +544,7 @@ export class FakeHumanExecution implements Execution { return null; } - private cost(type: UnitType): number { + private cost(type: UnitType): Gold { if (this.player === null) throw new Error("not initialized"); return this.mg.unitInfo(type).cost(this.player); } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index f27821f50..d054a6b04 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -12,7 +12,7 @@ import { Stats } from "./Stats"; export type PlayerID = string; export type Tick = number; -export type Gold = number; +export type Gold = bigint; export const AllPlayers = "AllPlayers" as const; @@ -450,7 +450,7 @@ export interface Player { troops(): number; targetTroopRatio(): number; addGold(toAdd: Gold): void; - removeGold(toRemove: Gold): void; + removeGold(toRemove: Gold): Gold; addWorkers(toAdd: number): void; removeWorkers(toRemove: number): void; setTargetTroopRatio(target: number): void; @@ -506,8 +506,8 @@ export interface Player { // Donation canDonate(recipient: Player): boolean; - donateTroops(recipient: Player, troops: number): void; - donateGold(recipient: Player, gold: number): void; + donateTroops(recipient: Player, troops: number): boolean; + donateGold(recipient: Player, gold: Gold): boolean; // Embargo hasEmbargoAgainst(other: Player): boolean; @@ -619,7 +619,7 @@ export interface PlayerActions { export interface BuildableUnit { canBuild: TileRef | false; type: UnitType; - cost: number; + cost: Gold; } export interface PlayerProfile { diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 385739d8a..b238365d0 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -2,6 +2,7 @@ import { AllPlayersStats, ClientID } from "../Schemas"; import { EmojiMessage, GameUpdates, + Gold, MessageType, NameViewData, PlayerID, @@ -103,7 +104,7 @@ export interface PlayerUpdate { playerType: PlayerType; isAlive: boolean; tilesOwned: number; - gold: number; + gold: Gold; population: number; workers: number; troops: number; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index ccb35498d..8d8cbf949 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -137,7 +137,7 @@ export class PlayerImpl implements Player { playerType: this.type(), isAlive: this.isAlive(), tilesOwned: this.numTilesOwned(), - gold: Number(this._gold), + gold: this._gold, population: this.population(), workers: this.workers(), troops: this.troops(), @@ -539,9 +539,13 @@ export class PlayerImpl implements Player { return true; } - donateTroops(recipient: Player, troops: number): void { + donateTroops(recipient: Player, troops: number): boolean { + if (troops <= 0) return false; + const removed = this.removeTroops(troops); + if (removed === 0) return false; + recipient.addTroops(removed); + this.sentDonations.push(new Donation(recipient, this.mg.ticks())); - recipient.addTroops(this.removeTroops(troops)); this.mg.displayMessage( `Sent ${renderTroops(troops)} troops to ${recipient.name()}`, MessageType.INFO, @@ -552,10 +556,16 @@ export class PlayerImpl implements Player { MessageType.SUCCESS, recipient.id(), ); + return true; } - donateGold(recipient: Player, gold: number): void { + + donateGold(recipient: Player, gold: Gold): boolean { + if (gold <= 0n) return false; + const removed = this.removeGold(gold); + if (removed === 0n) return false; + recipient.addGold(removed); + this.sentDonations.push(new Donation(recipient, this.mg.ticks())); - recipient.addGold(this.removeGold(gold)); this.mg.displayMessage( `Sent ${renderNumber(gold)} gold to ${recipient.name()}`, MessageType.INFO, @@ -566,6 +576,7 @@ export class PlayerImpl implements Player { MessageType.SUCCESS, recipient.id(), ); + return true; } hasEmbargoAgainst(other: Player): boolean { @@ -632,20 +643,20 @@ export class PlayerImpl implements Player { } gold(): Gold { - return Number(this._gold); + return this._gold; } addGold(toAdd: Gold): void { - this._gold += toInt(toAdd); + this._gold += toAdd; } - removeGold(toRemove: Gold): number { - if (toRemove <= 1) { - return 0; + removeGold(toRemove: Gold): Gold { + if (toRemove <= 0n) { + return 0n; } - const actualRemoved = minInt(this._gold, toInt(toRemove)); + const actualRemoved = minInt(this._gold, toRemove); this._gold -= actualRemoved; - return Number(actualRemoved); + return actualRemoved; } population(): number { From 3b24c00e6ed6dae1d0906992858847b58b25f90d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9odore=20L=C3=A9on?= Date: Mon, 2 Jun 2025 22:27:24 +0200 Subject: [PATCH 52/84] Fix : Donation when max pop already reached (#904) ## Description: Only donate up to what the receiving player can get so we don't silently loose troops See the chat here : ![image](https://github.com/user-attachments/assets/95b0917c-b7f3-47a0-b3a0-156110e727aa) ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: theodoreleon.aetarax --------- Co-authored-by: tnhnblgl <51187395+tnhnblgl@users.noreply.github.com> --- src/core/execution/DonateTroopExecution.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/execution/DonateTroopExecution.ts b/src/core/execution/DonateTroopExecution.ts index 40fde1781..3e362f611 100644 --- a/src/core/execution/DonateTroopExecution.ts +++ b/src/core/execution/DonateTroopExecution.ts @@ -30,6 +30,10 @@ export class DonateTroopsExecution implements Execution { if (this.troops === null) { this.troops = mg.config().defaultDonationAmount(this.sender); } + const maxDonation = + mg.config().maxPopulation(this.recipient) - + this.recipient.totalPopulation(); + this.troops = Math.min(this.troops, maxDonation); } tick(ticks: number): void { @@ -41,7 +45,7 @@ export class DonateTroopsExecution implements Execution { this.recipient.updateRelation(this.sender, 50); } else { consolex.warn( - `cannot send tropps from ${this.sender} to ${this.recipient}`, + `cannot send troops from ${this.sender} to ${this.recipient}`, ); } this.active = false; From 521ebff1c4d76019e6c923143580992d5448fb1a Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Mon, 2 Jun 2025 16:33:47 -0400 Subject: [PATCH 53/84] Validate incoming API data with zod (#891) ## Description: Validate incoming API data with zod. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/core/Schemas.ts | 2 +- src/core/WorkerSchemas.ts | 11 +++++++++++ src/server/MapPlaylist.ts | 3 +-- src/server/Master.ts | 4 +--- src/server/Worker.ts | 38 ++++++++++++++++++-------------------- 5 files changed, 32 insertions(+), 26 deletions(-) create mode 100644 src/core/WorkerSchemas.ts diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index f50ad78f8..74aa99b2a 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -112,7 +112,7 @@ export enum LogSeverity { Fatal = "FATAL", } -const GameConfigSchema = z.object({ +export const GameConfigSchema = z.object({ gameMap: z.nativeEnum(GameMapType), difficulty: z.nativeEnum(Difficulty), gameType: z.nativeEnum(GameType), diff --git a/src/core/WorkerSchemas.ts b/src/core/WorkerSchemas.ts new file mode 100644 index 000000000..0a06b1571 --- /dev/null +++ b/src/core/WorkerSchemas.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; +import { GameConfigSchema } from "./Schemas"; + +export const CreateGameInputSchema = GameConfigSchema.or( + z + .object({}) + .strict() + .transform((val) => undefined), +); + +export const GameInputSchema = GameConfigSchema.partial(); diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index cebf4a0b8..f03bf3232 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -58,11 +58,10 @@ export class MapPlaylist { infiniteTroops: false, instantBuild: false, disableNPCs: mode === GameMode.Team, - disableNukes: false, gameMode: mode, playerTeams: numPlayerTeams, bots: 400, - } as GameConfig; + } satisfies GameConfig; } private getNextMap(): MapWithMode { diff --git a/src/server/Master.ts b/src/server/Master.ts index 19bddcfad..388aba19c 100644 --- a/src/server/Master.ts +++ b/src/server/Master.ts @@ -282,9 +282,7 @@ async function schedulePublicGame(playlist: MapPlaylist) { "Content-Type": "application/json", [config.adminHeader()]: config.adminToken(), }, - body: JSON.stringify({ - gameConfig: playlist.gameConfig(), - }), + body: JSON.stringify(playlist.gameConfig()), }, ); diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 2ec20a3fd..f3e4d5de2 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -11,10 +11,10 @@ import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { ClientMessageSchema, - GameConfig, GameRecord, GameRecordSchema, } from "../core/Schemas"; +import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas"; import { archive, readGameRecord } from "./Archive"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; @@ -89,7 +89,13 @@ export function startWorker() { return res.status(400).json({ error: "Game ID is required" }); } const clientIP = req.ip || req.socket.remoteAddress || "unknown"; - const gc = req.body?.gameConfig as GameConfig; + const result = CreateGameInputSchema.safeParse(req.body); + if (!result.success) { + const error = z.prettifyError(result.error); + return res.status(400).json({ error }); + } + + const gc = result.data; if ( gc?.gameType === GameType.Public && req.headers[config.adminHeader()] !== config.adminToken() @@ -97,9 +103,7 @@ export function startWorker() { log.warn( `cannot create public game ${id}, ip ${ipAnonymize(clientIP)} incorrect admin token`, ); - return res - .status(400) - .json({ error: "Invalid admin token for public game creation" }); + return res.status(401).send("Unauthorized"); } // Double-check this worker should host this game @@ -144,9 +148,15 @@ export function startWorker() { app.put( "/api/game/:id", gatekeeper.httpHandler(LimiterType.Put, async (req, res) => { + const result = GameInputSchema.safeParse(req.body); + if (!result.success) { + const error = z.prettifyError(result.error); + return res.status(400).json({ error }); + } + const config = result.data; // TODO: only update public game if from local host const lobbyID = req.params.id; - if (req.body.gameType === GameType.Public) { + if (config.gameType === GameType.Public) { log.info(`cannot update game ${lobbyID} to public`); return res.status(400).json({ error: "Cannot update public game" }); } @@ -167,18 +177,7 @@ export function startWorker() { .status(400) .json({ error: "Cannot update game after it has started" }); } - game.updateGameConfig({ - gameMap: req.body.gameMap, - difficulty: req.body.difficulty, - infiniteGold: req.body.infiniteGold, - infiniteTroops: req.body.infiniteTroops, - instantBuild: req.body.instantBuild, - bots: req.body.bots, - disableNPCs: req.body.disableNPCs, - disabledUnits: req.body.disabledUnits, - gameMode: req.body.gameMode, - playerTeams: req.body.playerTeams, - }); + game.updateGameConfig(config); res.status(200).json({ success: true }); }), ); @@ -251,8 +250,7 @@ export function startWorker() { if (!result.success) { const error = z.prettifyError(result.error); log.info(error); - res.status(400).json({ error }); - return; + return res.status(400).json({ error }); } const gameRecord: GameRecord = result.data; From 8158ca966a83a6b60f98207d2a456f87797e43b9 Mon Sep 17 00:00:00 2001 From: Noface <166717111+Jerryslang@users.noreply.github.com> Date: Mon, 2 Jun 2025 21:36:51 +0100 Subject: [PATCH 54/84] this is a fix for the "possibly null" error. dosent seem to cause runtime issues but does cause the compiler to throw an error (#1005) ## Description: this is a fix for the "possibly null" error. dosent seem to cause runtime issues but does cause the compiler to throw an error this just adds a safety check ## Please complete the following: - [x] I have added screenshots for all UI updates (No UI Updates) - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file (No Text Updates) - [x] I have added relevant tests to the test directory (No Tests to add) - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: jerryslang --- src/client/graphics/layers/TerritoryLayer.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 550bab478..1c2dc1bf1 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -240,7 +240,13 @@ export class TerritoryLayer implements Layer { while (numToRender > 0) { numToRender--; - const tile = this.tileToRenderQueue.pop().tile; + + const entry = this.tileToRenderQueue.pop(); + if (!entry) { + break; + } + + const tile = entry.tile; this.paintTerritory(tile); for (const neighbor of this.game.neighbors(tile)) { this.paintTerritory(neighbor, true); From 33f7415d2cfa2d6848fbd14c44b61968ad5c5674 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 2 Jun 2025 14:17:55 -0700 Subject: [PATCH 55/84] fix build errors due to bad merge --- src/client/graphics/layers/Leaderboard.ts | 4 +++- src/core/execution/DonateTroopExecution.ts | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/client/graphics/layers/Leaderboard.ts b/src/client/graphics/layers/Leaderboard.ts index 0749a90aa..1d9dbb35d 100644 --- a/src/client/graphics/layers/Leaderboard.ts +++ b/src/client/graphics/layers/Leaderboard.ts @@ -89,7 +89,9 @@ export class Leaderboard extends LitElement implements Layer { switch (this._sortKey) { case "gold": - sorted = sorted.sort((a, b) => compare(a.gold(), b.gold())); + sorted = sorted.sort((a, b) => + compare(Number(a.gold()), Number(b.gold())), + ); break; case "troops": sorted = sorted.sort((a, b) => compare(a.troops(), b.troops())); diff --git a/src/core/execution/DonateTroopExecution.ts b/src/core/execution/DonateTroopExecution.ts index 3e362f611..3e5ac43da 100644 --- a/src/core/execution/DonateTroopExecution.ts +++ b/src/core/execution/DonateTroopExecution.ts @@ -31,8 +31,7 @@ export class DonateTroopsExecution implements Execution { this.troops = mg.config().defaultDonationAmount(this.sender); } const maxDonation = - mg.config().maxPopulation(this.recipient) - - this.recipient.totalPopulation(); + mg.config().maxPopulation(this.recipient) - this.recipient.population(); this.troops = Math.min(this.troops, maxDonation); } From 27ab2210f94462c1fcad6aaafd6f5750718994d6 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 2 Jun 2025 17:04:29 -0700 Subject: [PATCH 56/84] bugfix: ships in alternate view have incorrect color due to bad sprite caching, cached as [Object], not color string --- src/client/graphics/SpriteLoader.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/client/graphics/SpriteLoader.ts b/src/client/graphics/SpriteLoader.ts index 4bc552bc1..4be363134 100644 --- a/src/client/graphics/SpriteLoader.ts +++ b/src/client/graphics/SpriteLoader.ts @@ -125,10 +125,11 @@ export const getColoredSprite = ( customBorderColor?: Colord, ): HTMLCanvasElement => { const owner = unit.owner(); - const territoryColor = customTerritoryColor ?? theme.territoryColor(owner); - const borderColor = customBorderColor ?? theme.borderColor(owner); + const territoryColor: Colord = + customTerritoryColor ?? theme.territoryColor(owner); + const borderColor: Colord = customBorderColor ?? theme.borderColor(owner); const spawnHighlightColor = theme.spawnHighlightColor(); - const key = `${unit.type()}-${owner.id()}-${customTerritoryColor}-${customBorderColor}`; + const key = `${unit.type()}-${owner.id()}-${territoryColor.toRgbString()}-${borderColor.toRgbString()}`; if (coloredSpriteCache.has(key)) { return coloredSpriteCache.get(key)!; From 2151d267fae3637cec8e79cb026aef0c48286de9 Mon Sep 17 00:00:00 2001 From: Doo Date: Tue, 3 Jun 2025 16:22:13 +0200 Subject: [PATCH 57/84] Fixnukeboatbug (#1011) ## Description: Fixes : https://github.com/openfrontio/OpenFrontIO/issues/958 Use the actual troop count on the boat unit when it lands, not the original this.troops value. Tested locally with all nukes type and it works fine. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: .doo --------- Co-authored-by: rldtech Co-authored-by: tnhnblgl <51187395+tnhnblgl@users.noreply.github.com> --- src/core/execution/TransportShipExecution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 8d59d34d9..8851d8fdf 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -176,7 +176,7 @@ export class TransportShipExecution implements Execution { switch (result.type) { case PathFindResultType.Completed: if (this.mg.owner(this.dst) === this.attacker) { - this.attacker.addTroops(this.troops); + this.attacker.addTroops(this.boat.troops()); this.boat.delete(false); this.active = false; From b29cc56abce8aa5c2d5a623fdd8ea95af1fd3fa1 Mon Sep 17 00:00:00 2001 From: falc <76709589+falcolnic@users.noreply.github.com> Date: Wed, 4 Jun 2025 00:54:54 +0200 Subject: [PATCH 58/84] added ratio controls (#963) ## Description: added custom controls for attack ration to user setting ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: @qqkedsi ![Screenshot from 2025-05-31 01-18-30](https://github.com/user-attachments/assets/2460aab2-51ef-46d9-9d05-53e84dd57b29) Co-authored-by: evanpelle --- README.md | 6 ++++++ resources/lang/en.json | 5 +++++ src/client/InputHandler.ts | 10 ++++++---- src/client/UserSettingModal.ts | 22 ++++++++++++++++++++++ 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e4bd2aa27..f2081d255 100644 --- a/README.md +++ b/README.md @@ -97,10 +97,16 @@ npm run start:server-dev ``` - **Lint and fix code**: + ```bash npm run lint:fix ``` +- **Testing** + ```bash + npm test + ``` + ## 🏗️ Project Structure - `/src/client` - Frontend game client diff --git a/resources/lang/en.json b/resources/lang/en.json index d65dd52c0..58cf977c3 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -252,6 +252,11 @@ "view_options": "View Options", "toggle_view": "Toggle View", "toggle_view_desc": "Alternate view (terrain/countries)", + "attack_ratio_controls": "Attack Ratio Controls", + "attack_ratio_up": "Increase Attack Ratio", + "attack_ratio_up_desc": "Increase attack ratio by 10%", + "attack_ratio_down": "Decrease Attack Ratio", + "attack_ratio_down_desc": "Decrease attack ratio by 10%", "zoom_controls": "Zoom Controls", "zoom_out": "Zoom Out", "zoom_out_desc": "Zoom out the map", diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index aa6a225b6..47f57ae7a 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -122,6 +122,8 @@ export class InputHandler { moveRight: "KeyD", zoomOut: "KeyQ", zoomIn: "KeyE", + attackRatioDown: "Digit1", + attackRatioUp: "Digit2", ...JSON.parse(localStorage.getItem("settings.keybinds") ?? "{}"), }; this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e)); @@ -218,8 +220,8 @@ export class InputHandler { "ArrowRight", "Minus", "Equal", - "Digit1", - "Digit2", + keybinds.attackRatioDown, + keybinds.attackRatioUp, keybinds.centerCamera, "ControlLeft", "ControlRight", @@ -240,12 +242,12 @@ export class InputHandler { this.eventBus.emit(new RefreshGraphicsEvent()); } - if (e.code === "Digit1") { + if (e.code === keybinds.attackRatioDown) { e.preventDefault(); this.eventBus.emit(new AttackRatioEvent(-10)); } - if (e.code === "Digit2") { + if (e.code === keybinds.attackRatioUp) { e.preventDefault(); this.eventBus.emit(new AttackRatioEvent(10)); } diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index cfd9d21c1..405e24145 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -345,6 +345,28 @@ export class UserSettingModal extends LitElement { @change=${this.handleKeybindChange} > +
    + ${translateText("user_setting.attack_ratio_controls")} +
    + + + + +
    ${translateText("user_setting.zoom_controls")}
    From f5a561398cd00ca721b0db72f5a1c2224761812f Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 3 Jun 2025 16:37:19 -0700 Subject: [PATCH 59/84] page.redirect was not a function, causing the url not to redirect. als remove the # check so we are no longer serving ads --- src/client/Main.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/client/Main.ts b/src/client/Main.ts index c4b877eaa..cfcdd97de 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -240,21 +240,14 @@ class Client { page("/join/:lobbyId", (ctx) => { if (ctx.init && sessionStorage.getItem("inLobby")) { // On page reload, go back home - page.redirect("/"); + page("/"); return; } const lobbyId = ctx.params.lobbyId; - if (lobbyId?.endsWith("#")) { - // When the cookies button is pressed, '#' is added to the url - // causing the page to attempt to rejoin the lobby during game play. - console.error("Invalid lobby ID provided"); - return; - } - this.joinModal.open(lobbyId); - consolex.log(`joining lobby ${lobbyId}`); + console.log(`joining lobby ${lobbyId}`); }); page(); From 4eff20b50253cf0932a116ed5d91dd9a48513494 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 3 Jun 2025 17:34:19 -0700 Subject: [PATCH 60/84] bugfix: checking is socket was null broke hash verification on replay --- src/client/Transport.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 7d85ae931..ab06dc938 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -542,8 +542,7 @@ export class Transport { } private onSendHashEvent(event: SendHashEvent) { - if (this.socket === null) return; - if (this.isLocal || this.socket.readyState === WebSocket.OPEN) { + if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) { this.sendMsg( JSON.stringify({ type: "hash", @@ -554,7 +553,7 @@ export class Transport { } else { console.log( "WebSocket is not open. Current state:", - this.socket.readyState, + this.socket!.readyState, ); console.log("attempting reconnect"); } From 270244c835284fe6e34d36001da766ee44093f7b Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 3 Jun 2025 17:35:00 -0700 Subject: [PATCH 61/84] ensure that player records maintain the same order as in start info, this caused replay issues, as players were assigned to the wrong team --- src/server/GameServer.ts | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index caf67e0a1..003542bd8 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -538,20 +538,23 @@ export class GameServer { gameID: this.id, winner: this.winner?.winner, }); - const playerRecords: PlayerRecord[] = Array.from( - this.allClients.values(), - ).map((client) => { - const stats = this.winner?.allPlayersStats[client.clientID]; - if (stats === undefined) { - this.log.warn(`Unable to find stats for clientID ${client.clientID}`); - } - return { - clientID: client.clientID, - username: client.username, - persistentID: client.persistentID, - stats, - } satisfies PlayerRecord; - }); + + // Players must stay in the same order as the game start info. + const playerRecords: PlayerRecord[] = this.gameStartInfo.players.map( + (player) => { + const stats = this.winner?.allPlayersStats[player.clientID]; + if (stats === undefined) { + this.log.warn(`Unable to find stats for clientID ${player.clientID}`); + } + return { + clientID: player.clientID, + username: player.username, + persistentID: + this.allClients.get(player.clientID)?.persistentID ?? "", + stats, + } satisfies PlayerRecord; + }, + ); archive( createGameRecord( this.id, From 0810d5d4f36ca9658737a9c3a40813a38549c174 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Tue, 3 Jun 2025 22:28:22 -0400 Subject: [PATCH 62/84] Add a status check for the milestone field (#1029) ## Description: Add a status check for the milestone field. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- .github/workflows/pr-description.yml | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-description.yml b/.github/workflows/pr-description.yml index e38ba5037..292134f60 100644 --- a/.github/workflows/pr-description.yml +++ b/.github/workflows/pr-description.yml @@ -1,8 +1,13 @@ -name: 🧼 PR Description +name: 🧼 PR on: pull_request: - types: [opened, edited, synchronize] + types: + - demilestoned + - edited + - milestoned + - opened + - synchronize permissions: {} @@ -45,3 +50,18 @@ jobs: } else { console.log('✅ PR description and checklist look good.'); } + + has-milestone: + name: Has Milestone + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + script: | + // Get the pull request data + const milestone = context.payload.pull_request.milestone; + if (!milestone) { + core.setFailed('❌ Pull request must have a milestone assigned before merging.'); + return; + } + console.log(`✅ Milestone found: ${milestone.title}`); From a02037ae1c92a7a4c0cd7abf394c64a098752675 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Tue, 3 Jun 2025 22:33:35 -0400 Subject: [PATCH 63/84] Fix discord login issue (#1028) Fixes #1016 ## Description: Fix the zod user schema validation to make roles optional. This was causing a validation error when looking up the user's roles. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/client/jwt.ts | 22 +++++++--------------- src/core/ApiSchemas.ts | 2 +- src/server/Client.ts | 2 +- src/server/Worker.ts | 3 ++- 4 files changed, 11 insertions(+), 18 deletions(-) diff --git a/src/client/jwt.ts b/src/client/jwt.ts index 99c337f5a..985373ea2 100644 --- a/src/client/jwt.ts +++ b/src/client/jwt.ts @@ -1,4 +1,5 @@ import { decodeJwt } from "jose"; +import { z } from "zod/v4"; import { RefreshResponseSchema, TokenPayload, @@ -138,12 +139,9 @@ function _isLoggedIn(): IsLoggedInResponse { const result = TokenPayloadSchema.safeParse(payload); if (!result.success) { + const error = z.prettifyError(result.error); // Invalid response - console.error( - "Invalid payload", - // JSON.stringify(payload), - JSON.stringify(result.error), - ); + console.error("Invalid payload", error); return false; } @@ -171,11 +169,8 @@ export async function postRefresh(): Promise { const body = await response.json(); const result = RefreshResponseSchema.safeParse(body); if (!result.success) { - console.error( - "Invalid response", - JSON.stringify(body), - JSON.stringify(result.error), - ); + const error = z.prettifyError(result.error); + console.error("Invalid response", error); return false; } localStorage.setItem("token", result.data.token); @@ -201,11 +196,8 @@ export async function getUserMe(): Promise { const body = await response.json(); const result = UserMeResponseSchema.safeParse(body); if (!result.success) { - console.error( - "Invalid response", - JSON.stringify(body), - JSON.stringify(result.error), - ); + const error = z.prettifyError(result.error); + console.error("Invalid response", error); return false; } return result.data; diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index ebb8dc99b..aec1ae506 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -42,7 +42,7 @@ export const UserMeResponseSchema = z.object({ }), player: z.object({ publicId: z.string(), - roles: z.string().array(), + roles: z.string().array().optional(), }), }); export type UserMeResponse = z.infer; diff --git a/src/server/Client.ts b/src/server/Client.ts index 6eff8b1b8..295ca499e 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -12,7 +12,7 @@ export class Client { public readonly clientID: ClientID, public readonly persistentID: string, public readonly claims: TokenPayload | null, - public readonly roles: string[] | null, + public readonly roles: string[] | undefined, public readonly ip: string, public readonly username: string, public readonly ws: WebSocket, diff --git a/src/server/Worker.ts b/src/server/Worker.ts index f3e4d5de2..068799fed 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -314,7 +314,7 @@ export function startWorker() { config, ); - const roles: string[] | null = null; + let roles: string[] | undefined; // Check user roles if (claims !== null) { @@ -323,6 +323,7 @@ export function startWorker() { log.warn("Token is not valid", claims); return; } + roles = result.player.roles; } // TODO: Validate client settings based on roles From 77f57207be8b92788ca38c6c1f7bb488a5602fd3 Mon Sep 17 00:00:00 2001 From: falc <76709589+falcolnic@users.noreply.github.com> Date: Wed, 4 Jun 2025 18:22:17 +0200 Subject: [PATCH 64/84] Changed consolex to console logging (#1036) ## Description: Changed from consolex to console ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: @qqkedsi --- src/client/ClientGameRunner.ts | 30 +++++++------- src/client/HostLobbyModal.ts | 13 +++---- src/client/JoinPrivateLobbyModal.ts | 11 +++--- src/client/LocalPersistantStats.ts | 3 +- src/client/LocalServer.ts | 3 +- src/client/Main.ts | 27 +++++++------ src/client/PublicLobby.ts | 5 +-- src/client/SinglePlayerModal.ts | 9 ++--- src/client/Transport.ts | 13 ------- src/client/graphics/GameRenderer.ts | 17 ++++---- src/client/graphics/fx/SpriteFx.ts | 3 +- src/client/graphics/layers/RadialMenu.ts | 5 +-- src/core/Consolex.ts | 39 ------------------- src/core/configuration/ConfigLoader.ts | 9 ++--- src/core/execution/BoatRetreatExecution.ts | 3 +- src/core/execution/BotSpawner.ts | 3 +- src/core/execution/CityExecution.ts | 3 +- src/core/execution/ConstructionExecution.ts | 3 +- src/core/execution/DefensePostExecution.ts | 3 +- src/core/execution/DonateGoldExecution.ts | 3 +- src/core/execution/DonateTroopExecution.ts | 3 +- src/core/execution/EmojiExecution.ts | 5 +-- src/core/execution/FakeHumanExecution.ts | 5 +-- src/core/execution/MIRVExecution.ts | 3 +- src/core/execution/MissileSiloExecution.ts | 3 +- src/core/execution/NukeExecution.ts | 5 +-- src/core/execution/PlayerExecution.ts | 3 +- src/core/execution/PortExecution.ts | 3 +- src/core/execution/QuickChatExecution.ts | 7 ++-- src/core/execution/SAMLauncherExecution.ts | 3 +- .../execution/SetTargetTroopRatioExecution.ts | 3 +- src/core/execution/TradeShipExecution.ts | 5 +-- src/core/execution/TransportShipExecution.ts | 7 ++-- src/core/execution/WarshipExecution.ts | 5 +-- .../alliance/AllianceRequestExecution.ts | 5 +-- .../alliance/AllianceRequestReplyExecution.ts | 5 +-- .../alliance/BreakAllianceExecution.ts | 3 +- src/core/game/GameImpl.ts | 7 ++-- src/core/game/PlayerImpl.ts | 3 +- src/core/game/TerrainMapLoader.ts | 5 +-- src/core/pathfinding/PathFinding.ts | 5 +-- src/core/pathfinding/SerialAStar.ts | 3 +- 42 files changed, 104 insertions(+), 197 deletions(-) delete mode 100644 src/core/Consolex.ts diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 67d899f22..65ea5be59 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -1,5 +1,4 @@ import { translateText } from "../client/Utils"; -import { consolex, initRemoteSender } from "../core/Consolex"; import { EventBus } from "../core/EventBus"; import { ClientID, @@ -58,10 +57,9 @@ export function joinLobby( onJoin: () => void, ): () => void { const eventBus = new EventBus(); - initRemoteSender(eventBus); - consolex.log( - `joinging lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}`, + console.log( + `joining lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}`, ); const userSettings: UserSettings = new UserSettings(); @@ -70,21 +68,21 @@ export function joinLobby( const transport = new Transport(lobbyConfig, eventBus); const onconnect = () => { - consolex.log(`Joined game lobby ${lobbyConfig.gameID}`); + console.log(`Joined game lobby ${lobbyConfig.gameID}`); transport.joinGame(0); }; let terrainLoad: Promise | null = null; const onmessage = (message: ServerMessage) => { if (message.type === "prestart") { - consolex.log(`lobby: game prestarting: ${JSON.stringify(message)}`); + console.log(`lobby: game prestarting: ${JSON.stringify(message)}`); terrainLoad = loadTerrainMap(message.gameMap); onPrestart(); } if (message.type === "start") { // Trigger prestart for singleplayer games onPrestart(); - consolex.log(`lobby: game started: ${JSON.stringify(message, null, 2)}`); + console.log(`lobby: game started: ${JSON.stringify(message, null, 2)}`); onJoin(); // For multiplayer games, GameStartInfo is not known until game starts. lobbyConfig.gameStartInfo = message.gameStartInfo; @@ -99,7 +97,7 @@ export function joinLobby( }; transport.connect(onconnect, onmessage); return () => { - consolex.log("leaving game"); + console.log("leaving game"); transport.leaveGame(); }; } @@ -139,12 +137,12 @@ export async function createClientGame( lobbyConfig.gameStartInfo.gameID, ); - consolex.log("going to init path finder"); - consolex.log("inited path finder"); + console.log("going to init path finder"); + console.log("inited path finder"); const canvas = createCanvas(); const gameRenderer = createRenderer(canvas, gameView, eventBus); - consolex.log( + console.log( `creating private game got difficulty: ${lobbyConfig.gameStartInfo.config.difficulty}`, ); @@ -221,7 +219,7 @@ export class ClientGameRunner { } public start() { - consolex.log("starting client game"); + console.log("starting client game"); this.isActive = true; this.lastMessageTime = Date.now(); @@ -270,14 +268,14 @@ export class ClientGameRunner { requestAnimationFrame(keepWorkerAlive); const onconnect = () => { - consolex.log("Connected to game server!"); + console.log("Connected to game server!"); this.transport.joinGame(this.turnsSeen); }; const onmessage = (message: ServerMessage) => { this.lastMessageTime = Date.now(); if (message.type === "start") { this.hasJoined = true; - consolex.log("starting game!"); + console.log("starting game!"); for (const turn of message.turns) { if (turn.turnNumber < this.turnsSeen) { continue; @@ -312,7 +310,7 @@ export class ClientGameRunner { return; } if (this.turnsSeen !== message.turn.turnNumber) { - consolex.error( + console.error( `got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}`, ); } else { @@ -345,7 +343,7 @@ export class ClientGameRunner { if (!this.gameView.isValidCoord(cell.x, cell.y)) { return; } - consolex.log(`clicked cell ${cell}`); + console.log(`clicked cell ${cell}`); const tile = this.gameView.ref(cell.x, cell.y); if ( this.gameView.isLand(tile) && diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 6e12a4ad3..41447146f 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -3,7 +3,6 @@ import { customElement, query, state } from "lit/decorators.js"; import randomMap from "../../resources/images/RandomMap.webp"; import { translateText } from "../client/Utils"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; -import { consolex } from "../core/Consolex"; import { Difficulty, Duos, @@ -457,7 +456,7 @@ export class HostLobbyModal extends LitElement { private async handleDisableNPCsChange(e: Event) { this.disableNPCs = Boolean((e.target as HTMLInputElement).checked); - consolex.log(`updating disable npcs to ${this.disableNPCs}`); + console.log(`updating disable npcs to ${this.disableNPCs}`); this.putGameConfig(); } @@ -498,7 +497,7 @@ export class HostLobbyModal extends LitElement { } private toggleUnit(unit: UnitType, checked: boolean): void { - consolex.log(`Toggling unit type: ${unit} to ${checked}`); + console.log(`Toggling unit type: ${unit} to ${checked}`); this.disabledUnits = checked ? [...this.disabledUnits, unit] : this.disabledUnits.filter((u) => u !== unit); @@ -518,7 +517,7 @@ export class HostLobbyModal extends LitElement { } await this.putGameConfig(); - consolex.log( + console.log( `Starting private game with map: ${GameMapType[this.selectedMap]} ${this.useRandomMap ? " (Randomly selected)" : ""}`, ); this.close(); @@ -546,7 +545,7 @@ export class HostLobbyModal extends LitElement { this.copySuccess = false; }, 2000); } catch (err) { - consolex.error(`Failed to copy text: ${err}`); + console.error(`Failed to copy text: ${err}`); } } @@ -586,11 +585,11 @@ async function createLobby(): Promise { } const data = await response.json(); - consolex.log("Success:", data); + console.log("Success:", data); return data as GameInfo; } catch (error) { - consolex.error("Error creating lobby:", error); + console.error("Error creating lobby:", error); throw error; // Re-throw the error so the caller can handle it } } diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 01424e26a..3640bb220 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -1,7 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; -import { consolex } from "../core/Consolex"; import { GameInfo, GameRecord } from "../core/Schemas"; import { generateID } from "../core/Util"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; @@ -145,13 +144,13 @@ export class JoinPrivateLobbyModal extends LitElement { this.lobbyIdInput.value = lobbyId; } catch (err) { - consolex.error("Failed to read clipboard contents: ", err); + console.error("Failed to read clipboard contents: ", err); } } private async joinLobby(): Promise { const lobbyId = this.lobbyIdInput.value; - consolex.log(`Joining lobby with ID: ${lobbyId}`); + console.log(`Joining lobby with ID: ${lobbyId}`); this.message = `${translateText("private_lobby.checking")}`; try { @@ -165,7 +164,7 @@ export class JoinPrivateLobbyModal extends LitElement { this.message = `${translateText("private_lobby.not_found")}`; } catch (error) { - consolex.error("Error checking lobby existence:", error); + console.error("Error checking lobby existence:", error); this.message = `${translateText("private_lobby.error")}`; } } @@ -218,7 +217,7 @@ export class JoinPrivateLobbyModal extends LitElement { archiveData.success === false && archiveData.error === "Version mismatch" ) { - consolex.warn( + console.warn( `Git commit hash mismatch for game ${lobbyId}`, archiveData.details, ); @@ -266,7 +265,7 @@ export class JoinPrivateLobbyModal extends LitElement { this.players = data.clients?.map((p) => p.username) ?? []; }) .catch((error) => { - consolex.error("Error polling players:", error); + console.error("Error polling players:", error); }); } } diff --git a/src/client/LocalPersistantStats.ts b/src/client/LocalPersistantStats.ts index 616e40db7..c6dd1df5a 100644 --- a/src/client/LocalPersistantStats.ts +++ b/src/client/LocalPersistantStats.ts @@ -1,4 +1,3 @@ -import { consolex } from "../core/Consolex"; import { GameConfig, GameID, GameRecord } from "../core/Schemas"; import { replacer } from "../core/Util"; @@ -51,7 +50,7 @@ export function endGame(gameRecord: GameRecord) { const gameStat = stats[gameRecord.info.gameID]; if (!gameStat) { - consolex.log("LocalPersistantStats: game not found"); + console.log("LocalPersistantStats: game not found"); return; } diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index c3982510f..e3bc08dcf 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -1,4 +1,3 @@ -import { consolex } from "../core/Consolex"; import { AllPlayersStats, ClientMessage, @@ -172,7 +171,7 @@ export class LocalServer { } public endGame(saveFullGame: boolean = false) { - consolex.log("local server ending game"); + console.log("local server ending game"); clearInterval(this.turnCheckInterval); if (this.isReplay) { return; diff --git a/src/client/Main.ts b/src/client/Main.ts index cfcdd97de..4138224df 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -1,6 +1,5 @@ import page from "page"; import favicon from "../../resources/images/Favicon.svg"; -import { consolex } from "../core/Consolex"; import { GameRecord, GameStartInfo } from "../core/Schemas"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; @@ -62,16 +61,16 @@ class Client { initialize(): void { const newsModal = document.querySelector("news-modal") as NewsModal; if (!newsModal) { - consolex.warn("News modal element not found"); + console.warn("News modal element not found"); } else { - consolex.log("News modal element found"); + console.log("News modal element found"); } newsModal instanceof NewsModal; const newsButton = document.querySelector("news-button") as NewsButton; if (!newsButton) { - consolex.warn("News button element not found"); + console.warn("News button element not found"); } else { - consolex.log("News button element found"); + console.log("News button element found"); } // Comment out to show news button. @@ -84,22 +83,22 @@ class Client { "lang-selector", ) as LanguageModal; if (!langSelector) { - consolex.warn("Lang selector element not found"); + console.warn("Lang selector element not found"); } if (!LanguageModal) { - consolex.warn("Language modal element not found"); + console.warn("Language modal element not found"); } this.flagInput = document.querySelector("flag-input") as FlagInput; if (!this.flagInput) { - consolex.warn("Flag input element not found"); + console.warn("Flag input element not found"); } this.darkModeButton = document.querySelector( "dark-mode-button", ) as DarkModeButton; if (!this.darkModeButton) { - consolex.warn("Dark mode button element not found"); + console.warn("Dark mode button element not found"); } const loginDiscordButton = document.getElementById( @@ -113,7 +112,7 @@ class Client { "username-input", ) as UsernameInput; if (!this.usernameInput) { - consolex.warn("Username input element not found"); + console.warn("Username input element not found"); } this.publicLobby = document.querySelector("public-lobby") as PublicLobby; @@ -122,7 +121,7 @@ class Client { ) as NodeListOf; window.addEventListener("beforeunload", () => { - consolex.log("Browser is closing"); + console.log("Browser is closing"); if (this.gameStop !== null) { this.gameStop(); } @@ -267,9 +266,9 @@ class Client { private async handleJoinLobby(event: CustomEvent) { const lobby = event.detail as JoinLobbyEvent; - consolex.log(`joining lobby ${lobby.gameID}`); + console.log(`joining lobby ${lobby.gameID}`); if (this.gameStop !== null) { - consolex.log("joining lobby, stopping existing game"); + console.log("joining lobby, stopping existing game"); this.gameStop(); } const config = await getServerConfigFromClient(); @@ -341,7 +340,7 @@ class Client { if (this.gameStop === null) { return; } - consolex.log("leaving lobby, cancelling game"); + console.log("leaving lobby, cancelling game"); this.gameStop(); this.gameStop = null; this.publicLobby.leaveLobby(); diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index ee3069504..2292d9d8d 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -1,7 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; -import { consolex } from "../core/Consolex"; import { GameMode } from "../core/game/Game"; import { GameID, GameInfo } from "../core/Schemas"; import { generateID } from "../core/Util"; @@ -51,7 +50,7 @@ export class PublicLobby extends LitElement { } }); } catch (error) { - consolex.error("Error fetching lobbies:", error); + console.error("Error fetching lobbies:", error); } } @@ -63,7 +62,7 @@ export class PublicLobby extends LitElement { const data = await response.json(); return data.lobbies; } catch (error) { - consolex.error("Error fetching lobbies:", error); + console.error("Error fetching lobbies:", error); throw error; } } diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 509f564f6..f633c1e31 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -2,7 +2,6 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import randomMap from "../../resources/images/RandomMap.webp"; import { translateText } from "../client/Utils"; -import { consolex } from "../core/Consolex"; import { Difficulty, Duos, @@ -367,7 +366,7 @@ export class SinglePlayerModal extends LitElement { } private toggleUnit(unit: UnitType, checked: boolean): void { - consolex.log(`Toggling unit type: ${unit} to ${checked}`); + console.log(`Toggling unit type: ${unit} to ${checked}`); this.disabledUnits = checked ? [...this.disabledUnits, unit] : this.disabledUnits.filter((u) => u !== unit); @@ -379,7 +378,7 @@ export class SinglePlayerModal extends LitElement { this.selectedMap = this.getRandomMap(); } - consolex.log( + console.log( `Starting single player game with map: ${GameMapType[this.selectedMap]}${this.useRandomMap ? " (Randomly selected)" : ""}`, ); const clientID = generateID(); @@ -389,12 +388,12 @@ export class SinglePlayerModal extends LitElement { "username-input", ) as UsernameInput; if (!usernameInput) { - consolex.warn("Username input element not found"); + console.warn("Username input element not found"); } const flagInput = document.querySelector("flag-input") as FlagInput; if (!flagInput) { - consolex.warn("Flag input element not found"); + console.warn("Flag input element not found"); } this.dispatchEvent( new CustomEvent("join-lobby", { diff --git a/src/client/Transport.ts b/src/client/Transport.ts index ab06dc938..f208a55a1 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -1,4 +1,3 @@ -import { SendLogEvent } from "../core/Consolex"; import { EventBus, GameEvent } from "../core/EventBus"; import { AllPlayers, @@ -16,7 +15,6 @@ import { ClientHashMessage, ClientIntentMessage, ClientJoinMessage, - ClientLogMessage, ClientPingMessage, ClientSendWinnerMessage, Intent, @@ -211,7 +209,6 @@ export class Transport { ); this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e)); - this.eventBus.on(SendLogEvent, (e) => this.onSendLogEvent(e)); this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e)); this.eventBus.on(SendWinnerEvent, (e) => this.onSendWinnerEvent(e)); this.eventBus.on(SendHashEvent, (e) => this.onSendHashEvent(e)); @@ -336,16 +333,6 @@ export class Transport { } } - private onSendLogEvent(event: SendLogEvent) { - this.sendMsg( - JSON.stringify({ - type: "log", - log: event.log, - severity: event.severity, - } satisfies ClientLogMessage), - ); - } - joinGame(numTurns: number) { this.sendMsg( JSON.stringify({ diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 149706c8f..cf4ea6f7b 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -1,4 +1,3 @@ -import { consolex } from "../../core/Consolex"; import { EventBus } from "../../core/EventBus"; import { GameView } from "../../core/game/GameView"; import { GameStartingModal } from "../GameStartingModal"; @@ -51,7 +50,7 @@ export function createRenderer( // TODO maybe append this to dcoument instead of querying for them? const emojiTable = document.querySelector("emoji-table") as EmojiTable; if (!emojiTable || !(emojiTable instanceof EmojiTable)) { - consolex.error("EmojiTable element not found in the DOM"); + console.error("EmojiTable element not found in the DOM"); } emojiTable.eventBus = eventBus; emojiTable.transformHandler = transformHandler; @@ -60,28 +59,28 @@ export function createRenderer( const buildMenu = document.querySelector("build-menu") as BuildMenu; if (!buildMenu || !(buildMenu instanceof BuildMenu)) { - consolex.error("BuildMenu element not found in the DOM"); + console.error("BuildMenu element not found in the DOM"); } buildMenu.game = game; buildMenu.eventBus = eventBus; const leaderboard = document.querySelector("leader-board") as Leaderboard; if (!emojiTable || !(leaderboard instanceof Leaderboard)) { - consolex.error("EmojiTable element not found in the DOM"); + console.error("EmojiTable element not found in the DOM"); } leaderboard.eventBus = eventBus; leaderboard.game = game; const teamStats = document.querySelector("team-stats") as TeamStats; if (!emojiTable || !(teamStats instanceof TeamStats)) { - consolex.error("EmojiTable element not found in the DOM"); + console.error("EmojiTable element not found in the DOM"); } teamStats.eventBus = eventBus; teamStats.game = game; const controlPanel = document.querySelector("control-panel") as ControlPanel; if (!(controlPanel instanceof ControlPanel)) { - consolex.error("ControlPanel element not found in the DOM"); + console.error("ControlPanel element not found in the DOM"); } controlPanel.eventBus = eventBus; controlPanel.uiState = uiState; @@ -91,14 +90,14 @@ export function createRenderer( "events-display", ) as EventsDisplay; if (!(eventsDisplay instanceof EventsDisplay)) { - consolex.error("events display not found"); + console.error("events display not found"); } eventsDisplay.eventBus = eventBus; eventsDisplay.game = game; const chatDisplay = document.querySelector("chat-display") as ChatDisplay; if (!(chatDisplay instanceof ChatDisplay)) { - consolex.error("chat display not found"); + console.error("chat display not found"); } chatDisplay.eventBus = eventBus; chatDisplay.game = game; @@ -107,7 +106,7 @@ export function createRenderer( "player-info-overlay", ) as PlayerInfoOverlay; if (!(playerInfo instanceof PlayerInfoOverlay)) { - consolex.error("player info overlay not found"); + console.error("player info overlay not found"); } playerInfo.eventBus = eventBus; playerInfo.transform = transformHandler; diff --git a/src/client/graphics/fx/SpriteFx.ts b/src/client/graphics/fx/SpriteFx.ts index 54b4f18df..9293dd663 100644 --- a/src/client/graphics/fx/SpriteFx.ts +++ b/src/client/graphics/fx/SpriteFx.ts @@ -1,5 +1,4 @@ import { Theme } from "../../../core/configuration/Config"; -import { consolex } from "../../../core/Consolex"; import { PlayerView } from "../../../core/game/GameView"; import { AnimatedSprite } from "../AnimatedSprite"; import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; @@ -62,7 +61,7 @@ export class SpriteFx implements Fx { theme, ); if (!this.animatedSprite) { - consolex.error("Could not load animated sprite", fxType); + console.error("Could not load animated sprite", fxType); } else { this.duration = duration ?? this.animatedSprite.lifeTime() ?? 1000; } diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 4df72f104..74cbb7b27 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -6,7 +6,6 @@ import disabledIcon from "../../../../resources/images/DisabledIcon.svg"; import infoIcon from "../../../../resources/images/InfoIcon.svg"; import swordIcon from "../../../../resources/images/SwordIconWhite.svg"; import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg"; -import { consolex } from "../../../core/Consolex"; import { EventBus } from "../../../core/EventBus"; import { Cell, @@ -341,7 +340,7 @@ export class RadialMenu implements Layer { const myPlayer = this.g.myPlayer(); if (myPlayer === null) { - consolex.warn("my player not found"); + console.warn("my player not found"); return; } if (myPlayer && !myPlayer.isAlive() && !this.g.inSpawnPhase()) { @@ -453,7 +452,7 @@ export class RadialMenu implements Layer { if (!this.isCenterButtonEnabled) { return; } - consolex.log("Center button clicked"); + console.log("Center button clicked"); if (this.clickedCell === null) return; const clicked = this.g.ref(this.clickedCell.x, this.clickedCell.y); if (this.g.inSpawnPhase()) { diff --git a/src/core/Consolex.ts b/src/core/Consolex.ts deleted file mode 100644 index 7817d3b08..000000000 --- a/src/core/Consolex.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { EventBus, GameEvent } from "./EventBus"; -import { LogSeverity } from "./Schemas"; - -export const consolex = { - log: console.log, - warn: console.warn, - error: console.error, -}; - -let inited = false; - -// Only call this in client/browser! -export function initRemoteSender(eventBus: EventBus) { - if (inited) { - return; - } - inited = true; - - consolex.log = (...args: any[]): void => { - console.log(...args); - // eventBus.emit(new SendLogEvent(LogSeverity.Info, args.join(' '))) - }; - - consolex.warn = (...args: any[]): void => { - console.warn(...args); - // eventBus.emit(new SendLogEvent(LogSeverity.Warn, args.join(' '))) - }; - - consolex.error = (...args: any[]): void => { - console.error(...args); - // eventBus.emit(new SendLogEvent(LogSeverity.Error, args.join(' '))) - }; -} -export class SendLogEvent implements GameEvent { - constructor( - public readonly severity: LogSeverity, - public readonly log: string, - ) {} -} diff --git a/src/core/configuration/ConfigLoader.ts b/src/core/configuration/ConfigLoader.ts index 94a723c13..184902694 100644 --- a/src/core/configuration/ConfigLoader.ts +++ b/src/core/configuration/ConfigLoader.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { UserSettings } from "../game/UserSettings"; import { GameConfig } from "../Schemas"; import { Config, GameEnv, ServerConfig } from "./Config"; @@ -20,7 +19,7 @@ export async function getConfig( return new DevConfig(sc, gameConfig, userSettings, isReplay); case GameEnv.Preprod: case GameEnv.Prod: - consolex.log("using prod config"); + console.log("using prod config"); return new DefaultConfig(sc, gameConfig, userSettings, isReplay); default: throw Error(`unsupported server configuration: ${process.env.GAME_ENV}`); @@ -51,13 +50,13 @@ export function getServerConfigFromServer(): ServerConfig { export function getServerConfig(gameEnv: string) { switch (gameEnv) { case "dev": - consolex.log("using dev server config"); + console.log("using dev server config"); return new DevServerConfig(); case "staging": - consolex.log("using preprod server config"); + console.log("using preprod server config"); return preprodConfig; case "prod": - consolex.log("using prod server config"); + console.log("using prod server config"); return prodConfig; default: throw Error(`unsupported server configuration: ${gameEnv}`); diff --git a/src/core/execution/BoatRetreatExecution.ts b/src/core/execution/BoatRetreatExecution.ts index bcef746a4..c5c597639 100644 --- a/src/core/execution/BoatRetreatExecution.ts +++ b/src/core/execution/BoatRetreatExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Execution, Game, Player, PlayerID, UnitType } from "../game/Game"; export class BoatRetreatExecution implements Execution { @@ -33,7 +32,7 @@ export class BoatRetreatExecution implements Execution { ); if (!unit) { - consolex.warn(`Didn't find outgoing boat with id ${this.unitID}`); + console.warn(`Didn't find outgoing boat with id ${this.unitID}`); this.active = false; return; } diff --git a/src/core/execution/BotSpawner.ts b/src/core/execution/BotSpawner.ts index 521729a67..644fbc803 100644 --- a/src/core/execution/BotSpawner.ts +++ b/src/core/execution/BotSpawner.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Game, PlayerInfo, PlayerType } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; @@ -22,7 +21,7 @@ export class BotSpawner { let tries = 0; while (this.bots.length < numBots) { if (tries > 10000) { - consolex.log("too many retries while spawning bots, giving up"); + console.log("too many retries while spawning bots, giving up"); return this.bots; } const botName = this.randomBotName(); diff --git a/src/core/execution/CityExecution.ts b/src/core/execution/CityExecution.ts index c6046f2de..d488df6e3 100644 --- a/src/core/execution/CityExecution.ts +++ b/src/core/execution/CityExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Execution, Game, @@ -34,7 +33,7 @@ export class CityExecution implements Execution { if (this.city === null) { const spawnTile = this.player.canBuild(UnitType.City, this.tile); if (spawnTile === false) { - consolex.warn("cannot build city"); + console.warn("cannot build city"); this.active = false; return; } diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts index ba7a7c9c0..aa190bad2 100644 --- a/src/core/execution/ConstructionExecution.ts +++ b/src/core/execution/ConstructionExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Execution, Game, @@ -55,7 +54,7 @@ export class ConstructionExecution implements Execution { } const spawnTile = this.player.canBuild(this.constructionType, this.tile); if (spawnTile === false) { - consolex.warn(`cannot build ${this.constructionType}`); + console.warn(`cannot build ${this.constructionType}`); this.active = false; return; } diff --git a/src/core/execution/DefensePostExecution.ts b/src/core/execution/DefensePostExecution.ts index c0d6e4711..2ec384cb8 100644 --- a/src/core/execution/DefensePostExecution.ts +++ b/src/core/execution/DefensePostExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Execution, Game, @@ -63,7 +62,7 @@ export class DefensePostExecution implements Execution { if (this.post === null) { const spawnTile = this.player.canBuild(UnitType.DefensePost, this.tile); if (spawnTile === false) { - consolex.warn("cannot build Defense Post"); + console.warn("cannot build Defense Post"); this.active = false; return; } diff --git a/src/core/execution/DonateGoldExecution.ts b/src/core/execution/DonateGoldExecution.ts index 20837d66e..672c5861d 100644 --- a/src/core/execution/DonateGoldExecution.ts +++ b/src/core/execution/DonateGoldExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Execution, Game, Gold, Player, PlayerID } from "../game/Game"; export class DonateGoldExecution implements Execution { @@ -40,7 +39,7 @@ export class DonateGoldExecution implements Execution { ) { this.recipient.updateRelation(this.sender, 50); } else { - consolex.warn( + console.warn( `cannot send gold from ${this.sender.name()} to ${this.recipient.name()}`, ); } diff --git a/src/core/execution/DonateTroopExecution.ts b/src/core/execution/DonateTroopExecution.ts index 3e5ac43da..dd05689da 100644 --- a/src/core/execution/DonateTroopExecution.ts +++ b/src/core/execution/DonateTroopExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Execution, Game, Player, PlayerID } from "../game/Game"; export class DonateTroopsExecution implements Execution { @@ -43,7 +42,7 @@ export class DonateTroopsExecution implements Execution { ) { this.recipient.updateRelation(this.sender, 50); } else { - consolex.warn( + console.warn( `cannot send troops from ${this.sender} to ${this.recipient}`, ); } diff --git a/src/core/execution/EmojiExecution.ts b/src/core/execution/EmojiExecution.ts index a544411e4..068f11d89 100644 --- a/src/core/execution/EmojiExecution.ts +++ b/src/core/execution/EmojiExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { AllPlayers, Execution, @@ -43,7 +42,7 @@ export class EmojiExecution implements Execution { tick(ticks: number): void { const emojiString = flattenedEmojiTable[this.emoji]; if (emojiString === undefined) { - consolex.warn( + console.warn( `cannot send emoji ${this.emoji} from ${this.requestor} to ${this.recipient}`, ); } else if (this.requestor.canSendEmoji(this.recipient)) { @@ -56,7 +55,7 @@ export class EmojiExecution implements Execution { this.recipient.updateRelation(this.requestor, -100); } } else { - consolex.warn( + console.warn( `cannot send emoji from ${this.requestor} to ${this.recipient}`, ); } diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index 7a657c176..1d5ca31fe 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Cell, Difficulty, @@ -117,7 +116,7 @@ export class FakeHumanExecution implements Execution { if (this.mg.inSpawnPhase()) { const rl = this.randomLand(); if (rl === null) { - consolex.warn(`cannot spawn ${this.nation.playerInfo.name}`); + console.warn(`cannot spawn ${this.nation.playerInfo.name}`); return; } this.mg.addExecution(new SpawnExecution(this.nation.playerInfo, rl)); @@ -488,7 +487,7 @@ export class FakeHumanExecution implements Execution { } const canBuild = this.player.canBuild(UnitType.Warship, targetTile); if (canBuild === false) { - consolex.warn("cannot spawn destroyer"); + console.warn("cannot spawn destroyer"); return false; } this.mg.addExecution( diff --git a/src/core/execution/MIRVExecution.ts b/src/core/execution/MIRVExecution.ts index 41e617f8a..9b0e8e511 100644 --- a/src/core/execution/MIRVExecution.ts +++ b/src/core/execution/MIRVExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Execution, Game, @@ -64,7 +63,7 @@ export class MirvExecution implements Execution { if (this.nuke === null) { const spawn = this.player.canBuild(UnitType.MIRV, this.dst); if (spawn === false) { - consolex.warn(`cannot build MIRV`); + console.warn(`cannot build MIRV`); this.active = false; return; } diff --git a/src/core/execution/MissileSiloExecution.ts b/src/core/execution/MissileSiloExecution.ts index d9b30f9dd..b0c404405 100644 --- a/src/core/execution/MissileSiloExecution.ts +++ b/src/core/execution/MissileSiloExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Execution, Game, @@ -38,7 +37,7 @@ export class MissileSiloExecution implements Execution { if (this.silo === null) { const spawn = this.player.canBuild(UnitType.MissileSilo, this.tile); if (spawn === false) { - consolex.warn( + console.warn( `player ${this.player} cannot build missile silo at ${this.tile}`, ); this.active = false; diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index facdbb649..83955ded0 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Execution, Game, @@ -109,7 +108,7 @@ export class NukeExecution implements Execution { if (this.nuke === null) { const spawn = this.src ?? this.player.canBuild(this.type, this.dst); if (spawn === false) { - consolex.warn(`cannot build Nuke`); + console.warn(`cannot build Nuke`); this.active = false; return; } @@ -161,7 +160,7 @@ export class NukeExecution implements Execution { // make the nuke unactive if it was intercepted if (!this.nuke.isActive()) { - consolex.log(`Nuke destroyed before reaching target`); + console.log(`Nuke destroyed before reaching target`); this.active = false; return; } diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 4bf039072..276be4890 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -1,6 +1,5 @@ import { renderNumber } from "../../client/Utils"; import { Config } from "../configuration/Config"; -import { consolex } from "../Consolex"; import { Execution, Game, @@ -116,7 +115,7 @@ export class PlayerExecution implements Execution { this.removeClusters(); const end = performance.now(); if (end - start > 1000) { - consolex.log(`player ${this.player.name()}, took ${end - start}ms`); + console.log(`player ${this.player.name()}, took ${end - start}ms`); } } } diff --git a/src/core/execution/PortExecution.ts b/src/core/execution/PortExecution.ts index 74b6554f6..d234a8de8 100644 --- a/src/core/execution/PortExecution.ts +++ b/src/core/execution/PortExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Execution, Game, @@ -43,7 +42,7 @@ export class PortExecution implements Execution { const player = this.mg.player(this._owner); const spawn = player.canBuild(UnitType.Port, tile); if (spawn === false) { - consolex.warn(`player ${player} cannot build port at ${this.tile}`); + console.warn(`player ${player} cannot build port at ${this.tile}`); this.active = false; return; } diff --git a/src/core/execution/QuickChatExecution.ts b/src/core/execution/QuickChatExecution.ts index 002171a83..f9d4230bf 100644 --- a/src/core/execution/QuickChatExecution.ts +++ b/src/core/execution/QuickChatExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Execution, Game, Player, PlayerID } from "../game/Game"; export class QuickChatExecution implements Execution { @@ -18,12 +17,12 @@ export class QuickChatExecution implements Execution { init(mg: Game, ticks: number): void { this.mg = mg; if (!mg.hasPlayer(this.senderID)) { - consolex.warn(`QuickChatExecution: sender ${this.senderID} not found`); + console.warn(`QuickChatExecution: sender ${this.senderID} not found`); this.active = false; return; } if (!mg.hasPlayer(this.recipientID)) { - consolex.warn( + console.warn( `QuickChatExecution: recipient ${this.recipientID} not found`, ); this.active = false; @@ -55,7 +54,7 @@ export class QuickChatExecution implements Execution { this.recipient.name(), ); - consolex.log( + console.log( `[QuickChat] ${this.sender.name} → ${this.recipient.name}: ${message}`, ); diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index e2547218c..09b5a1d8a 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Execution, Game, @@ -102,7 +101,7 @@ export class SAMLauncherExecution implements Execution { } const spawnTile = this.player.canBuild(UnitType.SAMLauncher, this.tile); if (spawnTile === false) { - consolex.warn("cannot build SAM Launcher"); + console.warn("cannot build SAM Launcher"); this.active = false; return; } diff --git a/src/core/execution/SetTargetTroopRatioExecution.ts b/src/core/execution/SetTargetTroopRatioExecution.ts index 2d143e245..9ad8e1380 100644 --- a/src/core/execution/SetTargetTroopRatioExecution.ts +++ b/src/core/execution/SetTargetTroopRatioExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Execution, Game, Player, PlayerID } from "../game/Game"; export class SetTargetTroopRatioExecution implements Execution { @@ -22,7 +21,7 @@ export class SetTargetTroopRatioExecution implements Execution { tick(ticks: number): void { if (this.targetTroopsRatio < 0 || this.targetTroopsRatio > 1) { - consolex.warn( + console.warn( `target troop ratio of ${this.targetTroopsRatio} for player ${this.player} invalid`, ); } else { diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index 3094b9384..5e1fb10e9 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -1,5 +1,4 @@ import { renderNumber } from "../../client/Utils"; -import { consolex } from "../Consolex"; import { Execution, Game, @@ -41,7 +40,7 @@ export class TradeShipExecution implements Execution { this.srcPort.tile(), ); if (spawn === false) { - consolex.warn(`cannot build trade ship`); + console.warn(`cannot build trade ship`); this.active = false; return; } @@ -115,7 +114,7 @@ export class TradeShipExecution implements Execution { this.tradeShip.move(result.tile); break; case PathFindResultType.PathNotFound: - consolex.warn("captured trade ship cannot find route"); + console.warn("captured trade ship cannot find route"); if (this.tradeShip.isActive()) { this.tradeShip.delete(false); } diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 8851d8fdf..e7607cd05 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Execution, Game, @@ -100,7 +99,7 @@ export class TransportShipExecution implements Execution { this.dst = targetTransportTile(this.mg, this.ref); if (this.dst === null) { - consolex.warn( + console.warn( `${this.attacker} cannot send ship to ${this.target}, cannot find attack tile`, ); this.active = false; @@ -112,7 +111,7 @@ export class TransportShipExecution implements Execution { this.dst, ); if (closestTileSrc === false) { - consolex.warn(`can't build transport ship`); + console.warn(`can't build transport ship`); this.active = false; return; } @@ -215,7 +214,7 @@ export class TransportShipExecution implements Execution { break; case PathFindResultType.PathNotFound: // TODO: add to poisoned port list - consolex.warn(`path not found to dst`); + console.warn(`path not found to dst`); this.attacker.addTroops(this.troops); this.boat.delete(false); this.active = false; diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index 43233bd91..969eec2e0 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Execution, Game, @@ -192,7 +191,7 @@ export class WarshipExecution implements Execution { this.warship.touch(); break; case PathFindResultType.PathNotFound: - consolex.log(`path not found to target`); + console.log(`path not found to target`); break; } } @@ -222,7 +221,7 @@ export class WarshipExecution implements Execution { this.warship.touch(); return; case PathFindResultType.PathNotFound: - consolex.warn(`path not found to target tile`); + console.warn(`path not found to target tile`); this.warship.setTargetTile(undefined); break; } diff --git a/src/core/execution/alliance/AllianceRequestExecution.ts b/src/core/execution/alliance/AllianceRequestExecution.ts index 7698b0c82..0475d8f9f 100644 --- a/src/core/execution/alliance/AllianceRequestExecution.ts +++ b/src/core/execution/alliance/AllianceRequestExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../../Consolex"; import { Execution, Game, Player, PlayerID } from "../../game/Game"; export class AllianceRequestExecution implements Execution { @@ -36,9 +35,9 @@ export class AllianceRequestExecution implements Execution { throw new Error("Not initialized"); } if (this.requestor.isFriendly(this.recipient)) { - consolex.warn("already allied"); + console.warn("already allied"); } else if (!this.requestor.canSendAllianceRequest(this.recipient)) { - consolex.warn("recent or pending alliance request"); + console.warn("recent or pending alliance request"); } else { this.requestor.createAllianceRequest(this.recipient); } diff --git a/src/core/execution/alliance/AllianceRequestReplyExecution.ts b/src/core/execution/alliance/AllianceRequestReplyExecution.ts index 3c6bdc5e7..ed3177517 100644 --- a/src/core/execution/alliance/AllianceRequestReplyExecution.ts +++ b/src/core/execution/alliance/AllianceRequestReplyExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../../Consolex"; import { Execution, Game, Player, PlayerID } from "../../game/Game"; export class AllianceRequestReplyExecution implements Execution { @@ -36,13 +35,13 @@ export class AllianceRequestReplyExecution implements Execution { throw new Error("Not initialized"); } if (this.requestor.isFriendly(this.recipient)) { - consolex.warn("already allied"); + console.warn("already allied"); } else { const request = this.requestor .outgoingAllianceRequests() .find((ar) => ar.recipient() === this.recipient); if (request === undefined) { - consolex.warn("no alliance request found"); + console.warn("no alliance request found"); } else { if (this.accept) { request.accept(); diff --git a/src/core/execution/alliance/BreakAllianceExecution.ts b/src/core/execution/alliance/BreakAllianceExecution.ts index 65e2ebc16..a028b0cb0 100644 --- a/src/core/execution/alliance/BreakAllianceExecution.ts +++ b/src/core/execution/alliance/BreakAllianceExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../../Consolex"; import { Execution, Game, Player, PlayerID } from "../../game/Game"; export class BreakAllianceExecution implements Execution { @@ -42,7 +41,7 @@ export class BreakAllianceExecution implements Execution { } const alliance = this.requestor.allianceWith(this.recipient); if (alliance === null) { - consolex.warn("cant break alliance, not allied"); + console.warn("cant break alliance, not allied"); } else { this.requestor.breakAlliance(alliance); this.recipient.updateRelation(this.requestor, -200); diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index b54618bca..1b26d3f83 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -1,5 +1,4 @@ import { Config } from "../configuration/Config"; -import { consolex } from "../Consolex"; import { AllPlayersStats, ClientID } from "../Schemas"; import { simpleHash } from "../Util"; import { AllianceImpl } from "./AllianceImpl"; @@ -196,7 +195,7 @@ export class GameImpl implements Game { recipient: Player, ): AllianceRequest | null { if (requestor.isAlliedWith(recipient)) { - consolex.log("cannot request alliance, already allied"); + console.log("cannot request alliance, already allied"); return null; } if ( @@ -204,14 +203,14 @@ export class GameImpl implements Game { .incomingAllianceRequests() .find((ar) => ar.requestor() === requestor) !== undefined ) { - consolex.log(`duplicate alliance request from ${requestor.name()}`); + console.log(`duplicate alliance request from ${requestor.name()}`); return null; } const correspondingReq = requestor .incomingAllianceRequests() .find((ar) => ar.requestor() === recipient); if (correspondingReq !== undefined) { - consolex.log(`got corresponding alliance requests, accepting`); + console.log(`got corresponding alliance requests, accepting`); correspondingReq.accept(); return null; } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 8d8cbf949..a055799d5 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -1,5 +1,4 @@ import { renderNumber, renderTroops } from "../../client/Utils"; -import { consolex } from "../Consolex"; import { PseudoRandom } from "../PseudoRandom"; import { ClientID } from "../Schemas"; import { @@ -269,7 +268,7 @@ export class PlayerImpl implements Player { orderRetreat(id: string) { const attack = this._outgoingAttacks.filter((attack) => attack.id() === id); if (!attack || !attack[0]) { - consolex.warn(`Didn't find outgoing attack with id ${id}`); + console.warn(`Didn't find outgoing attack with id ${id}`); return; } attack[0].orderRetreat(); diff --git a/src/core/game/TerrainMapLoader.ts b/src/core/game/TerrainMapLoader.ts index adf2399fb..73098359e 100644 --- a/src/core/game/TerrainMapLoader.ts +++ b/src/core/game/TerrainMapLoader.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { GameMapType } from "./Game"; import { GameMap, GameMapImpl } from "./GameMap"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; @@ -65,13 +64,13 @@ export async function genTerrainFromBin(data: string): Promise { } function logBinaryAsAscii(data: string, length: number = 8) { - consolex.log("Binary data (1 = set bit, 0 = unset bit):"); + console.log("Binary data (1 = set bit, 0 = unset bit):"); for (let i = 0; i < Math.min(length, data.length); i++) { const byte = data.charCodeAt(i); let byteString = ""; for (let j = 7; j >= 0; j--) { byteString += byte & (1 << j) ? "1" : "0"; } - consolex.log(`Byte ${i}: ${byteString}`); + console.log(`Byte ${i}: ${byteString}`); } } diff --git a/src/core/pathfinding/PathFinding.ts b/src/core/pathfinding/PathFinding.ts index 9d26439e0..049407ad0 100644 --- a/src/core/pathfinding/PathFinding.ts +++ b/src/core/pathfinding/PathFinding.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Game } from "../game/Game"; import { GameMap, TileRef } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; @@ -117,11 +116,11 @@ export class PathFinder { dist: number = 1, ): TileResult { if (curr === null) { - consolex.error("curr is null"); + console.error("curr is null"); return { type: PathFindResultType.PathNotFound }; } if (dst === null) { - consolex.error("dst is null"); + console.error("dst is null"); return { type: PathFindResultType.PathNotFound }; } diff --git a/src/core/pathfinding/SerialAStar.ts b/src/core/pathfinding/SerialAStar.ts index 7655a75ba..927fc4b90 100644 --- a/src/core/pathfinding/SerialAStar.ts +++ b/src/core/pathfinding/SerialAStar.ts @@ -1,5 +1,4 @@ import { PriorityQueue } from "@datastructures-js/priority-queue"; -import { consolex } from "../Consolex"; import { GameMap, TileRef } from "../game/GameMap"; import { AStar, PathFindResultType } from "./AStar"; @@ -154,7 +153,7 @@ export class SerialAStar implements AStar { Math.abs(this.gameMap.y(a) - this.gameMap.y(b))) ); } catch { - consolex.log("uh oh"); + console.log("uh oh"); return 0; } } From d1c7af3344323feaadbcd3b1fe6c74a053c02cc3 Mon Sep 17 00:00:00 2001 From: Demonessica <37988730+Demonessica@users.noreply.github.com> Date: Wed, 4 Jun 2025 09:25:15 -0700 Subject: [PATCH 65/84] Center map on start (#1013) ## Description: - Adds logic to hard-set the transform handler directly to a new position, without a movement animation - Implements #1004 ![image](https://github.com/user-attachments/assets/e7298f33-fd8f-4828-94f7-2422c8e5cdb0) ![image](https://github.com/user-attachments/assets/6177c164-da68-47c3-9fc5-1c445027b881) ![image](https://github.com/user-attachments/assets/c0580034-0c7d-48b5-96f8-0d84fcb0f738) - removed a second initialization of the TransformHandler to prevent transformation desyncs between layers. Incidentally this probably fixes #62 ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: demonessica --------- Co-authored-by: tnhnblgl <51187395+tnhnblgl@users.noreply.github.com> --- src/client/graphics/GameRenderer.ts | 7 ++----- src/client/graphics/TransformHandler.ts | 27 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index cf4ea6f7b..c94a2bbc6 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -265,11 +265,8 @@ export class GameRenderer { window.addEventListener("resize", () => this.resizeCanvas()); this.resizeCanvas(); - this.transformHandler = new TransformHandler( - this.game, - this.eventBus, - this.canvas, - ); + //show whole map on startup + this.transformHandler.centerAll(0.9); requestAnimationFrame(() => this.renderGame()); } diff --git a/src/client/graphics/TransformHandler.ts b/src/client/graphics/TransformHandler.ts index 475d65eef..7f111a280 100644 --- a/src/client/graphics/TransformHandler.ts +++ b/src/client/graphics/TransformHandler.ts @@ -257,4 +257,31 @@ export class TransformHandler { } this.target = null; } + + override(x: number = 0, y: number = 0, s: number = 1) { + //hardset view position + this.clearTarget(); + this.offsetX = x; + this.offsetY = y; + this.scale = s; + this.changed = true; + } + + centerAll(fit: number = 1) { + //position entire map centered on the screen + + const vpWidth = this.boundingRect().width; + const vpHeight = this.boundingRect().height; + const mapWidth = this.game.width(); + const mapHeight = this.game.height(); + + const scHor = (vpWidth / mapWidth) * fit; + const scVer = (vpHeight / mapHeight) * fit; + const tScale = Math.min(scHor, scVer); + + const oHor = (mapWidth - vpWidth) / 2 / tScale; + const oVer = (mapHeight - vpHeight) / 2 / tScale; + + this.override(oHor, oVer, tScale); + } } From a18b5e418bee448b482ee06d79946f949dba8e06 Mon Sep 17 00:00:00 2001 From: Andrew Niziolek Date: Wed, 4 Jun 2025 12:27:04 -0400 Subject: [PATCH 66/84] Rev: Update "Japan and Neighbors" map to "East Asia" (#1007) **Closes Issue #1001.** _Will require updates to translations for each language but English is in place as are pointers to relevant map files and thumbnails._ ## Description: All assets and references to the Japan and Neighbors map have been updated to reflect East Asia. **New Behavior** ![East Asia Map](https://github.com/user-attachments/assets/64192590-bbb7-4408-a99b-7455de295d2b) **Old Behavior** ![Japan and Neighbors named map](https://github.com/user-attachments/assets/ba44dd56-1470-4c74-a70d-bd7fd8f3c795) Game test is functioning. Other languages will need to be updated for their displayed text to align. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: ajaxburger _My git name is included in my nickname on the server._ --------- Co-authored-by: evanpelle --- resources/lang/ar.json | 1 - resources/lang/bg.json | 1 - resources/lang/bn.json | 1 - resources/lang/cs.json | 1 - resources/lang/de.json | 1 - resources/lang/en.json | 2 +- resources/lang/eo.json | 1 - resources/lang/es.json | 1 - resources/lang/fr.json | 1 - resources/lang/he.json | 1 - resources/lang/hi.json | 1 - resources/lang/it.json | 1 - resources/lang/ja.json | 1 - resources/lang/nl.json | 1 - resources/lang/pl.json | 1 - resources/lang/pt_br.json | 1 - resources/lang/ru.json | 1 - resources/lang/sh.json | 1 - resources/lang/tp.json | 1 - resources/lang/tr.json | 1 - resources/lang/uk.json | 1 - resources/maps/{Japan.bin => EastAsia.bin} | 0 resources/maps/{Japan.json => EastAsia.json} | 2 +- resources/maps/{Japan.png => EastAsia.png} | Bin resources/maps/{JapanMini.bin => EastAsiaMini.bin} | 0 .../maps/{JapanThumb.webp => EastAsiaThumb.webp} | Bin src/client/components/Maps.ts | 2 +- src/client/utilities/Maps.ts | 6 +++--- src/core/configuration/DefaultConfig.ts | 2 +- src/core/game/Game.ts | 4 ++-- src/core/game/TerrainMapFileLoader.ts | 2 +- src/scripts/generateTerrainMaps.ts | 2 +- src/server/MapPlaylist.ts | 2 +- 33 files changed, 12 insertions(+), 32 deletions(-) rename resources/maps/{Japan.bin => EastAsia.bin} (100%) rename resources/maps/{Japan.json => EastAsia.json} (99%) rename resources/maps/{Japan.png => EastAsia.png} (100%) rename resources/maps/{JapanMini.bin => EastAsiaMini.bin} (100%) rename resources/maps/{JapanThumb.webp => EastAsiaThumb.webp} (100%) diff --git a/resources/lang/ar.json b/resources/lang/ar.json index 958265b4b..68492030d 100644 --- a/resources/lang/ar.json +++ b/resources/lang/ar.json @@ -122,7 +122,6 @@ "random": "عشوائي", "iceland": "آيسلندا", "pangaea": "بانجيا", - "japan": "اليابان والجيران", "betweentwoseas": "بين بحرين", "knownworld": "العالم المعروف", "faroeislands": "جزر فارو", diff --git a/resources/lang/bg.json b/resources/lang/bg.json index e6e174ca7..5889cbf2e 100644 --- a/resources/lang/bg.json +++ b/resources/lang/bg.json @@ -135,7 +135,6 @@ "random": "Произволна", "iceland": "Исландия", "pangaea": "Пангея", - "japan": "Япония и съседи", "betweentwoseas": "Между Две Морета", "knownworld": "Познат Свят", "faroeislands": "Фарьорски острови", diff --git a/resources/lang/bn.json b/resources/lang/bn.json index 047a5ec1e..4dca36559 100644 --- a/resources/lang/bn.json +++ b/resources/lang/bn.json @@ -118,7 +118,6 @@ "random": "যেকোনো", "iceland": "আইসল্যান্ড", "pangaea": "পাঞ্জিয়া", - "japan": "জাপান ও তার পার্শ্ববর্তী অঞ্চল", "betweentwoseas": "দুই সমুদ্রের মধ্যবর্তী অঞ্চল", "knownworld": "পরিচিত পৃথিবী", "faroeislands": "ফ্যারো দ্বীপপুঞ্জ" diff --git a/resources/lang/cs.json b/resources/lang/cs.json index b9a1f3a9d..bc3e827ef 100644 --- a/resources/lang/cs.json +++ b/resources/lang/cs.json @@ -135,7 +135,6 @@ "random": "Náhodná", "iceland": "Island", "pangaea": "Pangea", - "japan": "Japonsko a okolí", "betweentwoseas": "Mezi dvěma moři", "knownworld": "Známý svět", "faroeislands": "Faerské ostrovy", diff --git a/resources/lang/de.json b/resources/lang/de.json index 887b23dbd..b37ae2291 100644 --- a/resources/lang/de.json +++ b/resources/lang/de.json @@ -112,7 +112,6 @@ "pangaea": "Pangaea", "map": "Karte", "betweentwoseas": "Zwischen zwei Meeren", - "japan": "Japan und Nachbarländer", "knownworld": "Bekannte Welt" }, "private_lobby": { diff --git a/resources/lang/en.json b/resources/lang/en.json index 58cf977c3..275953b10 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -144,7 +144,7 @@ "random": "Random", "iceland": "Iceland", "pangaea": "Pangaea", - "japan": "Japan and Neighbors", + "eastasia": "East Asia", "betweentwoseas": "Between Two Seas", "faroeislands": "Faroe Islands", "deglaciatedantarctica": "Deglaciated Antarctica", diff --git a/resources/lang/eo.json b/resources/lang/eo.json index 04b4b8781..937c035e7 100644 --- a/resources/lang/eo.json +++ b/resources/lang/eo.json @@ -135,7 +135,6 @@ "random": "Hazarda", "iceland": "Islando", "pangaea": "Pangeo", - "japan": "Japanio kaj najbaroj", "betweentwoseas": "Inter du maroj", "knownworld": "Konata Mondo", "faroeislands": "Ferooj", diff --git a/resources/lang/es.json b/resources/lang/es.json index 8f8b85917..5ff191327 100644 --- a/resources/lang/es.json +++ b/resources/lang/es.json @@ -135,7 +135,6 @@ "random": "Aleatorio", "iceland": "Islandia", "pangaea": "Pangea", - "japan": "Japón y alrededores", "betweentwoseas": "Entre dos mares", "knownworld": "El Mundo Conocido", "faroeislands": "Islas Feroe", diff --git a/resources/lang/fr.json b/resources/lang/fr.json index 8308e5d28..4514eaf13 100644 --- a/resources/lang/fr.json +++ b/resources/lang/fr.json @@ -135,7 +135,6 @@ "random": "Aléatoire", "iceland": "Islande", "pangaea": "Pangée", - "japan": "Japon et pays voisins", "betweentwoseas": "Entre deux mers", "knownworld": "Monde connu", "faroeislands": "Îles Féroé", diff --git a/resources/lang/he.json b/resources/lang/he.json index 1740952e4..87f1c4845 100644 --- a/resources/lang/he.json +++ b/resources/lang/he.json @@ -135,7 +135,6 @@ "random": "רנדומלי", "iceland": "איסלנד", "pangaea": "פנגיאה", - "japan": "יפן ושכנותיה", "betweentwoseas": "בין שני ימים", "knownworld": "העולם הידוע", "faroeislands": "איי פארו", diff --git a/resources/lang/hi.json b/resources/lang/hi.json index b029cf89c..d1741d7e2 100644 --- a/resources/lang/hi.json +++ b/resources/lang/hi.json @@ -118,7 +118,6 @@ "random": "यादृच्छिक", "iceland": "आइसलैंड", "pangaea": "पांजिया", - "japan": "जापान और सीमावर्ती देश", "betweentwoseas": "समुद्रों के मध्य भूमि", "knownworld": "ज्ञात दुनिया", "faroeislands": "फ़रो द्वीपसमूह" diff --git a/resources/lang/it.json b/resources/lang/it.json index 5fa707f54..65d2e181b 100644 --- a/resources/lang/it.json +++ b/resources/lang/it.json @@ -135,7 +135,6 @@ "random": "Casuale", "iceland": "Islanda", "pangaea": "Pangea", - "japan": "Giappone e paesi confinanti", "betweentwoseas": "Tra I Due Mari", "knownworld": "Mondo Conosciuto", "faroeislands": "Isole Faroe", diff --git a/resources/lang/ja.json b/resources/lang/ja.json index 07f880222..1dbd03125 100644 --- a/resources/lang/ja.json +++ b/resources/lang/ja.json @@ -135,7 +135,6 @@ "random": "ランダム", "iceland": "アイスランド", "pangaea": "パンゲア", - "japan": "日本とその隣国", "betweentwoseas": "2つの海の間", "knownworld": "知られてる世界", "faroeislands": "フェロー諸島", diff --git a/resources/lang/nl.json b/resources/lang/nl.json index 4d4dd7e3b..270537185 100644 --- a/resources/lang/nl.json +++ b/resources/lang/nl.json @@ -135,7 +135,6 @@ "random": "Willekeurig", "iceland": "IJsland", "pangaea": "Pangea", - "japan": "Japan en buren", "betweentwoseas": "Tussen twee zeeën", "knownworld": "Bekende Wereld", "faroeislands": "Faeröer eilanden", diff --git a/resources/lang/pl.json b/resources/lang/pl.json index 9b45f7937..f5bf6b993 100644 --- a/resources/lang/pl.json +++ b/resources/lang/pl.json @@ -122,7 +122,6 @@ "random": "Losowe", "iceland": "Islandia", "pangaea": "Pangea", - "japan": "Japonia i sąsiedzi", "betweentwoseas": "Między dwoma morzami", "knownworld": "Znany Świat", "faroeislands": "Wyspy Owcze", diff --git a/resources/lang/pt_br.json b/resources/lang/pt_br.json index ba24a59ac..27d79f2a6 100644 --- a/resources/lang/pt_br.json +++ b/resources/lang/pt_br.json @@ -112,7 +112,6 @@ "pangaea": "Pangeia", "map": "Mapa", "betweentwoseas": "Entre Dois Mares", - "japan": "Japão e Vizinhos", "knownworld": "Mundo Conhecido" }, "private_lobby": { diff --git a/resources/lang/ru.json b/resources/lang/ru.json index 0d30d7478..3bb5995b9 100644 --- a/resources/lang/ru.json +++ b/resources/lang/ru.json @@ -135,7 +135,6 @@ "random": "Случайно", "iceland": "Исландия", "pangaea": "Пангея", - "japan": "Япония и соседи", "betweentwoseas": "Между двух морей", "knownworld": "Известный мир", "faroeislands": "Фарерские острова", diff --git a/resources/lang/sh.json b/resources/lang/sh.json index 520ff46f0..d0bbc546c 100644 --- a/resources/lang/sh.json +++ b/resources/lang/sh.json @@ -118,7 +118,6 @@ "random": "Nasumična", "iceland": "Island", "pangaea": "Pangea", - "japan": "Japan i susjedi", "betweentwoseas": "Između dva mora", "knownworld": "Poznati svijet", "faroeislands": "Farska ostrva", diff --git a/resources/lang/tp.json b/resources/lang/tp.json index a8de7c97b..c27219372 100644 --- a/resources/lang/tp.json +++ b/resources/lang/tp.json @@ -122,7 +122,6 @@ "random": "ma nasa", "iceland": "ma Isilan", "pangaea": "ma Pansija", - "japan": "ma Nijon en ma poka", "betweentwoseas": "insa pi telo tu", "knownworld": "ma ale", "faroeislands": "ma telo Paja", diff --git a/resources/lang/tr.json b/resources/lang/tr.json index eee305558..6b221eb20 100644 --- a/resources/lang/tr.json +++ b/resources/lang/tr.json @@ -112,7 +112,6 @@ "pangaea": "Pangea", "map": "Harita", "betweentwoseas": "İki Deniz Arası", - "japan": "Japonya ve Komşuları", "knownworld": "Bilinen Dünya" }, "private_lobby": { diff --git a/resources/lang/uk.json b/resources/lang/uk.json index c7436be3b..5f2f95a63 100644 --- a/resources/lang/uk.json +++ b/resources/lang/uk.json @@ -135,7 +135,6 @@ "random": "Випадково", "iceland": "Ісландія", "pangaea": "Пангея", - "japan": "Японія та сусіди", "betweentwoseas": "Поміж двох морів", "knownworld": "Відомий світ", "faroeislands": "Фарерські острови", diff --git a/resources/maps/Japan.bin b/resources/maps/EastAsia.bin similarity index 100% rename from resources/maps/Japan.bin rename to resources/maps/EastAsia.bin diff --git a/resources/maps/Japan.json b/resources/maps/EastAsia.json similarity index 99% rename from resources/maps/Japan.json rename to resources/maps/EastAsia.json index 9f3c53bd9..d47fad0c3 100644 --- a/resources/maps/Japan.json +++ b/resources/maps/EastAsia.json @@ -1,5 +1,5 @@ { - "name": "Japan", + "name": "East Asia", "width": 1562, "height": 1646, "nations": [ diff --git a/resources/maps/Japan.png b/resources/maps/EastAsia.png similarity index 100% rename from resources/maps/Japan.png rename to resources/maps/EastAsia.png diff --git a/resources/maps/JapanMini.bin b/resources/maps/EastAsiaMini.bin similarity index 100% rename from resources/maps/JapanMini.bin rename to resources/maps/EastAsiaMini.bin diff --git a/resources/maps/JapanThumb.webp b/resources/maps/EastAsiaThumb.webp similarity index 100% rename from resources/maps/JapanThumb.webp rename to resources/maps/EastAsiaThumb.webp diff --git a/src/client/components/Maps.ts b/src/client/components/Maps.ts index cdd952df9..feaa13c2e 100644 --- a/src/client/components/Maps.ts +++ b/src/client/components/Maps.ts @@ -22,7 +22,7 @@ export const MapDescription: Record = { GatewayToTheAtlantic: "Gateway to the Atlantic", Australia: "Australia", Iceland: "Iceland", - Japan: "Japan", + EastAsia: "East Asia", BetweenTwoSeas: "Between Two Seas", FaroeIslands: "Faroe Islands", DeglaciatedAntarctica: "Deglaciated Antarctica", diff --git a/src/client/utilities/Maps.ts b/src/client/utilities/Maps.ts index f583594e6..97aaac9f8 100644 --- a/src/client/utilities/Maps.ts +++ b/src/client/utilities/Maps.ts @@ -6,6 +6,7 @@ import betweenTwoSeas from "../../../resources/maps/BetweenTwoSeasThumb.webp"; import blackSea from "../../../resources/maps/BlackSeaThumb.webp"; import britannia from "../../../resources/maps/BritanniaThumb.webp"; import deglaciatedAntarctica from "../../../resources/maps/DeglaciatedAntarcticaThumb.webp"; +import eastasia from "../../../resources/maps/EastAsiaThumb.webp"; import europeClassic from "../../../resources/maps/EuropeClassicThumb.webp"; import europe from "../../../resources/maps/EuropeThumb.webp"; import falklandislands from "../../../resources/maps/FalklandIslandsThumb.webp"; @@ -13,7 +14,6 @@ import faroeislands from "../../../resources/maps/FaroeIslandsThumb.webp"; import gatewayToTheAtlantic from "../../../resources/maps/GatewayToTheAtlanticThumb.webp"; import halkidiki from "../../../resources/maps/HalkidikiThumb.webp"; import iceland from "../../../resources/maps/IcelandThumb.webp"; -import japan from "../../../resources/maps/JapanThumb.webp"; import mars from "../../../resources/maps/MarsThumb.webp"; import mena from "../../../resources/maps/MenaThumb.webp"; import northAmerica from "../../../resources/maps/NorthAmericaThumb.webp"; @@ -61,8 +61,8 @@ export function getMapsImage(map: GameMapType): string { return australia; case GameMapType.Iceland: return iceland; - case GameMapType.Japan: - return japan; + case GameMapType.EastAsia: + return eastasia; case GameMapType.BetweenTwoSeas: return betweenTwoSeas; case GameMapType.FaroeIslands: diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 738e0987b..49c3b5730 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -53,7 +53,7 @@ const numPlayersConfig = { [GameMapType.Mena]: [60, 50, 30], [GameMapType.Mars]: [50, 40, 30], [GameMapType.Oceania]: [30, 20, 10], - [GameMapType.Japan]: [50, 40, 30], + [GameMapType.EastAsia]: [50, 40, 30], [GameMapType.FaroeIslands]: [50, 40, 30], [GameMapType.DeglaciatedAntarctica]: [50, 40, 30], [GameMapType.EuropeClassic]: [80, 30, 50], diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index d054a6b04..ec2851747 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -70,7 +70,7 @@ export enum GameMapType { GatewayToTheAtlantic = "Gateway to the Atlantic", Australia = "Australia", Iceland = "Iceland", - Japan = "Japan", + EastAsia = "East Asia", BetweenTwoSeas = "Between Two Seas", FaroeIslands = "Faroe Islands", DeglaciatedAntarctica = "Deglaciated Antarctica", @@ -97,7 +97,7 @@ export const mapCategories: Record = { GameMapType.GatewayToTheAtlantic, GameMapType.BetweenTwoSeas, GameMapType.Iceland, - GameMapType.Japan, + GameMapType.EastAsia, GameMapType.Mena, GameMapType.Australia, GameMapType.FaroeIslands, diff --git a/src/core/game/TerrainMapFileLoader.ts b/src/core/game/TerrainMapFileLoader.ts index 821209f93..5edbe9b67 100644 --- a/src/core/game/TerrainMapFileLoader.ts +++ b/src/core/game/TerrainMapFileLoader.ts @@ -39,7 +39,7 @@ const MAP_FILE_NAMES: Record = { [GameMapType.GatewayToTheAtlantic]: "GatewayToTheAtlantic", [GameMapType.Australia]: "Australia", [GameMapType.Iceland]: "Iceland", - [GameMapType.Japan]: "Japan", + [GameMapType.EastAsia]: "EastAsia", [GameMapType.BetweenTwoSeas]: "BetweenTwoSeas", [GameMapType.FaroeIslands]: "FaroeIslands", [GameMapType.DeglaciatedAntarctica]: "DeglaciatedAntarctica", diff --git a/src/scripts/generateTerrainMaps.ts b/src/scripts/generateTerrainMaps.ts index 92c61062a..2a9fd37a3 100644 --- a/src/scripts/generateTerrainMaps.ts +++ b/src/scripts/generateTerrainMaps.ts @@ -22,7 +22,7 @@ const maps = [ "Pangaea", "Iceland", "BetweenTwoSeas", - "Japan", + "EastAsia", "KnownWorld", "FaroeIslands", "DeglaciatedAntarctica", diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index f03bf3232..c6c07c0ce 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -26,7 +26,7 @@ const frequency = { Asia: 1, Mars: 1, BetweenTwoSeas: 1, - Japan: 1, + EastAsia: 1, BlackSea: 1, FaroeIslands: 1, FalklandIslands: 1, From 14ab1bcbbafa8b99af305a1ef41b0619a0aa5fd2 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Wed, 4 Jun 2025 12:48:48 -0400 Subject: [PATCH 67/84] Close socket on ClientMessageSchema, improve zod error (#1003) ## Description: - Close the socket on parse failure. - Use `safeParse` and `prettifyError` to improve logging output on zod validation failures. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> Co-authored-by: evanpelle --- src/server/GameServer.ts | 16 ++++++++++------ src/server/Worker.ts | 13 ++++++++++--- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 003542bd8..5754ad218 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -1,9 +1,9 @@ import ipAnonymize from "ip-anonymize"; import { Logger } from "winston"; import WebSocket from "ws"; +import { z } from "zod/v4"; import { ClientID, - ClientMessage, ClientMessageSchema, ClientSendWinnerMessage, GameConfig, @@ -178,12 +178,16 @@ export class GameServer { "message", gatekeeper.wsHandler(client.ip, async (message: string) => { try { - let clientMsg: ClientMessage | null = null; - try { - clientMsg = ClientMessageSchema.parse(JSON.parse(message)); - } catch (error) { - throw Error(`error parsing schema for ${ipAnonymize(client.ip)}`); + const parsed = ClientMessageSchema.safeParse(JSON.parse(message)); + if (!parsed.success) { + const error = z.prettifyError(parsed.error); + this.log.error("Failed to parse client message", error, { + clientID: client.clientID, + }); + client.ws.close(); + return; } + const clientMsg = parsed.data; if (clientMsg.type === "intent") { if (clientMsg.intent.clientID !== client.clientID) { this.log.warn( diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 068799fed..3f072ebdf 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -10,7 +10,8 @@ import { GameEnv } from "../core/configuration/Config"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { - ClientMessageSchema, + ClientJoinMessageSchema, + GameConfig, GameRecord, GameRecordSchema, } from "../core/Schemas"; @@ -293,11 +294,17 @@ export function startWorker() { : forwarded || req.socket.remoteAddress || "unknown"; try { - // Process WebSocket messages as in your original code // Parse and handle client messages - const clientMsg = ClientMessageSchema.parse( + const parsed = ClientJoinMessageSchema.safeParse( JSON.parse(message.toString()), ); + if (!parsed.success) { + const error = z.prettifyError(parsed.error); + log.warn("Error parsing join message client", error); + ws.close(); + return; + } + const clientMsg = parsed.data; if (clientMsg.type === "join") { // Verify this worker should handle this game From bd820425baac8df4d84cb2c5d21f31d88627bb61 Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Wed, 4 Jun 2025 19:18:51 +0200 Subject: [PATCH 68/84] SAMs should target only nukes aimed at nearby targets (#1038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Currently, SAMs target any nuke within range. This can be frustrating when a random SAM from another player intercepts your nuke, especially since nukes follow a curved trajectory, leaving little room to adjust their path. This change modifies how SAMs intercept nukes: they will now only target those whose impact points are near the SAM. The “target range” is still generous, allowing players to defend against Hydrogen bombs, while preventing random SAMs to intercept your valued nukes. In this example, the target was the opponent missile silo: https://github.com/user-attachments/assets/0d8be2ac-e04d-44a4-a67e-54836cce8899 ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: IngloriousTom --- src/core/execution/SAMLauncherExecution.ts | 17 ++++++- tests/SAM.test.ts | 55 ++++++++++++++++++++-- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index 09b5a1d8a..d27737cc0 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -17,6 +17,7 @@ export class SAMLauncherExecution implements Execution { private active: boolean = true; private searchRangeRadius = 80; + private targetRangeRadius = 120; // Nuke's target should be in this range to be focusable // As MIRV go very fast we have to detect them very early but we only // shoot the one targeting very close (MIRVWarheadProtectionRadius) private MIRVWarheadSearchRadius = 400; @@ -44,6 +45,18 @@ export class SAMLauncherExecution implements Execution { this.player = mg.player(this.ownerId); } + private nukeTargetInRange(nuke: Unit) { + const targetTile = nuke.targetTile(); + if (this.sam === null || targetTile === undefined) { + return false; + } + const targetRangeSquared = this.targetRangeRadius * this.targetRangeRadius; + return ( + this.mg.euclideanDistSquared(this.sam.tile(), targetTile) < + targetRangeSquared + ); + } + private getSingleTarget(): Unit | null { if (this.sam === null) return null; const nukes = this.mg @@ -53,7 +66,9 @@ export class SAMLauncherExecution implements Execution { ]) .filter( ({ unit }) => - unit.owner() !== this.player && !this.player.isFriendly(unit.owner()), + unit.owner() !== this.player && + !this.player.isFriendly(unit.owner()) && + this.nukeTargetInRange(unit), ); return ( diff --git a/tests/SAM.test.ts b/tests/SAM.test.ts index d8eb2d325..fc463f960 100644 --- a/tests/SAM.test.ts +++ b/tests/SAM.test.ts @@ -1,3 +1,4 @@ +import { NukeExecution } from "../src/core/execution/NukeExecution"; import { SAMLauncherExecution } from "../src/core/execution/SAMLauncherExecution"; import { SpawnExecution } from "../src/core/execution/SpawnExecution"; import { @@ -13,10 +14,11 @@ import { constructionExecution, executeTicks } from "./util/utils"; let game: Game; let attacker: Player; let defender: Player; +let far_defender: Player; describe("SAM", () => { beforeEach(async () => { - game = await setup("Plains", { infiniteGold: true, instantBuild: true }); + game = await setup("BigPlains", { infiniteGold: true, instantBuild: true }); const defender_info = new PlayerInfo( "us", "defender_id", @@ -24,6 +26,13 @@ describe("SAM", () => { null, "defender_id", ); + const far_defender_info = new PlayerInfo( + "us", + "far_defender_id", + PlayerType.Human, + null, + "far_defender_id", + ); const attacker_info = new PlayerInfo( "fr", "attacker_id", @@ -32,10 +41,15 @@ describe("SAM", () => { "attacker_id", ); game.addPlayer(defender_info); + game.addPlayer(far_defender_info); game.addPlayer(attacker_info); game.addExecution( new SpawnExecution(game.player(defender_info.id).info(), game.ref(1, 1)), + new SpawnExecution( + game.player(far_defender_info.id).info(), + game.ref(199, 1), + ), new SpawnExecution(game.player(attacker_info.id).info(), game.ref(7, 7)), ); @@ -43,8 +57,9 @@ describe("SAM", () => { game.executeNextTick(); } - defender = game.player("defender_id"); attacker = game.player("attacker_id"); + defender = game.player("defender_id"); + far_defender = game.player("far_defender_id"); constructionExecution(game, attacker.id(), 7, 7, UnitType.MissileSilo); }); @@ -52,7 +67,9 @@ describe("SAM", () => { test("one sam should take down one nuke", async () => { const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); game.addExecution(new SAMLauncherExecution(defender.id(), null, sam)); - attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), {}); + attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), { + targetTile: game.ref(2, 1), + }); executeTicks(game, 3); @@ -112,4 +129,36 @@ describe("SAM", () => { expect(nuke.isActive()).toBeFalsy(); expect([sam1, sam2].filter((s) => s.isInCooldown())).toHaveLength(1); }); + + test("SAMs should target only nukes aimed at nearby targets", async () => { + const targetDistance = 199; + // Close SAM: should not intercept anything + const sam1 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); + game.addExecution(new SAMLauncherExecution(defender.id(), null, sam1)); + + // Far SAM: Should intercept the nuke. Use the far_defender so the SAM can be built + const sam2 = far_defender.buildUnit( + UnitType.SAMLauncher, + game.ref(targetDistance, 1), + {}, + ); + game.addExecution(new SAMLauncherExecution(far_defender.id(), null, sam2)); + + const nukeExecution = new NukeExecution( + UnitType.AtomBomb, + attacker.id(), + game.ref(targetDistance, 1), + null, + ); + game.addExecution(nukeExecution); + // Long distance nuke: compute the proper number of ticks + const ticksToExecute = Math.ceil( + targetDistance / game.config().defaultNukeSpeed(), + ); + executeTicks(game, ticksToExecute); + + expect(nukeExecution.isActive()).toBeFalsy(); + expect(sam1.isInCooldown()).toBeFalsy(); + expect(sam2.isInCooldown()).toBeTruthy(); + }); }); From fe293af735ee5e2a5d3a958ea0fa2fa98526b4ff Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 5 Jun 2025 10:01:01 -0700 Subject: [PATCH 69/84] removed unused import to fix prettier error --- src/server/Worker.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 3f072ebdf..5bee6b603 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -11,7 +11,6 @@ import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { ClientJoinMessageSchema, - GameConfig, GameRecord, GameRecordSchema, } from "../core/Schemas"; From 6d89431ef42dab24cb57be00e75113c821b6e822 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Thu, 5 Jun 2025 13:02:25 -0400 Subject: [PATCH 70/84] AI nukes avoid SAM launchers (#1045) ## Description: AI nukes avoid SAM launchers ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> Co-authored-by: evanpelle --- src/core/execution/FakeHumanExecution.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index 1d5ca31fe..7f4d5c024 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -373,14 +373,21 @@ export class FakeHumanExecution implements Execution { return 50_000; case UnitType.Port: return 10_000; - case UnitType.SAMLauncher: - return 5_000; default: return 0; } }) .reduce((prev, cur) => prev + cur, 0); + // Avoid areas defended by SAM launchers + const dist50 = euclDistFN(tile, 50, false); + tileValue -= + 50_000 * + targets.filter( + (unit) => + unit.type() === UnitType.SAMLauncher && dist50(this.mg, unit.tile()), + ).length; + // Prefer tiles that are closer to a silo const siloTiles = silos.map((u) => u.tile()); const result = closestTwoTiles(this.mg, siloTiles, [tile]); From 9e1e8a450b2f2f4ae5de4e9ea1f04882289d66a9 Mon Sep 17 00:00:00 2001 From: Max Lundgren Date: Fri, 6 Jun 2025 01:17:22 +0300 Subject: [PATCH 71/84] Show alliances on the PlayerPanel (#1053) ## Description: Added a list of Alliances to the PlayerPanel. Displays `None` if player has no Alliances Screenshot 2025-06-05 at 19 55 26 Screenshot 2025-06-05 at 19 54 41 ## 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 - [X] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: maxion_ --- resources/lang/en.json | 4 +++- src/client/graphics/layers/PlayerPanel.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 275953b10..d059c9261 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -424,7 +424,9 @@ "start_trade": "Start trading", "stop_trade": "Stop trading", "yes": "Yes", - "no": "No" + "no": "No", + "none": "None", + "alliances": "Alliances" }, "error_modal": { "crashed": "Game crashed!", diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 859803e7c..cf9d71a8d 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -329,6 +329,25 @@ export class PlayerPanel extends LitElement implements Layer {
    + +
    +
    + ${translateText("player_panel.alliances")} + (${other.allies().length}) +
    +
    + ${other.allies().length > 0 + ? other + .allies() + .map((p) => p.name()) + .join(", ") + : translateText("player_panel.none")} +
    +
    + ${this.allianceExpiryText !== null ? html`
    From b09abc8bf67a1776fbf5236a4025e3297aa5ef43 Mon Sep 17 00:00:00 2001 From: Arnaud Moreau <72945143+Nephty@users.noreply.github.com> Date: Fri, 6 Jun 2025 20:57:02 +0200 Subject: [PATCH 72/84] Improve readability of alliance acceptation logic for bots and add tests (#1049) ## Description: The method deciding whether bots should accept an alliance used to use multiple variables with negated names (notTraitor, notMalice, notTooManyAlliances). For readability, I have negated their value in order to given them a positive name (isTraitor, hasMalice, tooManyAlliances). I have also added tests for the alliances acceptation/rejection behavior of bots. - [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 - [X] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors Discord: nephty --- src/core/execution/utils/BotBehavior.ts | 11 +- tests/BotBehavior.test.ts | 150 ++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 tests/BotBehavior.test.ts diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts index e68a0c775..111e0baa6 100644 --- a/src/core/execution/utils/BotBehavior.ts +++ b/src/core/execution/utils/BotBehavior.ts @@ -202,11 +202,12 @@ export class BotBehavior { } function shouldAcceptAllianceRequest(player: Player, request: AllianceRequest) { - const notTraitor = !request.requestor().isTraitor(); - const noMalice = player.relation(request.requestor()) >= Relation.Neutral; + const isTraitor = request.requestor().isTraitor(); + const hasMalice = player.relation(request.requestor()) < Relation.Neutral; const requestorIsMuchLarger = request.requestor().numTilesOwned() > player.numTilesOwned() * 3; - const notTooManyAlliances = - requestorIsMuchLarger || request.requestor().alliances().length < 3; - return notTraitor && noMalice && notTooManyAlliances; + const tooManyAlliances = request.requestor().alliances().length >= 3; + return ( + !isTraitor && !hasMalice && (requestorIsMuchLarger || !tooManyAlliances) + ); } diff --git a/tests/BotBehavior.test.ts b/tests/BotBehavior.test.ts new file mode 100644 index 000000000..99df34764 --- /dev/null +++ b/tests/BotBehavior.test.ts @@ -0,0 +1,150 @@ +import { BotBehavior } from "../src/core/execution/utils/BotBehavior"; +import { + AllianceRequest, + Game, + Player, + PlayerInfo, + PlayerType, + Tick, +} from "../src/core/game/Game"; +import { PseudoRandom } from "../src/core/PseudoRandom"; +import { setup } from "./util/Setup"; + +let game: Game; +let player: Player; +let requestor: Player; +let botBehavior: BotBehavior; + +describe("BotBehavior.handleAllianceRequests", () => { + beforeEach(async () => { + game = await setup("BigPlains", { infiniteGold: true, instantBuild: true }); + + const playerInfo = new PlayerInfo( + "us", + "player_id", + PlayerType.Bot, + null, + "player_id", + ); + const requestorInfo = new PlayerInfo( + "fr", + "requestor_id", + PlayerType.Human, + null, + "requestor_id", + ); + + game.addPlayer(playerInfo); + game.addPlayer(requestorInfo); + + player = game.player("player_id"); + requestor = game.player("requestor_id"); + + const random = new PseudoRandom(42); + + botBehavior = new BotBehavior(random, game, player, 0.5, 0.5); + }); + + function setupAllianceRequest({ + isTraitor = false, + relationDelta = 2, + numTilesPlayer = 10, + numTilesRequestor = 10, + alliancesCount = 0, + } = {}) { + if (isTraitor) requestor.markTraitor(); + + player.updateRelation(requestor, relationDelta); + requestor.updateRelation(player, relationDelta); + + game.map().forEachTile((tile) => { + if (game.map().isLand(tile)) { + if (numTilesPlayer > 0) { + player.conquer(tile); + numTilesPlayer--; + } else if (numTilesRequestor > 0) { + requestor.conquer(tile); + numTilesRequestor--; + } + } + }); + + jest + .spyOn(requestor, "alliances") + .mockReturnValue(new Array(alliancesCount)); + + const mockRequest = { + requestor: () => requestor, + recipient: () => player, + createdAt: () => 0 as unknown as Tick, + accept: jest.fn(), + reject: jest.fn(), + } as unknown as AllianceRequest; + + jest + .spyOn(player, "incomingAllianceRequests") + .mockReturnValue([mockRequest]); + + return mockRequest; + } + + test("should accept alliance when all conditions are met", () => { + const request = setupAllianceRequest({}); + + botBehavior.handleAllianceRequests(); + + expect(request.accept).toHaveBeenCalled(); + expect(request.reject).not.toHaveBeenCalled(); + }); + + test("should reject alliance if requestor is a traitor", () => { + const request = setupAllianceRequest({ isTraitor: true }); + + botBehavior.handleAllianceRequests(); + + expect(request.accept).not.toHaveBeenCalled(); + expect(request.reject).toHaveBeenCalled(); + }); + + test("should reject alliance if relation is malicious", () => { + const request = setupAllianceRequest({ relationDelta: -2 }); + + botBehavior.handleAllianceRequests(); + + expect(request.accept).not.toHaveBeenCalled(); + expect(request.reject).toHaveBeenCalled(); + }); + + test("should accept alliance if requestor is much larger (> 3 times size of recipient) and has too many alliances (>= 3)", () => { + const request = setupAllianceRequest({ + numTilesRequestor: 40, + alliancesCount: 4, + }); + + botBehavior.handleAllianceRequests(); + + expect(request.accept).toHaveBeenCalled(); + expect(request.reject).not.toHaveBeenCalled(); + }); + + test("should accept alliance if requestor is much larger (> 3 times size of recipient) and does not have too many alliances (< 3)", () => { + const request = setupAllianceRequest({ + numTilesRequestor: 40, + alliancesCount: 2, + }); + + botBehavior.handleAllianceRequests(); + + expect(request.accept).toHaveBeenCalled(); + expect(request.reject).not.toHaveBeenCalled(); + }); + + test("should reject alliance if requestor is acceptably small (<= 3 times size of recipient) and has too many alliances (>= 3)", () => { + const request = setupAllianceRequest({ alliancesCount: 3 }); + + botBehavior.handleAllianceRequests(); + + expect(request.accept).not.toHaveBeenCalled(); + expect(request.reject).toHaveBeenCalled(); + }); +}); From 9c7e0ce32f1a15830d7ab920e5a1f345816221f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Joly?= Date: Fri, 6 Jun 2025 20:58:15 +0200 Subject: [PATCH 73/84] [Cleanup] Pass Player into execution constructor instead of PlayerID (#1022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Answering issue: #1017 [Cleanup] Pass Player into the execution constructor instead of PlayerID I have tested the changes running and playing a full game. I do not know other way to test the changes, please inform me ❤️ ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: Lele --------- Co-authored-by: lva --- src/core/execution/AttackExecution.ts | 9 +--- src/core/execution/BoatRetreatExecution.ts | 23 ++-------- src/core/execution/CityExecution.ts | 10 +---- src/core/execution/ConstructionExecution.ts | 24 ++++------ src/core/execution/DefensePostExecution.ts | 10 +---- src/core/execution/DonateGoldExecution.ts | 10 +---- src/core/execution/DonateTroopExecution.ts | 10 +---- src/core/execution/EmbargoExecution.ts | 5 --- src/core/execution/EmojiExecution.ts | 9 +--- src/core/execution/ExecutionManager.ts | 30 ++++++------- src/core/execution/FakeHumanExecution.ts | 20 +++------ src/core/execution/MIRVExecution.ts | 16 ++----- src/core/execution/MissileSiloExecution.ts | 16 +------ src/core/execution/NukeExecution.ts | 45 +++++-------------- src/core/execution/PlayerExecution.ts | 35 ++------------- src/core/execution/PortExecution.ts | 40 +++++------------ src/core/execution/QuickChatExecution.ts | 10 +---- src/core/execution/RetreatExecution.ts | 11 +---- src/core/execution/SAMLauncherExecution.ts | 10 +---- .../execution/SetTargetTroopRatioExecution.ts | 15 ++----- src/core/execution/SpawnExecution.ts | 2 +- src/core/execution/TargetPlayerExecution.ts | 11 +---- src/core/execution/TradeShipExecution.ts | 5 +-- src/core/execution/TransportShipExecution.ts | 16 ++----- .../alliance/AllianceRequestExecution.ts | 13 +----- .../alliance/AllianceRequestReplyExecution.ts | 13 +----- .../alliance/BreakAllianceExecution.ts | 11 +---- src/core/execution/utils/BotBehavior.ts | 4 +- tests/Attack.test.ts | 20 ++++++--- tests/MissileSilo.test.ts | 4 +- tests/SAM.test.ts | 18 ++++---- tests/util/utils.ts | 6 +-- 32 files changed, 120 insertions(+), 361 deletions(-) diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 42d361736..6c765a63c 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -22,7 +22,6 @@ export class AttackExecution implements Execution { private random = new PseudoRandom(123); - private _owner: Player; private target: Player | TerraNullius; private mg: Game; @@ -31,7 +30,7 @@ export class AttackExecution implements Execution { constructor( private startTroops: number | null = null, - private _ownerID: PlayerID, + private _owner: Player, private _targetID: PlayerID | null, private sourceTile: TileRef | null = null, private removeTroops: boolean = true, @@ -51,18 +50,12 @@ export class AttackExecution implements Execution { } this.mg = mg; - if (!mg.hasPlayer(this._ownerID)) { - console.warn(`player ${this._ownerID} not found`); - this.active = false; - return; - } if (this._targetID !== null && !mg.hasPlayer(this._targetID)) { console.warn(`target ${this._targetID} not found`); this.active = false; return; } - this._owner = mg.player(this._ownerID); this.target = this._targetID === this.mg.terraNullius().id() ? mg.terraNullius() diff --git a/src/core/execution/BoatRetreatExecution.ts b/src/core/execution/BoatRetreatExecution.ts index c5c597639..c6afedff1 100644 --- a/src/core/execution/BoatRetreatExecution.ts +++ b/src/core/execution/BoatRetreatExecution.ts @@ -1,29 +1,15 @@ -import { Execution, Game, Player, PlayerID, UnitType } from "../game/Game"; +import { Execution, Game, Player, UnitType } from "../game/Game"; export class BoatRetreatExecution implements Execution { private active = true; - private player: Player | undefined; constructor( - private playerID: PlayerID, + private player: Player, private unitID: number, ) {} - init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.playerID)) { - console.warn(`BoatRetreatExecution: Player ${this.playerID} not found`); - this.active = false; - return; - } - this.player = mg.player(this.playerID); - } + init(mg: Game, ticks: number): void {} tick(ticks: number): void { - if (!this.player) { - console.warn(`BoatRetreatExecution: Player ${this.playerID} not found`); - this.active = false; - return; - } - const unit = this.player .units() .find( @@ -42,9 +28,6 @@ export class BoatRetreatExecution implements Execution { } owner(): Player { - if (this.player === undefined) { - throw new Error("Not initialized"); - } return this.player; } diff --git a/src/core/execution/CityExecution.ts b/src/core/execution/CityExecution.ts index d488df6e3..706adfb21 100644 --- a/src/core/execution/CityExecution.ts +++ b/src/core/execution/CityExecution.ts @@ -2,31 +2,23 @@ import { Execution, Game, Player, - PlayerID, Unit, UnitType, } from "../game/Game"; import { TileRef } from "../game/GameMap"; export class CityExecution implements Execution { - private player: Player; private mg: Game; private city: Unit | null = null; private active: boolean = true; constructor( - private ownerId: PlayerID, + private player: Player, private tile: TileRef, ) {} init(mg: Game, ticks: number): void { this.mg = mg; - if (!mg.hasPlayer(this.ownerId)) { - console.warn(`CityExecution: player ${this.ownerId} not found`); - this.active = false; - return; - } - this.player = mg.player(this.ownerId); } tick(ticks: number): void { diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts index aa190bad2..10d31ddd2 100644 --- a/src/core/execution/ConstructionExecution.ts +++ b/src/core/execution/ConstructionExecution.ts @@ -3,7 +3,6 @@ import { Game, Gold, Player, - PlayerID, Tick, Unit, UnitType, @@ -19,7 +18,6 @@ import { SAMLauncherExecution } from "./SAMLauncherExecution"; import { WarshipExecution } from "./WarshipExecution"; export class ConstructionExecution implements Execution { - private player: Player; private construction: Unit | null = null; private active: boolean = true; private mg: Game; @@ -29,19 +27,13 @@ export class ConstructionExecution implements Execution { private cost: Gold; constructor( - private ownerId: PlayerID, + private player: Player, private tile: TileRef, private constructionType: UnitType, ) {} init(mg: Game, ticks: number): void { this.mg = mg; - if (!mg.hasPlayer(this.ownerId)) { - console.warn(`ConstructionExecution: owner ${this.ownerId} not found`); - this.active = false; - return; - } - this.player = mg.player(this.ownerId); } tick(ticks: number): void { @@ -97,11 +89,11 @@ export class ConstructionExecution implements Execution { case UnitType.AtomBomb: case UnitType.HydrogenBomb: this.mg.addExecution( - new NukeExecution(this.constructionType, player.id(), this.tile), + new NukeExecution(this.constructionType, player, this.tile), ); break; case UnitType.MIRV: - this.mg.addExecution(new MirvExecution(player.id(), this.tile)); + this.mg.addExecution(new MirvExecution(player, this.tile)); break; case UnitType.Warship: this.mg.addExecution( @@ -109,19 +101,19 @@ export class ConstructionExecution implements Execution { ); break; case UnitType.Port: - this.mg.addExecution(new PortExecution(player.id(), this.tile)); + this.mg.addExecution(new PortExecution(player, this.tile)); break; case UnitType.MissileSilo: - this.mg.addExecution(new MissileSiloExecution(player.id(), this.tile)); + this.mg.addExecution(new MissileSiloExecution(player, this.tile)); break; case UnitType.DefensePost: - this.mg.addExecution(new DefensePostExecution(player.id(), this.tile)); + this.mg.addExecution(new DefensePostExecution(player, this.tile)); break; case UnitType.SAMLauncher: - this.mg.addExecution(new SAMLauncherExecution(player.id(), this.tile)); + this.mg.addExecution(new SAMLauncherExecution(player, this.tile)); break; case UnitType.City: - this.mg.addExecution(new CityExecution(player.id(), this.tile)); + this.mg.addExecution(new CityExecution(player, this.tile)); break; default: throw Error(`unit type ${this.constructionType} not supported`); diff --git a/src/core/execution/DefensePostExecution.ts b/src/core/execution/DefensePostExecution.ts index 2ec384cb8..098b52aac 100644 --- a/src/core/execution/DefensePostExecution.ts +++ b/src/core/execution/DefensePostExecution.ts @@ -2,7 +2,6 @@ import { Execution, Game, Player, - PlayerID, Unit, UnitType, } from "../game/Game"; @@ -10,7 +9,6 @@ import { TileRef } from "../game/GameMap"; import { ShellExecution } from "./ShellExecution"; export class DefensePostExecution implements Execution { - private player: Player; private mg: Game; private post: Unit | null = null; private active: boolean = true; @@ -21,18 +19,12 @@ export class DefensePostExecution implements Execution { private alreadySentShell = new Set(); constructor( - private ownerId: PlayerID, + private player: Player, private tile: TileRef, ) {} init(mg: Game, ticks: number): void { this.mg = mg; - if (!mg.hasPlayer(this.ownerId)) { - console.warn(`DefensePostExectuion: owner ${this.ownerId} not found`); - this.active = false; - return; - } - this.player = mg.player(this.ownerId); } private shoot() { diff --git a/src/core/execution/DonateGoldExecution.ts b/src/core/execution/DonateGoldExecution.ts index 672c5861d..dfdb53622 100644 --- a/src/core/execution/DonateGoldExecution.ts +++ b/src/core/execution/DonateGoldExecution.ts @@ -1,30 +1,24 @@ import { Execution, Game, Gold, Player, PlayerID } from "../game/Game"; export class DonateGoldExecution implements Execution { - private sender: Player; + private recipient: Player; private active = true; constructor( - private senderID: PlayerID, + private sender: Player, private recipientID: PlayerID, private gold: Gold | null, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.senderID)) { - console.warn(`DonateExecution: sender ${this.senderID} not found`); - this.active = false; - return; - } if (!mg.hasPlayer(this.recipientID)) { console.warn(`DonateExecution recipient ${this.recipientID} not found`); this.active = false; return; } - this.sender = mg.player(this.senderID); this.recipient = mg.player(this.recipientID); if (this.gold === null) { this.gold = this.sender.gold() / 3n; diff --git a/src/core/execution/DonateTroopExecution.ts b/src/core/execution/DonateTroopExecution.ts index dd05689da..ea20a2b9c 100644 --- a/src/core/execution/DonateTroopExecution.ts +++ b/src/core/execution/DonateTroopExecution.ts @@ -1,30 +1,24 @@ import { Execution, Game, Player, PlayerID } from "../game/Game"; export class DonateTroopsExecution implements Execution { - private sender: Player; + private recipient: Player; private active = true; constructor( - private senderID: PlayerID, + private sender: Player, private recipientID: PlayerID, private troops: number | null, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.senderID)) { - console.warn(`DonateExecution: sender ${this.senderID} not found`); - this.active = false; - return; - } if (!mg.hasPlayer(this.recipientID)) { console.warn(`DonateExecution recipient ${this.recipientID} not found`); this.active = false; return; } - this.sender = mg.player(this.senderID); this.recipient = mg.player(this.recipientID); if (this.troops === null) { this.troops = mg.config().defaultDonationAmount(this.sender); diff --git a/src/core/execution/EmbargoExecution.ts b/src/core/execution/EmbargoExecution.ts index 79e4b8773..67a0664d1 100644 --- a/src/core/execution/EmbargoExecution.ts +++ b/src/core/execution/EmbargoExecution.ts @@ -10,11 +10,6 @@ export class EmbargoExecution implements Execution { ) {} init(mg: Game, _: number): void { - if (!mg.hasPlayer(this.player.id())) { - console.warn(`EmbargoExecution: sender ${this.player.id()} not found`); - this.active = false; - return; - } if (!mg.hasPlayer(this.targetID)) { console.warn(`EmbargoExecution recipient ${this.targetID} not found`); this.active = false; diff --git a/src/core/execution/EmojiExecution.ts b/src/core/execution/EmojiExecution.ts index 068f11d89..94f84e58d 100644 --- a/src/core/execution/EmojiExecution.ts +++ b/src/core/execution/EmojiExecution.ts @@ -9,30 +9,23 @@ import { import { flattenedEmojiTable } from "../Util"; export class EmojiExecution implements Execution { - private requestor: Player; private recipient: Player | typeof AllPlayers; private active = true; constructor( - private senderID: PlayerID, + private requestor: Player, private recipientID: PlayerID | typeof AllPlayers, private emoji: number, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.senderID)) { - console.warn(`EmojiExecution: sender ${this.senderID} not found`); - this.active = false; - return; - } if (this.recipientID !== AllPlayers && !mg.hasPlayer(this.recipientID)) { console.warn(`EmojiExecution: recipient ${this.recipientID} not found`); this.active = false; return; } - this.requestor = mg.player(this.senderID); this.recipient = this.recipientID === AllPlayers ? AllPlayers diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 0cd160cb0..66d2fd0e8 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -47,21 +47,21 @@ export class Executor { console.warn(`player with clientID ${intent.clientID} not found`); return new NoOpExecution(); } - const playerID = player.id(); + // create execution switch (intent.type) { case "attack": { return new AttackExecution( intent.troops, - playerID, + player, intent.targetID, null, ); } case "cancel_attack": - return new RetreatExecution(playerID, intent.attackID); + return new RetreatExecution(player, intent.attackID); case "cancel_boat": - return new BoatRetreatExecution(playerID, intent.unitID); + return new BoatRetreatExecution(player, intent.unitID); case "move_warship": return new MoveWarshipExecution(player, intent.unitId, intent.tile); case "spawn": @@ -75,47 +75,47 @@ export class Executor { src = this.mg.ref(intent.srcX, intent.srcY); } return new TransportShipExecution( - playerID, + player, intent.targetID, this.mg.ref(intent.dstX, intent.dstY), intent.troops, src, ); case "allianceRequest": - return new AllianceRequestExecution(playerID, intent.recipient); + return new AllianceRequestExecution(player, intent.recipient); case "allianceRequestReply": return new AllianceRequestReplyExecution( intent.requestor, - playerID, + player, intent.accept, ); case "breakAlliance": - return new BreakAllianceExecution(playerID, intent.recipient); + return new BreakAllianceExecution(player, intent.recipient); case "targetPlayer": - return new TargetPlayerExecution(playerID, intent.target); + return new TargetPlayerExecution(player, intent.target); case "emoji": - return new EmojiExecution(playerID, intent.recipient, intent.emoji); + return new EmojiExecution(player, intent.recipient, intent.emoji); case "donate_troops": return new DonateTroopsExecution( - playerID, + player, intent.recipient, intent.troops, ); case "donate_gold": - return new DonateGoldExecution(playerID, intent.recipient, intent.gold); + return new DonateGoldExecution(player, intent.recipient, intent.gold); case "troop_ratio": - return new SetTargetTroopRatioExecution(playerID, intent.ratio); + return new SetTargetTroopRatioExecution(player, intent.ratio); case "embargo": return new EmbargoExecution(player, intent.targetID, intent.action); case "build_unit": return new ConstructionExecution( - playerID, + player, this.mg.ref(intent.x, intent.y), intent.unit, ); case "quick_chat": return new QuickChatExecution( - playerID, + player, intent.recipient, intent.quickChatKey, intent.variables ?? {}, diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index 7f4d5c024..8c03a4d49 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -282,7 +282,7 @@ export class FakeHumanExecution implements Execution { this.lastEmojiSent.set(enemy, this.mg.ticks()); this.mg.addExecution( new EmojiExecution( - this.player.id(), + this.player, enemy.id(), this.random.randElement(this.heckleEmoji), ), @@ -354,7 +354,7 @@ export class FakeHumanExecution implements Execution { const tick = this.mg.ticks(); this.lastNukeSent.push([tick, tile]); this.mg.addExecution( - new NukeExecution(UnitType.AtomBomb, this.player.id(), tile), + new NukeExecution(UnitType.AtomBomb, this.player, tile), ); } @@ -421,7 +421,7 @@ export class FakeHumanExecution implements Execution { } this.mg.addExecution( new TransportShipExecution( - this.player.id(), + this.player, other.id(), closest.y, this.player.troops() / 5, @@ -441,7 +441,7 @@ export class FakeHumanExecution implements Execution { if (oceanTiles.length > 0) { const buildTile = this.random.randElement(oceanTiles); this.mg.addExecution( - new ConstructionExecution(player.id(), buildTile, UnitType.Port), + new ConstructionExecution(player, buildTile, UnitType.Port), ); } return; @@ -470,9 +470,7 @@ export class FakeHumanExecution implements Execution { if (canBuild === false) { return; } - this.mg.addExecution( - new ConstructionExecution(this.player.id(), tile, type), - ); + this.mg.addExecution(new ConstructionExecution(this.player, tile, type)); } private maybeSpawnWarship(): boolean { @@ -498,11 +496,7 @@ export class FakeHumanExecution implements Execution { return false; } this.mg.addExecution( - new ConstructionExecution( - this.player.id(), - targetTile, - UnitType.Warship, - ), + new ConstructionExecution(this.player, targetTile, UnitType.Warship), ); return true; } @@ -573,7 +567,7 @@ export class FakeHumanExecution implements Execution { this.mg.addExecution( new TransportShipExecution( - this.player.id(), + this.player, this.mg.owner(dst).id(), dst, this.player.troops() / 5, diff --git a/src/core/execution/MIRVExecution.ts b/src/core/execution/MIRVExecution.ts index 9b0e8e511..18411e80d 100644 --- a/src/core/execution/MIRVExecution.ts +++ b/src/core/execution/MIRVExecution.ts @@ -3,7 +3,6 @@ import { Game, MessageType, Player, - PlayerID, TerraNullius, Unit, UnitType, @@ -15,8 +14,6 @@ import { simpleHash } from "../Util"; import { NukeExecution } from "./NukeExecution"; export class MirvExecution implements Execution { - private player: Player; - private active = true; private mg: Game; @@ -37,21 +34,14 @@ export class MirvExecution implements Execution { private speed: number = -1; constructor( - private senderID: PlayerID, + private player: Player, private dst: TileRef, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.senderID)) { - console.warn(`MIRVExecution: player ${this.senderID} not found`); - this.active = false; - return; - } - - this.random = new PseudoRandom(mg.ticks() + simpleHash(this.senderID)); + this.random = new PseudoRandom(mg.ticks() + simpleHash(this.player.id())); this.mg = mg; this.pathFinder = new ParabolaPathFinder(mg); - this.player = mg.player(this.senderID); this.targetPlayer = this.mg.owner(this.dst); this.speed = this.mg.config().defaultNukeSpeed(); @@ -118,7 +108,7 @@ export class MirvExecution implements Execution { this.mg.addExecution( new NukeExecution( UnitType.MIRVWarhead, - this.senderID, + this.player, dst, this.nuke.tile(), 15 + Math.floor((i / this.warheadCount) * 5), diff --git a/src/core/execution/MissileSiloExecution.ts b/src/core/execution/MissileSiloExecution.ts index b0c404405..cadb4c8dc 100644 --- a/src/core/execution/MissileSiloExecution.ts +++ b/src/core/execution/MissileSiloExecution.ts @@ -2,7 +2,6 @@ import { Execution, Game, Player, - PlayerID, Unit, UnitType, } from "../game/Game"; @@ -10,30 +9,19 @@ import { TileRef } from "../game/GameMap"; export class MissileSiloExecution implements Execution { private active = true; - private mg: Game | null = null; - private player: Player | null = null; + private mg: Game; private silo: Unit | null = null; constructor( - private _owner: PlayerID, + private player: Player, private tile: TileRef, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this._owner)) { - console.warn(`MissileSiloExecution: owner ${this._owner} not found`); - this.active = false; - return; - } - this.mg = mg; - this.player = mg.player(this._owner); } tick(ticks: number): void { - if (this.player === null || this.mg === null) { - throw new Error("Not initialized"); - } if (this.silo === null) { const spawn = this.player.canBuild(UnitType.MissileSilo, this.tile); if (spawn === false) { diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 83955ded0..91ed7d725 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -3,7 +3,6 @@ import { Game, MessageType, Player, - PlayerID, TerraNullius, Unit, UnitType, @@ -15,8 +14,7 @@ import { NukeType } from "../StatsSchemas"; export class NukeExecution implements Execution { private active = true; - private player: Player | null = null; - private mg: Game | null = null; + private mg: Game; private nuke: Unit | null = null; private tilesToDestroyCache: Set | undefined; @@ -24,8 +22,8 @@ export class NukeExecution implements Execution { private pathFinder: ParabolaPathFinder; constructor( - private type: NukeType, - private senderID: PlayerID, + private nukeType: NukeType, + private player: Player, private dst: TileRef, private src?: TileRef | null, private speed: number = -1, @@ -33,14 +31,7 @@ export class NukeExecution implements Execution { ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.senderID)) { - console.warn(`NukeExecution: sender ${this.senderID} not found`); - this.active = false; - return; - } - this.mg = mg; - this.player = mg.player(this.senderID); this.random = new PseudoRandom(ticks); if (this.speed === -1) { this.speed = this.mg.config().defaultNukeSpeed(); @@ -49,9 +40,6 @@ export class NukeExecution implements Execution { } public target(): Player | TerraNullius { - if (this.mg === null) { - throw new Error("Not initialized"); - } return this.mg.owner(this.dst); } @@ -59,7 +47,7 @@ export class NukeExecution implements Execution { if (this.tilesToDestroyCache !== undefined) { return this.tilesToDestroyCache; } - if (this.mg === null || this.nuke === null) { + if (this.nuke === null) { throw new Error("Not initialized"); } const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type()); @@ -74,7 +62,7 @@ export class NukeExecution implements Execution { } private breakAlliances(toDestroy: Set) { - if (this.mg === null || this.player === null || this.nuke === null) { + if (this.nuke === null) { throw new Error("Not initialized"); } const attacked = new Map(); @@ -101,12 +89,8 @@ export class NukeExecution implements Execution { } tick(ticks: number): void { - if (this.mg === null || this.player === null) { - throw new Error("Not initialized"); - } - if (this.nuke === null) { - const spawn = this.src ?? this.player.canBuild(this.type, this.dst); + const spawn = this.src ?? this.player.canBuild(this.nukeType, this.dst); if (spawn === false) { console.warn(`cannot build Nuke`); this.active = false; @@ -115,16 +99,16 @@ export class NukeExecution implements Execution { this.pathFinder.computeControlPoints( spawn, this.dst, - this.type !== UnitType.MIRVWarhead, + this.nukeType !== UnitType.MIRVWarhead, ); - this.nuke = this.player.buildUnit(this.type, spawn, { + this.nuke = this.player.buildUnit(this.nukeType, spawn, { targetTile: this.dst, }); if (this.mg.hasOwner(this.dst)) { const target = this.mg.owner(this.dst); if (!target.isPlayer()) { // Ignore terra nullius - } else if (this.type === UnitType.AtomBomb) { + } else if (this.nukeType === UnitType.AtomBomb) { this.mg.displayIncomingUnit( this.nuke.id(), `${this.player.name()} - atom bomb inbound`, @@ -132,7 +116,7 @@ export class NukeExecution implements Execution { target.id(), ); this.breakAlliances(this.tilesToDestroy()); - } else if (this.type === UnitType.HydrogenBomb) { + } else if (this.nukeType === UnitType.HydrogenBomb) { this.mg.displayIncomingUnit( this.nuke.id(), `${this.player.name()} - hydrogen bomb inbound`, @@ -143,9 +127,7 @@ export class NukeExecution implements Execution { } // Record stats - this.mg - .stats() - .bombLaunch(this.player, target, this.nuke.type() as NukeType); + this.mg.stats().bombLaunch(this.player, target, this.nukeType); } // after sending a nuke set the missilesilo on cooldown @@ -181,7 +163,7 @@ export class NukeExecution implements Execution { } private detonate() { - if (this.mg === null || this.nuke === null || this.player === null) { + if (this.nuke === null) { throw new Error("Not initialized"); } @@ -248,9 +230,6 @@ export class NukeExecution implements Execution { } owner(): Player { - if (this.player === null) { - throw new Error("Not initialized"); - } return this.player; } diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 276be4890..44f69354d 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -5,7 +5,6 @@ import { Game, MessageType, Player, - PlayerID, UnitType, } from "../game/Game"; import { GameImpl } from "../game/GameImpl"; @@ -15,35 +14,25 @@ import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util"; export class PlayerExecution implements Execution { private readonly ticksPerClusterCalc = 20; - private player: Player | null = null; - private config: Config | null = null; + private config: Config; private lastCalc = 0; - private mg: Game | null = null; + private mg: Game; private active = true; - constructor(private playerID: PlayerID) {} + constructor(private player: Player) {} activeDuringSpawnPhase(): boolean { return false; } init(mg: Game, ticks: number) { - if (!mg.hasPlayer(this.playerID)) { - console.warn(`PlayerExecution: player ${this.playerID} not found`); - this.active = false; - return; - } this.mg = mg; this.config = mg.config(); - this.player = mg.player(this.playerID); this.lastCalc = ticks + (simpleHash(this.player.name()) % this.ticksPerClusterCalc); } tick(ticks: number) { - if (this.mg === null || this.config === null || this.player === null) { - throw new Error("Not initialized"); - } this.player.decayRelations(); this.player.units().forEach((u) => { const tileOwner = this.mg!.owner(u.tile()); @@ -122,9 +111,6 @@ export class PlayerExecution implements Execution { } private removeClusters() { - if (this.mg === null || this.player === null) { - throw new Error("Not initialized"); - } const clusters = this.calculateClusters(); clusters.sort((a, b) => b.size - a.size); @@ -144,9 +130,6 @@ export class PlayerExecution implements Execution { } private surroundedBySamePlayer(cluster: Set): false | Player { - if (this.mg === null || this.player === null) { - throw new Error("Not initialized"); - } const enemies = new Set(); for (const tile of cluster) { const isOceanShore = this.mg.isOceanShore(tile); @@ -181,9 +164,6 @@ export class PlayerExecution implements Execution { } private isSurrounded(cluster: Set): boolean { - if (this.mg === null || this.player === null) { - throw new Error("Not initialized"); - } const enemyTiles = new Set(); for (const tr of cluster) { if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) { @@ -207,9 +187,6 @@ export class PlayerExecution implements Execution { } private removeCluster(cluster: Set) { - if (this.mg === null || this.player === null) { - throw new Error("Not initialized"); - } if ( Array.from(cluster).some( (t) => this.mg?.ownerID(t) !== this.player?.smallID(), @@ -252,9 +229,6 @@ export class PlayerExecution implements Execution { } private getCapturingPlayer(cluster: Set): Player | null { - if (this.mg === null || this.player === null) { - throw new Error("Not initialized"); - } const neighborsIDs = new Set(); for (const t of cluster) { for (const neighbor of this.mg.neighbors(t)) { @@ -297,9 +271,6 @@ export class PlayerExecution implements Execution { } private calculateClusters(): Set[] { - if (this.mg === null || this.player === null) { - throw new Error("Not initialized"); - } const seen = new Set(); const border = this.player.borderTiles(); const clusters: Set[] = []; diff --git a/src/core/execution/PortExecution.ts b/src/core/execution/PortExecution.ts index d234a8de8..ef8769f71 100644 --- a/src/core/execution/PortExecution.ts +++ b/src/core/execution/PortExecution.ts @@ -1,11 +1,4 @@ -import { - Execution, - Game, - Player, - PlayerID, - Unit, - UnitType, -} from "../game/Game"; +import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; import { TradeShipExecution } from "./TradeShipExecution"; @@ -18,16 +11,11 @@ export class PortExecution implements Execution { private checkOffset: number | null = null; constructor( - private _owner: PlayerID, + private player: Player, private tile: TileRef, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this._owner)) { - console.warn(`PortExecution: player ${this._owner} not found`); - this.active = false; - return; - } this.mg = mg; this.random = new PseudoRandom(mg.ticks()); this.checkOffset = mg.ticks() % 10; @@ -39,14 +27,15 @@ export class PortExecution implements Execution { } if (this.port === null) { const tile = this.tile; - const player = this.mg.player(this._owner); - const spawn = player.canBuild(UnitType.Port, tile); + const spawn = this.player.canBuild(UnitType.Port, tile); if (spawn === false) { - console.warn(`player ${player} cannot build port at ${this.tile}`); + console.warn( + `player ${this.player.id()} cannot build port at ${this.tile}`, + ); this.active = false; return; } - this.port = player.buildUnit(UnitType.Port, spawn, {}); + this.port = this.player.buildUnit(UnitType.Port, spawn, {}); } if (!this.port.isActive()) { @@ -54,8 +43,8 @@ export class PortExecution implements Execution { return; } - if (this._owner !== this.port.owner().id()) { - this._owner = this.port.owner().id(); + if (this.player.id() !== this.port.owner().id()) { + this.player = this.port.owner(); } // Only check every 10 ticks for performance. @@ -70,7 +59,7 @@ export class PortExecution implements Execution { return; } - const ports = this.player().tradingPorts(this.port); + const ports = this.player.tradingPorts(this.port); if (ports.length === 0) { return; @@ -78,7 +67,7 @@ export class PortExecution implements Execution { const port = this.random.randElement(ports); this.mg.addExecution( - new TradeShipExecution(this.player().id(), this.port, port), + new TradeShipExecution(this.player, this.port, port), ); } @@ -89,11 +78,4 @@ export class PortExecution implements Execution { activeDuringSpawnPhase(): boolean { return false; } - - player(): Player { - if (this.port === null) { - throw new Error("Not initialized"); - } - return this.port.owner(); - } } diff --git a/src/core/execution/QuickChatExecution.ts b/src/core/execution/QuickChatExecution.ts index f9d4230bf..bf743ef46 100644 --- a/src/core/execution/QuickChatExecution.ts +++ b/src/core/execution/QuickChatExecution.ts @@ -1,14 +1,14 @@ import { Execution, Game, Player, PlayerID } from "../game/Game"; export class QuickChatExecution implements Execution { - private sender: Player; + private recipient: Player; private mg: Game; private active = true; constructor( - private senderID: PlayerID, + private sender: Player, private recipientID: PlayerID, private quickChatKey: string, private variables: Record, @@ -16,11 +16,6 @@ export class QuickChatExecution implements Execution { init(mg: Game, ticks: number): void { this.mg = mg; - if (!mg.hasPlayer(this.senderID)) { - console.warn(`QuickChatExecution: sender ${this.senderID} not found`); - this.active = false; - return; - } if (!mg.hasPlayer(this.recipientID)) { console.warn( `QuickChatExecution: recipient ${this.recipientID} not found`, @@ -29,7 +24,6 @@ export class QuickChatExecution implements Execution { return; } - this.sender = mg.player(this.senderID); this.recipient = mg.player(this.recipientID); } diff --git a/src/core/execution/RetreatExecution.ts b/src/core/execution/RetreatExecution.ts index c40929adc..3383aec4c 100644 --- a/src/core/execution/RetreatExecution.ts +++ b/src/core/execution/RetreatExecution.ts @@ -1,26 +1,19 @@ -import { Execution, Game, Player, PlayerID } from "../game/Game"; +import { Execution, Game, Player } from "../game/Game"; const cancelDelay = 20; export class RetreatExecution implements Execution { private active = true; private retreatOrdered = false; - private player: Player; private startTick: number; private mg: Game; constructor( - private playerID: PlayerID, + private player: Player, private attackID: string, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.playerID)) { - console.warn(`RetreatExecution: player ${this.playerID} not found`); - return; - } this.mg = mg; - - this.player = mg.player(this.playerID); this.startTick = mg.ticks(); } diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index d27737cc0..9fffdaab3 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -3,7 +3,6 @@ import { Game, MessageType, Player, - PlayerID, Unit, UnitType, } from "../game/Game"; @@ -12,7 +11,6 @@ import { PseudoRandom } from "../PseudoRandom"; import { SAMMissileExecution } from "./SAMMissileExecution"; export class SAMLauncherExecution implements Execution { - private player: Player; private mg: Game; private active: boolean = true; @@ -26,7 +24,7 @@ export class SAMLauncherExecution implements Execution { private pseudoRandom: PseudoRandom | undefined; constructor( - private ownerId: PlayerID, + private player: Player, private tile: TileRef | null, private sam: Unit | null = null, ) { @@ -37,12 +35,6 @@ export class SAMLauncherExecution implements Execution { init(mg: Game, ticks: number): void { this.mg = mg; - if (!mg.hasPlayer(this.ownerId)) { - console.warn(`SAMLauncherExecution: owner ${this.ownerId} not found`); - this.active = false; - return; - } - this.player = mg.player(this.ownerId); } private nukeTargetInRange(nuke: Unit) { diff --git a/src/core/execution/SetTargetTroopRatioExecution.ts b/src/core/execution/SetTargetTroopRatioExecution.ts index 9ad8e1380..d43834003 100644 --- a/src/core/execution/SetTargetTroopRatioExecution.ts +++ b/src/core/execution/SetTargetTroopRatioExecution.ts @@ -1,23 +1,14 @@ -import { Execution, Game, Player, PlayerID } from "../game/Game"; +import { Execution, Game, Player } from "../game/Game"; export class SetTargetTroopRatioExecution implements Execution { - private player: Player; - private active = true; constructor( - private playerID: PlayerID, + private player: Player, private targetTroopsRatio: number, ) {} - init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.playerID)) { - console.warn( - `SetTargetTRoopRatioExecution: player ${this.playerID} not found`, - ); - } - this.player = mg.player(this.playerID); - } + init(mg: Game, ticks: number): void {} tick(ticks: number): void { if (this.targetTroopsRatio < 0 || this.targetTroopsRatio > 1) { diff --git a/src/core/execution/SpawnExecution.ts b/src/core/execution/SpawnExecution.ts index add0550a4..d6d45b3d4 100644 --- a/src/core/execution/SpawnExecution.ts +++ b/src/core/execution/SpawnExecution.ts @@ -38,7 +38,7 @@ export class SpawnExecution implements Execution { }); if (!player.hasSpawned()) { - this.mg.addExecution(new PlayerExecution(player.id())); + this.mg.addExecution(new PlayerExecution(player)); if (player.type() === PlayerType.Bot) { this.mg.addExecution(new BotExecution(player)); } diff --git a/src/core/execution/TargetPlayerExecution.ts b/src/core/execution/TargetPlayerExecution.ts index 8b22d2998..e6e454534 100644 --- a/src/core/execution/TargetPlayerExecution.ts +++ b/src/core/execution/TargetPlayerExecution.ts @@ -1,31 +1,22 @@ import { Execution, Game, Player, PlayerID } from "../game/Game"; export class TargetPlayerExecution implements Execution { - private requestor: Player; private target: Player; private active = true; constructor( - private requestorID: PlayerID, + private requestor: Player, private targetID: PlayerID, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.requestorID)) { - console.warn( - `TargetPlayerExecution: requestor ${this.requestorID} not found`, - ); - this.active = false; - return; - } if (!mg.hasPlayer(this.targetID)) { console.warn(`TargetPlayerExecution: target ${this.targetID} not found`); this.active = false; return; } - this.requestor = mg.player(this.requestorID); this.target = mg.player(this.targetID); } diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index 5e1fb10e9..2ae94dca1 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -4,7 +4,6 @@ import { Game, MessageType, Player, - PlayerID, Unit, UnitType, } from "../game/Game"; @@ -16,20 +15,18 @@ import { distSortUnit } from "../Util"; export class TradeShipExecution implements Execution { private active = true; private mg: Game; - private origOwner: Player; private tradeShip: Unit | undefined; private wasCaptured = false; private pathFinder: PathFinder; constructor( - private _owner: PlayerID, + private origOwner: Player, private srcPort: Unit, private _dstPort: Unit, ) {} init(mg: Game, ticks: number): void { this.mg = mg; - this.origOwner = mg.player(this._owner); this.pathFinder = PathFinder.Mini(mg, 2500); } diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index e7607cd05..78cc71c06 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -23,7 +23,6 @@ export class TransportShipExecution implements Execution { private active = true; private mg: Game; - private attacker: Player; private target: Player | TerraNullius; // TODO make private @@ -35,7 +34,7 @@ export class TransportShipExecution implements Execution { private pathFinder: PathFinder; constructor( - private attackerID: PlayerID, + private attacker: Player, private targetID: PlayerID | null, private ref: TileRef, private troops: number, @@ -47,13 +46,6 @@ export class TransportShipExecution implements Execution { } init(mg: Game, ticks: number) { - if (!mg.hasPlayer(this.attackerID)) { - console.warn( - `TransportShipExecution: attacker ${this.attackerID} not found`, - ); - this.active = false; - return; - } if (this.targetID !== null && !mg.hasPlayer(this.targetID)) { console.warn(`TransportShipExecution: target ${this.targetID} not found`); this.active = false; @@ -64,8 +56,6 @@ export class TransportShipExecution implements Execution { this.mg = mg; this.pathFinder = PathFinder.Mini(mg, 10_000, 10); - this.attacker = mg.player(this.attackerID); - if ( this.attacker.units(UnitType.TransportShip).length >= mg.config().boatMaxNumber() @@ -73,7 +63,7 @@ export class TransportShipExecution implements Execution { mg.displayMessage( `No boats available, max ${mg.config().boatMaxNumber()}`, MessageType.WARN, - this.attackerID, + this.attacker.id(), ); this.active = false; this.attacker.addTroops(this.troops); @@ -192,7 +182,7 @@ export class TransportShipExecution implements Execution { this.mg.addExecution( new AttackExecution( this.troops, - this.attacker.id(), + this.attacker, this.targetID, this.dst, false, diff --git a/src/core/execution/alliance/AllianceRequestExecution.ts b/src/core/execution/alliance/AllianceRequestExecution.ts index 0475d8f9f..419b8b92c 100644 --- a/src/core/execution/alliance/AllianceRequestExecution.ts +++ b/src/core/execution/alliance/AllianceRequestExecution.ts @@ -2,22 +2,14 @@ import { Execution, Game, Player, PlayerID } from "../../game/Game"; export class AllianceRequestExecution implements Execution { private active = true; - private requestor: Player | null = null; private recipient: Player | null = null; constructor( - private requestorID: PlayerID, + private requestor: Player, private recipientID: PlayerID, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.requestorID)) { - console.warn( - `AllianceRequestExecution requester ${this.requestorID} not found`, - ); - this.active = false; - return; - } if (!mg.hasPlayer(this.recipientID)) { console.warn( `AllianceRequestExecution recipient ${this.recipientID} not found`, @@ -26,12 +18,11 @@ export class AllianceRequestExecution implements Execution { return; } - this.requestor = mg.player(this.requestorID); this.recipient = mg.player(this.recipientID); } tick(ticks: number): void { - if (this.requestor === null || this.recipient === null) { + if (this.recipient === null) { throw new Error("Not initialized"); } if (this.requestor.isFriendly(this.recipient)) { diff --git a/src/core/execution/alliance/AllianceRequestReplyExecution.ts b/src/core/execution/alliance/AllianceRequestReplyExecution.ts index ed3177517..bd3d90a58 100644 --- a/src/core/execution/alliance/AllianceRequestReplyExecution.ts +++ b/src/core/execution/alliance/AllianceRequestReplyExecution.ts @@ -3,11 +3,10 @@ import { Execution, Game, Player, PlayerID } from "../../game/Game"; export class AllianceRequestReplyExecution implements Execution { private active = true; private requestor: Player | null = null; - private recipient: Player | null = null; constructor( private requestorID: PlayerID, - private recipientID: PlayerID, + private recipient: Player, private accept: boolean, ) {} @@ -19,19 +18,11 @@ export class AllianceRequestReplyExecution implements Execution { this.active = false; return; } - if (!mg.hasPlayer(this.recipientID)) { - console.warn( - `AllianceRequestReplyExecution recipient ${this.recipientID} not found`, - ); - this.active = false; - return; - } this.requestor = mg.player(this.requestorID); - this.recipient = mg.player(this.recipientID); } tick(ticks: number): void { - if (this.requestor === null || this.recipient === null) { + if (this.requestor === null) { throw new Error("Not initialized"); } if (this.requestor.isFriendly(this.recipient)) { diff --git a/src/core/execution/alliance/BreakAllianceExecution.ts b/src/core/execution/alliance/BreakAllianceExecution.ts index a028b0cb0..de614c1cc 100644 --- a/src/core/execution/alliance/BreakAllianceExecution.ts +++ b/src/core/execution/alliance/BreakAllianceExecution.ts @@ -2,23 +2,15 @@ import { Execution, Game, Player, PlayerID } from "../../game/Game"; export class BreakAllianceExecution implements Execution { private active = true; - private requestor: Player | null = null; private recipient: Player | null = null; private mg: Game | null = null; constructor( - private requestorID: PlayerID, + private requestor: Player, private recipientID: PlayerID, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.requestorID)) { - console.warn( - `BreakAllianceExecution requester ${this.requestorID} not found`, - ); - this.active = false; - return; - } if (!mg.hasPlayer(this.recipientID)) { console.warn( `BreakAllianceExecution: recipient ${this.recipientID} not found`, @@ -26,7 +18,6 @@ export class BreakAllianceExecution implements Execution { this.active = false; return; } - this.requestor = mg.player(this.requestorID); this.recipient = mg.player(this.recipientID); this.mg = mg; } diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts index 111e0baa6..81262176f 100644 --- a/src/core/execution/utils/BotBehavior.ts +++ b/src/core/execution/utils/BotBehavior.ts @@ -41,7 +41,7 @@ export class BotBehavior { private emoji(player: Player, emoji: number) { if (player.type() !== PlayerType.Human) return; this.game.addExecution( - new EmojiExecution(this.player.id(), player.id(), emoji), + new EmojiExecution(this.player, player.id(), emoji), ); } @@ -194,7 +194,7 @@ export class BotBehavior { this.game.addExecution( new AttackExecution( troops, - this.player.id(), + this.player, target.isPlayer() ? target.id() : null, ), ); diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index 81f3b49d7..37294e53e 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -21,7 +21,13 @@ let attackerSpawn: TileRef; function sendBoat(target: TileRef, source: TileRef, troops: number) { game.addExecution( - new TransportShipExecution(defender.id(), null, target, troops, source), + new TransportShipExecution( + defender, + null, + target, + troops, + source, + ), ); } @@ -64,7 +70,7 @@ describe("Attack", () => { attacker = game.player(attackerInfo.id); defender = game.player(defenderInfo.id); - game.addExecution(new AttackExecution(100, defender.id(), null)); + game.addExecution(new AttackExecution(100, defender, game.terraNullius().id())); game.executeNextTick(); while (defender.outgoingAttacks().length > 0) { game.executeNextTick(); @@ -76,10 +82,10 @@ describe("Attack", () => { test("Nuke reduce attacking troop counts", async () => { // Not building exactly spawn to it's better protected from attacks (but still // on defender territory) - constructionExecution(game, defender.id(), 1, 1, UnitType.MissileSilo); + constructionExecution(game, defender, 1, 1, UnitType.MissileSilo); expect(defender.units(UnitType.MissileSilo)).toHaveLength(1); - game.addExecution(new AttackExecution(100, attacker.id(), defender.id())); - constructionExecution(game, defender.id(), 0, 15, UnitType.AtomBomb, 3); + game.addExecution(new AttackExecution(100, attacker, defender.id())); + constructionExecution(game, defender, 0, 15, UnitType.AtomBomb, 3); const nuke = defender.units(UnitType.AtomBomb)[0]; expect(nuke.isActive()).toBe(true); @@ -94,12 +100,12 @@ describe("Attack", () => { }); test("Nuke reduce attacking boat troop count", async () => { - constructionExecution(game, defender.id(), 1, 1, UnitType.MissileSilo); + constructionExecution(game, defender, 1, 1, UnitType.MissileSilo); expect(defender.units(UnitType.MissileSilo)).toHaveLength(1); sendBoat(game.ref(15, 8), game.ref(10, 5), 100); - constructionExecution(game, defender.id(), 0, 15, UnitType.AtomBomb, 3); + constructionExecution(game, defender, 0, 15, UnitType.AtomBomb, 3); const nuke = defender.units(UnitType.AtomBomb)[0]; expect(nuke.isActive()).toBe(true); diff --git a/tests/MissileSilo.test.ts b/tests/MissileSilo.test.ts index d5930680b..04d708546 100644 --- a/tests/MissileSilo.test.ts +++ b/tests/MissileSilo.test.ts @@ -20,7 +20,7 @@ function attackerBuildsNuke( initialize = true, ) { game.addExecution( - new NukeExecution(UnitType.AtomBomb, attacker.id(), target, source), + new NukeExecution(UnitType.AtomBomb, attacker, target, source), ); if (initialize) { game.executeNextTick(); @@ -50,7 +50,7 @@ describe("MissileSilo", () => { attacker = game.player("attacker_id"); - constructionExecution(game, attacker.id(), 1, 1, UnitType.MissileSilo); + constructionExecution(game, attacker, 1, 1, UnitType.MissileSilo); }); test("missilesilo should launch nuke", async () => { diff --git a/tests/SAM.test.ts b/tests/SAM.test.ts index fc463f960..d3f209673 100644 --- a/tests/SAM.test.ts +++ b/tests/SAM.test.ts @@ -61,12 +61,12 @@ describe("SAM", () => { defender = game.player("defender_id"); far_defender = game.player("far_defender_id"); - constructionExecution(game, attacker.id(), 7, 7, UnitType.MissileSilo); + constructionExecution(game, attacker, 7, 7, UnitType.MissileSilo); }); test("one sam should take down one nuke", async () => { const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); - game.addExecution(new SAMLauncherExecution(defender.id(), null, sam)); + game.addExecution(new SAMLauncherExecution(defender, null, sam)); attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), { targetTile: game.ref(2, 1), }); @@ -78,7 +78,7 @@ describe("SAM", () => { test("sam should only get one nuke at a time", async () => { const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); - game.addExecution(new SAMLauncherExecution(defender.id(), null, sam)); + game.addExecution(new SAMLauncherExecution(defender, null, sam)); attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 1), { targetTile: game.ref(2, 1), }); @@ -94,7 +94,7 @@ describe("SAM", () => { test("sam should cooldown as long as configured", async () => { const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); - game.addExecution(new SAMLauncherExecution(defender.id(), null, sam)); + game.addExecution(new SAMLauncherExecution(defender, null, sam)); expect(sam.isInCooldown()).toBeFalsy(); const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), { targetTile: game.ref(1, 2), @@ -117,9 +117,9 @@ describe("SAM", () => { const sam1 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), { cooldownDuration: 10, }); - game.addExecution(new SAMLauncherExecution(defender.id(), null, sam1)); + game.addExecution(new SAMLauncherExecution(defender, null, sam1)); const sam2 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 2), {}); - game.addExecution(new SAMLauncherExecution(defender.id(), null, sam2)); + game.addExecution(new SAMLauncherExecution(defender, null, sam2)); const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 2), { targetTile: game.ref(2, 2), }); @@ -134,7 +134,7 @@ describe("SAM", () => { const targetDistance = 199; // Close SAM: should not intercept anything const sam1 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); - game.addExecution(new SAMLauncherExecution(defender.id(), null, sam1)); + game.addExecution(new SAMLauncherExecution(defender, null, sam1)); // Far SAM: Should intercept the nuke. Use the far_defender so the SAM can be built const sam2 = far_defender.buildUnit( @@ -142,11 +142,11 @@ describe("SAM", () => { game.ref(targetDistance, 1), {}, ); - game.addExecution(new SAMLauncherExecution(far_defender.id(), null, sam2)); + game.addExecution(new SAMLauncherExecution(far_defender, null, sam2)); const nukeExecution = new NukeExecution( UnitType.AtomBomb, - attacker.id(), + attacker, game.ref(targetDistance, 1), null, ); diff --git a/tests/util/utils.ts b/tests/util/utils.ts index aa1b7a05d..921e5d102 100644 --- a/tests/util/utils.ts +++ b/tests/util/utils.ts @@ -4,18 +4,18 @@ // If you also need execution use function below. Does not work with things not import { ConstructionExecution } from "../../src/core/execution/ConstructionExecution"; -import { Game, PlayerID, UnitType } from "../../src/core/game/Game"; +import { Game, Player, UnitType } from "../../src/core/game/Game"; // built via UI (e.g.: trade ships) export function constructionExecution( game: Game, - playerID: PlayerID, + _owner: Player, x: number, y: number, unit: UnitType, ticks = 4, ) { - game.addExecution(new ConstructionExecution(playerID, game.ref(x, y), unit)); + game.addExecution(new ConstructionExecution(_owner, game.ref(x, y), unit)); // 4 ticks by default as it usually goes like this // Init of construction execution From 02f7caef883f6fcf6dcded57e6fc4bffd64737f3 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Fri, 6 Jun 2025 12:45:53 -0700 Subject: [PATCH 74/84] format code with prettier --- src/core/execution/CityExecution.ts | 8 +------- src/core/execution/DefensePostExecution.ts | 8 +------- src/core/execution/DonateGoldExecution.ts | 1 - src/core/execution/DonateTroopExecution.ts | 1 - src/core/execution/MissileSiloExecution.ts | 8 +------- src/core/execution/PlayerExecution.ts | 8 +------- src/core/execution/PortExecution.ts | 4 +--- src/core/execution/QuickChatExecution.ts | 1 - src/core/execution/utils/BotBehavior.ts | 4 +--- tests/Attack.test.ts | 12 ++++-------- 10 files changed, 10 insertions(+), 45 deletions(-) diff --git a/src/core/execution/CityExecution.ts b/src/core/execution/CityExecution.ts index 706adfb21..880755573 100644 --- a/src/core/execution/CityExecution.ts +++ b/src/core/execution/CityExecution.ts @@ -1,10 +1,4 @@ -import { - Execution, - Game, - Player, - Unit, - UnitType, -} from "../game/Game"; +import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; import { TileRef } from "../game/GameMap"; export class CityExecution implements Execution { diff --git a/src/core/execution/DefensePostExecution.ts b/src/core/execution/DefensePostExecution.ts index 098b52aac..ab36f81ae 100644 --- a/src/core/execution/DefensePostExecution.ts +++ b/src/core/execution/DefensePostExecution.ts @@ -1,10 +1,4 @@ -import { - Execution, - Game, - Player, - Unit, - UnitType, -} from "../game/Game"; +import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { ShellExecution } from "./ShellExecution"; diff --git a/src/core/execution/DonateGoldExecution.ts b/src/core/execution/DonateGoldExecution.ts index dfdb53622..c4b65939f 100644 --- a/src/core/execution/DonateGoldExecution.ts +++ b/src/core/execution/DonateGoldExecution.ts @@ -1,7 +1,6 @@ import { Execution, Game, Gold, Player, PlayerID } from "../game/Game"; export class DonateGoldExecution implements Execution { - private recipient: Player; private active = true; diff --git a/src/core/execution/DonateTroopExecution.ts b/src/core/execution/DonateTroopExecution.ts index ea20a2b9c..0570ac641 100644 --- a/src/core/execution/DonateTroopExecution.ts +++ b/src/core/execution/DonateTroopExecution.ts @@ -1,7 +1,6 @@ import { Execution, Game, Player, PlayerID } from "../game/Game"; export class DonateTroopsExecution implements Execution { - private recipient: Player; private active = true; diff --git a/src/core/execution/MissileSiloExecution.ts b/src/core/execution/MissileSiloExecution.ts index cadb4c8dc..7fd9459d8 100644 --- a/src/core/execution/MissileSiloExecution.ts +++ b/src/core/execution/MissileSiloExecution.ts @@ -1,10 +1,4 @@ -import { - Execution, - Game, - Player, - Unit, - UnitType, -} from "../game/Game"; +import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; import { TileRef } from "../game/GameMap"; export class MissileSiloExecution implements Execution { diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 44f69354d..effb78468 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -1,12 +1,6 @@ import { renderNumber } from "../../client/Utils"; import { Config } from "../configuration/Config"; -import { - Execution, - Game, - MessageType, - Player, - UnitType, -} from "../game/Game"; +import { Execution, Game, MessageType, Player, UnitType } from "../game/Game"; import { GameImpl } from "../game/GameImpl"; import { TileRef } from "../game/GameMap"; import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util"; diff --git a/src/core/execution/PortExecution.ts b/src/core/execution/PortExecution.ts index ef8769f71..1f5e10d64 100644 --- a/src/core/execution/PortExecution.ts +++ b/src/core/execution/PortExecution.ts @@ -66,9 +66,7 @@ export class PortExecution implements Execution { } const port = this.random.randElement(ports); - this.mg.addExecution( - new TradeShipExecution(this.player, this.port, port), - ); + this.mg.addExecution(new TradeShipExecution(this.player, this.port, port)); } isActive(): boolean { diff --git a/src/core/execution/QuickChatExecution.ts b/src/core/execution/QuickChatExecution.ts index bf743ef46..4e545b6c5 100644 --- a/src/core/execution/QuickChatExecution.ts +++ b/src/core/execution/QuickChatExecution.ts @@ -1,7 +1,6 @@ import { Execution, Game, Player, PlayerID } from "../game/Game"; export class QuickChatExecution implements Execution { - private recipient: Player; private mg: Game; diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts index 81262176f..39ff00907 100644 --- a/src/core/execution/utils/BotBehavior.ts +++ b/src/core/execution/utils/BotBehavior.ts @@ -40,9 +40,7 @@ export class BotBehavior { private emoji(player: Player, emoji: number) { if (player.type() !== PlayerType.Human) return; - this.game.addExecution( - new EmojiExecution(this.player, player.id(), emoji), - ); + this.game.addExecution(new EmojiExecution(this.player, player.id(), emoji)); } forgetOldEnemies() { diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index 37294e53e..0fcd709c1 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -21,13 +21,7 @@ let attackerSpawn: TileRef; function sendBoat(target: TileRef, source: TileRef, troops: number) { game.addExecution( - new TransportShipExecution( - defender, - null, - target, - troops, - source, - ), + new TransportShipExecution(defender, null, target, troops, source), ); } @@ -70,7 +64,9 @@ describe("Attack", () => { attacker = game.player(attackerInfo.id); defender = game.player(defenderInfo.id); - game.addExecution(new AttackExecution(100, defender, game.terraNullius().id())); + game.addExecution( + new AttackExecution(100, defender, game.terraNullius().id()), + ); game.executeNextTick(); while (defender.outgoingAttacks().length > 0) { game.executeNextTick(); From a4fffce7f3189f9e4626368d1f2aad691f844b3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9odore=20L=C3=A9on?= Date: Fri, 6 Jun 2025 22:30:23 +0200 Subject: [PATCH 75/84] Monitoring client connections (#941) ## Description: Disconnected client detection : If a client haven't send a ping to the server since more than 30 seconds They will then be marked disconnected with a dedicated icon. No action are yet taken, this allows for extensive in-game test before adding the *consequences* of the player leaving the game. I also added extensive unit tests, lessening the risk of regression for the future. ![image](https://github.com/user-attachments/assets/884e5e99-15e8-4544-bd52-7524542cc82a) ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: theodoreleon.aetarax --------- Co-authored-by: evanpelle --- resources/images/DisconnectedIcon.svg | 1 + src/client/graphics/layers/NameLayer.ts | 22 +++ src/core/Schemas.ts | 13 +- src/core/execution/ExecutionManager.ts | 3 + .../execution/MarkDisconnectedExecution.ts | 24 +++ src/core/game/Game.ts | 3 + src/core/game/GameUpdates.ts | 1 + src/core/game/GameView.ts | 3 + src/core/game/PlayerImpl.ts | 10 ++ src/server/Client.ts | 3 +- src/server/GameServer.ts | 43 +++++ tests/Disconnected.test.ts | 163 ++++++++++++++++++ 12 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 resources/images/DisconnectedIcon.svg create mode 100644 src/core/execution/MarkDisconnectedExecution.ts create mode 100644 tests/Disconnected.test.ts diff --git a/resources/images/DisconnectedIcon.svg b/resources/images/DisconnectedIcon.svg new file mode 100644 index 000000000..fd07fe512 --- /dev/null +++ b/resources/images/DisconnectedIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index db7993f90..23f596c01 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -2,6 +2,7 @@ import allianceIcon from "../../../../resources/images/AllianceIcon.svg"; import allianceRequestBlackIcon from "../../../../resources/images/AllianceRequestBlackIcon.svg"; import allianceRequestWhiteIcon from "../../../../resources/images/AllianceRequestWhiteIcon.svg"; import crownIcon from "../../../../resources/images/CrownIcon.svg"; +import disconnectedIcon from "../../../../resources/images/DisconnectedIcon.svg"; import embargoBlackIcon from "../../../../resources/images/EmbargoBlackIcon.svg"; import embargoWhiteIcon from "../../../../resources/images/EmbargoWhiteIcon.svg"; import nukeRedIcon from "../../../../resources/images/NukeIconRed.svg"; @@ -40,6 +41,7 @@ export class NameLayer implements Layer { private renders: RenderInfo[] = []; private seenPlayers: Set = new Set(); private traitorIconImage: HTMLImageElement; + private disconnectedIconImage: HTMLImageElement; private allianceRequestBlackIconImage: HTMLImageElement; private allianceRequestWhiteIconImage: HTMLImageElement; private allianceIconImage: HTMLImageElement; @@ -61,6 +63,8 @@ export class NameLayer implements Layer { ) { this.traitorIconImage = new Image(); this.traitorIconImage.src = traitorIcon; + this.disconnectedIconImage = new Image(); + this.disconnectedIconImage.src = disconnectedIcon; this.allianceIconImage = new Image(); this.allianceIconImage.src = allianceIcon; this.allianceRequestBlackIconImage = new Image(); @@ -370,6 +374,24 @@ export class NameLayer implements Layer { existingTraitor.remove(); } + // Disconnected icon + const existingDisconnected = iconsDiv.querySelector( + '[data-icon="disconnected"]', + ); + if (render.player.isDisconnected()) { + if (!existingDisconnected) { + iconsDiv.appendChild( + this.createIconElement( + this.disconnectedIconImage.src, + iconSize, + "disconnected", + ), + ); + } + } else if (existingDisconnected) { + existingDisconnected.remove(); + } + // Alliance icon const existingAlliance = iconsDiv.querySelector('[data-icon="alliance"]'); if (myPlayer !== null && myPlayer.isAlliedWith(render.player)) { diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 74aa99b2a..e4fff95a6 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -33,7 +33,8 @@ export type Intent = | BuildUnitIntent | EmbargoIntent | QuickChatIntent - | MoveWarshipIntent; + | MoveWarshipIntent + | MarkDisconnectedIntent; export type AttackIntent = z.infer; export type CancelAttackIntent = z.infer; @@ -56,6 +57,9 @@ export type TargetTroopRatioIntent = z.infer< export type BuildUnitIntent = z.infer; export type MoveWarshipIntent = z.infer; export type QuickChatIntent = z.infer; +export type MarkDisconnectedIntent = z.infer< + typeof MarkDisconnectedIntentSchema +>; export type Turn = z.infer; export type GameConfig = z.infer; @@ -166,6 +170,7 @@ const BaseIntentSchema = z.object({ "attack", "cancel_attack", "spawn", + "mark_disconnected", "boat", "cancel_boat", "name", @@ -290,10 +295,16 @@ export const QuickChatIntentSchema = BaseIntentSchema.extend({ variables: z.record(SafeString).optional(), }); +export const MarkDisconnectedIntentSchema = BaseIntentSchema.extend({ + type: z.literal("mark_disconnected"), + isDisconnected: z.boolean(), +}); + const IntentSchema = z.union([ AttackIntentSchema, CancelAttackIntentSchema, SpawnIntentSchema, + MarkDisconnectedIntentSchema, BoatAttackIntentSchema, CancelBoatIntentSchema, AllianceRequestIntentSchema, diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 66d2fd0e8..a333d95ec 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -15,6 +15,7 @@ import { DonateTroopsExecution } from "./DonateTroopExecution"; import { EmbargoExecution } from "./EmbargoExecution"; import { EmojiExecution } from "./EmojiExecution"; import { FakeHumanExecution } from "./FakeHumanExecution"; +import { MarkDisconnectedExecution } from "./MarkDisconnectedExecution"; import { MoveWarshipExecution } from "./MoveWarshipExecution"; import { NoOpExecution } from "./NoOpExecution"; import { QuickChatExecution } from "./QuickChatExecution"; @@ -120,6 +121,8 @@ export class Executor { intent.quickChatKey, intent.variables ?? {}, ); + case "mark_disconnected": + return new MarkDisconnectedExecution(player, intent.isDisconnected); default: throw new Error(`intent type ${intent} not found`); } diff --git a/src/core/execution/MarkDisconnectedExecution.ts b/src/core/execution/MarkDisconnectedExecution.ts new file mode 100644 index 000000000..95d530a4a --- /dev/null +++ b/src/core/execution/MarkDisconnectedExecution.ts @@ -0,0 +1,24 @@ +import { Execution, Game, Player } from "../game/Game"; + +export class MarkDisconnectedExecution implements Execution { + constructor( + private player: Player, + private isDisconnected: boolean, + ) {} + + init(mg: Game, ticks: number): void { + this.player.markDisconnected(this.isDisconnected); + } + + tick(ticks: number): void { + return; + } + + isActive(): boolean { + return false; + } + + activeDuringSpawnPhase(): boolean { + return false; + } +} diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index ec2851747..43433f403 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -433,6 +433,9 @@ export interface Player { largestClusterBoundingBox: { min: Cell; max: Cell } | null; lastTileChange(): Tick; + isDisconnected(): boolean; + markDisconnected(isDisconnected: boolean): void; + hasSpawned(): boolean; setHasSpawned(hasSpawned: boolean): void; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index b238365d0..7078d6b9f 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -103,6 +103,7 @@ export interface PlayerUpdate { smallID: number; playerType: PlayerType; isAlive: boolean; + isDisconnected: boolean; tilesOwned: number; gold: Gold; population: number; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 5358bc568..aaaaa591c 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -289,6 +289,9 @@ export class PlayerView { hasSpawned(): boolean { return this.data.hasSpawned; } + isDisconnected(): boolean { + return this.data.isDisconnected; + } } export class GameView implements GameMap { diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index a055799d5..2b4146b10 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -98,6 +98,7 @@ export class PlayerImpl implements Player { public _outgoingLandAttacks: Attack[] = []; private _hasSpawned = false; + private _isDisconnected = false; constructor( private mg: GameImpl, @@ -135,6 +136,7 @@ export class PlayerImpl implements Player { smallID: this.smallID(), playerType: this.type(), isAlive: this.isAlive(), + isDisconnected: this.isDisconnected(), tilesOwned: this.numTilesOwned(), gold: this._gold, population: this.population(), @@ -919,6 +921,14 @@ export class PlayerImpl implements Player { return this._lastTileChange; } + isDisconnected(): boolean { + return this._isDisconnected; + } + + markDisconnected(isDisconnected: boolean): void { + this._isDisconnected = isDisconnected; + } + hash(): number { return ( simpleHash(this.id()) * (this.population() + this.numTilesOwned()) + diff --git a/src/server/Client.ts b/src/server/Client.ts index 295ca499e..c367c0e04 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -4,7 +4,8 @@ import { Tick } from "../core/game/Game"; import { ClientID } from "../core/Schemas"; export class Client { - public lastPing: number; + public lastPing: number = Date.now(); + public isDisconnected: boolean = false; public hashes: Map = new Map(); diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 5754ad218..c74b0d8e4 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -35,6 +35,8 @@ export class GameServer { private maxGameDuration = 3 * 60 * 60 * 1000; // 3 hours + private disconnectedTimeout = 1 * 30 * 1000; // 30 seconds + private turns: Turn[] = []; private intents: Intent[] = []; public activeClients: Client[] = []; @@ -164,6 +166,10 @@ export class GameServer { }); return; } + + client.isDisconnected = existing.isDisconnected; + client.lastPing = existing.lastPing; + existing.ws.removeAllListeners("message"); this.activeClients = this.activeClients.filter((c) => c !== existing); } @@ -195,6 +201,12 @@ export class GameServer { ); return; } + if (clientMsg.intent.type === "mark_disconnected") { + this.log.warn( + `Should not receive mark_disconnected intent from client`, + ); + return; + } this.addIntent(clientMsg.intent); } if (clientMsg.type === "ping") { @@ -357,6 +369,7 @@ export class GameServer { this.intents = []; this.handleSynchronization(); + this.checkDisconnectedStatus(); let msg = ""; try { @@ -537,6 +550,36 @@ export class GameServer { } } + private checkDisconnectedStatus() { + if (this.turns.length % 5 !== 0) { + return; + } + + const now = Date.now(); + for (const [clientID, client] of this.allClients) { + if ( + client.isDisconnected === false && + now - client.lastPing > this.disconnectedTimeout + ) { + this.markClientDisconnected(client, true); + } else if ( + client.isDisconnected && + now - client.lastPing < this.disconnectedTimeout + ) { + this.markClientDisconnected(client, false); + } + } + } + + private markClientDisconnected(client: Client, isDisconnected: boolean) { + client.isDisconnected = isDisconnected; + this.addIntent({ + type: "mark_disconnected", + clientID: client.clientID, + isDisconnected: isDisconnected, + }); + } + private archiveGame() { this.log.info("archiving game", { gameID: this.id, diff --git a/tests/Disconnected.test.ts b/tests/Disconnected.test.ts new file mode 100644 index 000000000..1932500be --- /dev/null +++ b/tests/Disconnected.test.ts @@ -0,0 +1,163 @@ +import { MarkDisconnectedExecution } from "../src/core/execution/MarkDisconnectedExecution"; +import { SpawnExecution } from "../src/core/execution/SpawnExecution"; +import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game"; +import { setup } from "./util/Setup"; +import { executeTicks } from "./util/utils"; + +let game: Game; +let player1: Player; +let player2: Player; + +describe("Disconnected", () => { + beforeEach(async () => { + game = await setup("Plains", { + infiniteGold: true, + instantBuild: true, + }); + + const player1Info = new PlayerInfo( + "us", + "Active Player", + PlayerType.Human, + null, + "player1_id", + ); + + const player2Info = new PlayerInfo( + "fr", + "Disconnected Player", + PlayerType.Human, + null, + "player2_id", + ); + + player1 = game.addPlayer(player1Info); + player2 = game.addPlayer(player2Info); + + game.addExecution( + new SpawnExecution(player1Info, game.ref(1, 1)), + new SpawnExecution(player2Info, game.ref(7, 7)), + ); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + }); + + describe("Player disconnected state", () => { + test("should initialize players as not disconnected", () => { + expect(player1.isDisconnected()).toBe(false); + expect(player2.isDisconnected()).toBe(false); + }); + + test("should mark player as disconnected and not disconnected", () => { + player1.markDisconnected(true); + expect(player1.isDisconnected()).toBe(true); + + player1.markDisconnected(false); + expect(player1.isDisconnected()).toBe(false); + }); + + test("should include disconnected state in player update", () => { + player1.markDisconnected(true); + const update = player1.toUpdate(); + expect(update.isDisconnected).toBe(true); + }); + }); + + describe("Player view", () => { + test("should reflect disconnected state in player view", () => { + // Mark player2 as disconnected + player2.markDisconnected(true); + + // Get player1's view of player2 + const player2View = game.player(player2.id()); + expect(player2View.isDisconnected()).toBe(true); + + // Mark player2 as connected again + player2.markDisconnected(false); + + // Verify the view is updated + const updatedPlayer2View = game.player(player2.id()); + expect(updatedPlayer2View.isDisconnected()).toBe(false); + }); + + test("should maintain disconnected state in view across game ticks", () => { + player2.markDisconnected(true); + executeTicks(game, 3); + + const player2View = game.player(player2.id()); + expect(player2View.isDisconnected()).toBe(true); + }); + }); + + describe("MarkDisconnectedExecution", () => { + test("should mark player as disconnected when executed", () => { + const execution = new MarkDisconnectedExecution(player1, true); + game.addExecution(execution); + executeTicks(game, 1); + expect(player1.isDisconnected()).toBe(true); + expect(execution.isActive()).toBe(false); + }); + + test("should handle multiple players with different disconnected states", () => { + const execution1 = new MarkDisconnectedExecution(player1, true); + const execution2 = new MarkDisconnectedExecution(player2, false); + game.addExecution(execution1, execution2); + executeTicks(game, 1); + expect(player1.isDisconnected()).toBe(true); + expect(player2.isDisconnected()).toBe(false); + }); + + test("should not be active during spawn phase", () => { + const execution = new MarkDisconnectedExecution(player1, true); + expect(execution.activeDuringSpawnPhase()).toBe(false); + }); + + test("should handle multiple executions for same player in same tick", () => { + const execution1 = new MarkDisconnectedExecution(player1, true); + const execution2 = new MarkDisconnectedExecution(player1, false); + game.addExecution(execution1, execution2); + executeTicks(game, 1); + // Last execution should win + expect(player1.isDisconnected()).toBe(false); + }); + }); + + describe("Disconnected state persistence", () => { + test("should maintain disconnected state across game ticks", () => { + player1.markDisconnected(true); + executeTicks(game, 5); + expect(player1.isDisconnected()).toBe(true); + }); + + test("should maintain disconnected state in player updates across ticks", () => { + player1.markDisconnected(true); + executeTicks(game, 3); + const update = player1.toUpdate(); + expect(update.isDisconnected).toBe(true); + }); + }); + + describe("Edge cases", () => { + test("should handle marking same disconnected state multiple times", () => { + player1.markDisconnected(true); + player1.markDisconnected(true); + player1.markDisconnected(true); + expect(player1.isDisconnected()).toBe(true); + + player1.markDisconnected(false); + player1.markDisconnected(false); + player1.markDisconnected(false); + expect(player1.isDisconnected()).toBe(false); + }); + + test("should handle execution with same disconnected state", () => { + player1.markDisconnected(true); + const execution = new MarkDisconnectedExecution(player1, true); + game.addExecution(execution); + executeTicks(game, 1); + expect(player1.isDisconnected()).toBe(true); + }); + }); +}); From 49b01d801476c0d10a1e6f80d8424bc6ccca3a04 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Fri, 6 Jun 2025 13:43:21 -0700 Subject: [PATCH 76/84] have master create tunnels for all workers #780 (#1042) ## Description: We want to move away from using nginx to cloudflare to route among workers. This will simplify the nginx config, move routing computation off the server, and make it easier to implement a multi-host architecture. The worker tunnels are not currently used. I also moved the tunnel creation from startup.sh to Server. The shell script was getting too complex. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- package-lock.json | 8 + package.json | 1 + src/core/configuration/Config.ts | 4 + src/core/configuration/DefaultConfig.ts | 12 + src/server/Cloudflare.ts | 280 ++++++++++++++++++++++++ src/server/Server.ts | 35 +++ startup.sh | 105 --------- supervisord.conf | 9 +- tests/util/TestServerConfig.ts | 12 + 9 files changed, 353 insertions(+), 113 deletions(-) create mode 100644 src/server/Cloudflare.ts diff --git a/package-lock.json b/package-lock.json index 642cf039e..faad9e243 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,6 +84,7 @@ "@types/d3": "^7.4.3", "@types/jest": "^29.5.12", "@types/jquery": "^3.5.31", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.2", "@types/pg": "^8.11.11", "@types/sinon": "^17.0.3", @@ -8263,6 +8264,13 @@ "@types/sizzle": "*" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", diff --git a/package.json b/package.json index eedac982d..6eb96b8c3 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@types/d3": "^7.4.3", "@types/jest": "^29.5.12", "@types/jquery": "^3.5.31", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.2", "@types/pg": "^8.11.11", "@types/sinon": "^17.0.3", diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index e6f4af76f..07ba46e80 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -56,6 +56,10 @@ export interface ServerConfig { jwtAudience(): string; jwtIssuer(): string; jwkPublicKey(): Promise; + domain(): string; + subdomain(): string; + cloudflareAccountId(): string; + cloudflareApiToken(): string; } export interface NukeMagnitude { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 49c3b5730..e8bad94ed 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -66,6 +66,18 @@ const numPlayersConfig = { } as const satisfies Record; export abstract class DefaultServerConfig implements ServerConfig { + domain(): string { + return process.env.DOMAIN ?? ""; + } + subdomain(): string { + return process.env.SUBDOMAIN ?? ""; + } + cloudflareAccountId(): string { + return process.env.CF_ACCOUNT_ID ?? ""; + } + cloudflareApiToken(): string { + return process.env.CF_API_TOKEN ?? ""; + } private publicKey: JWK; abstract jwtAudience(): string; jwtIssuer(): string { diff --git a/src/server/Cloudflare.ts b/src/server/Cloudflare.ts new file mode 100644 index 000000000..aef377fc3 --- /dev/null +++ b/src/server/Cloudflare.ts @@ -0,0 +1,280 @@ +import { spawn } from "child_process"; +import { promises as fs } from "fs"; +import yaml from "js-yaml"; +import { homedir } from "os"; +import { join } from "path"; +import { logger } from "./Logger"; + +const log = logger.child({ + module: "cloudflare", +}); + +export interface TunnelConfig { + domain: string; + subdomain: string; + subdomainToService: Map; +} + +interface TunnelResponse { + result: { + id: string; + token: string; + }; +} + +interface ZoneResponse { + result: Array<{ + id: string; + }>; +} + +interface DNSRecordResponse { + result: Array<{ + id: string; + }>; +} + +interface CloudflaredConfig { + tunnel: string; + credentials_file: string; + ingress: Array<{ + hostname?: string; + service: string; + }>; +} + +export class Cloudflare { + private baseUrl = "https://api.cloudflare.com/client/v4"; + private configDir: string; + + constructor( + private accountId: string, + private apiToken: string, + configDir: string = "~/.cloudflared", + ) { + this.configDir = configDir.startsWith("~") + ? join(homedir(), configDir.slice(1)) + : configDir; + + log.info(`Using config directory: ${this.configDir}`); + } + + private async makeRequest( + url: string, + method: string = "GET", + data?: any, + ): Promise { + const response = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${this.apiToken}`, + "Content-Type": "application/json", + }, + body: data ? JSON.stringify(data) : undefined, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Cloudflare API error: url ${url} ${response.status} - ${errorText}`, + ); + } + + return response.json() as Promise; + } + + public async createTunnel(config: TunnelConfig): Promise<{ + tunnelId: string; + tunnelToken: string; + tunnelUrl: string; + configPath: string; + }> { + const { domain, subdomain, subdomainToService } = config; + + // Generate unique tunnel name + const timestamp = new Date().toISOString().replace(/[-:.]/g, ""); + const tunnelName = `${subdomain}-tunnel-${timestamp}`; + + log.info(`Creating tunnel with name: ${tunnelName}`); + + // Create tunnel via API to get official tunnel ID and token + const tunnelResponse = await this.makeRequest( + `${this.baseUrl}/accounts/${this.accountId}/cfd_tunnel`, + "POST", + { name: tunnelName }, + ); + + const tunnelId = tunnelResponse.result.id; + const tunnelToken = tunnelResponse.result.token; + + if (!tunnelId) { + throw new Error("Failed to create tunnel"); + } + + log.info(`Tunnel created with ID: ${tunnelId}`); + + // Create local config file instead of using API configuration + const configPath = await this.writeTunnelConfig( + tunnelId, + tunnelToken, + subdomain, + domain, + subdomainToService, + tunnelName, + ); + + // Get zone ID + const zoneResponse = await this.makeRequest( + `${this.baseUrl}/zones?name=${domain}`, + ); + + const zoneId = zoneResponse.result[0]?.id; + if (!zoneId) { + throw new Error(`Could not find zone ID for domain ${domain}`); + } + + await Promise.all( + Array.from(subdomainToService.entries()).map(([subdomain, _]) => + this.updateDNSRecord(zoneId, tunnelId, subdomain, domain), + ), + ); + + const tunnelUrl = `https://${subdomain}.${domain}`; + log.info(`Tunnel is set up! Site will be available at: ${tunnelUrl}`); + + return { tunnelId, tunnelToken, tunnelUrl, configPath }; + } + + private async writeTunnelConfig( + tunnelId: string, + tunnelToken: string, + subdomain: string, + domain: string, + subdomainToService: Map, + tunnelName: string, + ): Promise { + log.info(`Creating local config for tunnel ${subdomain}.${domain}...`); + + // Ensure config directory exists + await fs.mkdir(this.configDir, { recursive: true }); + + const configPath = join(this.configDir, `${tunnelName}.yml`); + const credentialsFile = join(this.configDir, `${tunnelId}.json`); + + const tokenData = JSON.parse( + Buffer.from(tunnelToken, "base64").toString("utf8"), + ); + + const credentials = { + AccountTag: tokenData.a || this.accountId, + TunnelID: tokenData.t || tunnelId, + TunnelName: tunnelName, + TunnelSecret: tokenData.s, + }; + + await fs.writeFile( + credentialsFile, + JSON.stringify(credentials, null, 2), + "utf8", + ); + log.info(`Created credentials file at: ${credentialsFile}`); + + const tunnelConfig: CloudflaredConfig = { + tunnel: tunnelId, + credentials_file: credentialsFile, + ingress: [ + ...Array.from(subdomainToService.entries()).map( + ([subdomain, service]) => ({ + hostname: `${subdomain}.${domain}`, + service: service, + }), + ), + { + service: "http_status:404", + }, + ], + }; + + // Write config file + await fs.writeFile(configPath, yaml.dump(tunnelConfig), "utf8"); + log.info(`Created config file at: ${configPath}`); + + return configPath; + } + + private async updateDNSRecord( + zoneId: string, + tunnelId: string, + subdomain: string, + domain: string, + ): Promise { + const existingRecords = await this.makeRequest( + `${this.baseUrl}/zones/${zoneId}/dns_records?name=${subdomain}.${domain}`, + ); + + const recordId = existingRecords.result[0]?.id; + const dnsData = { + type: "CNAME", + name: subdomain, + content: `${tunnelId}.cfargotunnel.com`, + ttl: 1, + proxied: true, + }; + + if (recordId) { + log.info(`Updating existing DNS record for ${subdomain}.${domain}...`); + await this.makeRequest( + `${this.baseUrl}/zones/${zoneId}/dns_records/${recordId}`, + "PUT", + dnsData, + ); + } else { + log.info(`Creating new DNS record for ${subdomain}.${domain}...`); + await this.makeRequest( + `${this.baseUrl}/zones/${zoneId}/dns_records`, + "POST", + dnsData, + ); + } + } + + public async startCloudflared(configPath: string) { + const cloudflared = spawn( + "cloudflared", + ["tunnel", "--config", configPath, "--loglevel", "error", "run"], + { + detached: true, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + // Set this to bypass origin cert requirement for named tunnels + TUNNEL_ORIGIN_CERT: "/dev/null", + }, + }, + ); + + cloudflared.stdout?.on("data", (data) => { + log.info(data.toString().trim()); + }); + cloudflared.stderr?.on("data", (data) => { + log.error(data.toString().trim()); + }); + + cloudflared.on("error", (error) => { + log.error("Failed to start cloudflared", { + error: error.message, + }); + }); + + cloudflared.on("exit", (code, signal) => { + if (code !== null) { + log.error(`Cloudflared exited with code ${code}`, { + exitCode: code, + signal, + }); + } + }); + + cloudflared.unref(); + } +} diff --git a/src/server/Server.ts b/src/server/Server.ts index 59468c10b..4b98e32aa 100644 --- a/src/server/Server.ts +++ b/src/server/Server.ts @@ -1,14 +1,22 @@ import cluster from "cluster"; import * as dotenv from "dotenv"; +import { GameEnv } from "../core/configuration/Config"; +import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; +import { Cloudflare, TunnelConfig } from "./Cloudflare"; import { startMaster } from "./Master"; import { startWorker } from "./Worker"; +const config = getServerConfigFromServer(); + dotenv.config(); // Main entry point of the application async function main() { // Check if this is the primary (master) process if (cluster.isPrimary) { + if (config.env() !== GameEnv.Dev) { + await setupTunnels(); + } console.log("Starting master process..."); await startMaster(); } else { @@ -23,3 +31,30 @@ main().catch((error) => { console.error("Failed to start server:", error); process.exit(1); }); + +async function setupTunnels() { + const cloudflare = new Cloudflare( + config.cloudflareAccountId(), + config.cloudflareApiToken(), + ); + + const domainToService = new Map().set( + config.subdomain(), + `http://localhost:3000`, + ); + + for (let i = 0; i < config.numWorkers(); i++) { + domainToService.set( + `w${i}-${config.subdomain()}`, + `http://localhost:${3000 + i + 1}`, + ); + } + + const tunnel = await cloudflare.createTunnel({ + subdomain: config.subdomain(), + domain: config.domain(), + subdomainToService: domainToService, + } as TunnelConfig); + + await cloudflare.startCloudflared(tunnel.configPath); +} diff --git a/startup.sh b/startup.sh index 052c3b200..28903d067 100644 --- a/startup.sh +++ b/startup.sh @@ -1,110 +1,5 @@ #!/bin/bash set -e - -# Check if required environment variables are set -if [ -z "$CF_API_TOKEN" ] || [ -z "$CF_ACCOUNT_ID" ] || [ -z "$SUBDOMAIN" ] || [ -z "$DOMAIN" ]; then - echo "Error: Required environment variables not set" - echo "Please set CF_API_TOKEN, CF_ACCOUNT_ID, SUBDOMAIN, and DOMAIN" - exit 1 -fi - -# Generate a unique tunnel name using timestamp -TIMESTAMP=$(date +%Y%m%d%H%M%S) -TUNNEL_NAME="${SUBDOMAIN}-tunnel-${TIMESTAMP}" -echo "Using unique tunnel name: ${TUNNEL_NAME}" - -# Create a new tunnel -echo "Creating Cloudflare tunnel for subdomain ${SUBDOMAIN}..." -TUNNEL_RESPONSE=$(curl -s -X POST "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel" \ - -H "Authorization: Bearer ${CF_API_TOKEN}" \ - -H "Content-Type: application/json" \ - --data "{\"name\":\"${TUNNEL_NAME}\"}") - -# Extract tunnel ID and token -TUNNEL_ID=$(echo $TUNNEL_RESPONSE | jq -r '.result.id') -TUNNEL_TOKEN=$(echo $TUNNEL_RESPONSE | jq -r '.result.token') - -if [ -z "$TUNNEL_ID" ] || [ "$TUNNEL_ID" == "null" ]; then - echo "Failed to create tunnel" - echo $TUNNEL_RESPONSE - exit 1 -fi - -echo "Tunnel created with ID: ${TUNNEL_ID}" - -# Configure the tunnel with hostname -echo "Configuring tunnel to point to ${SUBDOMAIN}.${DOMAIN}..." -curl -s -X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}/configurations" \ - -H "Authorization: Bearer ${CF_API_TOKEN}" \ - -H "Content-Type: application/json" \ - --data "{\"config\":{\"ingress\":[{\"hostname\":\"${SUBDOMAIN}.${DOMAIN}\",\"service\":\"http://localhost:80\"},{\"service\":\"http_status:404\"}]}}" - -# Update DNS record to point to the new tunnel -echo "Updating DNS record to point to the new tunnel..." - -# First check if DNS record exists -DNS_RECORDS=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=${DOMAIN}" \ - -H "Authorization: Bearer ${CF_API_TOKEN}" \ - -H "Content-Type: application/json") - -ZONE_ID=$(echo $DNS_RECORDS | jq -r '.result[0].id') - -if [ -z "$ZONE_ID" ] || [ "$ZONE_ID" == "null" ]; then - echo "Could not find zone ID for domain ${DOMAIN}" - exit 1 -fi - -# Check for existing record -EXISTING_RECORDS=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?name=${SUBDOMAIN}.${DOMAIN}" \ - -H "Authorization: Bearer ${CF_API_TOKEN}" \ - -H "Content-Type: application/json") - -RECORD_ID=$(echo $EXISTING_RECORDS | jq -r '.result[0].id') - -# Create or update the DNS record -if [ -z "$RECORD_ID" ] || [ "$RECORD_ID" == "null" ]; then - # Create new record - echo "Creating new DNS record..." - DNS_RESPONSE=$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \ - -H "Authorization: Bearer ${CF_API_TOKEN}" \ - -H "Content-Type: application/json" \ - --data "{\"type\":\"CNAME\",\"name\":\"${SUBDOMAIN}\",\"content\":\"${TUNNEL_ID}.cfargotunnel.com\",\"ttl\":1,\"proxied\":true}") -else - # Update existing record - echo "Updating existing DNS record..." - DNS_RESPONSE=$(curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \ - -H "Authorization: Bearer ${CF_API_TOKEN}" \ - -H "Content-Type: application/json" \ - --data "{\"type\":\"CNAME\",\"name\":\"${SUBDOMAIN}\",\"content\":\"${TUNNEL_ID}.cfargotunnel.com\",\"ttl\":1,\"proxied\":true}") -fi - -# Log the tunnel information -echo "Tunnel is set up! Site will be available at: https://${SUBDOMAIN}.${DOMAIN}" - -# Export the tunnel token for supervisord -export CLOUDFLARE_TUNNEL_TOKEN=${TUNNEL_TOKEN} - -# Check if Basic Auth credentials are set -if [ -z "$BASIC_AUTH_USER" ] || [ -z "$BASIC_AUTH_PASS" ]; then - echo "HTTP Basic Authentication will be disabled" -else - # Create the htpasswd file - echo "Creating basic auth credentials for user: ${BASIC_AUTH_USER}" - # Ensure apache2-utils is installed for htpasswd - command -v htpasswd > /dev/null 2>&1 || { - echo "htpasswd not found, installing apache2-utils..." - apt-get update && apt-get install -y apache2-utils - } - # Create the password file - htpasswd -bc /etc/nginx/.htpasswd ${BASIC_AUTH_USER} ${BASIC_AUTH_PASS} - - # Update Nginx configuration to enable Basic Auth - sed -i '1i auth_basic "Restricted Access";' /etc/nginx/conf.d/default.conf - sed -i '2i auth_basic_user_file /etc/nginx/.htpasswd;' /etc/nginx/conf.d/default.conf - - echo "HTTP Basic Authentication enabled for user: ${BASIC_AUTH_USER}" -fi - # Start supervisord if [ "$DOMAIN" = openfront.dev ] && [ "$SUBDOMAIN" != main ]; then exec timeout 18h /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf diff --git a/supervisord.conf b/supervisord.conf index 61b2aec3a..c31d0429c 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -22,11 +22,4 @@ user=node stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 - -[program:cloudflared] -command=cloudflared tunnel run --token %(ENV_CLOUDFLARE_TUNNEL_TOKEN)s -autostart=true -autorestart=true -stdout_logfile=/var/log/cloudflared.log -stderr_logfile=/var/log/cloudflared-err.log \ No newline at end of file +stderr_logfile_maxbytes=0 \ No newline at end of file diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 4c1f76eb4..7f6d88d30 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -4,6 +4,18 @@ import { GameMapType } from "../../src/core/game/Game"; import { GameID } from "../../src/core/Schemas"; export class TestServerConfig implements ServerConfig { + domain(): string { + throw new Error("Method not implemented."); + } + subdomain(): string { + throw new Error("Method not implemented."); + } + cloudflareAccountId(): string { + throw new Error("Method not implemented."); + } + cloudflareApiToken(): string { + throw new Error("Method not implemented."); + } jwtAudience(): string { throw new Error("Method not implemented."); } From 4367bacf710e8472c5f9f899113c7bb9e10bb5bb Mon Sep 17 00:00:00 2001 From: tnhnblgl <51187395+tnhnblgl@users.noreply.github.com> Date: Sat, 7 Jun 2025 00:11:39 +0300 Subject: [PATCH 77/84] Add Boat hotkey (#1060) ## Description: Pressing B to fast boat ![Screenshot_2025-06-06-09-32-53-865_com android chrome](https://github.com/user-attachments/assets/cdd4aefe-0088-4334-bf69-7cf3b32b6db6) ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: dovg --- resources/lang/en.json | 3 + src/client/ClientGameRunner.ts | 111 +++++++++++++++++++++++---------- src/client/InputHandler.ts | 8 +++ src/client/UserSettingModal.ts | 13 ++++ 4 files changed, 101 insertions(+), 34 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index d059c9261..d8f49b2de 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -257,6 +257,9 @@ "attack_ratio_up_desc": "Increase attack ratio by 10%", "attack_ratio_down": "Decrease Attack Ratio", "attack_ratio_down_desc": "Decrease attack ratio by 10%", + "attack_keybinds": "Attack Keybinds", + "boat_attack": "Boat Attack", + "boat_attack_desc": "Send a boat attack to the tile under your cursor.", "zoom_controls": "Zoom Controls", "zoom_out": "Zoom Out", "zoom_out_desc": "Zoom out the map", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 65ea5be59..1bde53319 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -12,7 +12,7 @@ import { import { createGameRecord } from "../core/Util"; import { ServerConfig } from "../core/configuration/Config"; import { getConfig } from "../core/configuration/ConfigLoader"; -import { Cell, UnitType } from "../core/game/Game"; +import { Cell, PlayerActions, UnitType } from "../core/game/Game"; import { TileRef } from "../core/game/GameMap"; import { ErrorUpdate, @@ -25,7 +25,12 @@ import { GameView, PlayerView } from "../core/game/GameView"; import { loadTerrainMap, TerrainMapData } from "../core/game/TerrainMapLoader"; import { UserSettings } from "../core/game/UserSettings"; import { WorkerClient } from "../core/worker/WorkerClient"; -import { InputHandler, MouseMoveEvent, MouseUpEvent } from "./InputHandler"; +import { + DoBoatAttackEvent, + InputHandler, + MouseMoveEvent, + MouseUpEvent, +} from "./InputHandler"; import { endGame, startGame, startTime } from "./LocalPersistantStats"; import { getPersistentID } from "./Main"; import { @@ -231,6 +236,7 @@ export class ClientGameRunner { }, 20000); this.eventBus.on(MouseUpEvent, (e) => this.inputEvent(e)); this.eventBus.on(MouseMoveEvent, (e) => this.onMouseMove(e)); + this.eventBus.on(DoBoatAttackEvent, (e) => this.doBoatAttackUnderCursor()); this.renderer.initialize(); this.input.initialize(); @@ -363,13 +369,6 @@ export class ClientGameRunner { } this.myPlayer.actions(tile).then((actions) => { if (this.myPlayer === null) return; - const bu = actions.buildableUnits.find( - (bu) => bu.type === UnitType.TransportShip, - ); - if (bu === undefined) { - console.warn(`no transport ship buildable units`); - return; - } if (actions.canAttack) { this.eventBus.emit( new SendAttackIntentEvent( @@ -377,31 +376,8 @@ export class ClientGameRunner { this.myPlayer.troops() * this.renderer.uiState.attackRatio, ), ); - } else if ( - bu.canBuild !== false && - this.shouldBoat(tile, bu.canBuild) && - this.gameView.isLand(tile) - ) { - this.myPlayer - .bestTransportShipSpawn(this.gameView.ref(cell.x, cell.y)) - .then((spawn: number | false) => { - if (this.myPlayer === null) throw new Error("not initialized"); - let spawnCell: Cell | null = null; - if (spawn !== false) { - spawnCell = new Cell( - this.gameView.x(spawn), - this.gameView.y(spawn), - ); - } - this.eventBus.emit( - new SendBoatAttackIntentEvent( - this.gameView.owner(tile).id(), - cell, - this.myPlayer.troops() * this.renderer.uiState.attackRatio, - spawnCell, - ), - ); - }); + } else if (this.canBoatAttack(actions, tile)) { + this.sendBoatAttackIntent(tile, cell); } const owner = this.gameView.owner(tile); @@ -413,6 +389,73 @@ export class ClientGameRunner { }); } + private doBoatAttackUnderCursor(): void { + if (!this.isActive || !this.lastMousePosition) { + return; + } + const cell = this.renderer.transformHandler.screenToWorldCoordinates( + this.lastMousePosition.x, + this.lastMousePosition.y, + ); + if (!this.gameView.isValidCoord(cell.x, cell.y)) { + return; + } + + const tile = this.gameView.ref(cell.x, cell.y); + if (this.gameView.inSpawnPhase()) { + return; + } + + if (this.myPlayer === null) { + const myPlayer = this.gameView.playerByClientID(this.lobby.clientID); + if (myPlayer === null) return; + this.myPlayer = myPlayer; + } + + this.myPlayer.actions(tile).then((actions) => { + if (!actions.canAttack && this.canBoatAttack(actions, tile)) { + this.sendBoatAttackIntent(tile, cell); + } + }); + } + + private canBoatAttack(actions: PlayerActions, tile: TileRef): boolean { + const bu = actions.buildableUnits.find( + (bu) => bu.type === UnitType.TransportShip, + ); + if (bu === undefined) { + console.warn(`no transport ship buildable units`); + return false; + } + return ( + bu.canBuild !== false && + this.shouldBoat(tile, bu.canBuild) && + this.gameView.isLand(tile) + ); + } + + private sendBoatAttackIntent(tile: TileRef, cell: Cell) { + if (!this.myPlayer) return; + + this.myPlayer + .bestTransportShipSpawn(this.gameView.ref(cell.x, cell.y)) + .then((spawn: number | false) => { + if (this.myPlayer === null) throw new Error("not initialized"); + let spawnCell: Cell | null = null; + if (spawn !== false) { + spawnCell = new Cell(this.gameView.x(spawn), this.gameView.y(spawn)); + } + this.eventBus.emit( + new SendBoatAttackIntentEvent( + this.gameView.owner(tile).id(), + cell, + this.myPlayer.troops() * this.renderer.uiState.attackRatio, + spawnCell, + ), + ); + }); + } + private shouldBoat(tile: TileRef, src: TileRef) { // TODO: Global enable flag // TODO: Global limit autoboat to nearby shore flag diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 47f57ae7a..597733705 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -76,6 +76,8 @@ export class ShowEmojiMenuEvent implements GameEvent { ) {} } +export class DoBoatAttackEvent implements GameEvent {} + export class AttackRatioEvent implements GameEvent { constructor(public readonly attackRatio: number) {} } @@ -124,6 +126,7 @@ export class InputHandler { zoomIn: "KeyE", attackRatioDown: "Digit1", attackRatioUp: "Digit2", + boatAttack: "KeyB", ...JSON.parse(localStorage.getItem("settings.keybinds") ?? "{}"), }; this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e)); @@ -242,6 +245,11 @@ export class InputHandler { this.eventBus.emit(new RefreshGraphicsEvent()); } + if (e.code === keybinds.boatAttack) { + e.preventDefault(); + this.eventBus.emit(new DoBoatAttackEvent()); + } + if (e.code === keybinds.attackRatioDown) { e.preventDefault(); this.eventBus.emit(new AttackRatioEvent(-10)); diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 405e24145..aca371552 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -367,6 +367,19 @@ export class UserSettingModal extends LitElement { @change=${this.handleKeybindChange} > +
    + ${translateText("user_setting.attack_keybinds")} +
    + + +
    ${translateText("user_setting.zoom_controls")}
    From 18c58903333f259cec3cf9fb4b9c7f45016d5a66 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Fri, 6 Jun 2025 17:44:36 -0400 Subject: [PATCH 78/84] bug: logout (#1073) Fixes #1069 ## Description: Fix logout bug by wrapping ternary expression. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/client/jwt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/jwt.ts b/src/client/jwt.ts index 985373ea2..9d76ff857 100644 --- a/src/client/jwt.ts +++ b/src/client/jwt.ts @@ -50,7 +50,7 @@ export async function logOut(allSessions: boolean = false) { __isLoggedIn = false; const response = await fetch( - getApiBase() + allSessions ? "/revoke" : "/logout", + getApiBase() + (allSessions ? "/revoke" : "/logout"), { method: "POST", headers: { From da93fe05ea97b33ad1548c96babbe2b2ad4610a5 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Fri, 6 Jun 2025 16:13:05 -0700 Subject: [PATCH 79/84] fix cloudflare tunnels (#1076) The Server didn't have correct permissions to create the directory for the cloudflare config. Have docker do it instead. Also the credentials file key was incorrect. --- Dockerfile | 3 +++ src/core/configuration/Config.ts | 1 + src/core/configuration/DefaultConfig.ts | 3 +++ src/server/Cloudflare.ts | 15 +++------------ src/server/Server.ts | 1 + tests/util/TestServerConfig.ts | 3 +++ 6 files changed, 14 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index 44870fbe8..513e5dfdb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,5 +59,8 @@ COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf COPY startup.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/startup.sh +RUN mkdir -p /tmp/.cloudflared && chmod 777 /tmp/.cloudflared +ENV CF_CONFIG_DIR=/tmp/.cloudflared + # Use the startup script as the entrypoint ENTRYPOINT ["/usr/local/bin/startup.sh"] diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 07ba46e80..69315c4db 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -60,6 +60,7 @@ export interface ServerConfig { subdomain(): string; cloudflareAccountId(): string; cloudflareApiToken(): string; + cloudflareConfigDir(): string; } export interface NukeMagnitude { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index e8bad94ed..b3daebb94 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -78,6 +78,9 @@ export abstract class DefaultServerConfig implements ServerConfig { cloudflareApiToken(): string { return process.env.CF_API_TOKEN ?? ""; } + cloudflareConfigDir(): string { + return process.env.CF_CONFIG_DIR ?? ""; + } private publicKey: JWK; abstract jwtAudience(): string; jwtIssuer(): string { diff --git a/src/server/Cloudflare.ts b/src/server/Cloudflare.ts index aef377fc3..ceea88fc3 100644 --- a/src/server/Cloudflare.ts +++ b/src/server/Cloudflare.ts @@ -1,7 +1,6 @@ import { spawn } from "child_process"; import { promises as fs } from "fs"; import yaml from "js-yaml"; -import { homedir } from "os"; import { join } from "path"; import { logger } from "./Logger"; @@ -36,7 +35,7 @@ interface DNSRecordResponse { interface CloudflaredConfig { tunnel: string; - credentials_file: string; + "credentials-file": string; ingress: Array<{ hostname?: string; service: string; @@ -45,17 +44,12 @@ interface CloudflaredConfig { export class Cloudflare { private baseUrl = "https://api.cloudflare.com/client/v4"; - private configDir: string; constructor( private accountId: string, private apiToken: string, - configDir: string = "~/.cloudflared", + private configDir: string, ) { - this.configDir = configDir.startsWith("~") - ? join(homedir(), configDir.slice(1)) - : configDir; - log.info(`Using config directory: ${this.configDir}`); } @@ -155,9 +149,6 @@ export class Cloudflare { ): Promise { log.info(`Creating local config for tunnel ${subdomain}.${domain}...`); - // Ensure config directory exists - await fs.mkdir(this.configDir, { recursive: true }); - const configPath = join(this.configDir, `${tunnelName}.yml`); const credentialsFile = join(this.configDir, `${tunnelId}.json`); @@ -181,7 +172,7 @@ export class Cloudflare { const tunnelConfig: CloudflaredConfig = { tunnel: tunnelId, - credentials_file: credentialsFile, + "credentials-file": credentialsFile, ingress: [ ...Array.from(subdomainToService.entries()).map( ([subdomain, service]) => ({ diff --git a/src/server/Server.ts b/src/server/Server.ts index 4b98e32aa..92cecff2f 100644 --- a/src/server/Server.ts +++ b/src/server/Server.ts @@ -36,6 +36,7 @@ async function setupTunnels() { const cloudflare = new Cloudflare( config.cloudflareAccountId(), config.cloudflareApiToken(), + config.cloudflareConfigDir(), ); const domainToService = new Map().set( diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 7f6d88d30..b6f6f8442 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -4,6 +4,9 @@ import { GameMapType } from "../../src/core/game/Game"; import { GameID } from "../../src/core/Schemas"; export class TestServerConfig implements ServerConfig { + cloudflareConfigDir(): string { + throw new Error("Method not implemented."); + } domain(): string { throw new Error("Method not implemented."); } From 5a617b5481c04e64f879f96bc0783b7e79436388 Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Sat, 7 Jun 2025 01:50:15 +0200 Subject: [PATCH 80/84] Duo partner SP always same: randomize players before team assignment (#1051) ## Description: See report here: https://discord.com/channels/1284581928254701718/1378762423175221319 In Singleplayer Teams > Duos, your duo partner will be the same every time. In World map for example, it will always Norway. Fix: randomizing Nation players before their team assignment. This is done for all Team modes in Singleplayer and Multiplayer. Other behaviour is unchanged. Clan-players keep their own assignment logic, and Human non-clan player assignment stays the same. The human in singleplayer Duos mode lands in Team 1 everytime but with a different Nation as other team member. BEFORE ![Before SP Duos modal](https://github.com/user-attachments/assets/78628b06-d621-4203-86ae-046e081a46fb) ![Before SP Duos always Team 1](https://github.com/user-attachments/assets/6e7ec19c-b1e5-4642-beb9-93b723a27618) ![Before SP Duos always Team 1 always Norway team member](https://github.com/user-attachments/assets/34794d16-ce90-43ca-a5c2-9827e7c944f5) AFTER ![After SP Duos Team 1 random duo partner](https://github.com/user-attachments/assets/5a1d7d6a-74f2-472f-a0ff-ad94e9ae80bd) ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: tryout33 --- src/core/game/TeamAssignment.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/core/game/TeamAssignment.ts b/src/core/game/TeamAssignment.ts index 8cd09f9b6..1ec1bb566 100644 --- a/src/core/game/TeamAssignment.ts +++ b/src/core/game/TeamAssignment.ts @@ -1,4 +1,6 @@ -import { PlayerInfo, Team } from "./Game"; +import { PseudoRandom } from "../PseudoRandom"; +import { simpleHash } from "../Util"; +import { PlayerInfo, PlayerType, Team } from "./Game"; export function assignTeams( players: PlayerInfo[], @@ -56,7 +58,19 @@ export function assignTeams( } // Then, assign non-clan players to balance teams - for (const player of noClanPlayers) { + let nationPlayers = noClanPlayers.filter( + (player) => player.playerType === PlayerType.FakeHuman, + ); + if (nationPlayers.length > 0) { + // Shuffle only nations to randomize their team assignment + const random = new PseudoRandom(simpleHash(nationPlayers[0].id)); + nationPlayers = random.shuffleArray(nationPlayers); + } + const otherPlayers = noClanPlayers.filter( + (player) => player.playerType !== PlayerType.FakeHuman, + ); + + for (const player of otherPlayers.concat(nationPlayers)) { let team: Team | null = null; let teamSize = 0; for (const t of teams) { From 871d8c499ccbcd5533e1dcaf9e252539a6e75260 Mon Sep 17 00:00:00 2001 From: oleksandr-shysh Date: Sat, 7 Jun 2025 03:04:24 +0300 Subject: [PATCH 81/84] Multi-level radial menu (#1018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: - Refactored the radial menu to enable multi-level functionality. - Organized the actions into submenus. Знімок екрана 2025-06-03 о 16 33 24 Знімок екрана 2025-06-03 о 16 34 17 Знімок екрана 2025-06-03 о 16 40 22 Знімок екрана 2025-06-03 о 16 37 04 Знімок екрана 2025-06-03 о 16 36 32 ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: oleksandr037617_47021 --------- Co-authored-by: Oleksandr Shysh Co-authored-by: evanpelle --- resources/images/BackIconWhite.svg | 11 + src/client/graphics/GameRenderer.ts | 4 +- src/client/graphics/layers/BuildMenu.ts | 14 +- src/client/graphics/layers/ChatIntegration.ts | 102 ++ src/client/graphics/layers/ChatModal.ts | 50 +- src/client/graphics/layers/MainRadialMenu.ts | 285 ++++ .../graphics/layers/MenuEventManager.ts | 185 +++ .../graphics/layers/PlayerActionHandler.ts | 109 ++ src/client/graphics/layers/PlayerPanel.ts | 2 +- src/client/graphics/layers/RadialMenu.ts | 1439 +++++++++++------ .../graphics/layers/RadialMenuElements.ts | 471 ++++++ 11 files changed, 2194 insertions(+), 478 deletions(-) create mode 100644 resources/images/BackIconWhite.svg create mode 100644 src/client/graphics/layers/ChatIntegration.ts create mode 100644 src/client/graphics/layers/MainRadialMenu.ts create mode 100644 src/client/graphics/layers/MenuEventManager.ts create mode 100644 src/client/graphics/layers/PlayerActionHandler.ts create mode 100644 src/client/graphics/layers/RadialMenuElements.ts diff --git a/resources/images/BackIconWhite.svg b/resources/images/BackIconWhite.svg new file mode 100644 index 000000000..651867162 --- /dev/null +++ b/resources/images/BackIconWhite.svg @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index c94a2bbc6..ee3bbc4b9 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -14,13 +14,13 @@ import { FxLayer } from "./layers/FxLayer"; import { HeadsUpMessage } from "./layers/HeadsUpMessage"; import { Layer } from "./layers/Layer"; import { Leaderboard } from "./layers/Leaderboard"; +import { MainRadialMenu } from "./layers/MainRadialMenu"; import { MultiTabModal } from "./layers/MultiTabModal"; import { NameLayer } from "./layers/NameLayer"; import { OptionsMenu } from "./layers/OptionsMenu"; import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay"; import { PlayerPanel } from "./layers/PlayerPanel"; import { PlayerTeamLabel } from "./layers/PlayerTeamLabel"; -import { RadialMenu } from "./layers/RadialMenu"; import { SpawnTimer } from "./layers/SpawnTimer"; import { StructureLayer } from "./layers/StructureLayer"; import { TeamStats } from "./layers/TeamStats"; @@ -199,7 +199,7 @@ export function createRenderer( eventsDisplay, chatDisplay, buildMenu, - new RadialMenu( + new MainRadialMenu( eventBus, game, transformHandler, diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index 70f28ea65..f1ab5973b 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -19,7 +19,7 @@ import { BuildUnitIntentEvent } from "../../Transport"; import { renderNumber } from "../../Utils"; import { Layer } from "./Layer"; -interface BuildItemDisplay { +export interface BuildItemDisplay { unitType: UnitType; icon: string; description?: string; @@ -27,7 +27,7 @@ interface BuildItemDisplay { countable?: boolean; } -const buildTable: BuildItemDisplay[][] = [ +export const buildTable: BuildItemDisplay[][] = [ [ { unitType: UnitType.AtomBomb, @@ -96,12 +96,14 @@ const buildTable: BuildItemDisplay[][] = [ ], ]; +export const flattenedBuildTable = buildTable.flat(); + @customElement("build-menu") export class BuildMenu extends LitElement implements Layer { public game: GameView; public eventBus: EventBus; private clickedTile: TileRef; - private playerActions: PlayerActions | null; + public playerActions: PlayerActions | null; private filteredBuildTable: BuildItemDisplay[][] = buildTable; tick() { @@ -302,7 +304,7 @@ export class BuildMenu extends LitElement implements Layer { @state() private _hidden = true; - private canBuild(item: BuildItemDisplay): boolean { + public canBuild(item: BuildItemDisplay): boolean { if (this.game?.myPlayer() === null || this.playerActions === null) { return false; } @@ -314,7 +316,7 @@ export class BuildMenu extends LitElement implements Layer { return unit[0].canBuild !== false; } - private cost(item: BuildItemDisplay): Gold { + public cost(item: BuildItemDisplay): Gold { for (const bu of this.playerActions?.buildableUnits ?? []) { if (bu.type === item.unitType) { return bu.cost; @@ -323,7 +325,7 @@ export class BuildMenu extends LitElement implements Layer { return 0n; } - private count(item: BuildItemDisplay): string { + public count(item: BuildItemDisplay): string { const player = this.game?.myPlayer(); if (!player) { return "?"; diff --git a/src/client/graphics/layers/ChatIntegration.ts b/src/client/graphics/layers/ChatIntegration.ts new file mode 100644 index 000000000..044b0b18f --- /dev/null +++ b/src/client/graphics/layers/ChatIntegration.ts @@ -0,0 +1,102 @@ +import { EventBus } from "../../../core/EventBus"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +import { SendQuickChatEvent } from "../../Transport"; +import { translateText } from "../../Utils"; +import { ChatModal, QuickChatPhrase, quickChatPhrases } from "./ChatModal"; +import { COLORS, MenuElement } from "./RadialMenuElements"; + +export class ChatIntegration { + private ctModal: ChatModal; + + constructor( + private game: GameView, + private eventBus: EventBus, + ) { + this.ctModal = document.querySelector("chat-modal") as ChatModal; + + if (!this.ctModal) { + throw new Error( + "Chat modal element not found. Ensure chat-modal element exists in DOM before initializing ChatIntegration", + ); + } + } + + setupChatModal(sender: PlayerView, recipient: PlayerView) { + this.ctModal.setSender(sender); + this.ctModal.setRecipient(recipient); + } + + createQuickChatMenu(recipient: PlayerView): MenuElement[] { + if (!this.ctModal) { + throw new Error("Chat modal not set"); + } + + const myPlayer = this.game.myPlayer(); + if (!myPlayer) { + throw new Error("Current player not found"); + } + + return this.ctModal.categories.map((category) => { + const categoryTranslation = translateText(`chat.cat.${category.id}`); + + const categoryColor = + COLORS.chat[category.id as keyof typeof COLORS.chat] || + COLORS.chat.default; + const phrases = quickChatPhrases[category.id] || []; + + const phraseItems: MenuElement[] = phrases.map( + (phrase: QuickChatPhrase) => { + const phraseText = translateText(`chat.${category.id}.${phrase.key}`); + + return { + id: `phrase-${category.id}-${phrase.key}`, + name: phraseText, + disabled: false, + text: this.shortenText(phraseText), + fontSize: "10px", + color: categoryColor, + tooltipItems: [ + { + text: phraseText, + className: "description", + }, + ], + action: () => { + if (phrase.requiresPlayer) { + this.ctModal.openWithSelection( + category.id, + phrase.key, + myPlayer, + recipient, + ); + } else { + this.eventBus.emit( + new SendQuickChatEvent( + recipient, + `${category.id}.${phrase.key}`, + {}, + ), + ); + } + }, + }; + }, + ); + + return { + id: `chat-category-${category.id}`, + name: categoryTranslation, + disabled: false, + text: categoryTranslation, + color: categoryColor, + _action: () => {}, // Empty action placeholder for RadialMenu + subMenu: () => phraseItems, + }; + }); + } + + shortenText(text: string, maxLength = 15): string { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength - 3) + "..."; + } +} diff --git a/src/client/graphics/layers/ChatModal.ts b/src/client/graphics/layers/ChatModal.ts index 7bec6d3b4..1118a027e 100644 --- a/src/client/graphics/layers/ChatModal.ts +++ b/src/client/graphics/layers/ChatModal.ts @@ -9,14 +9,14 @@ import { EventBus } from "../../../core/EventBus"; import { SendQuickChatEvent } from "../../Transport"; import { translateText } from "../../Utils"; -type QuickChatPhrase = { +export type QuickChatPhrase = { key: string; requiresPlayer: boolean; }; -type QuickChatPhrases = Record; +export type QuickChatPhrases = Record; -const quickChatPhrases: QuickChatPhrases = quickChatData; +export const quickChatPhrases: QuickChatPhrases = quickChatData; @customElement("chat-modal") export class ChatModal extends LitElement { @@ -57,7 +57,7 @@ export class ChatModal extends LitElement { misc: [{ text: "Let's go!", requiresPlayer: false }], }; - private categories = [ + public categories = [ { id: "help" }, { id: "attack" }, { id: "defend" }, @@ -71,17 +71,6 @@ export class ChatModal extends LitElement { } render() { - const sortedPlayers = [...this.players].sort((a, b) => a.localeCompare(b)); - - const filteredPlayers = sortedPlayers.filter((player) => - player.toLowerCase().includes(this.playerSearchQuery), - ); - - const otherPlayers = sortedPlayers.filter( - (player) => !player.toLowerCase().includes(this.playerSearchQuery), - ); - - const displayPlayers = [...filteredPlayers, ...otherPlayers]; return html`
    @@ -306,4 +295,35 @@ export class ChatModal extends LitElement { public setSender(value: PlayerView) { this.sender = value; } + + public openWithSelection( + categoryId: string, + phraseKey: string, + sender?: PlayerView, + recipient?: PlayerView, + ) { + if (sender && recipient) { + const alivePlayerNames = this.g + .players() + .filter((p) => p.isAlive() && !(p.data.playerType === PlayerType.Bot)) + .map((p) => p.data.name); + + this.players = alivePlayerNames; + this.recipient = recipient; + this.sender = sender; + } + + this.selectCategory(categoryId); + + const phrase = this.getPhrasesForCategory(categoryId).find( + (p) => p.key === phraseKey, + ); + + if (phrase) { + this.selectPhrase(phrase); + } + + this.requestUpdate(); + this.modalEl?.open(); + } } diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts new file mode 100644 index 000000000..739815f3c --- /dev/null +++ b/src/client/graphics/layers/MainRadialMenu.ts @@ -0,0 +1,285 @@ +import { LitElement } from "lit"; +import { customElement } from "lit/decorators.js"; +import { EventBus } from "../../../core/EventBus"; +import { PlayerActions, UnitType } from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +import { TransformHandler } from "../TransformHandler"; +import { UIState } from "../UIState"; +import { BuildMenu } from "./BuildMenu"; +import { ChatIntegration } from "./ChatIntegration"; +import { EmojiTable } from "./EmojiTable"; +import { Layer } from "./Layer"; +import { MenuEventManager } from "./MenuEventManager"; +import { PlayerActionHandler } from "./PlayerActionHandler"; +import { PlayerInfoOverlay } from "./PlayerInfoOverlay"; +import { PlayerPanel } from "./PlayerPanel"; +import { RadialMenu, RadialMenuConfig } from "./RadialMenu"; +import { + COLORS, + MenuElementParams, + Slot, + createRadialMenuItems, + getRootMenuItems, + updateCenterButton, +} from "./RadialMenuElements"; + +import boatIcon from "../../../../resources/images/BoatIconWhite.svg"; +import buildIcon from "../../../../resources/images/BuildIconWhite.svg"; +import infoIcon from "../../../../resources/images/InfoIcon.svg"; +import swordIcon from "../../../../resources/images/SwordIconWhite.svg"; + +@customElement("main-radial-menu") +export class MainRadialMenu extends LitElement implements Layer { + private radialMenu: RadialMenu; + private lastTickRefresh: number = 0; + private tickRefreshInterval: number = 500; + private needsRefresh: boolean = false; + + private playerActionHandler: PlayerActionHandler; + private menuEventManager: MenuEventManager; + private chatIntegration: ChatIntegration; + + constructor( + private eventBus: EventBus, + private game: GameView, + private transformHandler: TransformHandler, + private emojiTable: EmojiTable, + private buildMenu: BuildMenu, + private uiState: UIState, + private playerInfoOverlay: PlayerInfoOverlay, + private playerPanel: PlayerPanel, + ) { + super(); + + const menuConfig: RadialMenuConfig = { + centerButtonIcon: swordIcon, + tooltipStyle: ` + .radial-tooltip .cost { + margin-top: 4px; + color: ${COLORS.tooltip.cost}; + } + .radial-tooltip .count { + color: ${COLORS.tooltip.count}; + } + `, + }; + + this.radialMenu = new RadialMenu(menuConfig); + + this.playerActionHandler = new PlayerActionHandler( + this.eventBus, + this.uiState, + ); + + this.menuEventManager = new MenuEventManager( + this.eventBus, + this.game, + this.transformHandler, + this.radialMenu, + this.buildMenu, + this.emojiTable, + this.playerInfoOverlay, + this.playerPanel, + ); + + this.chatIntegration = new ChatIntegration(this.game, this.eventBus); + + this.radialMenu.setRootMenuItems(getRootMenuItems()); + } + + init() { + this.radialMenu.init(); + + this.menuEventManager.setContextMenuCallback((myPlayer, tile, actions) => { + this.handlePlayerActions(myPlayer, actions, tile); + }); + + this.menuEventManager.init(); + } + + private async handlePlayerActions( + myPlayer: PlayerView, + actions: PlayerActions, + tile: TileRef, + ) { + this.buildMenu.playerActions = actions; + + const tileOwner = this.game.owner(tile); + const recipient = tileOwner.isPlayer() ? (tileOwner as PlayerView) : null; + + if (myPlayer && recipient) { + this.chatIntegration.setupChatModal(myPlayer, recipient); + } + + const params: MenuElementParams = { + myPlayer, + selected: recipient, + tileOwner, + tile, + playerActions: actions, + game: this.game, + buildMenu: this.buildMenu, + emojiTable: this.emojiTable, + playerActionHandler: this.playerActionHandler, + playerPanel: this.playerPanel, + chatIntegration: this.chatIntegration, + closeMenu: () => this.menuEventManager.closeMenu(), + }; + + const menuItems = createRadialMenuItems(params); + + this.radialMenu.setRootMenuItems(menuItems); + + updateCenterButton(params, (enabled, action) => { + this.radialMenu.enableCenterButton(enabled, action); + }); + } + + async tick() { + const clickedCell = this.menuEventManager.getClickedCell(); + if (!this.radialMenu.isMenuVisible() || clickedCell === null) return; + + const currentTime = new Date().getTime(); + if ( + currentTime - this.lastTickRefresh < this.tickRefreshInterval && + !this.needsRefresh + ) { + return; + } + + const myPlayer = this.game.myPlayer(); + if (myPlayer === null || !myPlayer.isAlive()) return; + + const tile = this.game.ref(clickedCell.x, clickedCell.y); + + const isSpawnPhase = this.game.inSpawnPhase(); + const wasInSpawnPhase = this.menuEventManager.getWasInSpawnPhase(); + + if (wasInSpawnPhase !== isSpawnPhase) { + if (wasInSpawnPhase && !isSpawnPhase) { + this.needsRefresh = true; + this.menuEventManager.setWasInSpawnPhase(isSpawnPhase); + + const actions = await this.playerActionHandler.getPlayerActions( + myPlayer, + tile, + ); + this.updateMenuState(myPlayer, actions, tile); + this.radialMenu.refreshMenu(); + return; + } + + this.menuEventManager.closeMenu(); + return; + } + + // Check if tile ownership has changed + const originalTileOwner = this.menuEventManager.getOriginalTileOwner(); + if (originalTileOwner && originalTileOwner.isPlayer()) { + if (this.game.owner(tile) !== originalTileOwner) { + this.menuEventManager.closeMenu(); + return; + } + } else if (originalTileOwner) { + if ( + this.game.owner(tile).isPlayer() || + this.game.owner(tile) === myPlayer + ) { + this.menuEventManager.closeMenu(); + return; + } + } + + this.lastTickRefresh = currentTime; + this.needsRefresh = false; + + const actions = await this.playerActionHandler.getPlayerActions( + myPlayer, + tile, + ); + this.updateMenuState(myPlayer, actions, tile); + } + + private updateMenuState( + myPlayer: PlayerView, + actions: PlayerActions, + tile: TileRef, + ) { + if (!this.radialMenu.isMenuVisible()) return; + + const tileOwner = this.game.owner(tile); + const recipient = tileOwner.isPlayer() ? (tileOwner as PlayerView) : null; + + const params: MenuElementParams = { + myPlayer, + selected: recipient, + tileOwner, + tile, + playerActions: actions, + game: this.game, + buildMenu: this.buildMenu, + emojiTable: this.emojiTable, + playerActionHandler: this.playerActionHandler, + playerPanel: this.playerPanel, + chatIntegration: this.chatIntegration, + closeMenu: () => this.menuEventManager.closeMenu(), + }; + + if (this.radialMenu.getCurrentLevel() === 0) { + updateCenterButton(params, (enabled, action) => { + this.radialMenu.enableCenterButton(enabled, action); + }); + } + + const canBuildTransport = actions.buildableUnits.find( + (bu) => bu.type === UnitType.TransportShip, + )?.canBuild; + + this.radialMenu.updateMenuItem( + Slot.Build, + !this.game.inSpawnPhase(), + COLORS.build, + buildIcon, + ); + + if (actions?.interaction?.canSendAllianceRequest) { + this.radialMenu.updateMenuItem(Slot.Ally, true, COLORS.ally, undefined); + } else if (actions?.interaction?.canBreakAlliance) { + this.radialMenu.updateMenuItem( + Slot.Ally, + true, + COLORS.breakAlly, + undefined, + ); + } else { + this.radialMenu.updateMenuItem(Slot.Ally, false, undefined, undefined); + } + + this.radialMenu.updateMenuItem( + Slot.Boat, + !!canBuildTransport, + COLORS.boat, + boatIcon, + ); + + this.radialMenu.updateMenuItem( + Slot.Info, + this.game.hasOwner(tile), + COLORS.info, + infoIcon, + ); + } + + renderLayer(context: CanvasRenderingContext2D) { + this.radialMenu.renderLayer(context); + } + + shouldTransform(): boolean { + return this.radialMenu.shouldTransform(); + } + + redraw() { + // No redraw implementation needed + } +} diff --git a/src/client/graphics/layers/MenuEventManager.ts b/src/client/graphics/layers/MenuEventManager.ts new file mode 100644 index 000000000..1104529b2 --- /dev/null +++ b/src/client/graphics/layers/MenuEventManager.ts @@ -0,0 +1,185 @@ +import { EventBus } from "../../../core/EventBus"; +import { Cell, PlayerActions, TerraNullius } from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +import { + CloseViewEvent, + ContextMenuEvent, + MouseUpEvent, + ShowBuildMenuEvent, +} from "../../InputHandler"; +import { SendSpawnIntentEvent } from "../../Transport"; +import { TransformHandler } from "../TransformHandler"; +import { BuildMenu } from "./BuildMenu"; +import { EmojiTable } from "./EmojiTable"; +import { PlayerInfoOverlay } from "./PlayerInfoOverlay"; +import { PlayerPanel } from "./PlayerPanel"; +import { RadialMenu } from "./RadialMenu"; + +export type ContextMenuCallback = ( + myPlayer: PlayerView, + tile: TileRef, + actions: PlayerActions, +) => void; + +export class MenuEventManager { + private clickedCell: Cell | null = null; + private lastClosed: number = 0; + private originalTileOwner: PlayerView | TerraNullius | null = null; + private wasInSpawnPhase: boolean = false; + private onContextMenuCallback: ContextMenuCallback | null = null; + + constructor( + private eventBus: EventBus, + private game: GameView, + private transformHandler: TransformHandler, + private radialMenu: RadialMenu, + private buildMenu: BuildMenu, + private emojiTable: EmojiTable, + private playerInfoOverlay: PlayerInfoOverlay, + private playerPanel: PlayerPanel, + ) {} + + init() { + this.eventBus.on(ContextMenuEvent, (e) => this.onContextMenu(e)); + this.eventBus.on(MouseUpEvent, (e) => this.onPointerUp(e)); + this.eventBus.on(CloseViewEvent, () => this.closeMenu()); + this.eventBus.on(ShowBuildMenuEvent, (e) => this.onShowBuildMenu(e)); + } + + setContextMenuCallback(callback: ContextMenuCallback) { + this.onContextMenuCallback = callback; + } + + onContextMenu(event: ContextMenuEvent): Cell | null { + if (this.lastClosed + 200 > new Date().getTime()) return null; + + this.closeMenu(); + + if (this.radialMenu.isMenuVisible()) { + this.radialMenu.hideRadialMenu(); + return null; + } else { + this.radialMenu.showRadialMenu(event.x, event.y); + } + + this.radialMenu.disableAllButtons(); + this.clickedCell = this.transformHandler.screenToWorldCoordinates( + event.x, + event.y, + ); + + if ( + !this.clickedCell || + !this.game.isValidCoord(this.clickedCell.x, this.clickedCell.y) + ) { + return null; + } + + const tile = this.game.ref(this.clickedCell.x, this.clickedCell.y); + this.originalTileOwner = this.game.owner(tile); + this.wasInSpawnPhase = this.game.inSpawnPhase(); + + const myPlayer = this.game.myPlayer(); + if (myPlayer === null) { + throw new Error("my player not found"); + } + + if (myPlayer && !myPlayer.isAlive() && !this.game.inSpawnPhase()) { + this.radialMenu.hideRadialMenu(); + return null; + } + + if (this.game.inSpawnPhase()) { + if (this.game.isLand(tile) && !this.game.hasOwner(tile)) { + this.radialMenu.enableCenterButton(true, () => { + if (this.clickedCell === null) return; + this.eventBus.emit(new SendSpawnIntentEvent(this.clickedCell)); + this.radialMenu.hideRadialMenu(); + }); + + return this.clickedCell; + } + } + + myPlayer.actions(tile).then((actions) => { + if (this.onContextMenuCallback) { + this.onContextMenuCallback(myPlayer, tile, actions); + } + }); + + return this.clickedCell; + } + + getClickedCell(): Cell | null { + return this.clickedCell; + } + + getOriginalTileOwner(): PlayerView | TerraNullius | null { + return this.originalTileOwner; + } + + getWasInSpawnPhase(): boolean { + return this.wasInSpawnPhase; + } + + setWasInSpawnPhase(value: boolean) { + this.wasInSpawnPhase = value; + } + + onPointerUp(event: MouseUpEvent) { + this.playerInfoOverlay.hide(); + this.hideEverything(); + } + + onShowBuildMenu(e: ShowBuildMenuEvent): TileRef | null { + const clickedCell = this.transformHandler.screenToWorldCoordinates( + e.x, + e.y, + ); + if (clickedCell === null) { + return null; + } + if (!this.game.isValidCoord(clickedCell.x, clickedCell.y)) { + return null; + } + const tile = this.game.ref(clickedCell.x, clickedCell.y); + const p = this.game.myPlayer(); + if (p === null) { + return null; + } + this.buildMenu.showMenu(tile); + return tile; + } + + closeMenu() { + if (this.radialMenu.isMenuVisible()) { + this.radialMenu.hideRadialMenu(); + } + + if (this.buildMenu.isVisible) { + this.buildMenu.hideMenu(); + } + + if (this.emojiTable.isVisible) { + this.emojiTable.hideTable(); + } + + if (this.playerPanel.isVisible) { + this.playerPanel.hide(); + } + } + + hideEverything() { + if (this.radialMenu.isMenuVisible()) { + this.radialMenu.hideRadialMenu(); + this.lastClosed = new Date().getTime(); + } + this.emojiTable.hideTable(); + this.buildMenu.hideMenu(); + } + + enableCenterButton(enabled: boolean, action: () => void) { + this.radialMenu.enableCenterButton(enabled, action); + } +} diff --git a/src/client/graphics/layers/PlayerActionHandler.ts b/src/client/graphics/layers/PlayerActionHandler.ts new file mode 100644 index 000000000..b8bb154bf --- /dev/null +++ b/src/client/graphics/layers/PlayerActionHandler.ts @@ -0,0 +1,109 @@ +import { EventBus } from "../../../core/EventBus"; +import { Cell, PlayerActions, UnitType } from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { PlayerView } from "../../../core/game/GameView"; +import { + BuildUnitIntentEvent, + SendAllianceRequestIntentEvent, + SendAttackIntentEvent, + SendBoatAttackIntentEvent, + SendBreakAllianceIntentEvent, + SendDonateGoldIntentEvent, + SendDonateTroopsIntentEvent, + SendEmbargoIntentEvent, + SendEmojiIntentEvent, + SendQuickChatEvent, + SendSpawnIntentEvent, + SendTargetPlayerIntentEvent, +} from "../../Transport"; +import { UIState } from "../UIState"; + +export class PlayerActionHandler { + constructor( + private eventBus: EventBus, + private uiState: UIState, + ) {} + + async getPlayerActions( + player: PlayerView, + tile: TileRef, + ): Promise { + return await player.actions(tile); + } + + handleAttack(player: PlayerView, targetId: string | null) { + this.eventBus.emit( + new SendAttackIntentEvent( + targetId, + this.uiState.attackRatio * player.troops(), + ), + ); + } + + handleBoatAttack( + player: PlayerView, + targetId: string, + targetCell: Cell, + spawnTile: Cell | null, + ) { + this.eventBus.emit( + new SendBoatAttackIntentEvent( + targetId, + targetCell, + this.uiState.attackRatio * player.troops(), + spawnTile, + ), + ); + } + + async findBestTransportShipSpawn( + player: PlayerView, + tile: TileRef, + ): Promise { + return await player.bestTransportShipSpawn(tile); + } + + handleBuildUnit(unitType: UnitType, cellX: number, cellY: number) { + this.eventBus.emit( + new BuildUnitIntentEvent(unitType, new Cell(cellX, cellY)), + ); + } + + handleSpawn(spawnCell: Cell) { + this.eventBus.emit(new SendSpawnIntentEvent(spawnCell)); + } + + handleAllianceRequest(player: PlayerView, recipient: PlayerView) { + this.eventBus.emit(new SendAllianceRequestIntentEvent(player, recipient)); + } + + handleBreakAlliance(player: PlayerView, recipient: PlayerView) { + this.eventBus.emit(new SendBreakAllianceIntentEvent(player, recipient)); + } + + handleTargetPlayer(targetId: string | null) { + if (!targetId) return; + + this.eventBus.emit(new SendTargetPlayerIntentEvent(targetId)); + } + + handleDonateGold(recipient: PlayerView) { + this.eventBus.emit(new SendDonateGoldIntentEvent(recipient, null)); + } + + handleDonateTroops(recipient: PlayerView) { + this.eventBus.emit(new SendDonateTroopsIntentEvent(recipient, null)); + } + + handleEmbargo(recipient: PlayerView, action: "start" | "stop") { + this.eventBus.emit(new SendEmbargoIntentEvent(recipient, action)); + } + + handleEmoji(targetPlayer: PlayerView | "AllPlayers", emojiIndex: number) { + this.eventBus.emit(new SendEmojiIntentEvent(targetPlayer, emojiIndex)); + } + + handleQuickChat(recipient: PlayerView, chatKey: string, params: any = {}) { + this.eventBus.emit(new SendQuickChatEvent(recipient, chatKey, params)); + } +} diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index cf9d71a8d..b9804d2d1 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -40,7 +40,7 @@ export class PlayerPanel extends LitElement implements Layer { private tile: TileRef | null = null; @state() - private isVisible: boolean = false; + public isVisible: boolean = false; @state() private allianceExpiryText: string | null = null; diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 74cbb7b27..0ce75f91e 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -1,261 +1,166 @@ import * as d3 from "d3"; -import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg"; -import boatIcon from "../../../../resources/images/BoatIconWhite.svg"; -import buildIcon from "../../../../resources/images/BuildIconWhite.svg"; +import backIcon from "../../../../resources/images/BackIconWhite.svg"; import disabledIcon from "../../../../resources/images/DisabledIcon.svg"; -import infoIcon from "../../../../resources/images/InfoIcon.svg"; -import swordIcon from "../../../../resources/images/SwordIconWhite.svg"; -import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg"; -import { EventBus } from "../../../core/EventBus"; -import { - Cell, - PlayerActions, - TerraNullius, - UnitType, -} from "../../../core/game/Game"; -import { TileRef } from "../../../core/game/GameMap"; -import { GameView, PlayerView } from "../../../core/game/GameView"; -import { - CloseViewEvent, - ContextMenuEvent, - MouseUpEvent, - ShowBuildMenuEvent, -} from "../../InputHandler"; -import { - SendAllianceRequestIntentEvent, - SendAttackIntentEvent, - SendBoatAttackIntentEvent, - SendBreakAllianceIntentEvent, - SendSpawnIntentEvent, -} from "../../Transport"; -import { TransformHandler } from "../TransformHandler"; -import { UIState } from "../UIState"; -import { BuildMenu } from "./BuildMenu"; -import { EmojiTable } from "./EmojiTable"; import { Layer } from "./Layer"; -import { PlayerInfoOverlay } from "./PlayerInfoOverlay"; -import { PlayerPanel } from "./PlayerPanel"; +import { MenuElement } from "./RadialMenuElements"; -enum Slot { - Info, - Boat, - Build, - Ally, +export interface TooltipItem { + text: string; + className: string; } +export interface RadialMenuConfig { + menuSize?: number; + submenuScale?: number; + centerButtonSize?: number; + iconSize?: number; + centerIconSize?: number; + disabledColor?: string; + menuTransitionDuration?: number; + mainMenuInnerRadius?: number; + centerButtonIcon?: string; + maxNestedLevels?: number; + innerRadiusIncrement?: number; + tooltipStyle?: string; +} + +type RequiredRadialMenuConfig = Required; + export class RadialMenu implements Layer { - private clickedCell: Cell | null = null; - private lastClosed: number = 0; - - private originalTileOwner: PlayerView | TerraNullius; private menuElement: d3.Selection; + private tooltipElement: HTMLDivElement | null = null; private isVisible: boolean = false; - private readonly menuItems: Map< - Slot, - { - name: string; - disabled: boolean; - action: () => void; - color?: string | null; - icon?: string | null; - } - > = new Map([ - [ - Slot.Boat, - { - name: "boat", - disabled: true, - action: () => {}, - color: null, - icon: null, - }, - ], - [Slot.Ally, { name: "ally", disabled: true, action: () => {} }], - [Slot.Build, { name: "build", disabled: true, action: () => {} }], - [ - Slot.Info, - { - name: "info", - disabled: true, - action: () => {}, - color: null, - icon: null, - }, - ], - ]); - private readonly menuSize = 190; - private readonly centerButtonSize = 30; - private readonly iconSize = 32; - private readonly centerIconSize = 48; - private readonly disabledColor = d3.rgb(128, 128, 128).toString(); + private currentLevel: number = 0; // Current menu level (0 = main menu, 1 = submenu, etc.) + private menuStack: MenuElement[][] = []; // Stack to track menu navigation history + private currentMenuItems: MenuElement[] = []; // Current active menu items (changes based on level) + private rootMenuItems: MenuElement[] = []; // Store the original root menu items + + private readonly config: RequiredRadialMenuConfig; + private readonly backIconSize: number; private isCenterButtonEnabled = false; + private originalCenterButtonEnabled = false; + private centerButtonAction: (() => void) | null = null; + private originalCenterButtonAction: (() => void) | null = null; + private backAction: (() => void) | null = null; - constructor( - private eventBus: EventBus, - private g: GameView, - private transformHandler: TransformHandler, - private emojiTable: EmojiTable, - private buildMenu: BuildMenu, - private uiState: UIState, - private playerInfoOverlay: PlayerInfoOverlay, - private playerPanel: PlayerPanel, - ) {} + private isTransitioning: boolean = false; + private lastHideTime: number = 0; + private reopenCooldownMs: number = 300; - init() { - this.eventBus.on(ContextMenuEvent, (e) => this.onContextMenu(e)); - this.eventBus.on(MouseUpEvent, (e) => this.onPointerUp(e)); - this.eventBus.on(ShowBuildMenuEvent, (e) => { - const clickedCell = this.transformHandler.screenToWorldCoordinates( - e.x, - e.y, - ); - if (clickedCell === null) { - return; - } - if (!this.g.isValidCoord(clickedCell.x, clickedCell.y)) { - return; - } - const tile = this.g.ref(clickedCell.x, clickedCell.y); - const p = this.g.myPlayer(); - if (p === null) { - return; - } - this.buildMenu.showMenu(tile); - }); + private menuGroups: Map< + number, + d3.Selection + > = new Map(); + private menuPaths: Map< + string, + d3.Selection + > = new Map(); + private menuIcons: Map< + string, + d3.Selection + > = new Map(); - this.eventBus.on(CloseViewEvent, () => this.closeMenu()); + private selectedItemId: string | null = null; + private submenuHoverTimeout: number | null = null; + private backButtonHoverTimeout: number | null = null; + private navigationInProgress: boolean = false; + private originalCenterButtonIcon: string = ""; - this.createMenuElement(); + constructor(config: RadialMenuConfig = {}) { + this.config = { + menuSize: config.menuSize ?? 190, + submenuScale: config.submenuScale ?? 1.5, + centerButtonSize: config.centerButtonSize ?? 30, + iconSize: config.iconSize ?? 32, + centerIconSize: config.centerIconSize ?? 48, + disabledColor: config.disabledColor ?? d3.rgb(128, 128, 128).toString(), + menuTransitionDuration: config.menuTransitionDuration ?? 300, + mainMenuInnerRadius: config.mainMenuInnerRadius ?? 40, + centerButtonIcon: config.centerButtonIcon ?? "", + maxNestedLevels: config.maxNestedLevels ?? 3, + innerRadiusIncrement: config.innerRadiusIncrement ?? 20, + tooltipStyle: config.tooltipStyle ?? "", + }; + this.originalCenterButtonIcon = this.config.centerButtonIcon; + this.backIconSize = this.config.centerIconSize * 0.8; } - private closeMenu() { - if (this.isVisible) { - this.hideRadialMenu(); - } - - if (this.buildMenu.isVisible) { - this.buildMenu.hideMenu(); - } + init() { + this.createMenuElement(); + this.createTooltipElement(); } private createMenuElement() { + // Create an overlay to catch clicks outside the menu this.menuElement = d3 .select(document.body) .append("div") + .attr("class", "radial-menu-container") .style("position", "fixed") .style("display", "none") .style("z-index", "9999") .style("touch-action", "none") + .style("top", "0") + .style("left", "0") + .style("width", "100vw") + .style("height", "100vh") + .on("click", () => { + this.hideRadialMenu(); + }) .on("contextmenu", (e) => { e.preventDefault(); this.hideRadialMenu(); }); + // Calculate the total svg size needed for all potential nested menus + const totalSize = + this.config.menuSize * + Math.pow(this.config.submenuScale, this.config.maxNestedLevels - 1); + const svg = this.menuElement .append("svg") - .attr("width", this.menuSize) - .attr("height", this.menuSize) + .attr("width", totalSize) + .attr("height", totalSize) + .style("position", "absolute") + .style("top", "50%") + .style("left", "50%") + .style("transform", "translate(-50%, -50%)") + .style("pointer-events", "all") + .on("click", (event) => this.hideRadialMenu()); + + const container = svg .append("g") - .attr( - "transform", - `translate(${this.menuSize / 2},${this.menuSize / 2})`, - ); + .attr("class", "menu-container") + .attr("transform", `translate(${totalSize / 2},${totalSize / 2})`); - const pie = d3 - .pie() - .value(() => 1) - .padAngle(0.03) - .startAngle(Math.PI / 4) // Start at 45 degrees (π/4 radians) - .endAngle(2 * Math.PI + Math.PI / 4); // Complete the circle but shifted by 45 degrees - - const arc = d3 - .arc() - .innerRadius(this.centerButtonSize + 5) - .outerRadius(this.menuSize / 2 - 10); - - const arcs = svg - .selectAll("path") - .data(pie(Array.from(this.menuItems.values()))) - .enter() - .append("g"); - - arcs - .append("path") - .attr("d", arc) - .attr("fill", (d) => - d.data.disabled ? this.disabledColor : d.data.color, - ) - .attr("stroke", "#ffffff") - .attr("stroke-width", "2") - .style("cursor", (d) => (d.data.disabled ? "not-allowed" : "pointer")) - .style("opacity", (d) => (d.data.disabled ? 0.5 : 1)) - .attr("data-name", (d) => d.data.name) - .on("mouseover", function (event, d) { - if (!d.data.disabled) { - d3.select(this) - .transition() - .duration(200) - .attr("transform", "scale(1.05)") - .attr("filter", "url(#glow)"); - } - }) - .on("mouseout", function (event, d) { - if (!d.data.disabled) { - d3.select(this) - .transition() - .duration(200) - .attr("transform", "scale(1)") - .attr("filter", null); - } - }) - .on("click", (event, d) => { - if (!d.data.disabled) { - d.data.action(); - this.hideRadialMenu(); - } - }) - .on("touchstart", (event, d) => { - event.preventDefault(); - if (!d.data.disabled) { - d.data.action(); - this.hideRadialMenu(); - } - }); - - arcs - .append("image") - .attr("xlink:href", (d) => d.data.icon) - .attr("width", this.iconSize) - .attr("height", this.iconSize) - .attr("x", (d) => arc.centroid(d)[0] - this.iconSize / 2) - .attr("y", (d) => arc.centroid(d)[1] - this.iconSize / 2) - .style("pointer-events", "none") - .attr("data-name", (d) => d.data.name); - - // Add glow filter + // Add glow filter for hover effects const defs = svg.append("defs"); const filter = defs.append("filter").attr("id", "glow"); filter .append("feGaussianBlur") - .attr("stdDeviation", "3") + .attr("stdDeviation", "2") .attr("result", "coloredBlur"); const feMerge = filter.append("feMerge"); feMerge.append("feMergeNode").attr("in", "coloredBlur"); feMerge.append("feMergeNode").attr("in", "SourceGraphic"); - const centerButton = svg.append("g").attr("class", "center-button"); + const centerButton = container.append("g").attr("class", "center-button"); centerButton .append("circle") .attr("class", "center-button-hitbox") - .attr("r", this.centerButtonSize) + .attr("r", this.config.centerButtonSize) .attr("fill", "transparent") .style("cursor", "pointer") - .on("click", () => this.handleCenterButtonClick()) + .on("click", (event) => { + event.stopPropagation(); + this.handleCenterButtonClick(); + }) .on("touchstart", (event: Event) => { event.preventDefault(); + event.stopPropagation(); this.handleCenterButtonClick(); }) .on("mouseover", () => this.onCenterButtonHover(true)) @@ -264,41 +169,878 @@ export class RadialMenu implements Layer { centerButton .append("circle") .attr("class", "center-button-visible") - .attr("r", this.centerButtonSize) + .attr("r", this.config.centerButtonSize) .attr("fill", "#2c3e50") .style("pointer-events", "none"); centerButton .append("image") .attr("class", "center-button-icon") - .attr("xlink:href", swordIcon) - .attr("width", this.centerIconSize) - .attr("height", this.centerIconSize) - .attr("x", -this.centerIconSize / 2) - .attr("y", -this.centerIconSize / 2) + .attr("xlink:href", this.config.centerButtonIcon) + .attr("width", this.config.centerIconSize) + .attr("height", this.config.centerIconSize) + .attr("x", -this.config.centerIconSize / 2) + .attr("y", -this.config.centerIconSize / 2) .style("pointer-events", "none"); } - async tick() { - // Only update when menu is visible - if (!this.isVisible || this.clickedCell === null) return; - const myPlayer = this.g.myPlayer(); - if (myPlayer === null || !myPlayer.isAlive()) return; - const tile = this.g.ref(this.clickedCell.x, this.clickedCell.y); - if (this.originalTileOwner.isPlayer()) { - if (this.g.owner(tile) !== this.originalTileOwner) { - this.closeMenu(); - return; + private createTooltipElement() { + this.tooltipElement = document.createElement("div"); + this.tooltipElement.className = "radial-tooltip"; + this.tooltipElement.style.position = "absolute"; + this.tooltipElement.style.pointerEvents = "none"; + this.tooltipElement.style.background = "rgba(0, 0, 0, 0.7)"; + this.tooltipElement.style.color = "white"; + this.tooltipElement.style.padding = "6px 10px"; + this.tooltipElement.style.borderRadius = "6px"; + this.tooltipElement.style.fontSize = "12px"; + this.tooltipElement.style.zIndex = "10000"; + this.tooltipElement.style.maxWidth = "250px"; + this.tooltipElement.style.display = "none"; + document.body.appendChild(this.tooltipElement); + + const style = document.createElement("style"); + style.textContent = ` + .radial-tooltip .title { + font-weight: bold; + font-size: 14px; + margin-bottom: 4px; } + + ${this.config.tooltipStyle} + `; + document.head.appendChild(style); + } + + private getInnerRadiusForLevel(level: number): number { + return level === 0 + ? this.config.mainMenuInnerRadius + : this.config.mainMenuInnerRadius + 34; + } + + private getOuterRadiusForLevel(level: number): number { + const innerRadius = this.getInnerRadiusForLevel(level); + const arcWidth = + this.config.menuSize / 2 - this.config.mainMenuInnerRadius - 10; + return innerRadius + arcWidth; + } + + private renderMenuItems(items: MenuElement[], level: number) { + const container = this.menuElement.select(".menu-container"); + container.selectAll(`.menu-level-${level}`).remove(); + + const menuGroup = container + .append("g") + .attr("class", `menu-level-${level}`); + + // Set initial animation styles + if (level === 0) { + menuGroup.style("opacity", 0.5).style("transform", "scale(0.2)"); } else { - if (this.g.owner(tile).isPlayer() || this.g.owner(tile) === myPlayer) { - this.closeMenu(); + menuGroup.style("opacity", 0).style("transform", "scale(0.5)"); + } + + this.menuGroups.set(level, menuGroup as any); + + const pie = d3 + .pie() + .value(() => 1) + .padAngle(0.03) + .startAngle(Math.PI / 3) + .endAngle(2 * Math.PI + Math.PI / 3); + + const innerRadius = this.getInnerRadiusForLevel(level); + const outerRadius = this.getOuterRadiusForLevel(level); + + const arc = d3 + .arc>() + .innerRadius(innerRadius) + .outerRadius(outerRadius); + + const arcs = menuGroup + .selectAll(".menu-item") + .data(pie(items)) + .enter() + .append("g") + .attr("class", "menu-item-group"); + + this.renderPaths(arcs, arc, level); + this.setupEventHandlers(arcs, level); + this.renderIconsAndText(arcs, arc); + this.setupAnimations(menuGroup); + + return menuGroup; + } + + private renderPaths( + arcs: d3.Selection< + SVGGElement, + d3.PieArcDatum, + SVGGElement, + unknown + >, + arc: d3.Arc>, + level: number, + ) { + arcs + .append("path") + .attr("class", "menu-item-path") + .attr("d", arc) + .attr("fill", (d) => { + const color = d.data.disabled + ? this.config.disabledColor + : d.data.color || "#333333"; + const opacity = d.data.disabled ? 0.5 : 0.7; + + if (d.data.id === this.selectedItemId && this.currentLevel > level) { + return color; + } + + return d3.color(color)?.copy({ opacity: opacity })?.toString() || color; + }) + .attr("stroke", "#ffffff") + .attr("stroke-width", "2") + .style("cursor", (d) => (d.data.disabled ? "not-allowed" : "pointer")) + .style("opacity", (d) => (d.data.disabled ? 0.5 : 1)) + .style( + "transition", + `filter ${this.config.menuTransitionDuration / 2}ms, stroke-width ${ + this.config.menuTransitionDuration / 2 + }ms, fill ${this.config.menuTransitionDuration / 2}ms`, + ) + .attr("data-id", (d) => d.data.id); + + arcs.each((d) => { + const pathId = d.data.id; + const path = d3.select(`path[data-id="${pathId}"]`); + this.menuPaths.set(pathId, path as any); + + if ( + pathId === this.selectedItemId && + level === 0 && + this.currentLevel > 0 + ) { + path.attr("filter", "url(#glow)"); + path.attr("stroke-width", "3"); + + const color = d.data.disabled + ? this.config.disabledColor + : d.data.color || "#333333"; + path.attr("fill", color); + } + }); + + // Disable pointer events on previous menu levels + this.menuGroups.forEach((group, menuLevel) => { + if (menuLevel < this.currentLevel) { + group.selectAll("path").each(function () { + const pathElement = d3.select(this); + pathElement.style("pointer-events", "none"); + }); + } else if (menuLevel === this.currentLevel) { + group.selectAll("path").style("pointer-events", "auto"); + } + }); + } + + private setupEventHandlers( + arcs: d3.Selection< + SVGGElement, + d3.PieArcDatum, + SVGGElement, + unknown + >, + level: number, + ) { + const onHover = (d: d3.PieArcDatum, path: any) => { + if ( + d.data.disabled || + (this.currentLevel > 0 && this.currentLevel !== level) || + this.navigationInProgress + ) return; + + path.attr("filter", "url(#glow)"); + path.attr("stroke-width", "3"); + const color = d.data.disabled + ? this.config.disabledColor + : d.data.color || "#333333"; + path.attr("fill", color); + + if (d.data.tooltipItems && d.data.tooltipItems.length > 0) { + this.showTooltip(d.data.tooltipItems); + } + + if ( + d.data.children && + d.data.children.length > 0 && + !d.data.disabled && + !( + this.currentLevel > 0 && + d.data.id === this.selectedItemId && + level === 0 + ) + ) { + if (this.submenuHoverTimeout !== null) { + window.clearTimeout(this.submenuHoverTimeout); + } + + // Set a small delay before opening submenu to prevent accidental triggers + this.submenuHoverTimeout = window.setTimeout(() => { + if (this.navigationInProgress) return; + this.navigationInProgress = true; + this.selectedItemId = d.data.id; + this.navigateToSubMenu(d.data.children || []); + this.setCenterButtonAsBack(); + }, 200); + } + }; + + const onMouseOut = (d: d3.PieArcDatum, path: any) => { + if (this.submenuHoverTimeout !== null) { + window.clearTimeout(this.submenuHoverTimeout); + this.submenuHoverTimeout = null; + } + + this.hideTooltip(); + + if ( + d.data.disabled || + (this.currentLevel > 0 && + level === 0 && + d.data.id === this.selectedItemId) + ) + return; + path.attr("filter", null); + path.attr("stroke-width", "2"); + const color = d.data.disabled + ? this.config.disabledColor + : d.data.color || "#333333"; + const opacity = d.data.disabled ? 0.5 : 0.7; + path.attr( + "fill", + d3.color(color)?.copy({ opacity: opacity })?.toString() || color, + ); + }; + + const onClick = (d: d3.PieArcDatum, event: Event) => { + event.stopPropagation(); + if (d.data.disabled || this.navigationInProgress) return; + + if ( + this.currentLevel > 0 && + level === 0 && + d.data.id !== this.selectedItemId + ) + return; + + if (d.data.children && d.data.children.length > 0) { + this.navigationInProgress = true; + this.selectedItemId = d.data.id; + this.navigateToSubMenu(d.data.children || []); + this.setCenterButtonAsBack(); + } else if (d.data._action) { + d.data._action(); + this.hideRadialMenu(); + } else { + throw new Error(`Menu item action is not a function: ${d.data.id}`); + } + }; + + function handleMouseMove(event: MouseEvent) { + const tooltipEl = document.querySelector( + ".radial-tooltip", + ) as HTMLElement; + if (tooltipEl && tooltipEl.style.display !== "none") { + tooltipEl.style.left = event.pageX + 10 + "px"; + tooltipEl.style.top = event.pageY + 10 + "px"; } } - const actions = await myPlayer.actions(tile); - this.disableAllButtons(); - this.handlePlayerActions(myPlayer, actions, tile); + + arcs.each((d) => { + const pathId = d.data.id; + const path = d3.select(`path[data-id="${pathId}"]`); + + path.on("mouseover", function () { + onHover(d, path); + }); + + path.on("mouseout", function () { + onMouseOut(d, path); + }); + + path.on("mousemove", function (event) { + handleMouseMove(event as MouseEvent); + }); + + path.on("click", function (event) { + onClick(d, event); + }); + + path.on("touchstart", function (event) { + event.preventDefault(); + event.stopPropagation(); + onClick(d, event); + }); + }); + } + + private renderIconsAndText( + arcs: d3.Selection< + SVGGElement, + d3.PieArcDatum, + SVGGElement, + unknown + >, + arc: d3.Arc>, + ) { + arcs + .append("g") + .attr("class", "menu-item-content") + .style("pointer-events", "none") + .attr("data-id", (d) => d.data.id) + .each((d) => { + const contentId = d.data.id; + const content = d3.select(`g[data-id="${contentId}"]`); + + if (d.data.text) { + content + .append("text") + .attr("text-anchor", "middle") + .attr("dominant-baseline", "central") + .attr("x", arc.centroid(d)[0]) + .attr("y", arc.centroid(d)[1]) + .attr("fill", "white") + .attr("font-size", d.data.fontSize ?? "12px") + .attr("font-family", "Arial, sans-serif") + .style("opacity", d.data.disabled ? 0.5 : 1) + .text(d.data.text); + } else { + content + .append("image") + .attr( + "xlink:href", + d.data.disabled ? disabledIcon : d.data.icon || disabledIcon, + ) + .attr("width", this.config.iconSize) + .attr("height", this.config.iconSize) + .attr("x", arc.centroid(d)[0] - this.config.iconSize / 2) + .attr("y", arc.centroid(d)[1] - this.config.iconSize / 2); + } + + this.menuIcons.set(contentId, content as any); + }); + } + + private setupAnimations( + menuGroup: d3.Selection, + ) { + menuGroup + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("opacity", 1) + .style("transform", "scale(1)") + .on("start", () => { + this.isTransitioning = true; + }) + .on("end", () => { + this.isTransitioning = false; + }); + } + + private navigateToSubMenu(children: MenuElement[]) { + this.isTransitioning = true; + + this.menuStack.push(this.currentMenuItems); + this.currentMenuItems = children; + this.currentLevel++; + + this.renderMenuItems(this.currentMenuItems, this.currentLevel); + this.updateMenuGroupVisibility(); + this.animatePreviousMenu(); + } + + private updateMenuGroupVisibility() { + // Hide all menus except the current and immediate previous one + this.menuGroups.forEach((menuGroup, level) => { + if (level === this.currentLevel) { + menuGroup.style("display", "block"); + } else if (level === this.currentLevel - 1) { + menuGroup.style("display", "block"); + + menuGroup + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("transform", "scale(0.59)") + .style("opacity", 0.8); + + menuGroup.selectAll("path").each(function () { + const pathElement = d3.select(this); + pathElement.style("pointer-events", "none"); + }); + } else { + menuGroup + .transition() + .duration(this.config.menuTransitionDuration * 0.5) + .style("transform", "scale(0.5)") + .style("opacity", 0) + .on("end", function () { + d3.select(this).style("display", "none"); + }); + } + }); + } + + private animatePreviousMenu() { + const container = this.menuElement.select(".menu-container"); + const currentMenu = container.select( + `.menu-level-${this.currentLevel - 1}`, + ); + + currentMenu + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("transform", `scale(${this.currentLevel === 1 ? "0.8" : "0.59"})`) + .style("opacity", 0.8) + .on("end", () => { + this.navigationInProgress = false; + }); + } + + private navigateBack() { + if (this.menuStack.length === 0) { + return; + } + + this.isTransitioning = true; + + this.updateMenuLevels(); + this.clearSelectedItemHoverState(); + this.updateMenuVisibility(); + this.animateMenuTransitions(); + } + + private updateMenuLevels() { + const previousItems = this.menuStack.pop(); + const previousLevel = this.currentLevel - 1; + this.currentLevel = previousLevel; + + if (previousLevel === 0) { + this.selectedItemId = null; + } + + this.currentMenuItems = previousItems || []; + + if (this.currentLevel === 0) { + this.resetCenterButton(); + } + } + + private clearSelectedItemHoverState() { + // Clear the hover state on the item that opened the submenu + if (this.selectedItemId) { + const selectedPath = this.menuPaths.get(this.selectedItemId); + if (selectedPath) { + selectedPath.attr("filter", null); + selectedPath.attr("stroke-width", "2"); + + const item = this.findMenuItem(this.selectedItemId); + if (item) { + const color = item.disabled + ? this.config.disabledColor + : item.color || "#333333"; + const opacity = item.disabled ? 0.5 : 0.7; + selectedPath.attr( + "fill", + d3.color(color)?.copy({ opacity: opacity })?.toString() || color, + ); + } + } + } + } + + private updateMenuVisibility() { + this.menuGroups.forEach((menuGroup, level) => { + if (level === this.currentLevel) { + menuGroup.style("display", "block"); + menuGroup + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("transform", "scale(1)") + .style("opacity", 1); + + menuGroup.selectAll("path").style("pointer-events", "auto"); + } else if (level === this.currentLevel - 1 && this.currentLevel > 0) { + menuGroup.style("display", "block"); + menuGroup + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style( + "transform", + `scale(${this.currentLevel === 1 ? "0.8" : "0.59"})`, + ) + .style("opacity", 0.8); + } else if (level !== this.currentLevel + 1) { + menuGroup + .transition() + .duration(this.config.menuTransitionDuration * 0.5) + .style("opacity", 0) + .on("end", function () { + d3.select(this).style("display", "none"); + }); + } + }); + } + + private animateMenuTransitions() { + const container = this.menuElement.select(".menu-container"); + const currentSubmenu = container.select( + `.menu-level-${this.currentLevel + 1}`, + ); + const previousMenu = container.select(`.menu-level-${this.currentLevel}`); + + // Animate the current submenu (sliding out) + currentSubmenu + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("transform", "scale(0.5)") + .style("opacity", 0) + .on("end", function () { + d3.select(this).remove(); + }); + + // Handle previous menu animation + if (previousMenu.empty()) { + this.renderAndAnimateNewMenu(); + } else { + this.animateExistingMenu(previousMenu); + } + } + + private renderAndAnimateNewMenu() { + const menu = this.renderMenuItems(this.currentMenuItems, this.currentLevel); + menu + .style("transform", "scale(0.8)") + .style("opacity", 0.3) + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("transform", "scale(1)") + .style("opacity", 1) + .on("end", () => { + this.isTransitioning = false; + this.navigationInProgress = false; + }); + } + + private animateExistingMenu( + previousMenu: d3.Selection, + ) { + previousMenu + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("transform", "scale(1)") + .style("opacity", 1) + .on("end", () => { + this.isTransitioning = false; + this.navigationInProgress = false; + }); + + previousMenu.selectAll("path").style("pointer-events", "auto"); + } + + private setCenterButtonAsBack() { + if (this.currentLevel === 1) { + this.originalCenterButtonEnabled = this.isCenterButtonEnabled; + this.originalCenterButtonAction = this.centerButtonAction; + } + + this.backAction = () => { + this.navigateBack(); + }; + + // Clear any hover state on the center button + this.menuElement + .select(".center-button-hitbox") + .transition() + .duration(0) + .attr("r", this.config.centerButtonSize); + this.menuElement + .select(".center-button-visible") + .transition() + .duration(0) + .attr("r", this.config.centerButtonSize); + + const backIconImg = this.menuElement.select(".center-button-icon"); + backIconImg + .attr("xlink:href", backIcon) + .attr("width", this.backIconSize) + .attr("height", this.backIconSize) + .attr("x", -this.backIconSize / 2) + .attr("y", -this.backIconSize / 2); + + this.enableCenterButton(true, this.backAction); + } + + private resetCenterButton() { + this.backAction = null; + + const iconImg = this.menuElement.select(".center-button-icon"); + iconImg + .attr("xlink:href", this.originalCenterButtonIcon) + .attr("width", this.config.centerIconSize) + .attr("height", this.config.centerIconSize) + .attr("x", -this.config.centerIconSize / 2) + .attr("y", -this.config.centerIconSize / 2); + + this.enableCenterButton( + this.originalCenterButtonEnabled, + this.originalCenterButtonAction, + ); + } + + public showRadialMenu(x: number, y: number) { + if (!this.isReopeningAllowed()) return; + + this.resetMenu(); + this.isTransitioning = false; + this.selectedItemId = null; + + this.menuElement.style("display", "block"); + + this.menuElement + .select("svg") + .style("top", `${y}px`) + .style("left", `${x}px`) + .style("transform", `translate(-50%, -50%)`); + + this.isVisible = true; + + this.renderMenuItems(this.currentMenuItems, this.currentLevel); + this.onCenterButtonHover(true); + } + + public hideRadialMenu() { + if (!this.isVisible || this.isTransitioning) { + return; + } + + this.menuElement.style("display", "none"); + this.isVisible = false; + this.selectedItemId = null; + this.hideTooltip(); + + this.resetMenu(); + this.isTransitioning = false; + + this.menuGroups.clear(); + this.menuPaths.clear(); + this.menuIcons.clear(); + + this.lastHideTime = Date.now(); + } + + private handleCenterButtonClick() { + if ( + !this.isCenterButtonEnabled || + !this.centerButtonAction || + this.navigationInProgress + ) { + return; + } + + if (this.currentLevel > 0 && this.backAction) { + this.navigationInProgress = true; + } + + this.centerButtonAction(); + } + + public disableAllButtons() { + this.originalCenterButtonEnabled = this.isCenterButtonEnabled; + this.originalCenterButtonAction = this.centerButtonAction; + + this.enableCenterButton(false); + + for (const item of this.currentMenuItems) { + item.disabled = true; + item.color = this.config.disabledColor; + } + } + + public enableCenterButton(enabled: boolean, action?: (() => void) | null) { + if (this.currentLevel > 0 && this.backAction) { + this.isCenterButtonEnabled = true; + + if (action !== undefined && action !== this.backAction) { + this.originalCenterButtonAction = action; + } + + this.centerButtonAction = this.backAction; + } else { + this.isCenterButtonEnabled = enabled; + if (action !== undefined) { + this.centerButtonAction = action; + } + } + + const centerButton = this.menuElement.select(".center-button"); + + centerButton + .select(".center-button-hitbox") + .style("cursor", this.isCenterButtonEnabled ? "pointer" : "not-allowed"); + + centerButton + .select(".center-button-visible") + .attr("fill", this.isCenterButtonEnabled ? "#2c3e50" : "#999999"); + + centerButton + .select(".center-button-icon") + .style("opacity", this.isCenterButtonEnabled ? 1 : 0.5); + } + + private onCenterButtonHover(isHovering: boolean) { + if (!this.isCenterButtonEnabled) return; + + const scale = isHovering ? 1.2 : 1; + + this.menuElement + .select(".center-button-hitbox") + .transition() + .duration(200) + .attr("r", this.config.centerButtonSize * scale); + + this.menuElement + .select(".center-button-visible") + .transition() + .duration(200) + .attr("r", this.config.centerButtonSize * scale); + + if (this.currentLevel > 0 && this.backAction) { + if (isHovering) { + if (this.backButtonHoverTimeout !== null) { + window.clearTimeout(this.backButtonHoverTimeout); + } + + this.backButtonHoverTimeout = window.setTimeout(() => { + if (this.navigationInProgress || !this.backAction) return; + + this.navigationInProgress = true; + this.backAction(); + }, 300); + } else { + if (this.backButtonHoverTimeout !== null) { + window.clearTimeout(this.backButtonHoverTimeout); + this.backButtonHoverTimeout = null; + } + } + } + } + + public isMenuVisible(): boolean { + return this.isVisible; + } + + public getCurrentLevel(): number { + return this.currentLevel; + } + + public updateMenuItem( + id: string, + enabled: boolean, + color?: string, + icon?: string, + text?: string, + ) { + const path = this.menuPaths.get(id); + if (!path) return; + + const item = this.findMenuItem(id); + if (item) { + item.disabled = !enabled; + if (color) item.color = enabled ? color : this.config.disabledColor; + if (icon) item.icon = icon; + if (text !== undefined) item.text = text; + } + + const fillColor = enabled && color ? color : this.config.disabledColor; + const opacity = enabled ? 0.7 : 0.5; + + const isSelected = id === this.selectedItemId && this.currentLevel > 0; + const finalOpacity = isSelected ? 1.0 : opacity; + + path + .attr( + "fill", + d3.color(fillColor)?.copy({ opacity: finalOpacity })?.toString() || + fillColor, + ) + .style("opacity", enabled ? 1 : 0.5) + .style("cursor", enabled ? "pointer" : "not-allowed"); + + const iconElement = this.menuIcons.get(id); + if (iconElement) { + if (item?.text) { + const textElement = iconElement.select("text"); + if (textElement.size() > 0) { + textElement + .style("opacity", enabled ? 1 : 0.5) + .text(text || item.text); + } + } else if (icon) { + const imageElement = iconElement.select("image"); + if (imageElement.size() > 0) { + imageElement.attr("xlink:href", enabled ? icon : disabledIcon); + } + } + } + } + + public setRootMenuItems(items: MenuElement[]) { + this.currentMenuItems = [...items]; + this.rootMenuItems = [...items]; + if (this.isVisible) { + this.refreshMenu(); + } + } + + private findMenuItem(id: string): MenuElement | undefined { + return this.currentMenuItems.find((item) => item.id === id); + } + + private resetMenu() { + this.currentLevel = 0; + this.menuStack = []; + + this.currentMenuItems = [...this.rootMenuItems]; + + this.backAction = null; + this.navigationInProgress = false; + + this.menuGroups.clear(); + this.menuPaths.clear(); + this.menuIcons.clear(); + + const menuContainer = this.menuElement?.select(".menu-container"); + if (menuContainer) { + menuContainer.selectAll("[class^='menu-level-']").remove(); + } + + this.resetCenterButton(); + + if (this.submenuHoverTimeout !== null) { + window.clearTimeout(this.submenuHoverTimeout); + this.submenuHoverTimeout = null; + } + + if (this.backButtonHoverTimeout !== null) { + window.clearTimeout(this.backButtonHoverTimeout); + this.backButtonHoverTimeout = null; + } + } + + public refreshMenu() { + if (!this.isVisible) return; + this.renderMenuItems(this.currentMenuItems, this.currentLevel); } renderLayer(context: CanvasRenderingContext2D) { @@ -309,241 +1051,30 @@ export class RadialMenu implements Layer { return false; } - private onContextMenu(event: ContextMenuEvent) { - if (this.lastClosed + 200 > new Date().getTime()) return; - if (this.buildMenu.isVisible) { - this.buildMenu.hideMenu(); - return; - } - if (this.isVisible) { - this.hideRadialMenu(); - return; - } else { - this.showRadialMenu(event.x, event.y); - } - this.disableAllButtons(); - this.clickedCell = this.transformHandler.screenToWorldCoordinates( - event.x, - event.y, - ); - if (!this.g.isValidCoord(this.clickedCell.x, this.clickedCell.y)) { - return; - } - const tile = this.g.ref(this.clickedCell.x, this.clickedCell.y); - this.originalTileOwner = this.g.owner(tile); - if (this.g.inSpawnPhase()) { - if (this.g.isLand(tile) && !this.g.hasOwner(tile)) { - this.enableCenterButton(true); - } - return; - } - - const myPlayer = this.g.myPlayer(); - if (myPlayer === null) { - console.warn("my player not found"); - return; - } - if (myPlayer && !myPlayer.isAlive() && !this.g.inSpawnPhase()) { - return this.hideRadialMenu(); - } - myPlayer.actions(tile).then((actions) => { - this.handlePlayerActions(myPlayer, actions, tile); - }); + private isReopeningAllowed(): boolean { + const now = Date.now(); + const timeSinceHide = now - this.lastHideTime; + return timeSinceHide >= this.reopenCooldownMs; } - private handlePlayerActions( - myPlayer: PlayerView, - actions: PlayerActions, - tile: TileRef, - ) { - if (!this.g.inSpawnPhase()) { - this.activateMenuElement(Slot.Build, "#ebe250", buildIcon, () => { - this.buildMenu.showMenu(tile); - }); + private showTooltip(items: TooltipItem[]) { + if (!this.tooltipElement) return; + + this.tooltipElement.innerHTML = ""; + + for (const item of items) { + const div = document.createElement("div"); + div.className = item.className; + div.textContent = item.text; + this.tooltipElement.appendChild(div); } - if (this.g.hasOwner(tile)) { - this.activateMenuElement(Slot.Info, "#64748B", infoIcon, () => { - this.playerPanel.show(actions, tile); - }); - } - - if (actions?.interaction?.canSendAllianceRequest) { - this.activateMenuElement(Slot.Ally, "#53ac75", allianceIcon, () => { - this.eventBus.emit( - new SendAllianceRequestIntentEvent( - myPlayer, - this.g.owner(tile) as PlayerView, - ), - ); - }); - } - if (actions?.interaction?.canBreakAlliance) { - this.activateMenuElement(Slot.Ally, "#c74848", traitorIcon, () => { - this.eventBus.emit( - new SendBreakAllianceIntentEvent( - myPlayer, - this.g.owner(tile) as PlayerView, - ), - ); - }); - } - if ( - actions.buildableUnits.find((bu) => bu.type === UnitType.TransportShip) - ?.canBuild - ) { - this.activateMenuElement(Slot.Boat, "#3f6ab1", boatIcon, () => { - // BestTransportShipSpawn is an expensive operation, so - // we calculate it here and send the spawn tile to other clients. - myPlayer.bestTransportShipSpawn(tile).then((spawn) => { - let spawnTile: Cell | null = null; - if (spawn !== false) { - spawnTile = new Cell(this.g.x(spawn), this.g.y(spawn)); - } - - if (this.clickedCell === null) return; - this.eventBus.emit( - new SendBoatAttackIntentEvent( - this.g.owner(tile).id(), - this.clickedCell, - this.uiState.attackRatio * myPlayer.troops(), - spawnTile, - ), - ); - }); - }); - } - if (actions.canAttack) { - this.enableCenterButton(true); - } - - if (!this.g.hasOwner(tile)) { - return; - } + this.tooltipElement.style.display = "block"; } - private onPointerUp(event: MouseUpEvent) { - this.hideRadialMenu(); - this.emojiTable.hideTable(); - this.buildMenu.hideMenu(); - this.playerInfoOverlay.hide(); - } - - private showRadialMenu(x: number, y: number) { - // Delay so center button isn't clicked immediately on press. - setTimeout(() => { - this.menuElement - .style("left", `${x - this.menuSize / 2}px`) - .style("top", `${y - this.menuSize / 2}px`) - .style("display", "block"); - this.playerInfoOverlay.maybeShow(x, y); - this.isVisible = true; - }, 50); - } - - private hideRadialMenu() { - this.menuElement.style("display", "none"); - this.isVisible = false; - this.playerInfoOverlay.hide(); - this.lastClosed = new Date().getTime(); - } - - private handleCenterButtonClick() { - if (!this.isCenterButtonEnabled) { - return; + private hideTooltip() { + if (this.tooltipElement) { + this.tooltipElement.style.display = "none"; } - console.log("Center button clicked"); - if (this.clickedCell === null) return; - const clicked = this.g.ref(this.clickedCell.x, this.clickedCell.y); - if (this.g.inSpawnPhase()) { - this.eventBus.emit(new SendSpawnIntentEvent(this.clickedCell)); - } else { - const myPlayer = this.g.myPlayer(); - if (myPlayer !== null && this.g.owner(clicked) !== myPlayer) { - this.eventBus.emit( - new SendAttackIntentEvent( - this.g.owner(clicked).id(), - this.uiState.attackRatio * myPlayer.troops(), - ), - ); - } - } - this.hideRadialMenu(); - } - - private disableAllButtons() { - this.enableCenterButton(false); - for (const item of this.menuItems.values()) { - item.disabled = true; - this.updateMenuItemState(item); - } - } - - private activateMenuElement( - slot: Slot, - color: string, - icon: string, - action: () => void, - ) { - const menuItem = this.menuItems.get(slot); - if (menuItem === undefined) return; - menuItem.action = action; - menuItem.disabled = false; - menuItem.color = color; - menuItem.icon = icon; - this.updateMenuItemState(menuItem); - } - - private updateMenuItemState(item: any) { - const menuItem = this.menuElement.select(`path[data-name="${item.name}"]`); - menuItem - .attr("fill", item.disabled ? this.disabledColor : item.color) - .style("cursor", item.disabled ? "not-allowed" : "pointer") - .style("opacity", item.disabled ? 0.5 : 1); - - this.menuElement - .select(`image[data-name="${item.name}"]`) - .attr("xlink:href", item.disabled ? disabledIcon : item.icon) - .attr("fill", item.disabled ? "#999999" : "white"); - } - - private onCenterButtonHover(isHovering: boolean) { - if (!this.isCenterButtonEnabled) return; - - const scale = isHovering ? 1.2 : 1; - const fontSize = isHovering ? "18px" : "16px"; - - this.menuElement - .select(".center-button-hitbox") - .transition() - .duration(200) - .attr("r", this.centerButtonSize * scale); - this.menuElement - .select(".center-button-visible") - .transition() - .duration(200) - .attr("r", this.centerButtonSize * scale); - this.menuElement - .select(".center-button-text") - .transition() - .duration(200) - .style("font-size", fontSize); - } - - private enableCenterButton(enabled: boolean) { - this.isCenterButtonEnabled = enabled; - const centerButton = this.menuElement.select(".center-button"); - - centerButton - .select(".center-button-hitbox") - .style("cursor", enabled ? "pointer" : "not-allowed"); - - centerButton - .select(".center-button-visible") - .attr("fill", enabled ? "#2c3e50" : "#999999"); - - centerButton - .select(".center-button-text") - .attr("fill", enabled ? "white" : "#cccccc"); } } diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts new file mode 100644 index 000000000..1b5ed0fa0 --- /dev/null +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -0,0 +1,471 @@ +import { + AllPlayers, + Cell, + PlayerActions, + TerraNullius, + UnitType, +} from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +import { flattenedEmojiTable } from "../../../core/Util"; +import { renderNumber, translateText } from "../../Utils"; +import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu"; +import { ChatIntegration } from "./ChatIntegration"; +import { EmojiTable } from "./EmojiTable"; +import { PlayerActionHandler } from "./PlayerActionHandler"; +import { PlayerPanel } from "./PlayerPanel"; +import { TooltipItem } from "./RadialMenu"; + +import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg"; +import boatIcon from "../../../../resources/images/BoatIconWhite.svg"; +import buildIcon from "../../../../resources/images/BuildIconWhite.svg"; +import chatIcon from "../../../../resources/images/ChatIconWhite.svg"; +import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg"; +import donateTroopIcon from "../../../../resources/images/DonateTroopIconWhite.svg"; +import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg"; +import infoIcon from "../../../../resources/images/InfoIcon.svg"; +import targetIcon from "../../../../resources/images/TargetIconWhite.svg"; +import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg"; + +export interface MenuElementParams { + myPlayer: PlayerView; + selected: PlayerView | null; + tileOwner: PlayerView | TerraNullius; + tile: TileRef; + playerActions: PlayerActions; + game: GameView; + buildMenu: BuildMenu; + emojiTable: EmojiTable; + playerActionHandler: PlayerActionHandler; + playerPanel: PlayerPanel; + chatIntegration: ChatIntegration; + closeMenu: () => void; +} + +export interface MenuElement { + id: string; + name: string; + disabled: boolean; + displayed?: boolean; + color?: string; + icon?: string; + text?: string; + fontSize?: string; + tooltipItems?: TooltipItem[]; + + action?: (params: MenuElementParams) => void; // For leaf items that perform actions + subMenu?: (params: MenuElementParams) => MenuElement[]; // For non-leaf items that open submenus + + // Runtime properties used by RadialMenu (not to be set by menu element creators) + children?: MenuElement[]; + _action?: () => void; +} + +export const COLORS = { + build: "#ebe250", + building: "#2c2c2c", + boat: "#3f6ab1", + ally: "#53ac75", + breakAlly: "#c74848", + info: "#64748B", + target: "#ff0000", + infoDetails: "#7f8c8d", + infoEmoji: "#f1c40f", + trade: "#008080", + embargo: "#6600cc", + tooltip: { + cost: "#ffd700", + count: "#aaa", + }, + chat: { + default: "#66c", + help: "#4caf50", + attack: "#f44336", + defend: "#2196f3", + greet: "#ff9800", + misc: "#9c27b0", + warnings: "#e3c532", + }, +}; + +export enum Slot { + Info = "info", + Boat = "boat", + Build = "build", + Ally = "ally", + Back = "back", +} + +/** + * Convert a MenuElement tree to a version usable by the RadialMenu + * by resolving subMenu functions and setting up actions + */ +export function prepareMenuElementsForRadialMenu( + elements: MenuElement[], + params: MenuElementParams, +): MenuElement[] { + return elements.map((element) => { + const prepared: MenuElement = { ...element }; + + // If the element has a subMenu function, execute it to get the children + if (element.subMenu) { + prepared.children = prepareMenuElementsForRadialMenu( + element.subMenu(params), + params, + ); + // We don't need the subMenu function anymore + prepared.subMenu = undefined; + } + + // Set up the action function to call the element's action with params + if (element.action) { + prepared._action = () => element.action!(params); + } else { + prepared._action = () => {}; + } + + return prepared; + }); +} + +export const buildMenuElement: MenuElement = { + id: Slot.Build, + name: "build", + disabled: false, + icon: buildIcon, + color: COLORS.build, + + subMenu: (params: MenuElementParams) => { + const buildElements: MenuElement[] = flattenedBuildTable.map( + (item: BuildItemDisplay) => ({ + id: `build_${item.unitType}`, + name: item.key + ? item.key.replace("unit_type.", "") + : item.unitType.toString(), + disabled: !params.buildMenu.canBuild(item), + color: params.buildMenu.canBuild(item) ? COLORS.building : undefined, + icon: item.icon, + tooltipItems: [ + { text: translateText(item.key || ""), className: "title" }, + { + text: translateText(item.description || ""), + className: "description", + }, + { + text: `${renderNumber(params.buildMenu.cost(item))} ${translateText("player_panel.gold")}`, + className: "cost", + }, + item.countable + ? { text: `${params.buildMenu.count(item)}x`, className: "count" } + : null, + ].filter((item): item is TooltipItem => item !== null), + action: (params: MenuElementParams) => { + params.playerActionHandler.handleBuildUnit( + item.unitType, + params.game.x(params.tile), + params.game.y(params.tile), + ); + params.closeMenu(); + }, + }), + ); + + buildElements.push({ + id: "build_menu", + name: "build", + disabled: false, + color: COLORS.build, + icon: buildIcon, + action: (params: MenuElementParams) => { + params.buildMenu.showMenu(params.tile); + }, + }); + + return buildElements; + }, +}; + +export const boatMenuElement: MenuElement = { + id: Slot.Boat, + name: "boat", + disabled: false, + icon: boatIcon, + color: COLORS.boat, + + action: async (params: MenuElementParams) => { + if (!params.selected) return; + + const spawn = await params.playerActionHandler.findBestTransportShipSpawn( + params.myPlayer, + params.tile, + ); + + let spawnTile: Cell | null = null; + if (spawn !== false) { + spawnTile = new Cell(params.game.x(spawn), params.game.y(spawn)); + } + + params.playerActionHandler.handleBoatAttack( + params.myPlayer, + params.selected.id(), + new Cell(params.game.x(params.tile), params.game.y(params.tile)), + spawnTile, + ); + + params.closeMenu(); + }, +}; + +export const infoMenuElement: MenuElement = { + id: Slot.Info, + name: "info", + disabled: false, + icon: infoIcon, + color: COLORS.info, + + subMenu: (params: MenuElementParams) => { + if (!params.selected) return []; + + return [ + { + id: "info_chat", + name: "chat", + disabled: false, + color: COLORS.chat.default, + icon: chatIcon, + subMenu: (params: MenuElementParams) => + params.chatIntegration + .createQuickChatMenu(params.selected!) + .map((item) => ({ + ...item, + action: item.action + ? (_params: MenuElementParams) => item.action!(params) + : undefined, + })), + }, + { + id: "ally_target", + name: "target", + disabled: false, + color: COLORS.target, + icon: targetIcon, + action: (params: MenuElementParams) => { + params.playerActionHandler.handleTargetPlayer(params.selected!.id()); + params.closeMenu(); + }, + }, + { + id: "ally_trade", + name: "trade", + disabled: !!params.playerActions?.interaction?.canEmbargo, + displayed: !params.playerActions?.interaction?.canEmbargo, + color: COLORS.trade, + text: translateText("player_panel.start_trade"), + action: (params: MenuElementParams) => { + params.playerActionHandler.handleEmbargo(params.selected!, "start"); + params.closeMenu(); + }, + }, + { + id: "ally_embargo", + name: "embargo", + disabled: !params.playerActions?.interaction?.canEmbargo, + displayed: !!params.playerActions?.interaction?.canEmbargo, + color: COLORS.embargo, + text: translateText("player_panel.stop_trade"), + action: (params: MenuElementParams) => { + params.playerActionHandler.handleEmbargo(params.selected!, "stop"); + params.closeMenu(); + }, + }, + { + id: "ally_request", + name: "request", + disabled: !params.playerActions?.interaction?.canSendAllianceRequest, + displayed: !params.playerActions?.interaction?.canBreakAlliance, + color: COLORS.ally, + icon: allianceIcon, + action: (params: MenuElementParams) => { + params.playerActionHandler.handleAllianceRequest( + params.myPlayer, + params.selected!, + ); + params.closeMenu(); + }, + }, + { + id: "ally_break", + name: "break", + disabled: !params.playerActions?.interaction?.canBreakAlliance, + displayed: !!params.playerActions?.interaction?.canBreakAlliance, + color: COLORS.breakAlly, + icon: traitorIcon, + action: (params: MenuElementParams) => { + params.playerActionHandler.handleBreakAlliance( + params.myPlayer, + params.selected!, + ); + params.closeMenu(); + }, + }, + + { + id: "ally_donate_gold", + name: "donate gold", + disabled: !params.playerActions?.interaction?.canDonate, + color: COLORS.ally, + icon: donateGoldIcon, + action: (params: MenuElementParams) => { + params.playerActionHandler.handleDonateGold(params.selected!); + params.closeMenu(); + }, + }, + { + id: "ally_donate_troops", + name: "donate troops", + disabled: !params.playerActions?.interaction?.canDonate, + color: COLORS.ally, + icon: donateTroopIcon, + action: (params: MenuElementParams) => { + params.playerActionHandler.handleDonateTroops(params.selected!); + params.closeMenu(); + }, + }, + { + id: "info_player", + name: "player", + disabled: false, + color: COLORS.info, + icon: infoIcon, + action: (params: MenuElementParams) => { + params.playerPanel.show(params.playerActions, params.tile); + }, + }, + { + id: "info_emoji", + name: "emoji", + disabled: false, + color: COLORS.infoEmoji, + icon: emojiIcon, + subMenu: () => { + const emojiElements: MenuElement[] = []; + + const emojiCount = 15; + for (let i = 0; i < emojiCount; i++) { + emojiElements.push({ + id: `emoji_${i}`, + name: flattenedEmojiTable[i], + text: flattenedEmojiTable[i], + disabled: false, + fontSize: "25px", + action: (params: MenuElementParams) => { + const targetPlayer = + params.selected === params.game.myPlayer() + ? AllPlayers + : params.selected; + params.playerActionHandler.handleEmoji(targetPlayer!, i); + params.closeMenu(); + }, + }); + } + + emojiElements.push({ + id: "emoji_more", + name: "more", + disabled: false, + color: COLORS.infoEmoji, + icon: emojiIcon, + action: (params: MenuElementParams) => { + params.emojiTable.showTable((emoji) => { + const targetPlayer = + params.selected === params.game.myPlayer() + ? AllPlayers + : params.selected; + params.playerActionHandler.handleEmoji( + targetPlayer!, + flattenedEmojiTable.indexOf(emoji), + ); + params.emojiTable.hideTable(); + }); + }, + }); + + return emojiElements; + }, + }, + ].filter((item) => item.displayed !== false); + }, +}; + +export function createMenuItems(params: MenuElementParams): MenuElement[] { + const canBuildTransport = params.playerActions.buildableUnits.find( + (bu) => bu.type === UnitType.TransportShip, + )?.canBuild; + + return [ + { + ...boatMenuElement, + disabled: !canBuildTransport || !params.selected, + }, + { + ...buildMenuElement, + disabled: params.game.inSpawnPhase(), + }, + { + ...infoMenuElement, + disabled: !params.game.hasOwner(params.tile), + }, + ]; +} + +export function createRadialMenuItems( + params: MenuElementParams, +): MenuElement[] { + const elements = createMenuItems(params); + return prepareMenuElementsForRadialMenu(elements, params); +} + +export function getRootMenuItems(): MenuElement[] { + return [ + { + id: Slot.Boat, + name: "boat", + disabled: true, + _action: () => {}, + icon: boatIcon, + }, + { + id: Slot.Build, + name: "build", + disabled: true, + _action: () => {}, + icon: buildIcon, + }, + { + id: Slot.Info, + name: "info", + disabled: true, + _action: () => {}, + icon: infoIcon, + }, + ]; +} + +export function updateCenterButton( + params: MenuElementParams, + enableCenterButton: (enabled: boolean, action?: (() => void) | null) => void, +) { + if (params.playerActions.canAttack) { + enableCenterButton(true, () => { + if (params.tileOwner !== params.myPlayer) { + params.playerActionHandler.handleAttack( + params.myPlayer, + params.tileOwner.id(), + ); + } + params.closeMenu(); + }); + } else { + enableCenterButton(false); + } +} From 5507ead156cb1d05156df909b073297f3db1cebc Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Sat, 7 Jun 2025 05:33:48 +0200 Subject: [PATCH 82/84] Fix broken flag images (#1078) ## Description: Some flag images are missing from the flag menu, and can thus not be used as a flag by the player in the game. This this fixes that by equating the names in countries.json to the filenames of the flags. Example of missing flag image from before this fix: ![afbeelding](https://github.com/user-attachments/assets/ec7aecdc-ee69-4852-98f9-14f3dc05febc) ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: tryout33 --- src/client/data/countries.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/client/data/countries.json b/src/client/data/countries.json index 6c5cb64f9..90d67d102 100644 --- a/src/client/data/countries.json +++ b/src/client/data/countries.json @@ -473,7 +473,7 @@ "name": "Comoros" }, { - "code": "communist flag", + "code": "Communist flag", "name": "Communist Flag" }, { @@ -740,7 +740,7 @@ "name": "Franks" }, { - "code": "french foreign legion", + "code": "French foreign legion", "name": "French Foreign Legion" }, { @@ -1188,7 +1188,7 @@ "name": "Luxembourg" }, { - "code": "lydia", + "code": "Lydia", "continent": "Asia", "name": "Lydia" }, @@ -1238,7 +1238,7 @@ "name": "Malta" }, { - "code": "Māori Flag", + "code": "Māori flag", "continent": "Oceania", "name": "Māori Flag" }, @@ -1466,7 +1466,7 @@ "name": "Normandy" }, { - "code": "North Karelia", + "code": "North karelia", "continent": "Europe", "name": "North Karelia" }, From e6071b6a56a85e0fddc786135f2dc294b24207aa Mon Sep 17 00:00:00 2001 From: evanpelle Date: Fri, 6 Jun 2025 20:43:55 -0700 Subject: [PATCH 83/84] kick existing client when duplicate persistent id is found (#1077) ## Description: Kick the existing client instead of the new client because this was causing issues with replays. Players were unable to replay a game if they were a player. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors --- src/server/GameServer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index c74b0d8e4..88db909dd 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -147,7 +147,9 @@ export class GameServer { existingIP: ipAnonymize(conflicting.ip), existingPersistentID: conflicting.persistentID, }); - return; + // Kick the existing client instead of the new one, because this was causing issues when + // a client wanted to replay the game afterwards. + this.kickClient(conflicting.clientID); } } From 7a9cc66c7fb42ab0de59895ac1c3f5a3714a3ba5 Mon Sep 17 00:00:00 2001 From: E-EE-E <38805542+E-EE-E@users.noreply.github.com> Date: Sat, 7 Jun 2025 12:57:41 +0900 Subject: [PATCH 84/84] Update PlayerImpl.ts (#1079) ## Description: The removeTroops function works with the given parameter value of 1. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: _bjkim_ --- src/core/game/PlayerImpl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 2b4146b10..1faa13d93 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -698,7 +698,7 @@ export class PlayerImpl implements Player { this._troops += toInt(troops); } removeTroops(troops: number): number { - if (troops <= 1) { + if (troops <= 0) { return 0; } const toRemove = minInt(this._troops, toInt(troops));