diff --git a/old_territory_layer.txt b/old_territory_layer.txt new file mode 100644 index 000000000..19230ca03 Binary files /dev/null and b/old_territory_layer.txt differ diff --git a/package-lock.json b/package-lock.json index 36dec8651..10eb97267 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "@datastructures-js/priority-queue": "^6.3.3", "@eslint/compat": "^1.2.7", "@eslint/js": "^9.21.0", + "@swc/core": "^1.15.11", "@swc/jest": "^0.2.39", "@types/benchmark": "^2.1.5", "@types/chai": "^4.3.17", @@ -6888,13 +6889,12 @@ } }, "node_modules/@swc/core": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.10.tgz", - "integrity": "sha512-udNofxftduMUEv7nqahl2nvodCiCDQ4Ge0ebzsEm6P8s0RC2tBM0Hqx0nNF5J/6t9uagFJyWIDjXy3IIWMHDJw==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.11.tgz", + "integrity": "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" @@ -6907,16 +6907,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.10", - "@swc/core-darwin-x64": "1.15.10", - "@swc/core-linux-arm-gnueabihf": "1.15.10", - "@swc/core-linux-arm64-gnu": "1.15.10", - "@swc/core-linux-arm64-musl": "1.15.10", - "@swc/core-linux-x64-gnu": "1.15.10", - "@swc/core-linux-x64-musl": "1.15.10", - "@swc/core-win32-arm64-msvc": "1.15.10", - "@swc/core-win32-ia32-msvc": "1.15.10", - "@swc/core-win32-x64-msvc": "1.15.10" + "@swc/core-darwin-arm64": "1.15.11", + "@swc/core-darwin-x64": "1.15.11", + "@swc/core-linux-arm-gnueabihf": "1.15.11", + "@swc/core-linux-arm64-gnu": "1.15.11", + "@swc/core-linux-arm64-musl": "1.15.11", + "@swc/core-linux-x64-gnu": "1.15.11", + "@swc/core-linux-x64-musl": "1.15.11", + "@swc/core-win32-arm64-msvc": "1.15.11", + "@swc/core-win32-ia32-msvc": "1.15.11", + "@swc/core-win32-x64-msvc": "1.15.11" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -6928,171 +6928,171 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.10.tgz", - "integrity": "sha512-U72pGqmJYbjrLhMndIemZ7u9Q9owcJczGxwtfJlz/WwMaGYAV/g4nkGiUVk/+QSX8sFCAjanovcU1IUsP2YulA==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.11.tgz", + "integrity": "sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==", "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=10" } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.10.tgz", - "integrity": "sha512-NZpDXtwHH083L40xdyj1sY31MIwLgOxKfZEAGCI8xHXdHa+GWvEiVdGiu4qhkJctoHFzAEc7ZX3GN5phuJcPuQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.11.tgz", + "integrity": "sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA==", "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=10" } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.10.tgz", - "integrity": "sha512-ioieF5iuRziUF1HkH1gg1r93e055dAdeBAPGAk40VjqpL5/igPJ/WxFHGvc6WMLhUubSJI4S0AiZAAhEAp1jDg==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.11.tgz", + "integrity": "sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg==", "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.10.tgz", - "integrity": "sha512-tD6BClOrxSsNus9cJL7Gxdv7z7Y2hlyvZd9l0NQz+YXzmTWqnfzLpg16ovEI7gknH2AgDBB5ywOsqu8hUgSeEQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.11.tgz", + "integrity": "sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA==", "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.10.tgz", - "integrity": "sha512-4uAHO3nbfbrTcmO/9YcVweTQdx5fN3l7ewwl5AEK4yoC4wXmoBTEPHAVdKNe4r9+xrTgd4BgyPsy0409OjjlMw==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.11.tgz", + "integrity": "sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==", "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.10.tgz", - "integrity": "sha512-W0h9ONNw1pVIA0cN7wtboOSTl4Jk3tHq+w2cMPQudu9/+3xoCxpFb9ZdehwCAk29IsvdWzGzY6P7dDVTyFwoqg==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.11.tgz", + "integrity": "sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==", "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.10.tgz", - "integrity": "sha512-XQNZlLZB62S8nAbw7pqoqwy91Ldy2RpaMRqdRN3T+tAg6Xg6FywXRKCsLh6IQOadr4p1+lGnqM/Wn35z5a/0Vw==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.11.tgz", + "integrity": "sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==", "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.10.tgz", - "integrity": "sha512-qnAGrRv5Nj/DATxAmCnJQRXXQqnJwR0trxLndhoHoxGci9MuguNIjWahS0gw8YZFjgTinbTxOwzatkoySihnmw==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.11.tgz", + "integrity": "sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==", "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.10.tgz", - "integrity": "sha512-i4X/q8QSvzVlaRtv1xfnfl+hVKpCfiJ+9th484rh937fiEZKxZGf51C+uO0lfKDP1FfnT6C1yBYwHy7FLBVXFw==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.11.tgz", + "integrity": "sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw==", "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.10.tgz", - "integrity": "sha512-HvY8XUFuoTXn6lSccDLYFlXv1SU/PzYi4PyUqGT++WfTnbw/68N/7BdUZqglGRwiSqr0qhYt/EhmBpULj0J9rA==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.11.tgz", + "integrity": "sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw==", "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } @@ -7128,7 +7128,6 @@ "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3" } diff --git a/package.json b/package.json index ddfe3288f..8e262b45f 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@datastructures-js/priority-queue": "^6.3.3", "@eslint/compat": "^1.2.7", "@eslint/js": "^9.21.0", + "@swc/core": "^1.15.11", "@swc/jest": "^0.2.39", "@types/benchmark": "^2.1.5", "@types/chai": "^4.3.17", @@ -136,6 +137,5 @@ "ws": "^8.18.0", "zod": "^4.0.5" }, - "optionalDependencies": {}, "type": "module" } diff --git a/resources/ai-profiles.json b/resources/ai-profiles.json new file mode 100644 index 000000000..318fe5e0d --- /dev/null +++ b/resources/ai-profiles.json @@ -0,0 +1,180 @@ +{ + "profiles": [ + { + "id": "default", + "name": "Default", + "params": { + "spawnHopping": true, + "spawnHopRate": 40, + "spawnSniping": false, + "spawnAvoidance": false, + "spawnAvoidanceDistance": 50, + "terraNulliusTroopThreshold": 0.5, + "terraNulliusOwnTroopPercent": 0.3, + "terraNulliusBoatTroopPercent": 0.05, + "terraNulliusMaxDistance": 300, + "terraNulliusBoatSpacing": 30, + "terraNulliusOpportunisticBoatRange": 20, + "botAttackTroopThreshold": 0.5, + "botAttackMaxDistance": 300, + "botAttackOwnTroopPercent": 0.3, + "botAttackEnemyTroopMultiplier": 1.5, + "botAttackBoatInitialRange": 50, + "botAttackBoatSearchRangeGrowth": 0.5, + "attackTroopThreshold": 0.5, + "attackOwnTroopPercent": 0.2, + "attackEnemyTroopMultiplier": 1.5, + "attackBoatTroopPercent": 0.1, + "attackBoatInitialRange": 50, + "attackBoatRangeGrowth": 0.5, + "defendingTroopTarget": 0.5, + "productivityInvestmentRate": 0.1, + "researchInvestmentRate": 0.2, + "roadInvestmentRate": 0.1, + "targetTroopRatio": 0.6, + "roadInvestmentCapToMaintenance": true, + "roadBuildBoost": 0.1, + "roadQualityAdjust": 0.01, + "targetRoadQuality": 100, + "buildCities": true, + "buildFactories": true, + "buildPorts": true, + "buildHospitals": true, + "buildAcademies": true, + "buildAirfields": true, + "buildResearchLabs": true, + "buildMissileSilos": false, + "buildSAMLaunchers": true, + "buildDefensePosts": true, + "buildDoomsdayDevices": false, + "buildWarships": true, + "aiAvoidPlayerDistance": 40, + "weightCity": 1, + "weightFactory": 1, + "weightPort": 1, + "weightHospital": 1, + "weightAcademy": 1, + "weightAirfield": 1, + "weightResearchLab": 1, + "weightMissileSilo": 1, + "weightSAMLauncher": 1, + "samCoverageDecay": 0.13, + "weightDefensePost": 1, + "weightDoomsdayDevice": 1, + "aiAssumedPopPercent": 0.7, + "tileNearPlayerPenalty": 0.6, + "tileNearStructurePenalty": 0.1, + "tileCapitalDistancePenalty": 0.0001, + "otherTileWaterCheckDistance": 20, + "otherTileNearWaterPenalty": 0.2, + "tileNearbyStructureStackBonus": 0.03, + "defensePostNearWaterPenalty": 0.5, + "warDeclarationThreshold": 100, + "warScoreSharedBorderWeight": 35, + "warScoreMilitaryStrengthWeight": 65, + "warScoreNonReachableEnemyWeight": 0.2, + "warScoreCoBelligerentDiscount": 0.9, + "warScoreAllyPenalty": 0, + "warScoreDistancePenaltyWeight": 390, + "warScoreDominanceWeight": 135, + "peaceThresholdGap": 45, + "nukeScoreConstructionThreshold": 0, + "nukeFriendlyDamageWeight": 1.0, + "nukeScoreMultiplier": 1, + "nukeWarScoreSigmoidScale": 0.027, + "discountFactor": 0.1, + "weightWarship": 1, + "warshipTradeIncomeWeight": 0, + "warshipCoastalThreatWeight": 8e4, + "firstPortIncomeShare": 0.45 + } + }, + { + "id": "default2", + "name": "Default 2", + "params": { + "spawnHopping": true, + "spawnHopRate": 40, + "spawnSniping": false, + "spawnAvoidance": false, + "spawnAvoidanceDistance": 50, + "terraNulliusTroopThreshold": 0.5, + "terraNulliusOwnTroopPercent": 0.3, + "terraNulliusBoatTroopPercent": 0.05, + "terraNulliusMaxDistance": 300, + "terraNulliusBoatSpacing": 30, + "terraNulliusOpportunisticBoatRange": 20, + "botAttackTroopThreshold": 0.5, + "botAttackMaxDistance": 300, + "botAttackOwnTroopPercent": 0.3, + "botAttackEnemyTroopMultiplier": 1.5, + "botAttackBoatInitialRange": 50, + "botAttackBoatSearchRangeGrowth": 0.5, + "attackTroopThreshold": 0.5, + "attackOwnTroopPercent": 0.2, + "attackEnemyTroopMultiplier": 1.5, + "attackBoatTroopPercent": 0.1, + "attackBoatInitialRange": 50, + "attackBoatRangeGrowth": 0.5, + "defendingTroopTarget": 0.5, + "productivityInvestmentRate": 0.1, + "researchInvestmentRate": 0.2, + "roadInvestmentRate": 0.1, + "targetTroopRatio": 0.6, + "roadInvestmentCapToMaintenance": true, + "roadBuildBoost": 0.1, + "roadQualityAdjust": 0.01, + "targetRoadQuality": 100, + "buildCities": true, + "buildFactories": true, + "buildPorts": true, + "buildHospitals": true, + "buildAcademies": true, + "buildAirfields": true, + "buildResearchLabs": true, + "buildMissileSilos": false, + "buildSAMLaunchers": true, + "buildDefensePosts": true, + "buildDoomsdayDevices": false, + "buildWarships": true, + "aiAvoidPlayerDistance": 40, + "weightCity": 1, + "weightFactory": 1, + "weightPort": 1, + "weightHospital": 1, + "weightAcademy": 1, + "weightAirfield": 1, + "weightResearchLab": 1, + "weightMissileSilo": 1, + "weightSAMLauncher": 1, + "samCoverageDecay": 0.08, + "weightDefensePost": 1, + "weightDoomsdayDevice": 1, + "aiAssumedPopPercent": 0.7, + "tileNearPlayerPenalty": 0.6, + "tileNearStructurePenalty": 0.1, + "tileCapitalDistancePenalty": 0.0001, + "otherTileWaterCheckDistance": 20, + "otherTileNearWaterPenalty": 0.2, + "tileNearbyStructureStackBonus": 0.01, + "defensePostNearWaterPenalty": 0.8, + "warDeclarationThreshold": 100, + "warScoreSharedBorderWeight": 10, + "warScoreMilitaryStrengthWeight": 61, + "warScoreNonReachableEnemyWeight": 0.2, + "warScoreCoBelligerentDiscount": 0.1, + "warScoreAllyPenalty": 0, + "warScoreDistancePenaltyWeight": 1150, + "warScoreDominanceWeight": 32, + "peaceThresholdGap": 45, + "nukeScoreConstructionThreshold": 0, + "nukeFriendlyDamageWeight": 1.0, + "nukeScoreMultiplier": 0.1, + "discountFactor": 0.1, + "weightWarship": 1, + "warshipTradeIncomeWeight": 0, + "warshipCoastalThreatWeight": 0 + } + } + ] +} diff --git a/resources/lang/en.json b/resources/lang/en.json index 67fce0104..19c68dccf 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -603,9 +603,15 @@ "sent_emoji": "Sent {name}: {emoji}", "renew_alliance": "Request to renew", "request_alliance": "{name} requests an alliance!", + "request_peace": "{name} requests peace!", "focus": "Focus", "accept_alliance": "Accept", "reject_alliance": "Reject", + "accept_peace": "Accept", + "reject_peace": "Reject", + "peace_request_status": "{name} {status} your peace request", + "peace_accepted": "accepted", + "peace_rejected": "rejected", "alliance_renewed": "Your alliance with {name} has been renewed", "ignore": "Ignore", "paratrooper_sent": "Paratrooper", diff --git a/src/client/AICalibrationModal.ts b/src/client/AICalibrationModal.ts new file mode 100644 index 000000000..1758eb7cc --- /dev/null +++ b/src/client/AICalibrationModal.ts @@ -0,0 +1,677 @@ +import { LitElement, css, html } from "lit"; +import { customElement, query, state } from "lit/decorators.js"; +import { AIProfile, getAllAIProfiles } from "../core/ai/AIBehaviorParams"; +import { Difficulty, GameMapType, GameMode, GameType } from "../core/game/Game"; +import { generateID } from "../core/Util"; +import { CalibrationConfig, CalibrationResult } from "./CalibrationRunner"; +import type { + CalibrationWorkerMessage, + CalibrationWorkerRequest, +} from "./CalibrationWorker"; +import "./components/baseComponents/Button"; +import "./components/baseComponents/Modal"; +import type { JoinLobbyEvent } from "./Main"; + +interface BatchResult { + matchIndex: number; + result: CalibrationResult; +} + +@customElement("ai-calibration-modal") +export class AICalibrationModal extends LitElement { + @query("o-modal") private modalEl!: HTMLElement & { + open: () => void; + close: () => void; + }; + + @state() private numPlayers = 10; + @state() private selectedProfileA = ""; + @state() private selectedProfileB = ""; + @state() private selectedMap: GameMapType = GameMapType.WorldMap; + @state() private bots = 0; + @state() private maxTicks = 30000; + @state() private numMatches = 10; + @state() private isRunning = false; + @state() private completedMatches = 0; + @state() private totalMatches = 0; + @state() private batchResults: BatchResult[] = []; + @state() private renderMatch = false; + + private profiles: AIProfile[] = []; + private activeWorkers: Worker[] = []; + + connectedCallback() { + super.connectedCallback(); + this.profiles = getAllAIProfiles(); + if (this.profiles.length > 0) { + this.selectedProfileA = this.profiles[0].id; + this.selectedProfileB = + this.profiles.length > 1 ? this.profiles[1].id : this.profiles[0].id; + } + } + + static styles = css` + .calib-layout { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 500px; + } + + .calib-row { + display: flex; + gap: 12px; + align-items: center; + } + + .calib-row label { + min-width: 140px; + font-weight: 600; + color: var(--ui-text-default); + } + + .calib-row select, + .calib-row input { + flex: 1; + padding: 6px 10px; + border-radius: 6px; + border: 1px solid var(--ui-panel-border, #555); + background: var(--ui-input-bg, #2a2a2a); + color: var(--ui-text-default, #fff); + font-size: 14px; + } + + .calib-row input[type="range"] { + cursor: pointer; + } + + .calib-row .range-value { + min-width: 50px; + text-align: right; + font-variant-numeric: tabular-nums; + } + + .calib-section { + border-top: 1px solid var(--ui-panel-border, #555); + padding-top: 12px; + } + + .calib-progress { + background: var(--ui-input-bg, #2a2a2a); + border-radius: 8px; + padding: 12px; + font-family: monospace; + font-size: 13px; + max-height: 200px; + overflow-y: auto; + } + + .calib-progress-bar { + height: 8px; + background: var(--ui-panel-border, #555); + border-radius: 4px; + overflow: hidden; + margin-bottom: 8px; + } + + .calib-progress-bar-fill { + height: 100%; + background: var(--ui-primary, #4a9eff); + border-radius: 4px; + transition: width 0.3s ease; + } + + .calib-result { + background: var(--ui-input-bg, #2a2a2a); + border-radius: 8px; + padding: 16px; + text-align: center; + } + + .calib-result h3 { + margin: 0 0 8px 0; + font-size: 18px; + } + + .calib-result .winner-name { + font-size: 22px; + font-weight: bold; + color: var(--ui-primary, #4a9eff); + } + + .calib-result .profile-name { + font-size: 16px; + color: var(--ui-text-secondary, #aaa); + } + + .calib-result .tick-count { + font-size: 13px; + color: var(--ui-text-secondary, #aaa); + margin-top: 8px; + } + + .calib-checkbox { + display: flex; + align-items: center; + gap: 8px; + } + + .calib-checkbox input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + } + + .calib-player-list { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px 16px; + font-size: 13px; + } + + .calib-player-a { + color: #4a9eff; + } + + .calib-player-b { + color: #ff6b6b; + } + + .calib-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + } + + .draw-result { + color: var(--ui-text-secondary, #aaa); + } + + .calib-batch-summary { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + font-size: 18px; + font-weight: bold; + margin-bottom: 12px; + } + + .calib-vs { + color: var(--ui-text-secondary, #aaa); + font-size: 14px; + font-weight: normal; + } + + .calib-match-list { + max-height: 200px; + overflow-y: auto; + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 4px; + } + + .calib-match-row { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + padding: 4px 8px; + border-radius: 4px; + background: var(--ui-panel-bg, #1e1e1e); + } + + .calib-match-num { + min-width: 30px; + color: var(--ui-text-secondary, #aaa); + font-family: monospace; + } + + .calib-match-ticks { + margin-left: auto; + color: var(--ui-text-secondary, #aaa); + font-family: monospace; + font-size: 12px; + } + `; + + open() { + this.batchResults = []; + this.isRunning = false; + this.completedMatches = 0; + this.totalMatches = 0; + this.cleanupWorkers(); + this.modalEl.open(); + } + + close() { + this.cleanupWorkers(); + this.modalEl.close(); + } + + private cleanupWorkers() { + for (const w of this.activeWorkers) { + w.terminate(); + } + this.activeWorkers = []; + } + + render() { + const maps = Object.values(GameMapType); + const playerSliderPercent = ((this.numPlayers - 2) / 38) * 100; + const botSliderPercent = (this.bots / 400) * 100; + const matchSliderPercent = ((this.numMatches - 1) / 49) * 100; + + // Aggregate batch results + const profileAWins = this.batchResults.filter( + (r) => r.result.winnerProfile === this.selectedProfileA, + ).length; + const profileBWins = this.batchResults.filter( + (r) => r.result.winnerProfile === this.selectedProfileB, + ).length; + const draws = this.batchResults.filter( + (r) => r.result.winnerProfile === null, + ).length; + + return html` + +
+ +
+ + +
+ +
+ + +
+ + +
+ + + (this.numPlayers = Number( + (e.target as HTMLInputElement).value, + ))} + /> + ${this.numPlayers} +
+ + +
+ + +
+ + +
+ + + (this.bots = Number((e.target as HTMLInputElement).value))} + /> + ${this.bots} +
+ + +
+ + + (this.maxTicks = Number((e.target as HTMLInputElement).value))} + /> +
+ + +
+ +
+ + (this.renderMatch = (e.target as HTMLInputElement).checked)} + /> + ${this.renderMatch + ? "Will render 1 match (slower)" + : "Headless (fast)"} +
+
+ + + ${!this.renderMatch + ? html` +
+ + + (this.numMatches = Number( + (e.target as HTMLInputElement).value, + ))} + /> + ${this.numMatches} +
+ ` + : html``} + + + ${this.isRunning + ? html` +
+
+
+
+
+
+ Match ${this.completedMatches} / ${this.totalMatches} + completed (${this.totalMatches - this.completedMatches} + running on worker threads) +
+
+
+ ` + : html``} + + + ${this.batchResults.length > 0 + ? html` +
+
+

+ Results (${this.batchResults.length}/${this.totalMatches} + matches) +

+
+ ${this.getProfileName(this.selectedProfileA)}: + ${profileAWins} wins + vs + ${this.getProfileName(this.selectedProfileB)}: + ${profileBWins} wins + ${draws > 0 + ? html`(${draws} draws)` + : html``} +
+ + +
+ ${this.batchResults + .sort((a, b) => a.matchIndex - b.matchIndex) + .map( + (r) => html` +
+ #${r.matchIndex + 1} + ${r.result.winnerProfile + ? html` + + ${r.result.winnerPlayerName} + (${this.getProfileName( + r.result.winnerProfile, + )}) + + ` + : html`Draw`} + ${r.result.ticksElapsed} ticks +
+ `, + )} +
+
+
+ ` + : html``} + + +
+ ${this.isRunning + ? html`Running ${this.totalMatches} matches...` + : html` + 1 ? "es" : ""}`} + @click=${this.startCalibration} + > + `} +
+
+
+ `; + } + + private getProfileName(id: string): string { + return this.profiles.find((p) => p.id === id)?.name ?? id; + } + + private async startCalibration() { + const profileA = this.profiles.find((p) => p.id === this.selectedProfileA); + const profileB = this.profiles.find((p) => p.id === this.selectedProfileB); + + if (!profileA || !profileB) { + console.error("Profile not found"); + return; + } + + const calibConfig: CalibrationConfig = { + numPlayers: this.numPlayers, + profileA, + profileB, + gameMap: this.selectedMap, + bots: this.bots, + render: this.renderMatch, + maxTicks: this.maxTicks, + }; + + if (this.renderMatch) { + this.launchRenderedCalibration(calibConfig); + return; + } + + // Headless batch mode — spawn workers + this.isRunning = true; + this.batchResults = []; + this.completedMatches = 0; + this.totalMatches = this.numMatches; + this.cleanupWorkers(); + + const promises: Promise[] = []; + + for (let i = 0; i < this.numMatches; i++) { + promises.push(this.runMatchInWorker(i, calibConfig)); + } + + await Promise.all(promises); + this.isRunning = false; + } + + private runMatchInWorker( + matchIndex: number, + config: CalibrationConfig, + ): Promise { + return new Promise((resolve) => { + const worker = new Worker( + new URL("./CalibrationWorker.ts", import.meta.url), + ); + this.activeWorkers.push(worker); + + worker.addEventListener( + "message", + (e: MessageEvent) => { + const msg = e.data; + if (msg.type === "result") { + this.batchResults = [ + ...this.batchResults, + { matchIndex: msg.matchIndex, result: msg.result }, + ]; + this.completedMatches++; + } else if (msg.type === "error") { + console.error(`Match #${msg.matchIndex + 1} failed: ${msg.error}`); + this.batchResults = [ + ...this.batchResults, + { + matchIndex: msg.matchIndex, + result: { + winnerProfile: null, + winnerPlayerName: null, + winnerPlayerID: null, + ticksElapsed: 0, + profileAPlayers: [], + profileBPlayers: [], + }, + }, + ]; + this.completedMatches++; + } + + // Clean up this worker + worker.terminate(); + this.activeWorkers = this.activeWorkers.filter((w) => w !== worker); + resolve(); + }, + ); + + const request: CalibrationWorkerRequest = { + type: "run", + matchIndex, + config, + }; + worker.postMessage(request); + }); + } + + private launchRenderedCalibration(calibConfig: CalibrationConfig) { + const clientID = generateID(); + const gameID = generateID(); + + this.dispatchEvent( + new CustomEvent("join-lobby", { + detail: { + clientID: clientID, + gameID: gameID, + gameStartInfo: { + gameID: gameID, + players: [ + { + clientID, + username: "Spectator", + flag: "", + }, + ], + config: { + gameMap: calibConfig.gameMap, + gameType: GameType.Singleplayer, + gameMode: GameMode.FFA, + difficulty: Difficulty.Medium, + disableNPCs: false, + bots: calibConfig.bots, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + peaceTimerDurationMinutes: 0, + startingGold: 0, + goldMultiplier: 1, + chatEnabled: false, + }, + }, + calibration: { + numPlayers: calibConfig.numPlayers, + profileA: calibConfig.profileA, + profileB: calibConfig.profileB, + }, + } satisfies JoinLobbyEvent, + bubbles: true, + composed: true, + }), + ); + this.close(); + } +} diff --git a/src/client/CalibrationRunner.ts b/src/client/CalibrationRunner.ts new file mode 100644 index 000000000..5138e9630 --- /dev/null +++ b/src/client/CalibrationRunner.ts @@ -0,0 +1,420 @@ +/** + * AI Calibration Runner + * + * Runs AI-vs-AI matches on the world map to compare two AI profiles. + * Players are split evenly between two profiles and spawned uniformly. + * Reports the winner and which profile they used. + */ +import { AIBehaviorParams, AIProfile } from "../core/ai/AIBehaviorParams"; +import { getConfig } from "../core/configuration/ConfigLoader"; +import { AllianceExpireCheckExecution } from "../core/execution/alliance/AllianceExpireCheckExecution"; +import { CapitalRecalculationExecution } from "../core/execution/CapitalRecalculationExecution"; +import { Executor } from "../core/execution/ExecutionManager"; +import { TradeManagerExecution } from "../core/execution/TradeManagerExecution"; +import { WinCheckExecution } from "../core/execution/WinCheckExecution"; +import { + Cell, + Difficulty, + Game, + GameMapType, + GameMode, + GameType, + Nation, + PlayerInfo, + PlayerType, +} from "../core/game/Game"; +import { createGame } from "../core/game/GameImpl"; +import { TileRef } from "../core/game/GameMap"; +import { GameUpdateType, WinUpdate } from "../core/game/GameUpdates"; +import { + loadTerrainMapFresh, + TerrainMapData, +} from "../core/game/TerrainMapLoader"; +import { UserSettings } from "../core/game/UserSettings"; +import { PseudoRandom } from "../core/PseudoRandom"; +import { GameConfig, GameStartInfo, PeaceTimerDuration } from "../core/Schemas"; +import { generateID, simpleHash } from "../core/Util"; + +export interface CalibrationConfig { + /** Number of AI players total (split evenly between profiles) */ + numPlayers: number; + /** First AI profile */ + profileA: AIProfile; + /** Second AI profile */ + profileB: AIProfile; + /** Map to use */ + gameMap: GameMapType; + /** Number of bots (simple NPCs) to add, default 0 */ + bots: number; + /** Whether to render/watch the match */ + render: boolean; + /** Maximum ticks before declaring a draw (default: 30000 ~= 50 min at 10 ticks/sec) */ + maxTicks: number; +} + +export interface CalibrationResult { + winnerProfile: string | null; // profile id or null for draw + winnerPlayerName: string | null; + winnerPlayerID: string | null; + ticksElapsed: number; + profileAPlayers: string[]; + profileBPlayers: string[]; +} + +/** + * Callback for progress updates during calibration. + */ +export type CalibrationProgressCallback = (info: { + tick: number; + maxTicks: number; + players: { name: string; profile: string; tiles: number }[]; +}) => void; + +/** + * Run a headless calibration match. Returns the result when complete. + */ +export async function runCalibrationMatch( + calibrationConfig: CalibrationConfig, + progressCallback?: CalibrationProgressCallback, +): Promise { + const gameID = generateID(); + const spectatorClientID = generateID(); + const random = new PseudoRandom(simpleHash(gameID)); + + // Load a fresh (uncached) map so each run starts clean + const mapData = await loadTerrainMapFresh(calibrationConfig.gameMap); + + // Create game config + const gameConfig: GameConfig = { + gameMap: calibrationConfig.gameMap, + difficulty: Difficulty.Medium, + gameType: GameType.Singleplayer, + gameMode: GameMode.FFA, + disableNPCs: true, // We'll create our own AI nations + bots: calibrationConfig.bots, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + peaceTimerDurationMinutes: PeaceTimerDuration.None, + startingGold: 0, + goldMultiplier: 1, + chatEnabled: false, + }; + + const config = await getConfig(gameConfig, new UserSettings()); + + // Generate uniformly distributed spawn points on land + const spawnPoints = generateUniformSpawnPoints( + mapData, + calibrationConfig.numPlayers, + random, + ); + + // Build a shuffled profile assignment array so positions are random + const half = Math.floor(calibrationConfig.numPlayers / 2); + const profileAssignments: boolean[] = []; // true = Profile A + for (let i = 0; i < calibrationConfig.numPlayers; i++) { + profileAssignments.push(i < half); + } + // Fisher-Yates shuffle + for (let i = profileAssignments.length - 1; i > 0; i--) { + const j = random.nextInt(0, i + 1); + [profileAssignments[i], profileAssignments[j]] = [ + profileAssignments[j], + profileAssignments[i], + ]; + } + + // Create nations - profiles randomly assigned to spawn positions + const nations: Nation[] = []; + const profileMap = new Map(); + const profileAPlayers: string[] = []; + const profileBPlayers: string[] = []; + const playerProfileMap = new Map(); // playerID -> profileID + + let profileACount = 0; + let profileBCount = 0; + + for (let i = 0; i < calibrationConfig.numPlayers; i++) { + const isProfileA = profileAssignments[i]; + const profile = isProfileA + ? calibrationConfig.profileA + : calibrationConfig.profileB; + const num = isProfileA ? ++profileACount : ++profileBCount; + const playerName = `${profile.name} #${num}`; + const playerID = random.nextID(); + + const playerInfo = new PlayerInfo( + "", // flag + playerName, + PlayerType.AI, + null, + playerID, + ); + + const nation = new Nation( + new Cell( + mapData.gameMap.x(spawnPoints[i]), + mapData.gameMap.y(spawnPoints[i]), + ), + 1, // Equal strength for all + playerInfo, + ); + + nations.push(nation); + profileMap.set(playerID, profile.params); + playerProfileMap.set(playerID, profile.id); + + if (isProfileA) { + profileAPlayers.push(playerName); + } else { + profileBPlayers.push(playerName); + } + } + + // Create a dummy human player entry (spectator - never spawns) + const spectatorInfo = new PlayerInfo( + "", + "Spectator", + PlayerType.Human, + spectatorClientID, + random.nextID(), + ); + + // Create the game + const game: Game = createGame( + [spectatorInfo], + nations, + mapData.gameMap, + mapData.miniGameMap, + config, + ); + + // Set up executions + const executor = new Executor(game, gameID, spectatorClientID); + + // Spawn bots if configured + if (calibrationConfig.bots > 0) { + game.addExecution(...executor.spawnBots(calibrationConfig.bots)); + } + + // Add AI player executions with profile map + game.addExecution(...executor.aiPlayerExecutions(profileMap)); + + // Add standard game executions + game.addExecution(new WinCheckExecution()); + game.addExecution(new AllianceExpireCheckExecution()); + game.addExecution(new CapitalRecalculationExecution()); + game.addExecution(new TradeManagerExecution()); + + // Run the game tick loop + let tick = 0; + let winUpdate: WinUpdate | null = null; + + while (tick < calibrationConfig.maxTicks) { + // Execute tick (AI executions run automatically each tick) + const updates = game.executeNextTick(); + + tick++; + + // Check for win + const winUpdates = updates[GameUpdateType.Win]; + if (winUpdates && winUpdates.length > 0) { + winUpdate = winUpdates[0] as WinUpdate; + break; + } + + // Report progress every 100 ticks + if (progressCallback && tick % 100 === 0) { + const playerStats = game + .players() + .filter((p) => p.type() === PlayerType.AI) + .map((p) => ({ + name: p.name(), + profile: playerProfileMap.get(p.id()) ?? "unknown", + tiles: p.numTilesOwned(), + })); + + progressCallback({ + tick, + maxTicks: calibrationConfig.maxTicks, + players: playerStats, + }); + } + } + + // Determine the winner + let winnerProfile: string | null = null; + let winnerPlayerName: string | null = null; + let winnerPlayerID: string | null = null; + + if (winUpdate?.winner) { + const [winType, winnerId] = winUpdate.winner; + if (winType === "player") { + // Find the player by clientID or playerID + const winningPlayer = game + .players() + .find((p) => p.clientID() === winnerId || p.id() === winnerId); + + if (winningPlayer) { + winnerPlayerName = winningPlayer.name(); + winnerPlayerID = winningPlayer.id(); + winnerProfile = playerProfileMap.get(winningPlayer.id()) ?? null; + } + } + } + + return { + winnerProfile, + winnerPlayerName, + winnerPlayerID, + ticksElapsed: tick, + profileAPlayers, + profileBPlayers, + }; +} + +/** + * Creates a GameStartInfo for a calibration match that can be used with the + * normal rendering pipeline (LocalServer + ClientGameRunner). + */ +export function createCalibrationGameStartInfo( + calibrationConfig: CalibrationConfig, +): { + gameStartInfo: GameStartInfo; + clientID: string; + gameID: string; + nations: Nation[]; + profileMap: Map; + playerProfileMap: Map; + profileAPlayers: string[]; + profileBPlayers: string[]; +} { + const gameID = generateID(); + const spectatorClientID = generateID(); + const random = new PseudoRandom(simpleHash(gameID)); + + const gameConfig: GameConfig = { + gameMap: calibrationConfig.gameMap, + difficulty: Difficulty.Medium, + gameType: GameType.Singleplayer, + gameMode: GameMode.FFA, + disableNPCs: true, + bots: calibrationConfig.bots, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + peaceTimerDurationMinutes: PeaceTimerDuration.None, + startingGold: 0, + goldMultiplier: 1, + chatEnabled: false, + }; + + const profileMap = new Map(); + const playerProfileMap = new Map(); + const profileAPlayers: string[] = []; + const profileBPlayers: string[] = []; + const half = Math.floor(calibrationConfig.numPlayers / 2); + + // We'll create the nations later when map is loaded + // For the GameStartInfo, we just need the spectator player + const gameStartInfo: GameStartInfo = { + gameID, + config: gameConfig, + players: [ + { + clientID: spectatorClientID, + username: "Spectator", + flag: "", + }, + ], + }; + + return { + gameStartInfo, + clientID: spectatorClientID, + gameID, + nations: [], // will be populated when map loads + profileMap, + playerProfileMap, + profileAPlayers, + profileBPlayers, + }; +} + +/** + * Generate N uniformly distributed spawn points on land tiles. + * Uses a grid-based approach to ensure uniform distribution. + */ +function generateUniformSpawnPoints( + mapData: TerrainMapData, + numPlayers: number, + random: PseudoRandom, +): TileRef[] { + const gameMap = mapData.gameMap; + const width = gameMap.width(); + const height = gameMap.height(); + + // Collect all land tiles + const landTiles: TileRef[] = []; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const ref = gameMap.ref(x, y); + if (gameMap.isLand(ref)) { + landTiles.push(ref); + } + } + } + + if (landTiles.length < numPlayers) { + throw new Error( + `Not enough land tiles (${landTiles.length}) for ${numPlayers} players`, + ); + } + + // Use a greedy farthest-point sampling approach for uniform distribution + const selected: TileRef[] = []; + const minDistances = new Float32Array(landTiles.length).fill(Infinity); + + // Start with a random land tile + const firstIndex = random.nextInt(0, landTiles.length); + selected.push(landTiles[firstIndex]); + + // Update distances from the first point + for (let i = 0; i < landTiles.length; i++) { + const dist = gameMap.manhattanDist(landTiles[i], landTiles[firstIndex]); + if (dist < minDistances[i]) { + minDistances[i] = dist; + } + } + + // Greedily select the farthest point from all selected points + while (selected.length < numPlayers) { + let bestIndex = -1; + let bestDist = -1; + + for (let i = 0; i < landTiles.length; i++) { + if (minDistances[i] > bestDist) { + bestDist = minDistances[i]; + bestIndex = i; + } + } + + if (bestIndex === -1) break; + + selected.push(landTiles[bestIndex]); + minDistances[bestIndex] = 0; // Mark as selected + + // Update min distances + for (let i = 0; i < landTiles.length; i++) { + if (minDistances[i] > 0) { + const dist = gameMap.manhattanDist(landTiles[i], landTiles[bestIndex]); + if (dist < minDistances[i]) { + minDistances[i] = dist; + } + } + } + } + + return selected; +} diff --git a/src/client/CalibrationWorker.ts b/src/client/CalibrationWorker.ts new file mode 100644 index 000000000..405d2b2dc --- /dev/null +++ b/src/client/CalibrationWorker.ts @@ -0,0 +1,60 @@ +/** + * Web Worker entry point for running a single headless calibration match. + * Runs synchronously without yielding so it performs at full speed + * even in background tabs. + */ +import { + CalibrationConfig, + CalibrationResult, + runCalibrationMatch, +} from "./CalibrationRunner"; + +export interface CalibrationWorkerRequest { + type: "run"; + matchIndex: number; + config: CalibrationConfig; +} + +export interface CalibrationWorkerResponse { + type: "result"; + matchIndex: number; + result: CalibrationResult; +} + +export interface CalibrationWorkerError { + type: "error"; + matchIndex: number; + error: string; +} + +export type CalibrationWorkerMessage = + | CalibrationWorkerResponse + | CalibrationWorkerError; + +const ctx: Worker = self as any; + +ctx.addEventListener( + "message", + async (e: MessageEvent) => { + const { matchIndex, config } = e.data; + + try { + // Run without progress callback — no yields, maximum speed + const result = await runCalibrationMatch(config); + + const response: CalibrationWorkerResponse = { + type: "result", + matchIndex, + result, + }; + ctx.postMessage(response); + } catch (err) { + const errorResponse: CalibrationWorkerError = { + type: "error", + matchIndex, + error: err instanceof Error ? err.message : String(err), + }; + ctx.postMessage(errorResponse); + } + }, +); diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 6a662191c..f4842f2f1 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -69,6 +69,12 @@ export interface LobbyConfig { gameStartInfo?: GameStartInfo; // GameRecord exists when replaying an archived game. gameRecord?: GameRecord; + // Calibration mode data for AI-vs-AI matches. + calibration?: { + numPlayers: number; + profileA: import("../core/ai/AIBehaviorParams").AIProfile; + profileB: import("../core/ai/AIBehaviorParams").AIProfile; + }; } export function joinLobby( @@ -157,6 +163,7 @@ export async function createClientGame( const worker = new WorkerClient( lobbyConfig.gameStartInfo, lobbyConfig.clientID, + lobbyConfig.calibration, ); await worker.initialize(); const gameView = new GameView( @@ -324,7 +331,7 @@ export class ClientGameRunner { this.gameView .players() .filter((p) => - [PlayerType.Human, PlayerType.FakeHuman].includes( + [PlayerType.Human, PlayerType.AI].includes( p.type() as PlayerType, ), ), diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index c8a3d993f..2cf226732 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -120,7 +120,6 @@ import { maxUnitLevel, playerMaxUnitLevel, } from "../core/game/Upgradeables"; -import { ToggleBomberUpgradeModeEvent } from "./events/ToggleBomberUpgradeModeEvent"; import { ToggleUpgradeModeEvent } from "./events/ToggleUpgradeModeEvent"; import { TransformHandler } from "./graphics/TransformHandler"; import { UIState } from "./graphics/UIState"; @@ -407,11 +406,6 @@ export class InputHandler { this.uiState.upgradeMode = false; this.eventBus.emit(new ToggleUpgradeModeEvent(false)); } - // Disable bomber upgrade mode on build action - if (this.uiState.bomberUpgradeMode) { - this.uiState.bomberUpgradeMode = false; - this.eventBus.emit(new ToggleBomberUpgradeModeEvent(false)); - } const cell = this.transformHandler.screenToWorldCoordinates( this.lastPointerX, this.lastPointerY, @@ -511,12 +505,6 @@ export class InputHandler { this.uiState.upgradeMode = false; this.eventBus.emit(new ToggleUpgradeModeEvent(false)); } - // Disable bomber upgrade mode on build action - if (this.uiState.bomberUpgradeMode) { - this.uiState.bomberUpgradeMode = false; - this.eventBus.emit(new ToggleBomberUpgradeModeEvent(false)); - } - if ( this.uiState.pendingBuildUnitType === UnitType.Artillery && !this.validateArtilleryBuildDistance(tile, true) diff --git a/src/client/Main.ts b/src/client/Main.ts index c3b85e876..c482c044f 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -3,6 +3,7 @@ import { GameRecord, GameStartInfo, ID } from "../core/Schemas"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; +import { AICalibrationModal } from "./AICalibrationModal"; import { joinLobby } from "./ClientGameRunner"; import "./DarkModeButton"; import { DarkModeButton } from "./DarkModeButton"; @@ -82,6 +83,12 @@ export interface JoinLobbyEvent { gameStartInfo?: GameStartInfo; // GameRecord exists when replaying an archived game. gameRecord?: GameRecord; + // Calibration-specific data for AI-vs-AI matches. + calibration?: { + numPlayers: number; + profileA: import("../core/ai/AIBehaviorParams").AIProfile; + profileB: import("../core/ai/AIBehaviorParams").AIProfile; + }; } class Client { @@ -287,6 +294,28 @@ class Client { } }); + // AI Calibration modal — open with D key on main menu + const calibModal = document.querySelector( + "ai-calibration-modal", + ) as AICalibrationModal; + calibModal instanceof AICalibrationModal; + window.addEventListener("keydown", (e) => { + if ( + (e.key === "d" || e.key === "D") && + this.isOnMainMenu && + !(e.target instanceof HTMLInputElement) && + !(e.target instanceof HTMLTextAreaElement) + ) { + calibModal.open(); + } + }); + + // const ctModal = document.querySelector("chat-modal") as ChatModal; + // ctModal instanceof ChatModal; + // document.getElementById("chat-button").addEventListener("click", () => { + // ctModal.open(); + // }); + const hlpModal = document.querySelector("help-modal") as HelpModal; hlpModal instanceof HelpModal; const helpButton = document.getElementById("help-button"); @@ -539,6 +568,7 @@ class Client { clientID: lobby.clientID, gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, gameRecord: lobby.gameRecord, + calibration: lobby.calibration, }, () => { console.log("Closing modals"); @@ -558,6 +588,7 @@ class Client { "host-lobby-modal", "join-private-lobby-modal", "game-starting-modal", + "ai-calibration-modal", "top-bar", "help-modal", "user-setting", diff --git a/src/client/StatisticsModal.ts b/src/client/StatisticsModal.ts index b45c97ee1..e9785cd9f 100644 --- a/src/client/StatisticsModal.ts +++ b/src/client/StatisticsModal.ts @@ -82,9 +82,7 @@ export class StatisticsModal extends LitElement { return this.game .players() .filter((p) => - [PlayerType.Human, PlayerType.FakeHuman].includes( - p.type() as PlayerType, - ), + [PlayerType.Human, PlayerType.AI].includes(p.type() as PlayerType), ) .sort((a, b) => a.displayName().localeCompare(b.displayName())); } @@ -94,7 +92,7 @@ export class StatisticsModal extends LitElement { const me = this.game?.myPlayer(); if ( me && - [PlayerType.Human, PlayerType.FakeHuman].includes(me.type() as PlayerType) + [PlayerType.Human, PlayerType.AI].includes(me.type() as PlayerType) ) { this.selectedPlayerId = me.id(); return; @@ -338,9 +336,7 @@ export class StatisticsModal extends LitElement { } case "List": { const allPlayers = (this.game?.players?.() ?? []).filter((p) => - [PlayerType.Human, PlayerType.FakeHuman].includes( - p.type() as PlayerType, - ), + [PlayerType.Human, PlayerType.AI].includes(p.type() as PlayerType), ); const opts = this._availableListStats(); const rows = allPlayers.map((p) => { @@ -482,9 +478,7 @@ export class StatisticsModal extends LitElement { return this.game .players() .filter((p) => - [PlayerType.Human, PlayerType.FakeHuman].includes( - p.type() as PlayerType, - ), + [PlayerType.Human, PlayerType.AI].includes(p.type() as PlayerType), ) .slice() .sort((a, b) => a.displayName().localeCompare(b.displayName())); diff --git a/src/client/TechTooltips.ts b/src/client/TechTooltips.ts index c7ee8c104..c604b417c 100644 --- a/src/client/TechTooltips.ts +++ b/src/client/TechTooltips.ts @@ -29,7 +29,7 @@ export function getDetailedTechTooltip(techId: string): string { // --- LAND --- case RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS: - return `Unlocks:\n• Roads: Increases unit movement speed, generates passive trade income per connected tile\n• Trade Routes: Trade ships establish international commerce routes for continuous gold income`; + return `Unlocks:\n• Roads: Increases unit movement speed, generates passive trade income per connected tile`; case RESEARCH_TECH_IDS.LAND_MILITARY_ACADEMY: { const a1 = ARTILLERY_UPGRADES[0]; return `Unlocks:\n• City Anti-Air: Cities automatically engage enemy aircraft with AA batteries\n• Improved SAM: +35% range to 94.5 pixels, improved accuracy vs bombers/fighters/missiles\n• Artillery Level 1 (${a1.maxHealth} health, ${a1.damageMin}-${a1.damageMax} damage, 60 tile range): Land-based heavy artillery spawns from Factories`; diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 76259debe..244612205 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -54,10 +54,6 @@ export class SendUpgradeStructureIntentEvent implements GameEvent { ) {} } -export class SendUpgradeBomberIntentEvent implements GameEvent { - constructor(public readonly airfieldId: number) {} -} - export class SendAllianceReplyIntentEvent implements GameEvent { constructor( // The original alliance requestor @@ -74,6 +70,15 @@ export class SendPeaceRequestIntentEvent implements GameEvent { ) {} } +export class SendPeaceReplyIntentEvent implements GameEvent { + constructor( + // The original peace requestor + public readonly requestor: PlayerView, + public readonly recipient: PlayerView, + public readonly accepted: boolean, + ) {} +} + export class SendDeclareWarIntentEvent implements GameEvent { constructor( public readonly requestor: PlayerView, @@ -311,6 +316,9 @@ export class Transport { this.eventBus.on(SendPeaceRequestIntentEvent, (e) => this.onSendPeaceRequestIntent(e), ); + this.eventBus.on(SendPeaceReplyIntentEvent, (e) => + this.onPeaceRequestReplyUIEvent(e), + ); this.eventBus.on(SendDeclareWarIntentEvent, (e) => this.onSendDeclareWarIntent(e), ); @@ -355,9 +363,6 @@ export class Transport { this.eventBus.on(SendUpgradeStructureIntentEvent, (e) => this.onSendUpgradeStructureIntent(e), ); - this.eventBus.on(SendUpgradeBomberIntentEvent, (e) => - this.onSendUpgradeBomberIntent(e), - ); this.eventBus.on(SendParatrooperAttackIntentEvent, (e) => this.onSendParatrooperAttackIntent(e), ); @@ -616,6 +621,15 @@ export class Transport { }); } + private onPeaceRequestReplyUIEvent(event: SendPeaceReplyIntentEvent) { + this.sendIntent({ + type: "peaceRequestReply", + clientID: this.lobbyConfig.clientID, + requestor: event.requestor.id(), + accept: event.accepted, + }); + } + private onSendDeclareWarIntent(event: SendDeclareWarIntentEvent) { this.sendIntent({ type: "declareWar", @@ -764,7 +778,6 @@ export class Transport { // Read stack count from localStorage (in-game communication) let stackCount: number | undefined; - let bomberLevel: number | undefined; try { const rawStack = localStorage.getItem("buildSettings.stackCount"); if (rawStack) { @@ -776,7 +789,6 @@ export class Transport { } } catch { stackCount = undefined; - bomberLevel = undefined; } console.log( @@ -788,7 +800,6 @@ export class Transport { unit: event.unit, tile: event.tile, targetLevel: stackCount, // Renamed semantically but keeping wire format for now - bomberLevel, }); } @@ -802,14 +813,6 @@ export class Transport { }); } - private onSendUpgradeBomberIntent(event: SendUpgradeBomberIntentEvent) { - this.sendIntent({ - type: "upgrade_bomber", - clientID: this.lobbyConfig.clientID, - airfieldId: event.airfieldId, - }); - } - private onSendResearchTreeSelectIntent( event: SendResearchTreeSelectIntentEvent, ) { diff --git a/src/client/Utils.ts b/src/client/Utils.ts index 8f5a96114..6882c1d67 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -201,9 +201,13 @@ export function getMessageTypeClasses(type: MessageType): string { return severityColors["warn"]; // war start: highlight prominently case MessageType.CHAT: case MessageType.ALLIANCE_REQUEST: + case MessageType.PEACE_REQUEST: return severityColors["info"]; case MessageType.PEACE_MADE: + case MessageType.PEACE_ACCEPTED: return severityColors["success"]; // peace achieved: green + case MessageType.PEACE_REJECTED: + return severityColors["fail"]; default: console.warn(`Message type ${type} has no explicit color`); return severityColors["white"]; diff --git a/src/client/events/ToggleBomberUpgradeModeEvent.ts b/src/client/events/ToggleBomberUpgradeModeEvent.ts deleted file mode 100644 index fe4d550bb..000000000 --- a/src/client/events/ToggleBomberUpgradeModeEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { GameEvent } from "../../core/EventBus"; - -export class ToggleBomberUpgradeModeEvent implements GameEvent { - constructor(public readonly enabled: boolean) {} -} diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index c8d46fdfe..aa9e30d49 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -7,11 +7,13 @@ import { TransformHandler } from "./TransformHandler"; import { UIState } from "./UIState"; import { AABulletLayer } from "./layers/AABulletLayer"; import { ArtilleryLayer } from "./layers/ArtilleryLayer"; +import { AttackDebugOverlay } from "./layers/AttackDebugOverlay"; import { AttackWarningOverlay } from "./layers/AttackWarningOverlay"; import { BuildMenu } from "./layers/BuildMenu"; import { CargoTruckLayer } from "./layers/CargoTruckLayer"; import { ChatDisplay } from "./layers/ChatDisplay"; import { ChatModal } from "./layers/ChatModal"; +import { ConstructionDebugOverlay } from "./layers/ConstructionDebugOverlay"; import { ControlPanel } from "./layers/ControlPanel"; import { ControlPanel2 } from "./layers/ControlPanel2"; import { DevHud } from "./layers/DevHud"; @@ -40,10 +42,12 @@ import { TechUnlockNotification } from "./layers/TechUnlockNotification"; import { TerrainLayer } from "./layers/TerrainLayer"; import { TerritoryLayer } from "./layers/TerritoryLayer"; import { TopBar } from "./layers/TopBar"; +import { TradeDebugOverlay } from "./layers/TradeDebugOverlay"; import { TutorialToast } from "./layers/TutorialToast"; import { TutorialTriggers } from "./layers/TutorialTriggers"; import { UILayer } from "./layers/UILayer"; import { UnitLayer } from "./layers/UnitLayer"; +import { WarScoreOverlay } from "./layers/WarScoreOverlay"; import { WinModal } from "./layers/WinModal"; // Debug flags (keep off for normal gameplay) @@ -73,7 +77,6 @@ export function createRenderer( pendingBuildUnitType: null, multibuildEnabled: false, upgradeMode: false, - bomberUpgradeMode: false, unitLevels: {}, }; @@ -325,6 +328,10 @@ export function createRenderer( headsUpMessage, multiTabModal, new DevHud(game, transformHandler), + new WarScoreOverlay(game), + new AttackDebugOverlay(game), + new TradeDebugOverlay(game), + new ConstructionDebugOverlay(game), ]; return new GameRenderer( @@ -375,10 +382,10 @@ export class GameRenderer { } resizeCanvas() { - this.canvas.width = window.innerWidth; - this.canvas.height = window.innerHeight; + const dpr = window.devicePixelRatio || 1; + this.canvas.width = window.innerWidth * dpr; + this.canvas.height = window.innerHeight * dpr; this.transformHandler.updateCanvasBoundingRect(); - //this.redraw() } redraw() { @@ -392,13 +399,16 @@ export class GameRenderer { renderGame() { PerformanceMetrics.getInstance().resetVisibleCount(); const start = performance.now(); + const dpr = window.devicePixelRatio || 1; + // Apply DPR base transform so all drawing uses CSS-pixel coordinates + this.context.setTransform(dpr, 0, 0, dpr, 0, 0); // Set background this.context.fillStyle = this.game .config() .theme() .backgroundColor() .toHex(); - this.context.fillRect(0, 0, this.canvas.width, this.canvas.height); + this.context.fillRect(0, 0, window.innerWidth, window.innerHeight); // Render layers in order, switching transform state as needed let isTransformed = false; @@ -445,7 +455,8 @@ export class GameRenderer { } resize(width: number, height: number): void { - this.canvas.width = Math.ceil(width / window.devicePixelRatio); - this.canvas.height = Math.ceil(height / window.devicePixelRatio); + const dpr = window.devicePixelRatio || 1; + this.canvas.width = Math.ceil(width * dpr); + this.canvas.height = Math.ceil(height * dpr); } } diff --git a/src/client/graphics/TransformHandler.ts b/src/client/graphics/TransformHandler.ts index a493b64fc..5f1dda855 100644 --- a/src/client/graphics/TransformHandler.ts +++ b/src/client/graphics/TransformHandler.ts @@ -56,17 +56,18 @@ export class TransformHandler { } handleTransform(context: CanvasRenderingContext2D) { + const dpr = window.devicePixelRatio || 1; // Disable image smoothing for pixelated effect context.imageSmoothingEnabled = false; - // Apply zoom and pan + // Apply zoom and pan, scaled by DPR for the high-res backing buffer context.setTransform( - this.scale, + this.scale * dpr, 0, 0, - this.scale, - this.game.width() / 2 - this.offsetX * this.scale, - this.game.height() / 2 - this.offsetY * this.scale, + this.scale * dpr, + (this.game.width() / 2 - this.offsetX * this.scale) * dpr, + (this.game.height() / 2 - this.offsetY * this.scale) * dpr, ); } diff --git a/src/client/graphics/UIState.ts b/src/client/graphics/UIState.ts index 8984ccbb2..eacb9a805 100644 --- a/src/client/graphics/UIState.ts +++ b/src/client/graphics/UIState.ts @@ -7,8 +7,6 @@ export interface UIState { multibuildEnabled: boolean; // Whether the player is currently in city upgrade targeting mode upgradeMode: boolean; - // Whether the player is currently in bomber upgrade targeting mode - bomberUpgradeMode: boolean; // Local client-side unit levels (id -> level) unitLevels: Record; } diff --git a/src/client/graphics/layers/AABulletLayer.ts b/src/client/graphics/layers/AABulletLayer.ts index e860a306e..a00a94452 100644 --- a/src/client/graphics/layers/AABulletLayer.ts +++ b/src/client/graphics/layers/AABulletLayer.ts @@ -41,7 +41,18 @@ export class AABulletLayer implements Layer { this.pixiCanvas = document.createElement("canvas"); this.pixiCanvas.width = window.innerWidth; this.pixiCanvas.height = window.innerHeight; + + // DOM overlay: avoids expensive WebGL-to-2D drawImage compositing. + this.pixiCanvas.style.position = "fixed"; + this.pixiCanvas.style.left = "0"; + this.pixiCanvas.style.top = "0"; + this.pixiCanvas.style.width = "100%"; + this.pixiCanvas.style.height = "100%"; this.pixiCanvas.style.pointerEvents = "none"; + // Above UnitLayer PIXI (z-33), below FxLayer PIXI (z-35) + this.pixiCanvas.style.zIndex = "34"; + document.body.appendChild(this.pixiCanvas); + this.stage = new PIXI.Container(); this.bulletContainer = new PIXI.Container(); this.stage.addChild(this.bulletContainer); @@ -112,8 +123,6 @@ export class AABulletLayer implements Layer { renderLayer(context: CanvasRenderingContext2D) { const now = Date.now(); if (now - this.lastRefresh < this.refreshRate) { - // Still draw the cached frame - context.drawImage(this.pixiCanvas, 0, 0); return; } this.lastRefresh = now; @@ -163,11 +172,8 @@ export class AABulletLayer implements Layer { bullet.graphics.fill({ color: 0xffdd66, alpha: 0.4 }); } - // Render PIXI stage + // Render PIXI stage to its own DOM-overlaid canvas. this.renderer.render(this.stage); - - // Draw onto the main canvas - context.drawImage(this.pixiCanvas, 0, 0); } redraw() { diff --git a/src/client/graphics/layers/ArtilleryLayer.ts b/src/client/graphics/layers/ArtilleryLayer.ts index e2c3910d4..908146672 100644 --- a/src/client/graphics/layers/ArtilleryLayer.ts +++ b/src/client/graphics/layers/ArtilleryLayer.ts @@ -76,6 +76,18 @@ export class ArtilleryLayer implements Layer { this.pixiCanvas = document.createElement("canvas"); this.pixiCanvas.width = window.innerWidth; this.pixiCanvas.height = window.innerHeight; + + // DOM overlay: avoids expensive WebGL-to-2D drawImage compositing. + this.pixiCanvas.style.position = "fixed"; + this.pixiCanvas.style.left = "0"; + this.pixiCanvas.style.top = "0"; + this.pixiCanvas.style.width = "100%"; + this.pixiCanvas.style.height = "100%"; + this.pixiCanvas.style.pointerEvents = "none"; + // Above StructureLayer PIXI (z-31), below UnitLayer PIXI (z-33) + this.pixiCanvas.style.zIndex = "32"; + document.body.appendChild(this.pixiCanvas); + this.stage = new PIXI.Container(); await this.renderer.init({ canvas: this.pixiCanvas, @@ -170,7 +182,6 @@ export class ArtilleryLayer implements Layer { } this.renderer.render(this.stage); - mainContext.drawImage(this.renderer.canvas, 0, 0); } private updateSpritePosition(render: ArtilleryRenderInfo) { diff --git a/src/client/graphics/layers/AttackDebugOverlay.ts b/src/client/graphics/layers/AttackDebugOverlay.ts new file mode 100644 index 000000000..d93291e84 --- /dev/null +++ b/src/client/graphics/layers/AttackDebugOverlay.ts @@ -0,0 +1,235 @@ +import { + AttackDebugData, + AttackTargetBreakdown, +} from "../../../core/ai/AIAttackHandler"; +import { GameView } from "../../../core/game/GameView"; +import { Layer } from "./Layer"; + +/** + * Debug overlay toggled by F10 that shows AI attack diagnostics + * for every AI player: troop thresholds, boat status, per-enemy + * attack path and block reasons. + * + * Temporary – remove when calibration is done. + */ +export class AttackDebugOverlay implements Layer { + layerName = "AttackDebugOverlay"; + private container: HTMLDivElement | null = null; + private visible = false; + private lastFetch = 0; + private cachedData: AttackDebugData[] = []; + private fetching = false; + private selectedPlayer: string | null = null; + + private static readonly REFRESH_INTERVAL = 2000; + + constructor(private game: GameView) {} + + init() { + this.container = document.createElement("div"); + Object.assign(this.container.style, { + position: "fixed", + top: "10px", + right: "10px", + maxWidth: "95vw", + maxHeight: "90vh", + overflowY: "auto", + overflowX: "auto", + backgroundColor: "rgba(0, 0, 0, 0.88)", + color: "#e0e0e0", + fontFamily: "monospace", + fontSize: "11px", + padding: "8px 10px", + zIndex: "200", + pointerEvents: "auto", + display: "none", + borderRadius: "4px", + border: "1px solid rgba(255,255,255,0.15)", + }); + document.body.appendChild(this.container); + + this.container.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + const id = target.dataset.playerId; + if (id !== undefined) { + this.selectedPlayer = id === this.selectedPlayer ? null : id; + this.renderContent(); + } + }); + + window.addEventListener("keydown", (e) => { + if (e.key === "F10") { + e.preventDefault(); + this.visible = !this.visible; + if (this.container) { + this.container.style.display = this.visible ? "block" : "none"; + } + if (this.visible) { + this.fetchData(); + } + } + }); + } + + renderLayer(_ctx: CanvasRenderingContext2D) { + if (!this.visible) return; + + const now = performance.now(); + if (now - this.lastFetch >= AttackDebugOverlay.REFRESH_INTERVAL) { + this.fetchData(); + } + } + + private fetchData() { + if (this.fetching) return; + this.fetching = true; + this.lastFetch = performance.now(); + + this.game.worker + .attackDebug() + .then((data) => { + this.cachedData = data; + this.renderContent(); + }) + .catch((err) => { + console.warn("AttackDebugOverlay fetch failed:", err); + }) + .finally(() => { + this.fetching = false; + }); + } + + private renderContent() { + if (!this.container) return; + + if (this.cachedData.length === 0) { + this.container.innerHTML = + '
No AI players with attack data.
'; + return; + } + + // Auto-select first player if none selected + if ( + this.selectedPlayer === null || + !this.cachedData.find((d) => d.playerId === this.selectedPlayer) + ) { + this.selectedPlayer = this.cachedData[0].playerId; + } + + // Build tabs + const tabs = this.cachedData + .map((d) => { + const active = d.playerId === this.selectedPlayer; + const hasWar = d.targets.length > 0; + const bg = active + ? "background: rgba(80,120,200,0.5);" + : hasWar + ? "background: rgba(255,100,100,0.2);" + : "background: rgba(255,255,255,0.1);"; + return `${this.esc(d.playerName)}`; + }) + .join(""); + + const sel = this.cachedData.find((d) => d.playerId === this.selectedPlayer); + let body = ""; + if (sel) { + body = this.renderPlayerDetail(sel); + } + + this.container.innerHTML = ` +
+ AI Attack Debug (F10 to close) +
+
${tabs}
+ ${body} + `; + } + + private renderPlayerDetail(d: AttackDebugData): string { + const reached = d.handleAttackReached + ? 'YES' + : 'NO (suppressed by TN/Bot)'; + + const troopColor = + d.troopRatio >= d.attackThreshold ? "#88cc88" : "#ff5555"; + const defColor = + d.defendingRatio >= d.defendingTarget ? "#88cc88" : "#ff5555"; + const oceanColor = d.bordersOcean ? "#88cc88" : "#ff5555"; + const boatColor = d.boatCount < d.boatMax ? "#88cc88" : "#ff5555"; + const cooldownOk = + d.ticksSinceLastBoat >= d.boatCooldown ? "#88cc88" : "#ffaa00"; + + const summary = ` +
+ handleAttack reached: ${reached} +  |  last tick: ${d.lastHandleAttackTick} +
+ troopRatio: ${d.troopRatio.toFixed(3)} / ${d.attackThreshold.toFixed(3)} +  |  defendingRatio: ${d.defendingRatio.toFixed(3)} / ${d.defendingTarget.toFixed(3)} +
+ bordersOcean: ${d.bordersOcean} +  |  oceanShore: ${d.oceanShoreTileCount} tiles +  |  boats: ${d.boatCount}/${d.boatMax} +  |  boatCooldown: ${d.ticksSinceLastBoat}/${d.boatCooldown} +  |  boatRange: ${d.boatSearchRange.toFixed(1)} +
+ `; + + if (d.targets.length === 0) { + return summary + '
No enemies at war.
'; + } + + const rows = d.targets.map((t) => this.renderTargetRow(t)).join(""); + + const table = ` + + + + + + + + + ${rows} +
EnemyBorderPathEnemyOceanBoatDistStatus / Block Reason
`; + + return summary + table; + } + + private renderTargetRow(t: AttackTargetBreakdown): string { + const borderBadge = t.sharesBorder + ? 'YES' + : 'no'; + + const pathBadge = + t.attackPath === "land" + ? 'LAND' + : t.attackPath === "boat" + ? 'BOAT' + : ''; + + const oceanBadge = t.enemyBordersOcean + ? 'yes' + : 'NO'; + + const isOk = t.blockReason.startsWith("OK"); + const statusColor = isOk ? "#88cc88" : "#ff5555"; + + const rowBg = isOk + ? "background: rgba(50,200,50,0.07);" + : "background: rgba(255,50,50,0.07);"; + + return ` + ${this.esc(t.targetName)} + ${borderBadge} + ${pathBadge} + ${oceanBadge} + ${t.boatDistance > 0 ? t.boatDistance : "—"} + ${this.esc(t.blockReason)} + `; + } + + private esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); + } +} diff --git a/src/client/graphics/layers/AttackWarningOverlay.ts b/src/client/graphics/layers/AttackWarningOverlay.ts index 400de2bbe..88ef063f7 100644 --- a/src/client/graphics/layers/AttackWarningOverlay.ts +++ b/src/client/graphics/layers/AttackWarningOverlay.ts @@ -86,7 +86,7 @@ export class AttackWarningOverlay extends LitElement implements Layer { return; } - // Only consider attacks from human or fakehuman players (not bots) + // Only consider attacks from human or AI players (not bots) const incomingAttacks = myPlayer.incomingAttacks().filter((attack) => { const attacker = this.game.playerBySmallID(attack.attackerID); if ( diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index 895e178bd..e580c862f 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -21,10 +21,7 @@ import shieldIcon from "../../../../resources/images/ShieldIconWhite.svg"; import submarineIcon from "../../../../resources/images/submarine.svg"; import { translateText } from "../../../client/Utils"; import { EventBus } from "../../../core/EventBus"; -import { - aggregateStructureBuildCost, - computeBomberUpgradeCost, -} from "../../../core/game/Costs"; +import { aggregateStructureBuildCost } from "../../../core/game/Costs"; import { Gold, UnitType, UpgradeType } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { @@ -37,7 +34,6 @@ import { playerMaxStructureTechLevel, playerMaxUnitLevel, } from "../../../core/game/Upgradeables"; -import { ToggleBomberUpgradeModeEvent } from "../../events/ToggleBomberUpgradeModeEvent"; import { ToggleUpgradeModeEvent } from "../../events/ToggleUpgradeModeEvent"; import { displayKey, renderNumber } from "../../Utils"; import { UIState } from "../UIState"; @@ -561,7 +557,7 @@ export class BuildMenu extends LitElement { // Stackable structures: use stack count for cost calculation if (isStackableStructure(item.unitType)) { const stackCount = this._desiredStackCount(item.unitType); - let structureCost = + const structureCost = stackCount <= 1 ? base : aggregateStructureBuildCost( @@ -571,16 +567,6 @@ export class BuildMenu extends LitElement { stackCount, this.game.config().structureUpgradeCostMultiplier(item.unitType), ); - // Add bomber upgrade cost for airfields (based on tech level, not stack) - if (item.unitType === UnitType.Airfield) { - const bomberLevel = this._structureTechLevel(UnitType.Airfield); - structureCost += computeBomberUpgradeCost( - this.game.config(), - this.game.myPlayer()!, - bomberLevel, - stackCount, - ); - } return structureCost; } // Units: use hardcoded costs from UnitUpgrades (aggregateStructureBuildCost handles this) @@ -794,11 +780,6 @@ export class BuildMenu extends LitElement { this.uiState.upgradeMode = false; this.eventBus?.emit(new ToggleUpgradeModeEvent(false)); } - // Disable bomber upgrade mode on build action - if (this.uiState?.bomberUpgradeMode) { - this.uiState.bomberUpgradeMode = false; - this.eventBus?.emit(new ToggleBomberUpgradeModeEvent(false)); - } if (this.uiState.pendingBuildUnitType === item.unitType) { this.uiState.pendingBuildUnitType = null; } else { diff --git a/src/client/graphics/layers/ConstructionDebugOverlay.ts b/src/client/graphics/layers/ConstructionDebugOverlay.ts new file mode 100644 index 000000000..6b636582b --- /dev/null +++ b/src/client/graphics/layers/ConstructionDebugOverlay.ts @@ -0,0 +1,353 @@ +import { + ConstructionDebugData, + ConstructionScoreEntry, + NukeScoreDebugInfo, + NukeSequenceDebugInfo, + UnitScoreEntry, +} from "../../../core/ai/ConstructionDebugData"; +import { GameView } from "../../../core/game/GameView"; +import { Layer } from "./Layer"; + +/** + * Debug overlay toggled by F12 that shows AI construction/spending diagnostics + * for every AI player: what it's saving up for, structure/unit/nuke scores, + * active nuke sequence state, and cost breakdowns. + * + * Temporary – remove when calibration is done. + */ +export class ConstructionDebugOverlay implements Layer { + layerName = "ConstructionDebugOverlay"; + private container: HTMLDivElement | null = null; + private visible = false; + private lastFetch = 0; + private cachedData: ConstructionDebugData[] = []; + private fetching = false; + private selectedPlayer: string | null = null; + + private static readonly REFRESH_INTERVAL = 2000; + + constructor(private game: GameView) {} + + init() { + this.container = document.createElement("div"); + Object.assign(this.container.style, { + position: "fixed", + top: "10px", + right: "10px", + maxWidth: "95vw", + maxHeight: "90vh", + overflowY: "auto", + overflowX: "auto", + backgroundColor: "rgba(0, 0, 0, 0.88)", + color: "#e0e0e0", + fontFamily: "monospace", + fontSize: "11px", + padding: "8px 10px", + zIndex: "200", + pointerEvents: "auto", + display: "none", + borderRadius: "4px", + border: "1px solid rgba(255,255,255,0.15)", + }); + document.body.appendChild(this.container); + + this.container.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + const id = target.dataset.playerId; + if (id !== undefined) { + this.selectedPlayer = id === this.selectedPlayer ? null : id; + this.renderContent(); + } + }); + + window.addEventListener("keydown", (e) => { + if (e.key === "F12") { + e.preventDefault(); + this.visible = !this.visible; + if (this.container) { + this.container.style.display = this.visible ? "block" : "none"; + } + if (this.visible) { + this.fetchData(); + } + } + }); + } + + renderLayer(_ctx: CanvasRenderingContext2D) { + if (!this.visible) return; + + const now = performance.now(); + if (now - this.lastFetch >= ConstructionDebugOverlay.REFRESH_INTERVAL) { + this.fetchData(); + } + } + + private fetchData() { + if (this.fetching) return; + this.fetching = true; + this.lastFetch = performance.now(); + + this.game.worker + .constructionDebug() + .then((data) => { + this.cachedData = data; + this.renderContent(); + }) + .catch((err) => { + console.warn("ConstructionDebugOverlay fetch failed:", err); + }) + .finally(() => { + this.fetching = false; + }); + } + + private renderContent() { + if (!this.container) return; + + if (this.cachedData.length === 0) { + this.container.innerHTML = + '
No AI players with construction data.
'; + return; + } + + // Auto-select first player if none selected + if ( + this.selectedPlayer === null || + !this.cachedData.find((d) => d.playerId === this.selectedPlayer) + ) { + this.selectedPlayer = this.cachedData[0].playerId; + } + + // Build tabs + const tabs = this.cachedData + .map((d) => { + const active = d.playerId === this.selectedPlayer; + const isNuking = d.spendingWinner === "nuke"; + const bg = active + ? "background: rgba(80,120,200,0.5);" + : isNuking + ? "background: rgba(255,100,100,0.2);" + : "background: rgba(255,255,255,0.1);"; + return `${this.esc(d.playerName)}`; + }) + .join(""); + + const sel = this.cachedData.find((d) => d.playerId === this.selectedPlayer); + let body = ""; + if (sel) { + body = this.renderPlayerDetail(sel); + } + + this.container.innerHTML = ` +
+ AI Construction Debug (F12 to close) +
+
${tabs}
+ ${body} + `; + } + + private renderPlayerDetail(d: ConstructionDebugData): string { + const winnerColors: Record = { + construction: "#88cc88", + unit: "#4488cc", + nuke: "#ff5555", + none: "#888", + }; + const winnerColor = winnerColors[d.spendingWinner] ?? "#888"; + + const summary = ` +
+ Gold: ${this.formatNum(d.gold)} +  |  Income/min: ${this.formatNum(d.goldPerMinute)} +
+ Spending Winner: ${d.spendingWinner} +
+ Best Construction Score: ${this.formatScore(d.bestConstructionScore)} +  |  Best Unit Score: ${this.formatScore(d.bestUnitScore)} +  |  Adjusted Nuke Score: ${this.formatScore(d.nukeScores.adjustedBestNukeScore)} +
+ `; + + const constructionTable = this.renderConstructionScores( + d.constructionScores, + ); + const unitTable = this.renderUnitScores(d.unitScores); + const nukeSection = this.renderNukeScores(d.nukeScores); + const nukeSequenceSection = this.renderNukeSequence(d.nukeSequence); + + return ( + summary + + constructionTable + + unitTable + + nukeSection + + nukeSequenceSection + ); + } + + private renderConstructionScores(scores: ConstructionScoreEntry[]): string { + if (scores.length === 0) + return '
No construction candidates.
'; + + const rows = scores + .map((s) => { + const isTop = s === scores[0] && s.score > 0; + const rowBg = isTop ? "background: rgba(50,200,50,0.07);" : ""; + const upgBadge = s.upgradePreferred + ? 'UPG' + : ""; + return ` + ${this.esc(s.unitType)}${upgBadge} + ${this.formatScore(s.score)} + `; + }) + .join(""); + + return ` +
+ Structure Scores + + + + + + ${rows} +
StructureScore
+
+ `; + } + + private renderUnitScores(scores: UnitScoreEntry[]): string { + if (scores.length === 0) + return '
No unit candidates.
'; + + const rows = scores + .map((s) => { + const isTop = s === scores[0] && s.score > 0; + const rowBg = isTop ? "background: rgba(50,100,200,0.07);" : ""; + return ` + ${this.esc(s.unitType)} + ${this.formatScore(s.score)} + `; + }) + .join(""); + + return ` +
+ Unit Scores + + + + + + ${rows} +
UnitScore
+
+ `; + } + + private renderNukeScores(n: NukeScoreDebugInfo): string { + const atomColor = n.bestAtomScore > 0 ? "#ff8888" : "#888"; + const hydroColor = n.bestHydrogenScore > 0 ? "#ff5555" : "#888"; + + return ` +
+ Nuke Scores + + + + + + + + + + + + + + + + +
TypeRaw ScoreTarget
Atom Bomb${this.formatScore(n.bestAtomScore)}${this.esc(n.bestAtomTargetPlayerName)}
Hydrogen Bomb${this.formatScore(n.bestHydrogenScore)}${this.esc(n.bestHydrogenTargetPlayerName)}
+
+ Adjusted best nuke score (×multiplier×7): ${this.formatScore(n.adjustedBestNukeScore)} +
+
+ `; + } + + private renderNukeSequence(seq: NukeSequenceDebugInfo | null): string { + if (!seq) { + return '
Nuke Sequence: idle
'; + } + + const phaseColors: Record = { + waitForFunds: "#ffaa00", + buildSilo: "#ff8844", + launchSAMs: "#ff5555", + waitForMain: "#ff4444", + launchMain: "#ff0000", + }; + const phaseColor = phaseColors[seq.phase] ?? "#888"; + + return ` +
+ Active Nuke Sequence + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Phase:${this.esc(seq.phase)}
Bomb Type:${this.esc(seq.bombType)}
Target Player:${this.esc(seq.targetPlayerName)}
SAM Nukes Needed:${seq.samNukesNeeded}
Silo Capacity:${seq.siloCapacity}
Total Bombs Needed:${seq.bombsNeeded}
Est. Total Cost:${this.formatNum(seq.estimatedTotalCost)}
Current Score:${this.formatScore(seq.currentScore)}
+
+ `; + } + + private formatScore(n: number): string { + if (n === 0) return '0'; + if (Math.abs(n) >= 1e6) return n.toExponential(2); + if (Math.abs(n) >= 1000) return Math.round(n).toLocaleString(); + if (Math.abs(n) >= 1) return n.toFixed(1); + return n.toExponential(2); + } + + private formatNum(n: number): string { + if (n >= 1e6) return (n / 1e6).toFixed(2) + "M"; + if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"; + return Math.round(n).toString(); + } + + private esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); + } +} diff --git a/src/client/graphics/layers/ControlPanel2.ts b/src/client/graphics/layers/ControlPanel2.ts index 40836922b..951f0a6f0 100644 --- a/src/client/graphics/layers/ControlPanel2.ts +++ b/src/client/graphics/layers/ControlPanel2.ts @@ -24,7 +24,6 @@ import { type InvestmentSyncDetail, } from "../../events/InvestmentEvents"; import { PlayerListChangedEvent } from "../../events/PlayerListChangedEvent"; -import { ToggleBomberUpgradeModeEvent } from "../../events/ToggleBomberUpgradeModeEvent"; import { ToggleUpgradeModeEvent } from "../../events/ToggleUpgradeModeEvent"; import { AttackRatioEvent } from "../../InputHandler"; import "../../StatisticsModal"; // ensure statistics modal is registered @@ -797,11 +796,6 @@ export class ControlPanel2 extends LitElement implements Layer { this.uiState.upgradeMode = false; this.eventBus.emit(new ToggleUpgradeModeEvent(false)); } - // Disable bomber upgrade mode if mass production is enabled - if (this._multibuildEnabled && this.uiState.bomberUpgradeMode) { - this.uiState.bomberUpgradeMode = false; - this.eventBus.emit(new ToggleBomberUpgradeModeEvent(false)); - } this.requestUpdate(); } @@ -1226,12 +1220,6 @@ export class ControlPanel2 extends LitElement implements Layer { this._multibuildEnabled = false; this.uiState.multibuildEnabled = false; } - if (enabled && this.uiState.bomberUpgradeMode) { - this.uiState.bomberUpgradeMode = false; - this.eventBus.emit( - new ToggleBomberUpgradeModeEvent(false), - ); - } if (enabled) { this.uiState.pendingBuildUnitType = null; } @@ -1743,14 +1731,13 @@ export class ControlPanel2 extends LitElement implements Layer { const myPorts = me.units(UnitType.Port).filter((u) => u.isActive()); if (myPorts.length === 0) return html``; - // Count MY trade ships (not global) - const myTradeShips = me - .units(UnitType.TradeShip) - .filter((u) => u.isActive()); - const myShipCount = myTradeShips.length; + const queueLen = me.tradeDemandQueueLength(); + + // Use player method for metrics calculation + const metrics = me.tradeDemandMetrics(queueLen); // If I have no trade ships, show "No Ships" - if (myShipCount === 0) { + if (metrics.shipCount === 0) { const icon = html` { - const isReturning = s.returning(); - const phase = s.tradePhase(); - const hasTarget = s.targetUnitId() !== undefined; - const dockOwner = s.dockedAtPortOwner(); - // Available = at my port, not assigned, not in transit - return ( - !isReturning && - phase === null && - !hasTarget && - dockOwner?.smallID() === me.smallID() - ); - }).length; - - const queueLen = me.tradeDemandQueueLength(); - // Only update cache if values changed significantly (reduce re-render flicker) const now = Date.now(); const cacheValid = this._tradeDemandCache !== null && - this._tradeDemandCache.queueLen === queueLen && - this._tradeDemandCache.availableShips === availableShips && - this._tradeDemandCache.myShipCount === myShipCount && + this._tradeDemandCache.queueLen === metrics.queueLen && + this._tradeDemandCache.availableShips === metrics.availableShips && + this._tradeDemandCache.myShipCount === metrics.shipCount && now - this._tradeDemandCache.timestamp < 2000; // 2 second cache if (cacheValid) { @@ -1847,37 +1817,33 @@ export class ControlPanel2 extends LitElement implements Layer { `; } - // Compare queue vs MY ships (not global) - const queueRatio = queueLen / Math.max(1, myShipCount); - const availableRatio = availableShips / Math.max(1, myShipCount); - let demandLabel = "Medium"; let demandColor = "var(--ui-text-default)"; - // High demand = lots of routes waiting, need more ships - if (queueRatio > 2) { + // Check available ships first (low demand = surplus capacity) + if (metrics.availableRatio > 0.6) { + demandLabel = "Very Low"; + demandColor = "var(--ui-info)"; + } else if (metrics.availableRatio > 0.3) { + demandLabel = "Low"; + demandColor = "var(--ui-success)"; + } else if (metrics.queueRatio > 2) { + // High demand = lots of routes waiting, need more ships demandLabel = "Very High"; demandColor = "var(--ui-alert)"; - } else if (queueRatio > 1) { + } else if (metrics.queueRatio > 1) { demandLabel = "High"; demandColor = "var(--ui-warning)"; - } else if (availableRatio > 0.5) { - // Low demand = most ships idle, surplus capacity - demandLabel = "Low"; - demandColor = "var(--ui-success)"; - } else if (queueLen === 0 && availableShips > 0) { - demandLabel = "Very Low"; - demandColor = "var(--ui-info)"; } // Update cache - const tooltipText = `Trade Demand: ${queueLen} routes waiting, ${availableShips}/${myShipCount} ships available`; + const tooltipText = `Trade Demand: ${metrics.queueLen} routes waiting, ${metrics.availableShips}/${metrics.shipCount} ships available`; this._tradeDemandCache = { label: demandLabel, color: demandColor, - queueLen, - availableShips, - myShipCount, + queueLen: metrics.queueLen, + availableShips: metrics.availableShips, + myShipCount: metrics.shipCount, tooltip: tooltipText, timestamp: now, }; diff --git a/src/client/graphics/layers/DevHud.ts b/src/client/graphics/layers/DevHud.ts index 31873e690..f29cd3d1a 100644 --- a/src/client/graphics/layers/DevHud.ts +++ b/src/client/graphics/layers/DevHud.ts @@ -150,7 +150,7 @@ export class DevHud implements Layer { // Player counts const allPlayers = this.game.players(); const humans = { alive: 0, total: 0 }; - const fakeHumans = { alive: 0, total: 0 }; + const aiPlayers = { alive: 0, total: 0 }; const bots = { alive: 0, total: 0 }; for (const p of allPlayers) { const type = p.type(); @@ -158,9 +158,9 @@ export class DevHud implements Layer { if (type === PlayerType.Human) { humans.total++; if (isAlive) humans.alive++; - } else if (type === PlayerType.FakeHuman) { - fakeHumans.total++; - if (isAlive) fakeHumans.alive++; + } else if (type === PlayerType.AI) { + aiPlayers.total++; + if (isAlive) aiPlayers.alive++; } else if (type === PlayerType.Bot) { bots.total++; if (isAlive) bots.alive++; @@ -318,13 +318,13 @@ export class DevHud implements Layer { "Human players (alive / total)", ); html += renderRow( - "FakeHumans", - `${fakeHumans.alive} / ${fakeHumans.total}`, - fakeHumans.alive, - fakeHumans.total || 1, + "AI", + `${aiPlayers.alive} / ${aiPlayers.total}`, + aiPlayers.alive, + aiPlayers.total || 1, "#ffff00", true, - "FakeHuman players (alive / total)", + "AI players (alive / total)", ); html += renderRow( "Bots", diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index 86dec62e7..bb17738ff 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -26,6 +26,8 @@ import { DisplayMessageUpdate, EmojiUpdate, GameUpdateType, + PeaceRequestReplyUpdate, + PeaceRequestUpdate, TargetPlayerUpdate, UnitIncomingUpdate, } from "../../../core/game/GameUpdates"; @@ -36,6 +38,7 @@ import { CancelParatrooperIntentEvent, SendAllianceExtensionIntentEvent, SendAllianceReplyIntentEvent, + SendPeaceReplyIntentEvent, } from "../../Transport"; import { Layer } from "./Layer"; @@ -166,6 +169,11 @@ export class EventsDisplay extends LitElement implements Layer { [GameUpdateType.Emoji, this.onEmojiMessageEvent.bind(this)], [GameUpdateType.UnitIncoming, this.onUnitIncomingEvent.bind(this)], [GameUpdateType.AllianceExpired, this.onAllianceExpiredEvent.bind(this)], + [GameUpdateType.PeaceRequest, this.onPeaceRequestEvent.bind(this)], + [ + GameUpdateType.PeaceRequestReply, + this.onPeaceRequestReplyEvent.bind(this), + ], ] as const; constructor() { @@ -567,6 +575,109 @@ export class EventsDisplay extends LitElement implements Layer { }); } + onPeaceRequestEvent(update: PeaceRequestUpdate) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer || update.recipientID !== myPlayer.smallID()) { + return; + } + + const requestor = this.game.playerBySmallID( + update.requestorID, + ) as PlayerView; + const recipient = this.game.playerBySmallID( + update.recipientID, + ) as PlayerView; + + this.addEvent({ + description: translateText("events_display.request_peace", { + name: requestor.name(), + }), + buttons: [ + { + text: translateText("events_display.focus"), + className: "btn-gray", + action: () => this.eventBus.emit(new GoToPlayerEvent(requestor)), + preventClose: true, + }, + { + text: translateText("events_display.accept_peace"), + className: "btn", + action: () => + this.eventBus.emit( + new SendPeaceReplyIntentEvent(requestor, recipient, true), + ), + }, + { + text: translateText("events_display.reject_peace"), + className: "btn-info", + action: () => + this.eventBus.emit( + new SendPeaceReplyIntentEvent(requestor, recipient, false), + ), + }, + ], + highlight: true, + type: MessageType.PEACE_REQUEST, + createdAt: this.game.ticks(), + onDelete: () => + this.eventBus.emit( + new SendPeaceReplyIntentEvent(requestor, recipient, false), + ), + priority: 0, + duration: 150, + focusID: update.requestorID, + }); + } + + onPeaceRequestReplyEvent(update: PeaceRequestReplyUpdate) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) { + return; + } + // If I'm the recipient, remove the peace request UI + if (update.request.recipientID === myPlayer.smallID()) { + this.events = this.events.filter( + (event) => + !( + event.type === MessageType.PEACE_REQUEST && + event.focusID === update.request.requestorID + ), + ); + this.requestUpdate(); + return; + } + if (update.request.requestorID !== myPlayer.smallID()) { + return; + } + + const myID = myPlayer.smallID(); + const requestorID = update.request.requestorID; + const recipientID = update.request.recipientID; + + // Only show rejection to requestor + if (!update.accepted && requestorID !== myID) { + return; + } + + const otherID = requestorID === myID ? recipientID : requestorID; + const otherPlayer = this.game.playerBySmallID(otherID) as PlayerView; + + this.addEvent({ + description: translateText("events_display.peace_request_status", { + name: otherPlayer.name(), + status: update.accepted + ? translateText("events_display.peace_accepted") + : translateText("events_display.peace_rejected"), + }), + type: update.accepted + ? MessageType.PEACE_ACCEPTED + : MessageType.PEACE_REJECTED, + highlight: true, + createdAt: this.game.ticks(), + focusID: otherID, + }); + } + onBrokeAllianceEvent(update: BrokeAllianceUpdate) { const myPlayer = this.game.myPlayer(); if (!myPlayer) return; diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index e5d823c5c..de2ee3038 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -51,6 +51,18 @@ export class FxLayer implements Layer { this.pixicanvas = document.createElement("canvas"); this.pixicanvas.width = window.innerWidth; this.pixicanvas.height = window.innerHeight; + + // DOM overlay: avoids expensive WebGL-to-2D drawImage compositing. + this.pixicanvas.style.position = "fixed"; + this.pixicanvas.style.left = "0"; + this.pixicanvas.style.top = "0"; + this.pixicanvas.style.width = "100%"; + this.pixicanvas.style.height = "100%"; + this.pixicanvas.style.pointerEvents = "none"; + // Above AABulletLayer PIXI (z-34), below NameLayer DOM (z-40) + this.pixicanvas.style.zIndex = "35"; + document.body.appendChild(this.pixicanvas); + this.stage = new PIXI.Container(); await this.renderer.init({ @@ -278,8 +290,6 @@ export class FxLayer implements Layer { } this.renderer.render(this.stage); - - context.drawImage(this.pixicanvas, 0, 0); } } diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index bd93f8a10..57808bc73 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -467,12 +467,12 @@ export class NameLayer implements Layer { // Alliance icon const existingAlliance = iconsDiv.querySelector('[data-icon="alliance"]'); const isSelf = myPlayer !== null && render.player === myPlayer; - const isHumanOrFakeHuman = + const isHumanOrAI = render.player.type() === PlayerType.Human || - render.player.type() === PlayerType.FakeHuman; + render.player.type() === PlayerType.AI; if ( !isSelf && - isHumanOrFakeHuman && + isHumanOrAI && myPlayer !== null && myPlayer.isAlliedWith(render.player) ) { @@ -515,7 +515,7 @@ export class NameLayer implements Layer { const existingWar = iconsDiv.querySelector('[data-icon="war"]'); if ( !isSelf && - isHumanOrFakeHuman && + isHumanOrAI && myPlayer !== null && myPlayer.isAtWarWith(render.player) ) { @@ -532,7 +532,7 @@ export class NameLayer implements Layer { const existingNeutral = iconsDiv.querySelector('[data-icon="neutral"]'); if ( !isSelf && - isHumanOrFakeHuman && + isHumanOrAI && myPlayer !== null && !myPlayer.isAlliedWith(render.player) && !myPlayer.isAtWarWith(render.player) diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index f4d8f67dc..46fcc9f26 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -226,7 +226,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { relationClass = "text-yellow-300"; relationName = translateText("relation.neutral"); displayRelation = true; - } else if (player.type() === PlayerType.FakeHuman) { + } else if (player.type() === PlayerType.AI) { const relation = this.playerProfile?.relations[myPlayer.smallID()] ?? Relation.Neutral; relationClass = this.getRelationClass(relation); @@ -246,7 +246,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { case PlayerType.Bot: playerType = translateText("player_info_overlay.bot"); break; - case PlayerType.FakeHuman: + case PlayerType.AI: playerType = translateText("player_info_overlay.nation"); break; case PlayerType.Human: diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 32f79a09b..c6c177072 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -482,6 +482,7 @@ export class RadialMenu implements Layer { return; const dst = this.g.ref(this.clickedCell.x, this.clickedCell.y); + const src = spawnTile ? this.g.ref(spawnTile.x, spawnTile.y) : null; this.eventBus.emit( new SendBoatAttackIntentEvent( dst, @@ -599,10 +600,8 @@ export class RadialMenu implements Layer { const defenderType = (owner as PlayerView).type(); if ( - (attackerType === PlayerType.Human || - attackerType === PlayerType.FakeHuman) && - (defenderType === PlayerType.Human || - defenderType === PlayerType.FakeHuman) + (attackerType === PlayerType.Human || attackerType === PlayerType.AI) && + (defenderType === PlayerType.Human || defenderType === PlayerType.AI) ) { return false; } diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index d8ffc3f64..b3e38d1dd 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -21,21 +21,15 @@ import { type PlayerUpdate, } from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; -import { getUnitUpgradeCost } from "../../../core/game/UnitUpgrades"; import { isUpgradeableStructure, playerMaxStructureLevel, playerMaxStructureTechLevel, - playerMaxUnitLevel, } from "../../../core/game/Upgradeables"; -import { ToggleBomberUpgradeModeEvent } from "../../events/ToggleBomberUpgradeModeEvent"; import { ToggleUpgradeModeEvent } from "../../events/ToggleUpgradeModeEvent"; import { UnitCooldownEndedEvent } from "../../events/UnitCooldownEndedEvent"; import { MouseMoveEvent, MouseUpEvent } from "../../InputHandler"; -import { - SendUpgradeBomberIntentEvent, - SendUpgradeStructureIntentEvent, -} from "../../Transport"; +import { SendUpgradeStructureIntentEvent } from "../../Transport"; import { PerformanceMetrics } from "../../utilities/PerformanceMetrics"; import { renderNumber } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; @@ -105,7 +99,6 @@ export class StructureLayer implements Layer { private previouslySelected: UnitView | null = null; private hoveredStructure: UnitView | null = null; private upgradeMode: boolean = false; // When true, clicking own cities/ports sends upgrade intent - private bomberUpgradeMode: boolean = false; // When true, clicking own airfields upgrades their bombers // Track affordability per structure type to refresh highlights correctly private lastAffordableForUpgrade: Map = new Map(); // Client-side level tracking for structures (temporary) @@ -216,21 +209,6 @@ export class StructureLayer implements Layer { this.updateLabels(); if (this.renderer) this.renderer.render(this.stage); }); - this.eventBus.on(ToggleBomberUpgradeModeEvent, (e) => { - this.bomberUpgradeMode = e.enabled; - // Rebuild textures for airfields so border tint updates immediately. - for (const r of this.renders) { - if (r.unit.type() === UnitType.Airfield) { - r.pixiSprite.texture = this.createTexture(r.unit); - } - } - // Force redraw so highlight state applies instantly. - this.shouldRedraw = true; - this.updateHighlights(); - // Rebuild price labels when toggling bomber upgrade mode - this.updateLabels(); - if (this.renderer) this.renderer.render(this.stage); - }); } async setupRenderer() { @@ -238,6 +216,18 @@ export class StructureLayer implements Layer { this.pixicanvas = document.createElement("canvas"); this.pixicanvas.width = window.innerWidth; this.pixicanvas.height = window.innerHeight; + + // DOM overlay: avoids expensive WebGL-to-2D drawImage compositing. + this.pixicanvas.style.position = "fixed"; + this.pixicanvas.style.left = "0"; + this.pixicanvas.style.top = "0"; + this.pixicanvas.style.width = "100%"; + this.pixicanvas.style.height = "100%"; + this.pixicanvas.style.pointerEvents = "none"; + // Above main 2D canvas (z-30), below ArtilleryLayer PIXI (z-32) + this.pixicanvas.style.zIndex = "31"; + document.body.appendChild(this.pixicanvas); + this.stage = new PIXI.Container(); this.stage.position.set(0, 0); this.stage.width = this.pixicanvas.width; @@ -416,7 +406,6 @@ export class StructureLayer implements Layer { this.renderer.render(this.stage); this.shouldRedraw = false; } - mainContext.drawImage(this.renderer.canvas, 0, 0); } private canAffordUpgrade(unit?: UnitView): boolean { @@ -466,48 +455,6 @@ export class StructureLayer implements Layer { return true; } - // Bomber upgrade cost: uses hardcoded costs from UnitUpgrades - private computeBomberUpgradeCost(airfield: UnitView): bigint { - const me = this.game.myPlayer(); - if (!me) return 0n; - const currentBomberLevel = airfield.bomberLevel?.() ?? 1; - // Check if already at max bomber level - if (currentBomberLevel >= playerMaxUnitLevel(me, UnitType.Bomber)) { - return 0n; - } - // getUnitUpgradeCost takes fromLevel and returns cost to upgrade to next level - return getUnitUpgradeCost(UnitType.Bomber, currentBomberLevel); - } - - // Check if player can afford to upgrade bombers for this airfield - private canAffordBomberUpgrade(airfield: UnitView): boolean { - const me = this.game.myPlayer(); - if (!me) return false; - if (airfield.type() !== UnitType.Airfield) return false; - // Check if any bombers for this airfield can be upgraded - if (!this.hasBombersToUpgrade(airfield)) return false; - const upgradeCost = this.computeBomberUpgradeCost(airfield); - return me.gold() >= upgradeCost; - } - - // Check if the airfield has bombers (server enforces max level) - private hasBombersToUpgrade(airfield: UnitView): boolean { - const me = this.game.myPlayer(); - if (!me) return false; - // Check if airfield has any bombers (level > 0 means at least one bomber) - const airfieldLevel = airfield.level?.() ?? 1; - if (airfieldLevel === 0) return false; - return true; - } - - // Check if airfield is eligible for bomber upgrade mode highlighting - private isEligibleForBomberUpgrade(unit: UnitView): boolean { - if (unit.type() !== UnitType.Airfield) return false; - const me = this.game.myPlayer(); - if (!me || unit.owner() !== me) return false; - return this.hasBombersToUpgrade(unit); - } - private updateHighlights() { // Build current affordability map for all upgradeable structure types const currentAffordable = new Map(); @@ -518,7 +465,7 @@ export class StructureLayer implements Layer { } } - if (!this.upgradeMode && !this.bomberUpgradeMode) { + if (!this.upgradeMode) { // When exiting upgrade mode, clear affordability cache and refresh upgradeable structures if (this.lastAffordableForUpgrade.size > 0) { for (const r of this.renders) { @@ -543,26 +490,6 @@ export class StructureLayer implements Layer { return; } - // Handle bomber upgrade mode highlighting for airfields - if (this.bomberUpgradeMode) { - let anyChanged = false; - for (const r of this.renders) { - if (r.unit.type() !== UnitType.Airfield) continue; - const should = this.shouldHighlightForBomberUpgrade(r.unit); - const prev = this.lastHighlight.get(r.unit.id()) ?? false; - if (prev !== should) { - r.pixiSprite.texture = this.createTexture(r.unit); - this.lastHighlight.set(r.unit.id(), should); - anyChanged = true; - } - } - if (anyChanged) { - this.shouldRedraw = true; - } - // Still fall through to handle regular upgrade mode if both are somehow on - if (!this.upgradeMode) return; - } - // Check if affordability changed for any structure type const changedTypes = new Set(); for (const [type, affordable] of currentAffordable) { @@ -680,11 +607,6 @@ export class StructureLayer implements Layer { const hl = this.shouldHighlight(unit) ? 1 : 0; cacheKey += `-hl${hl}`; } - // Add bomber upgrade highlight state for airfields - if (!isConstruction && structureType === UnitType.Airfield) { - const bhl = this.shouldHighlightForBomberUpgrade(unit) ? 1 : 0; - cacheKey += `-bhl${bhl}`; - } // Add tech level to cache key for SAM and Airfield (for star display) if ( !isConstruction && @@ -751,17 +673,6 @@ export class StructureLayer implements Layer { borderColor = highlightTint; highlightEligibleIcon = true; } - // Apply highlight for bomber upgrade mode on airfields - if ( - !isConstruction && - structureType === UnitType.Airfield && - this.shouldHighlightForBomberUpgrade(unit) - ) { - // Use the same neon green as regular upgrade mode - highlightTint = this.blendHexColors("#00FF8A", borderColor, 0.6); - borderColor = highlightTint; - highlightEligibleIcon = true; - } // Draw background shape ctx.beginPath(); @@ -956,17 +867,6 @@ export class StructureLayer implements Layer { return unit.owner().id() === me.id() && this.canAffordUpgrade(unit); } - private shouldHighlightForBomberUpgrade(unit: UnitView): boolean { - if (!this.bomberUpgradeMode) return false; - const me = this.game.myPlayer(); - if (!me) return false; - if (unit.type() !== UnitType.Airfield) return false; - if (unit.owner().id() !== me.id()) return false; - return ( - this.isEligibleForBomberUpgrade(unit) && this.canAffordBomberUpgrade(unit) - ); - } - private createPixiSprite(unit: UnitView): PIXI.Sprite { const sprite = new PIXI.Sprite(this.createTexture(unit)); sprite.anchor.set(0.5, 0.5); @@ -1112,17 +1012,6 @@ export class StructureLayer implements Layer { if (clickedUnit.owner() !== this.game.myPlayer()) { return; } - // In bomber upgrade mode: attempt to upgrade bombers for clicked airfield - if (this.bomberUpgradeMode && clickedUnit.type() === UnitType.Airfield) { - // Check if any bombers can be upgraded and player can afford it - if (this.canAffordBomberUpgrade(clickedUnit)) { - // Fire transport event to send intent - this.eventBus.emit( - new SendUpgradeBomberIntentEvent(clickedUnit.id()), - ); - } - return; // Do not change selection while upgrading - } // In upgrade mode: attempt to upgrade upgradeable structures immediately if (this.upgradeMode && isUpgradeableStructure(clickedUnit.type())) { // Check if upgradeable (not at max level) and affordable @@ -1405,76 +1294,6 @@ export class StructureLayer implements Layer { } } - // 3) In bomber upgrade mode, show UPGRADE PRICE BELOW for all eligible airfields owned by me - if (this.bomberUpgradeMode) { - const me = this.game.myPlayer(); - if (me) { - for (const r of this.renders) { - const u = r.unit; - if (!u.isActive()) continue; - if (u.owner() !== me) continue; - if (u.type() !== UnitType.Airfield) continue; - if (!this.isEligibleForBomberUpgrade(u)) continue; - - const tile = u.tile(); - const worldX = this.game.x(tile); - const worldY = this.game.y(tile); - const screenPos = this.transformHandler.worldToScreenCoordinates( - new Cell(worldX, worldY), - ); - const shape: BgShape = - STRUCTURE_BG_SHAPES[u.type() as UnitType] ?? "circle"; - const iconDim = ICON_SIZES[shape] ?? ICON_SIZE; - const iconScale = this.iconScreenScale(); - const labelScale = iconScale * ICON_TEXTURE_QUALITY; - - // Shrink cost indicator by 50% - const fontSize = Math.round(iconDim * labelScale * 0.25); - // Use orange/amber for bomber upgrades when affordable; otherwise white - const affordable = this.canAffordBomberUpgrade(u); - const fillColor = affordable ? 0xffa500 : 0xffffff; - const style = new PIXI.TextStyle({ - fontFamily: - "system-ui, -apple-system, Segoe UI, Roboto, sans-serif", - fontSize, - fontWeight: "600", - fill: fillColor, - align: "center", - }); - const priceText = this.formatGoldCompact( - this.computeBomberUpgradeCost(u), - ); - const t = new PIXI.Text(priceText, style); - - const paddingX = Math.round(fontSize * 0.5); - const paddingY = Math.round(fontSize * 0.35); - const pillWidth = t.width + paddingX * 2; - const pillHeight = t.height + paddingY * 2; - const bg = new PIXI.Graphics(); - // Nudge even closer to icon (further up) - const gapBelow = Math.round(1 * labelScale); - const bgX = Math.round(screenPos.x - pillWidth / 2); - const bgY = Math.round( - screenPos.y + (iconDim * labelScale) / 2 + gapBelow, - ); - bg.roundRect( - bgX, - bgY, - pillWidth, - pillHeight, - Math.min(14, fontSize), - ).fill({ - color: 0x000000, - alpha: 0.55, - }); - this.labelContainer.addChild(bg); - t.x = bgX + Math.round((pillWidth - t.width) / 2); - t.y = bgY + Math.round((pillHeight - t.height) / 2); - this.labelContainer.addChild(t); - } - } - } - // Request redraw after rebuilding labels this.shouldRedraw = true; if (this.renderer) { @@ -1583,13 +1402,23 @@ export class StructureLayer implements Layer { private updateLoadingBar(render: StructureRenderInfo) { const unit = render.unit; - // Only show loading bar for structures on cooldown - if ( - !unit.isActive() || - !unit.isCooldown() || - (unit.type() !== UnitType.MissileSilo && - unit.type() !== UnitType.SAMLauncher) - ) { + // Determine whether this unit should show a loading bar: + // 1. Silo / SAM on cooldown + // 2. Construction in progress (only for local player) + const isConstruction = unit.type() === UnitType.Construction; + const isSiloOrSAM = + unit.type() === UnitType.MissileSilo || + unit.type() === UnitType.SAMLauncher; + + const showConstruction = + isConstruction && + unit.isActive() && + this.game.myPlayer()?.id() !== undefined && + unit.owner().id() === this.game.myPlayer()!.id(); + + const showCooldown = isSiloOrSAM && unit.isActive() && unit.isCooldown(); + + if (!showConstruction && !showCooldown) { if (render.loadingBarGraphics) { render.loadingBarGraphics.destroy(); render.loadingBarGraphics = null; @@ -1607,45 +1436,75 @@ export class StructureLayer implements Layer { const graphics = render.loadingBarGraphics; graphics.clear(); - // Get the structure's icon size and scale - const unitType = - unit.type() === UnitType.Construction - ? unit.constructionType() - : unit.type(); - const shape: BgShape = - unitType !== undefined - ? (STRUCTURE_BG_SHAPES[unitType as UnitType] ?? "circle") - : "circle"; - const iconDim = ICON_SIZES[shape] ?? ICON_SIZE; - const spriteScale = render.pixiSprite.scale.x; // Assumes uniform scaling - const scaledIconSize = iconDim * spriteScale; - - // Bar dimensions scale with the icon (same as health bar) - const barWidth = scaledIconSize * 3; // 300% of icon width - const barHeight = scaledIconSize * 0.3; // 30% of icon height, min 4px - const gap = scaledIconSize * 1.8; - const yOffset = scaledIconSize / 2 + barHeight + gap; // Below the icon with scaled gap + // Bar dimensions and positioning differ between construction and silo/SAM + let barWidth: number; + let barHeight: number; + const scale = this.transformHandler.scale; - // Position relative to sprite center - graphics.x = render.pixiSprite.x; - graphics.y = render.pixiSprite.y + yOffset; + if (isConstruction) { + // Use the same effective scale as the icon so the bar stops/starts + // growing at the same zoom thresholds the icon does. + const effectiveScale = render.pixiSprite.scale.x * ICON_TEXTURE_QUALITY; + // World-pixel dimensions converted to screen space + barWidth = 26 * effectiveScale; + barHeight = 2 * effectiveScale; + // Centered horizontally on the icon, just below it + graphics.x = render.pixiSprite.x; + graphics.y = render.pixiSprite.y + 12 * effectiveScale; + } else { + // Silo / SAM: icon-relative sizing (same as health bar) + const unitType = unit.type(); + const shape: BgShape = + STRUCTURE_BG_SHAPES[unitType as UnitType] ?? "circle"; + const iconDim = ICON_SIZES[shape] ?? ICON_SIZE; + const spriteScale = render.pixiSprite.scale.x; + const scaledIconSize = iconDim * spriteScale; + + barWidth = scaledIconSize * 3; + barHeight = scaledIconSize * 0.3; + const gap = scaledIconSize * 1.8; + const yOffset = scaledIconSize / 2 + barHeight + gap; + + graphics.x = render.pixiSprite.x; + graphics.y = render.pixiSprite.y + yOffset; + } - // Calculate progress using cooldownEndsAt (authoritative field) - const totalCooldown = - unit.type() === UnitType.MissileSilo - ? (unit.cooldownDuration() ?? this.game.config().SiloCooldown()) - : (unit.cooldownDuration() ?? this.game.config().SAMNukeCooldown()); - const endsAt = unit.cooldownEndsAt(); + // Calculate progress + let progress: number; const currentTick = this.game.ticks(); - // Progress from 0 (just started) to 1 (ready) - const startTick = endsAt ? endsAt - totalCooldown : currentTick; - const elapsed = currentTick - startTick; - const progress = Math.min(1, Math.max(0, elapsed / totalCooldown)); + if (isConstruction) { + // Construction progress: use cooldownEndsAt and cooldownDuration + const endsAt = unit.cooldownEndsAt(); + const duration = unit.cooldownDuration(); + if (endsAt !== undefined && duration !== undefined && duration > 0) { + const startTick = endsAt - duration; + const elapsed = currentTick - startTick; + progress = Math.min(1, Math.max(0, elapsed / duration)); + } else { + progress = 0; + } + } else { + // Silo / SAM cooldown progress using cooldownEndsAt + const totalCooldown = + unit.type() === UnitType.MissileSilo + ? (unit.cooldownDuration() ?? this.game.config().SiloCooldown()) + : (unit.cooldownDuration() ?? this.game.config().SAMNukeCooldown()); + const endsAt = unit.cooldownEndsAt(); + const startTick = endsAt ? endsAt - totalCooldown : currentTick; + const elapsed = currentTick - startTick; + progress = Math.min(1, Math.max(0, elapsed / totalCooldown)); + } - // Background (black border) + // Background (black border) — padding scales with zoom so bar looks consistent + const pad = barHeight; graphics.beginFill(0x000000, 1); - graphics.drawRect(-barWidth / 2 - 1, -1, barWidth + 2, barHeight + 2); + graphics.drawRect( + -barWidth / 2 - pad, + -pad, + barWidth + pad * 2, + barHeight + pad * 2, + ); graphics.endFill(); // Progress fill (color based on progress) diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 9d8347826..7368e5870 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -1,17 +1,12 @@ import type { Colord } from "colord"; import type { Theme } from "../../../core/configuration/Config"; import type { EventBus } from "../../../core/EventBus"; -import { Cell, PlayerType, UnitType } from "../../../core/game/Game"; +import { PlayerType, UnitType } from "../../../core/game/Game"; import type { TileRef } from "../../../core/game/GameMap"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import type { GameView } from "../../../core/game/GameView"; import { PlayerView } from "../../../core/game/GameView"; -import { PseudoRandom } from "../../../core/PseudoRandom"; -import { - AlternateViewEvent, - DragEvent, - MouseOverEvent, -} from "../../InputHandler"; +import { AlternateViewEvent, MouseOverEvent } from "../../InputHandler"; import type { TransformHandler } from "../TransformHandler"; import type { Layer } from "./Layer"; @@ -20,21 +15,20 @@ export class TerritoryLayer implements Layer { private canvas: HTMLCanvasElement; private context: CanvasRenderingContext2D; private imageData: ImageData; + private imageData32: Uint32Array; private alternativeImageData: ImageData; + private altData32: Uint32Array; // Used for spawn highlighting private highlightCanvas: HTMLCanvasElement; private highlightContext: CanvasRenderingContext2D; - private tileToRenderQueue: Set = new Set(); - private random = new PseudoRandom(123); + private renderQueue: TileRef[] = []; private theme: Theme; private highlightedTerritory: PlayerView | null = null; private alternativeView = false; - private lastDragTime = 0; - private nodrawDragDuration = 200; private lastMousePosition: { x: number; y: number } | null = null; private refreshRate = 10; //refresh every 10ms @@ -51,20 +45,35 @@ export class TerritoryLayer implements Layer { // 0 = unknown, 1 = false, 2 = true private borderCache: Uint8Array | null = null; private defendedCache: Uint8Array | null = null; - private borderColorsCache = new Map< - string, - { light: Colord; dark: Colord } - >(); - private territoryColorCache = new Map(); + + // Pre-packed RGBA color tables indexed by player smallID + private territoryPacked!: Uint32Array; + private borderPacked!: Uint32Array; + private defLightPacked!: Uint32Array; + private defDarkPacked!: Uint32Array; + private focusedBorderPacked = 0; + private falloutPacked = 0; + private lastPlayerCount = -1; + + // Bitmap for deduplicating tile repaints in renderTerritory + private repaintFlags!: Uint8Array; + + // Per-render-pass cached state + private _cachedMyPlayer: PlayerView | null = null; + private _cachedFocusedSID = -1; + private _cachedHighlightedSID = -1; // Dirty tracking to minimize putImageData calls private isDirty = false; private dirtyRect: { x0: number; y0: number; x1: number; y1: number } | null = null; + private needsFullRepaint = false; // Cached map dimensions to avoid repeated method calls in hot render path private _width: number; private _height: number; + private _widthM1: number; + private _heightM1: number; constructor( private game: GameView, @@ -74,21 +83,80 @@ export class TerritoryLayer implements Layer { this.theme = game.config().theme(); this._width = game.width(); this._height = game.height(); + this._widthM1 = this._width - 1; + this._heightM1 = this._height - 1; } shouldTransform(): boolean { return true; } + private static packRGBA(c: Colord, alpha: number): number { + const { r, g, b } = c.rgba; + return ( + ((alpha & 0xff) << 24) | + ((b & 0xff) << 16) | + ((g & 0xff) << 8) | + (r & 0xff) + ); + } + + private buildColorCache(force = false) { + const players = this.game.playerViews(); + if (!force && players.length === this.lastPlayerCount) return; + this.lastPlayerCount = players.length; + let maxSID = 0; + for (let i = 0; i < players.length; i++) { + const sid = players[i].smallID(); + if (sid > maxSID) maxSID = sid; + } + const size = maxSID + 1; + this.territoryPacked = new Uint32Array(size); + this.borderPacked = new Uint32Array(size); + this.defLightPacked = new Uint32Array(size); + this.defDarkPacked = new Uint32Array(size); + + for (let i = 0; i < players.length; i++) { + const p = players[i]; + const sid = p.smallID(); + this.territoryPacked[sid] = TerritoryLayer.packRGBA( + this.theme.territoryColor(p), + 150, + ); + this.borderPacked[sid] = TerritoryLayer.packRGBA( + this.theme.borderColor(p), + 255, + ); + const def = this.theme.defendedBorderColors(p); + this.defLightPacked[sid] = TerritoryLayer.packRGBA(def.light, 255); + this.defDarkPacked[sid] = TerritoryLayer.packRGBA(def.dark, 255); + } + + this.focusedBorderPacked = TerritoryLayer.packRGBA( + this.theme.focusedBorderColor(), + 255, + ); + this.falloutPacked = TerritoryLayer.packRGBA( + this.theme.falloutColor(), + 150, + ); + } + async paintPlayerBorder(player: PlayerView) { const tiles = await player.borderTiles(); + this._cachedMyPlayer = this.game.myPlayer(); + const fp = this.game.focusedPlayer(); + this._cachedFocusedSID = fp ? fp.smallID() : -1; + this._cachedHighlightedSID = this.highlightedTerritory + ? this.highlightedTerritory.smallID() + : -1; tiles.borderTiles.forEach((tile: TileRef) => { this.paintTerritory(tile, true); // Immediately paint the tile instead of enqueueing }); } tick() { - this.game.recentlyUpdatedTiles().forEach((t) => this.enqueueTile(t)); + this.game.recentlyUpdatedTiles().forEach((t) => this.renderQueue.push(t)); const updates = this.game.updatesSinceLastTick(); const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : []; unitUpdates.forEach((update) => { @@ -117,7 +185,7 @@ export class TerritoryLayer implements Layer { this.game.ownerID(t) === update.lastOwnerID) && this.game.isBorder(t) ) { - this.enqueueTile(t); + this.renderQueue.push(t); } } } @@ -203,7 +271,7 @@ export class TerritoryLayer implements Layer { if (this.defendedCache) { this.defendedCache[update.tile] = 0; } - this.enqueueTile(update.tile); + this.renderQueue.push(update.tile); }); const focusedPlayer = this.game.focusedPlayer(); @@ -269,10 +337,7 @@ export class TerritoryLayer implements Layer { this.eventBus.on(MouseOverEvent, (e) => this.onMouseOver(e)); this.eventBus.on(AlternateViewEvent, (e) => { this.alternativeView = e.alternateView; - }); - this.eventBus.on(DragEvent, (e) => { - // TODO: consider re-enabling this on mobile or low end devices for smoother dragging. - // this.lastDragTime = Date.now(); + this.needsFullRepaint = true; }); this.redraw(); } @@ -345,11 +410,12 @@ export class TerritoryLayer implements Layer { // Allocate blank ImageData buffers rather than reading back from the canvas. // This avoids expensive GPU->CPU readbacks and the Chrome warning about getImageData. this.imageData = new ImageData(this.canvas.width, this.canvas.height); + this.imageData32 = new Uint32Array(this.imageData.data.buffer); this.alternativeImageData = new ImageData( this.canvas.width, this.canvas.height, ); - this.initImageData(); + this.altData32 = new Uint32Array(this.alternativeImageData.data.buffer); this.context.putImageData( this.alternativeView ? this.alternativeImageData : this.imageData, @@ -371,8 +437,16 @@ export class TerritoryLayer implements Layer { const size = this._width * this._height; this.borderCache = new Uint8Array(size); this.defendedCache = new Uint8Array(size); - this.borderColorsCache.clear(); - this.territoryColorCache.clear(); + this.repaintFlags = new Uint8Array(size); + this.buildColorCache(true); + + // Cache per-pass values for the full redraw + this._cachedMyPlayer = this.game.myPlayer(); + const fp = this.game.focusedPlayer(); + this._cachedFocusedSID = fp ? fp.smallID() : -1; + this._cachedHighlightedSID = this.highlightedTerritory + ? this.highlightedTerritory.smallID() + : -1; this.game.forEachTile((t) => { this.paintTerritory(t); @@ -383,6 +457,13 @@ export class TerritoryLayer implements Layer { const territories = Array.isArray(territory) ? territory : [territory]; const territorySet = new Set(territories); + this._cachedMyPlayer = this.game.myPlayer(); + const fp = this.game.focusedPlayer(); + this._cachedFocusedSID = fp ? fp.smallID() : -1; + this._cachedHighlightedSID = this.highlightedTerritory + ? this.highlightedTerritory.smallID() + : -1; + this.game.forEachTile((t) => { const owner = this.game.owner(t) as PlayerView; if (territorySet.has(owner)) { @@ -391,27 +472,23 @@ export class TerritoryLayer implements Layer { }); } - initImageData() { - this.game.forEachTile((tile) => { - const cell = new Cell(this.game.x(tile), this.game.y(tile)); - const index = cell.y * this._width + cell.x; - const offset = index * 4; - this.imageData.data[offset + 3] = 0; - this.alternativeImageData.data[offset + 3] = 0; - }); - } - renderLayer(context: CanvasRenderingContext2D) { const now = Date.now(); - if ( - now > this.lastDragTime + this.nodrawDragDuration && - now > this.lastRefresh + this.refreshRate - ) { + if (now > this.lastRefresh + this.refreshRate) { this.lastRefresh = now; this.renderTerritory(); - // Only call putImageData if something actually changed - if (this.isDirty && this.dirtyRect) { + // Full repaint when switching between normal and diplomacy view + if (this.needsFullRepaint) { + this.context.putImageData( + this.alternativeView ? this.alternativeImageData : this.imageData, + 0, + 0, + ); + this.needsFullRepaint = false; + this.isDirty = false; + this.dirtyRect = null; + } else if (this.isDirty && this.dirtyRect) { // Apply the dirty rect directly without viewport clipping // The canvas needs to stay in sync with ImageData even for off-screen areas // so that when the user zooms out, those areas are already rendered @@ -458,30 +535,87 @@ export class TerritoryLayer implements Layer { } renderTerritory() { - if (this.tileToRenderQueue.size === 0) return; + const queue = this.renderQueue; + const len = queue.length; + if (len === 0) return; - // Collect tiles to paint: queued tiles + their neighbors (for border updates) - // Use a Set to deduplicate since many neighbors overlap - const tilesToPaint = new Set(this.tileToRenderQueue); - for (const tile of this.tileToRenderQueue) { - // Invalidate border/defended cache for the tile and neighbors - if (this.borderCache) { - this.borderCache[tile] = 0; + // Rebuild color tables so new players are always covered + this.buildColorCache(); + + let numToRender = (len / 10) | 0; + if (numToRender === 0 || this.game.inSpawnPhase()) { + numToRender = len; + } + + // Cache per-pass values + this._cachedMyPlayer = this.game.myPlayer(); + const fp = this.game.focusedPlayer(); + this._cachedFocusedSID = fp ? fp.smallID() : -1; + this._cachedHighlightedSID = this.highlightedTerritory + ? this.highlightedTerritory.smallID() + : -1; + + const flags = this.repaintFlags; + const w = this._width; + const FLAG_MAIN = 1; + const FLAG_NEIGHBOR = 2; + const repaintList: TileRef[] = []; + + // Collect tiles to repaint with deduplication via bitmap + for (let i = 0; i < numToRender; i++) { + const tile = queue[i]; + + // Invalidate caches for queued tile + this.borderCache![tile] = 0; + this.defendedCache![tile] = 0; + + if (flags[tile] === 0) repaintList.push(tile); + flags[tile] |= FLAG_MAIN; + + // Inline neighbor processing + const x = tile % w; + const y = (tile / w) | 0; + let n: number; + if (x > 0) { + n = tile - 1; + this.borderCache![n] = 0; + if (flags[n] === 0) repaintList.push(n); + flags[n] |= FLAG_NEIGHBOR; } - if (this.defendedCache) { - this.defendedCache[tile] = 0; + if (x < this._widthM1) { + n = tile + 1; + this.borderCache![n] = 0; + if (flags[n] === 0) repaintList.push(n); + flags[n] |= FLAG_NEIGHBOR; } - for (const neighbor of this.game.neighbors(tile)) { - tilesToPaint.add(neighbor); - if (this.borderCache) { - this.borderCache[neighbor] = 0; - } + if (y > 0) { + n = tile - w; + this.borderCache![n] = 0; + if (flags[n] === 0) repaintList.push(n); + flags[n] |= FLAG_NEIGHBOR; + } + if (y < this._heightM1) { + n = tile + w; + this.borderCache![n] = 0; + if (flags[n] === 0) repaintList.push(n); + flags[n] |= FLAG_NEIGHBOR; } } - this.tileToRenderQueue.clear(); - for (const tile of tilesToPaint) { - this.paintTerritory(tile); + // Remove processed entries + if (numToRender >= len) { + queue.length = 0; + } else { + this.renderQueue = queue.slice(numToRender); + } + + // Paint all unique tiles exactly once + for (let i = 0; i < repaintList.length; i++) { + const tile = repaintList[i]; + const tileFlags = flags[tile]; + flags[tile] = 0; // reset for next pass + const isNeighborOnly = (tileFlags & FLAG_MAIN) === 0; + this.paintTerritory(tile, isNeighborOnly); } } @@ -492,23 +626,18 @@ export class TerritoryLayer implements Layer { if (!this.game.hasOwner(tile)) { if (this.game.hasFallout(tile)) { - this.paintTile(this.imageData, tile, this.theme.falloutColor(), 150); - this.paintTile( - this.alternativeImageData, - tile, - this.theme.falloutColor(), - 150, - ); + this.imageData32[tile] = this.falloutPacked; + this.altData32[tile] = this.falloutPacked; + this.markDirty(tile); return; } this.clearTile(tile); return; } - const owner = this.game.owner(tile) as PlayerView; - const isHighlighted = - this.highlightedTerritory && - this.highlightedTerritory.id() === owner.id(); - const myPlayer = this.game.myPlayer(); + const sid = this.game.ownerID(tile); + const isHighlighted = this._cachedHighlightedSID === sid; + const myPlayer = this._cachedMyPlayer; + const focusedSID = this._cachedFocusedSID; // Check border cache let isBorderTile = false; @@ -522,30 +651,18 @@ export class TerritoryLayer implements Layer { } if (isBorderTile) { - const playerIsFocused = owner && this.game.focusedPlayer() === owner; + const playerIsFocused = focusedSID >= 0 && focusedSID === sid; if (myPlayer) { - // Diplomacy alternate view colors: - // - Red (enemyColor) for bots and players at war - // - Green (selfColor) for self and allies - // - Yellow (allyColor) for neutral/peace - let alternativeColor = this.theme.allyColor(); // default: neutral/peace (yellow) - if (owner.type() === PlayerType.Bot) { - alternativeColor = this.theme.enemyColor(); // bots always red - } else if ( - owner.smallID() === myPlayer.smallID() || - owner.isFriendly(myPlayer) - ) { - alternativeColor = this.theme.selfColor(); // self and allies (green) - } else if (myPlayer.isAtWarWith(owner)) { - alternativeColor = this.theme.enemyColor(); // at war (red) - } - this.paintTile(this.alternativeImageData, tile, alternativeColor, 255); + const owner = this.game.owner(tile) as PlayerView; + const alternativeColor = this.getDiplomacyColor(owner, myPlayer); + this.altData32[tile] = TerritoryLayer.packRGBA(alternativeColor, 255); } // Check defended cache let isDefended = false; if (this.defendedCache) { if (this.defendedCache[tile] === 0) { + const owner = this.game.owner(tile) as PlayerView; const defended = this.game.hasUnitNearby( tile, this.game.config().defensePostRange(), @@ -556,6 +673,7 @@ export class TerritoryLayer implements Layer { } isDefended = this.defendedCache[tile] === 2; } else { + const owner = this.game.owner(tile) as PlayerView; isDefended = this.game.hasUnitNearby( tile, this.game.config().defensePostRange(), @@ -565,84 +683,41 @@ export class TerritoryLayer implements Layer { } if (isDefended) { - let borderColors = this.borderColorsCache.get(owner.id()); - if (!borderColors) { - borderColors = this.theme.defendedBorderColors(owner); - this.borderColorsCache.set(owner.id(), borderColors); - } const x = this.game.x(tile); const y = this.game.y(tile); const lightTile = (x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1); - const borderColor = lightTile ? borderColors.light : borderColors.dark; - this.paintTile(this.imageData, tile, borderColor, 255); + this.imageData32[tile] = lightTile + ? this.defLightPacked[sid] + : this.defDarkPacked[sid]; } else { - const useBorderColor = playerIsFocused - ? this.theme.focusedBorderColor() - : this.theme.borderColor(owner); - this.paintTile(this.imageData, tile, useBorderColor, 255); + this.imageData32[tile] = playerIsFocused + ? this.focusedBorderPacked + : this.borderPacked[sid]; } } else { if (myPlayer) { - // Diplomacy alternate view colors: - // - Red (enemyColor) for bots and players at war - // - Green (selfColor) for self and allies - // - Yellow (allyColor) for neutral/peace - let alternativeColor = this.theme.allyColor(); // default: neutral/peace (yellow) - if (owner.type() === PlayerType.Bot) { - alternativeColor = this.theme.enemyColor(); // bots always red - } else if ( - owner.smallID() === myPlayer.smallID() || - owner.isFriendly(myPlayer) - ) { - alternativeColor = this.theme.selfColor(); // self and allies (green) - } else if (myPlayer.isAtWarWith(owner)) { - alternativeColor = this.theme.enemyColor(); // at war (red) - } - this.paintTile( - this.alternativeImageData, - tile, + const owner = this.game.owner(tile) as PlayerView; + const alternativeColor = this.getDiplomacyColor(owner, myPlayer); + this.altData32[tile] = TerritoryLayer.packRGBA( alternativeColor, isHighlighted ? 150 : 60, ); } - let territoryColor = this.territoryColorCache.get(owner.id()); - if (!territoryColor) { - territoryColor = this.theme.territoryColor(owner); - this.territoryColorCache.set(owner.id(), territoryColor); - } - this.paintTile(this.imageData, tile, territoryColor, 150); + this.imageData32[tile] = this.territoryPacked[sid]; } - } - paintTile(imageData: ImageData, tile: TileRef, color: Colord, alpha: number) { - const offset = tile * 4; - imageData.data[offset] = color.rgba.r; - imageData.data[offset + 1] = color.rgba.g; - imageData.data[offset + 2] = color.rgba.b; - imageData.data[offset + 3] = alpha; - - // Track dirty region - this.isDirty = true; - const x = tile % this._width; - const y = Math.floor(tile / this._width); - if (!this.dirtyRect) { - this.dirtyRect = { x0: x, y0: y, x1: x, y1: y }; - } else { - if (x < this.dirtyRect.x0) this.dirtyRect.x0 = x; - if (y < this.dirtyRect.y0) this.dirtyRect.y0 = y; - if (x > this.dirtyRect.x1) this.dirtyRect.x1 = x; - if (y > this.dirtyRect.y1) this.dirtyRect.y1 = y; - } + this.markDirty(tile); } clearTile(tile: TileRef) { - const offset = tile * 4; - this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) - this.alternativeImageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) + this.imageData32[tile] = 0; + this.altData32[tile] = 0; + this.markDirty(tile); + } - // Track dirty region + private markDirty(tile: TileRef) { this.isDirty = true; const x = tile % this._width; const y = Math.floor(tile / this._width); @@ -656,10 +731,6 @@ export class TerritoryLayer implements Layer { } } - enqueueTile(tile: TileRef) { - this.tileToRenderQueue.add(tile); - } - paintHighlightTile(tile: TileRef, color: Colord, alpha: number) { this.clearTile(tile); const x = this.game.x(tile); @@ -668,10 +739,18 @@ export class TerritoryLayer implements Layer { this.highlightContext.fillRect(x, 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); + /** Diplomacy alternate view color for a tile owner relative to myPlayer. */ + private getDiplomacyColor(owner: PlayerView, myPlayer: PlayerView): Colord { + if (owner.type() === PlayerType.Bot) { + return this.theme.enemyColor(); + } + if (owner.smallID() === myPlayer.smallID() || owner.isFriendly(myPlayer)) { + return this.theme.selfColor(); + } + if (myPlayer.isAtWarWith(owner)) { + return this.theme.enemyColor(); + } + return this.theme.allyColor(); } private getOffsets( diff --git a/src/client/graphics/layers/TradeDebugOverlay.ts b/src/client/graphics/layers/TradeDebugOverlay.ts new file mode 100644 index 000000000..8e09e7a4e --- /dev/null +++ b/src/client/graphics/layers/TradeDebugOverlay.ts @@ -0,0 +1,407 @@ +import { + TradeDebugPayload, + TradeDemandDebug, + TradePlayerDebug, + TradeShipDebug, +} from "../../../core/execution/TradeDebugData"; +import { GameView } from "../../../core/game/GameView"; +import { Layer } from "./Layer"; + +/** + * Debug overlay toggled by F11 that shows per-country trade ship + * diagnostics: ship positions, phases, docked/stuck status, and + * distance to target. Designed to help verify whether ships are + * getting stuck in or near ports. + * + * Temporary – remove when trade debugging is done. + */ +export class TradeDebugOverlay implements Layer { + layerName = "TradeDebugOverlay"; + private container: HTMLDivElement | null = null; + private visible = false; + private lastFetch = 0; + private cachedData: TradeDebugPayload | null = null; + private fetching = false; + private selectedPlayer: string | null = null; + private viewMode: "ships" | "demand" = "ships"; + private demandFilter: string = ""; + + private static readonly REFRESH_INTERVAL = 2000; + + constructor(private game: GameView) {} + + init() { + this.container = document.createElement("div"); + Object.assign(this.container.style, { + position: "fixed", + top: "10px", + right: "10px", + maxWidth: "95vw", + maxHeight: "90vh", + overflowY: "auto", + overflowX: "auto", + backgroundColor: "rgba(0, 0, 0, 0.88)", + color: "#e0e0e0", + fontFamily: "monospace", + fontSize: "11px", + padding: "8px 10px", + zIndex: "200", + pointerEvents: "auto", + display: "none", + borderRadius: "4px", + border: "1px solid rgba(255,255,255,0.15)", + }); + document.body.appendChild(this.container); + + this.container.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + const id = target.dataset.playerId; + if (id !== undefined) { + this.selectedPlayer = id === this.selectedPlayer ? null : id; + this.renderContent(); + } + if (target.dataset.viewMode) { + this.viewMode = target.dataset.viewMode as "ships" | "demand"; + this.renderContent(); + } + }); + + this.container.addEventListener("input", (e) => { + const target = e.target as HTMLInputElement; + if (target.dataset.demandFilter !== undefined) { + this.demandFilter = target.value; + this.renderContent(); + } + }); + + window.addEventListener("keydown", (e) => { + if (e.key === "F11") { + e.preventDefault(); + this.visible = !this.visible; + if (this.container) { + this.container.style.display = this.visible ? "block" : "none"; + } + if (this.visible) { + this.fetchData(); + } + } + }); + } + + renderLayer(_ctx: CanvasRenderingContext2D) { + if (!this.visible) return; + + const now = performance.now(); + if (now - this.lastFetch >= TradeDebugOverlay.REFRESH_INTERVAL) { + this.fetchData(); + } + } + + private fetchData() { + if (this.fetching) return; + this.fetching = true; + this.lastFetch = performance.now(); + + this.game.worker + .tradeDebug() + .then((data) => { + this.cachedData = data; + this.renderContent(); + }) + .catch((err) => { + console.warn("TradeDebugOverlay fetch failed:", err); + }) + .finally(() => { + this.fetching = false; + }); + } + + private renderContent() { + if (!this.container || !this.cachedData) return; + + const data = this.cachedData; + + if (data.players.length === 0) { + this.container.innerHTML = + '
No players with trade ships.
'; + return; + } + + // Auto-select first player if none selected + if ( + this.selectedPlayer === null || + !data.players.find((d) => d.playerId === this.selectedPlayer) + ) { + this.selectedPlayer = data.players[0].playerId; + } + + // Build tabs + const tabs = data.players + .map((d) => { + const active = d.playerId === this.selectedPlayer; + const hasStuck = d.stuckAtPort > 0; + const bg = active + ? "background: rgba(80,120,200,0.5);" + : hasStuck + ? "background: rgba(255,100,100,0.2);" + : "background: rgba(255,255,255,0.1);"; + return `${this.esc(d.playerName)} (${d.totalShips})`; + }) + .join(""); + + const globalSummary = ` + + Tick: ${data.tick}  |  Queue: ${data.queueLength}  |  Total Ships: ${data.totalTradeShips} + + `; + + const shipsBg = + this.viewMode === "ships" + ? "background: rgba(80,120,200,0.5);" + : "background: rgba(255,255,255,0.1);"; + const demandBg = + this.viewMode === "demand" + ? "background: rgba(80,120,200,0.5);" + : "background: rgba(255,255,255,0.1);"; + const viewTabs = ` + Ships + Demand + `; + + let body = ""; + if (this.viewMode === "ships") { + const sel = data.players.find((d) => d.playerId === this.selectedPlayer); + if (sel) { + body = this.renderPlayerDetail(sel); + } + } else { + body = this.renderDemandView(data.demands); + } + + const playerTabs = + this.viewMode === "ships" + ? `
${tabs}
` + : ""; + + this.container.innerHTML = ` +
+ Trade Ship Debug (F11 to close) +
+
${globalSummary}
+
${viewTabs}
+ ${playerTabs} + ${body} + `; + } + + private renderPlayerDetail(d: TradePlayerDebug): string { + const stuckColor = d.stuckAtPort > 0 ? "#ff5555" : "#88cc88"; + const stationaryColor = + d.stationaryShips > d.totalShips * 0.5 ? "#ffaa00" : "#88cc88"; + + const summary = ` +
+ Ships: ${d.totalShips} +  |  Ports: ${d.portCount} +  |  Gold/min: ${d.goldPerMinute.toFixed(1)} +
+ Idle: ${d.idleShips} +  |  →Start: ${d.toStartShips} +  |  →End: ${d.toEndShips} +  |  Returning: ${d.returningShips} +
+ Stuck@Port: ${d.stuckAtPort} +  |  Stationary: ${d.stationaryShips} +
+ `; + + if (d.ships.length === 0) { + return summary + '
No trade ships.
'; + } + + const rows = d.ships.map((s) => this.renderShipRow(s)).join(""); + + const table = ` + + + + + + + + + + + + + + ${rows} +
ShipPhasePosOnOceanAtPortTargetDistOcnAdjStillRouteStatus
`; + + return summary + table; + } + + private renderDemandView(demands: TradeDemandDebug[]): string { + if (demands.length === 0) { + return '
No demand data available.
'; + } + + // Filter by search term + const filter = this.demandFilter.toLowerCase(); + const filtered = filter + ? demands.filter( + (d) => + d.fromName.toLowerCase().includes(filter) || + d.toName.toLowerCase().includes(filter), + ) + : demands; + + const searchBox = ` +
+ + ${filtered.length} of ${demands.length} pairs +
+ `; + + // Demand bar: visual representation of fractional demand (0–1) + const demandBar = (frac: number): string => { + const pct = Math.min(frac * 100, 100); + const color = + frac >= 0.8 + ? "#88cc88" + : frac >= 0.5 + ? "#cccc44" + : frac >= 0.2 + ? "#cc8844" + : "#666"; + return `
+
+
`; + }; + + const rows = filtered + .map((d) => { + const hasQueue = d.queuedRoutes > 0; + const hasActive = d.activeShips > 0; + const rowBg = hasQueue + ? "background: rgba(255,170,0,0.07);" + : hasActive + ? "background: rgba(80,200,80,0.07);" + : ""; + return ` + ${this.esc(d.fromName)} + → + ${this.esc(d.toName)} + ${demandBar(d.fractionalDemand)} ${d.fractionalDemand.toFixed(3)} + ${d.queuedRoutes} + ${d.activeShips} + `; + }) + .join(""); + + return ` + ${searchBox} + + + + + + + + + + ${rows} +
FromToDemandQueuedActive
+ `; + } + + private renderShipRow(s: TradeShipDebug): string { + // Determine status + const isStuck = + s.isAtPort && + s.stationaryThisTick && + s.targetUnitId !== null && + s.distToTarget !== null && + s.distToTarget <= 2; + const isPossiblyStuck = + s.stationaryThisTick && s.targetUnitId !== null && !isStuck; + + let status: string; + let statusColor: string; + if (isStuck) { + status = "STUCK@PORT"; + statusColor = "#ff5555"; + } else if (s.returning) { + status = "RETURNING"; + statusColor = "#ffaa00"; + } else if (isPossiblyStuck) { + status = "STATIONARY"; + statusColor = "#ffaa00"; + } else if (s.phase === "idle") { + status = "IDLE"; + statusColor = "#888"; + } else { + status = "OK"; + statusColor = "#88cc88"; + } + + const rowBg = isStuck + ? "background: rgba(255,50,50,0.12);" + : isPossiblyStuck + ? "background: rgba(255,170,0,0.07);" + : ""; + + const phaseBadge = + s.phase === "toStart" + ? '→Start' + : s.phase === "toEnd" + ? '→End' + : 'idle'; + + const oceanBadge = s.isOnOcean + ? 'yes' + : 'NO'; + + const portBadge = s.isAtPort + ? `P${s.dockedPortId}` + : ''; + + const targetStr = + s.targetUnitId !== null + ? `#${s.targetUnitId} (${s.targetX},${s.targetY})` + : "—"; + + const distStr = s.distToTarget !== null ? String(s.distToTarget) : "—"; + + const stillBadge = s.stationaryThisTick + ? 'YES' + : 'no'; + + const routeStr = + s.startOwner || s.endOwner + ? `${this.esc(s.startOwner ?? "?")} → ${this.esc(s.endOwner ?? "?")}` + : "—"; + + return ` + #${s.shipId} + ${phaseBadge} + (${s.x},${s.y}) + ${oceanBadge} + ${portBadge} + ${targetStr} + ${distStr} + ${s.adjacentOceanCount} + ${stillBadge} + ${routeStr} + ${status} + `; + } + + private esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); + } +} diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts index 1b58faf47..9e1dc0b74 100644 --- a/src/client/graphics/layers/UILayer.ts +++ b/src/client/graphics/layers/UILayer.ts @@ -144,24 +144,8 @@ export class UILayer implements Layer { onUnitEvent(unit: UnitView) { switch (unit.type()) { - case UnitType.Construction: { - const playerId = this.game.myPlayer()?.id(); - if ( - unit.isActive() && - playerId !== undefined && - unit.owner().id() === playerId - ) { - const constructionType = unit.constructionType(); - if (constructionType === undefined) { - // Skip units without construction type - return; - } - const endTick = - this.game.unitInfo(constructionType).constructionDuration ?? 0; - this.drawLoadingBar(unit, endTick); - } - break; - } + // Construction loading bars are handled in StructureLayer + // for proper z-ordering with the PIXI-rendered structure icons. case UnitType.Warship: { this.drawHealthBar(unit); break; diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 42cb65ba7..824669926 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -266,6 +266,19 @@ export class UnitLayer implements Layer { this.pixiCanvas = document.createElement("canvas"); this.pixiCanvas.width = window.innerWidth; this.pixiCanvas.height = window.innerHeight; + + // Use DOM overlay instead of drawImage compositing to avoid + // expensive WebGL-to-2D-canvas GPU readback every frame. + this.pixiCanvas.style.position = "fixed"; + this.pixiCanvas.style.left = "0"; + this.pixiCanvas.style.top = "0"; + this.pixiCanvas.style.width = "100%"; + this.pixiCanvas.style.height = "100%"; + this.pixiCanvas.style.pointerEvents = "none"; + // Render above ArtilleryLayer PIXI (z-32), below AABulletLayer PIXI (z-34) + this.pixiCanvas.style.zIndex = "33"; + document.body.appendChild(this.pixiCanvas); + this.pixiStage = new PIXI.Container(); await this.pixiRenderer.init({ canvas: this.pixiCanvas, @@ -605,38 +618,6 @@ export class UnitLayer implements Layer { ); } } - - // DEBUG: Log bomber status every 10 ticks - if (this.game.ticks() % 10 === 0) { - const myPlayer = this.game.myPlayer(); - if (myPlayer) { - // First log all airfields - const allAirfields = this.game.units(UnitType.Airfield); - const myAirfields = allAirfields.filter( - (a) => a.owner().smallID() === myPlayer.smallID(), - ); - const airfieldTiles = myAirfields.map((a) => a.tile()); - console.log( - `[My Airfields] Total: ${myAirfields.length}, tiles: [${airfieldTiles.join(", ")}]`, - ); - - for (const render of this.pixiRenders) { - if ( - render.unit.type() === UnitType.Bomber && - render.unit.owner().smallID() === myPlayer.smallID() - ) { - const unit = render.unit; - const cachedAtAirfield = this.bomberAtAirfield.get(unit.id()); - const bomberTile = unit.tile(); - const tileMatches = airfieldTiles.includes(bomberTile); - console.log( - `[Bomber ${unit.id()}] tile=${bomberTile}, tileMatchesAnyAirfield=${tileMatches}, ` + - `cachedAtAirfield=${cachedAtAirfield}, spriteVisible=${render.pixiSprite.visible}`, - ); - } - } - } - } } private isUnitAtOwnedAirfield(unit: UnitView): boolean { @@ -970,7 +951,7 @@ export class UnitLayer implements Layer { this.updateInterpolatedUnits(); // Update and render PIXI units - this.renderPixiUnits(context); + this.renderPixiUnits(); PerformanceMetrics.getInstance().incrementVisibleEntities( this.renderedUnits.size + this.pixiRenders.length, @@ -1000,7 +981,7 @@ export class UnitLayer implements Layer { } } - private renderPixiUnits(mainContext: CanvasRenderingContext2D) { + private renderPixiUnits() { if (!this.pixiRenderer) return; const metrics = PerformanceMetrics.getInstance(); @@ -1051,15 +1032,10 @@ export class UnitLayer implements Layer { // Update ghost sprite positions this.updatePixiGhosts(); - // Render PIXI stage to its canvas + // Render PIXI stage to its own DOM-overlaid canvas. + // No drawImage compositing needed — the browser's hardware compositor + // layers the WebGL canvas on top of the 2D canvas for free. this.pixiRenderer.render(this.pixiStage); - - // Save current transform, reset to identity, draw PIXI canvas, then restore - // This prevents the canvas transform from affecting the PIXI canvas positioning - mainContext.save(); - mainContext.setTransform(1, 0, 0, 1, 0, 0); // Reset to identity matrix - mainContext.drawImage(this.pixiRenderer.canvas, 0, 0); - mainContext.restore(); } private updatePixiSpritePosition(render: UnitRenderInfo) { diff --git a/src/client/graphics/layers/WarScoreOverlay.ts b/src/client/graphics/layers/WarScoreOverlay.ts new file mode 100644 index 000000000..4a6565fa4 --- /dev/null +++ b/src/client/graphics/layers/WarScoreOverlay.ts @@ -0,0 +1,223 @@ +import { WarScoreDebugData } from "../../../core/ai/AIDiplomacyHandler"; +import { GameView } from "../../../core/game/GameView"; +import { Layer } from "./Layer"; + +/** + * Debug overlay toggled by F9 that shows war score breakdowns + * for every AI player vs every other AI/Human player. + * + * Temporary – remove when calibration is done. + */ +export class WarScoreOverlay implements Layer { + layerName = "WarScoreOverlay"; + private container: HTMLDivElement | null = null; + private visible = false; + private lastFetch = 0; + private cachedData: WarScoreDebugData[] = []; + private fetching = false; + private selectedPlayer: string | null = null; // PlayerID to show detail for + + // How often to re-fetch from worker (ms) + private static readonly REFRESH_INTERVAL = 2000; + + constructor(private game: GameView) {} + + init() { + this.container = document.createElement("div"); + Object.assign(this.container.style, { + position: "fixed", + top: "10px", + left: "10px", + maxWidth: "95vw", + maxHeight: "90vh", + overflowY: "auto", + overflowX: "auto", + backgroundColor: "rgba(0, 0, 0, 0.85)", + color: "#e0e0e0", + fontFamily: "monospace", + fontSize: "11px", + padding: "8px 10px", + zIndex: "200", + pointerEvents: "auto", + display: "none", + borderRadius: "4px", + border: "1px solid rgba(255,255,255,0.15)", + }); + document.body.appendChild(this.container); + + // Click delegation for player selection tabs + this.container.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + const id = target.dataset.playerId; + if (id !== undefined) { + this.selectedPlayer = id === this.selectedPlayer ? null : id; + this.renderContent(); + } + }); + + window.addEventListener("keydown", (e) => { + if (e.key === "F9") { + e.preventDefault(); + this.visible = !this.visible; + if (this.container) { + this.container.style.display = this.visible ? "block" : "none"; + } + if (this.visible) { + this.fetchData(); + } + } + }); + } + + renderLayer(_ctx: CanvasRenderingContext2D) { + if (!this.visible) return; + + const now = performance.now(); + if (now - this.lastFetch >= WarScoreOverlay.REFRESH_INTERVAL) { + this.fetchData(); + } + } + + private fetchData() { + if (this.fetching) return; + this.fetching = true; + this.lastFetch = performance.now(); + + this.game.worker + .warScoreDebug() + .then((data) => { + this.cachedData = data; + this.renderContent(); + }) + .catch((err) => { + console.warn("WarScoreOverlay fetch failed:", err); + }) + .finally(() => { + this.fetching = false; + }); + } + + private renderContent() { + if (!this.container) return; + + if (this.cachedData.length === 0) { + this.container.innerHTML = + '
No AI players with war score data.
'; + return; + } + + // Auto-select first player if none selected + if ( + this.selectedPlayer === null || + !this.cachedData.find((d) => d.playerId === this.selectedPlayer) + ) { + this.selectedPlayer = this.cachedData[0].playerId; + } + + // Build tabs + const tabs = this.cachedData + .map((d) => { + const active = d.playerId === this.selectedPlayer; + const bg = active + ? "background: rgba(80,120,200,0.5);" + : "background: rgba(255,255,255,0.1);"; + return `${this.escHtml(d.playerName)}`; + }) + .join(""); + + const selected = this.cachedData.find( + (d) => d.playerId === this.selectedPlayer, + ); + let table = ""; + if (selected) { + // Sort breakdowns: at war first, then by total descending + const sorted = [...selected.breakdowns].sort((a, b) => { + if (a.isAtWar !== b.isAtWar) return a.isAtWar ? -1 : 1; + return b.total - a.total; + }); + + table = ` + + + + + + + + + + + + + + ${sorted.map((b) => this.renderRow(b)).join("")} +
TargetTotalMovAvgThreshBorderMilitaryAllyPenDistPenDomnMilShrStatus
`; + } + + this.container.innerHTML = ` +
+ War Score Debug (F9 to close) +
+
${tabs}
+ ${table} + `; + } + + private renderRow(b: { + targetName: string; + total: number; + movingAverage: number; + threshold: number; + borderScore: number; + militaryScore: number; + allyPenalty: number; + distancePenalty: number; + dominanceBonus: number; + militaryStrengthShare: number; + isAtWar: boolean; + isFriendly: boolean; + unreachable: boolean; + }): string { + let status = ""; + let rowBg = ""; + if (b.isAtWar) { + status = 'WAR'; + rowBg = "background: rgba(255,50,50,0.1);"; + } else if (b.isFriendly) { + status = 'ALLY'; + rowBg = "background: rgba(50,255,50,0.05);"; + } else if (b.unreachable) { + status = 'UNREACH'; + } else if (b.movingAverage >= b.threshold) { + status = 'READY'; + rowBg = "background: rgba(255,170,0,0.1);"; + } else { + status = ''; + } + + const fmt = (v: number) => v.toFixed(1); + const colorNum = (v: number) => { + if (v > 0.5) return `${fmt(v)}`; + if (v < -0.5) return `${fmt(v)}`; + return `${fmt(v)}`; + }; + + return ` + ${this.escHtml(b.targetName)} + ${colorNum(b.total)} + ${colorNum(b.movingAverage)} + ${fmt(b.threshold)} + ${colorNum(b.borderScore)} + ${colorNum(b.militaryScore)} + ${colorNum(-b.allyPenalty)} + ${colorNum(-b.distancePenalty)} + ${colorNum(b.dominanceBonus)} + ${(b.militaryStrengthShare * 100).toFixed(1)}% + ${status} + `; + } + + private escHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); + } +} diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index 68224fab8..5b99768bc 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -357,7 +357,18 @@ export class WinModal extends LitElement implements Layer { } this.show(); } else { - const winner = this.game.playerByClientID(wu.winner[1]); + // Try clientID lookup first (human winners), then player ID (AI/bot winners) + let winner = this.game.playerByClientID(wu.winner[1]); + if (!winner?.isPlayer()) { + try { + const playerById = this.game.player(wu.winner[1]); + if (playerById?.isPlayer()) { + winner = playerById; + } + } catch { + // Player not found by ID either + } + } if (!winner?.isPlayer()) return; const winnerClient = winner.clientID(); if (winnerClient !== null) { diff --git a/src/client/index.html b/src/client/index.html index 1bbc97f04..11b8ecc6e 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -100,7 +100,7 @@ -
v0.2.2
+
DEV
@@ -332,6 +332,7 @@ + diff --git a/src/client/stats/StatsStore.ts b/src/client/stats/StatsStore.ts index df4a2bbfb..b3f590d32 100644 --- a/src/client/stats/StatsStore.ts +++ b/src/client/stats/StatsStore.ts @@ -86,6 +86,8 @@ class StatsStore { s.aliveUntil ??= now; continue; } + // Clear stale death marker — player is alive (e.g. late spawn) + s.aliveUntil = undefined; const v = config.sampler(metric, p); if (s.samples.length > 0 && s.samples[s.samples.length - 1].t === now) { diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 8e10b6972..fda93161f 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -1,8 +1,18 @@ import { placeName } from "../client/graphics/NameBoxCalculator"; +import { AIAttackHandler, AttackDebugData } from "./ai/AIAttackHandler"; +import { AIBehaviorParams } from "./ai/AIBehaviorParams"; +import { AIDiplomacyHandler, WarScoreDebugData } from "./ai/AIDiplomacyHandler"; +import { AIPlayerExecution } from "./ai/AIPlayerExecution"; +import { ConstructionDebugData } from "./ai/ConstructionDebugData"; import { getConfig } from "./configuration/ConfigLoader"; import { AllianceExpireCheckExecution } from "./execution/alliance/AllianceExpireCheckExecution"; import { CapitalRecalculationExecution } from "./execution/CapitalRecalculationExecution"; import { Executor } from "./execution/ExecutionManager"; +import { + TradeDebugPayload, + TradePlayerDebug, + TradeShipDebug, +} from "./execution/TradeDebugData"; import { TradeManagerExecution } from "./execution/TradeManagerExecution"; import { WinCheckExecution } from "./execution/WinCheckExecution"; import { AllianceImpl } from "./game/AllianceImpl"; @@ -38,10 +48,17 @@ import { getTechNodes } from "./tech/ResearchTree"; import { sanitize, simpleHash } from "./Util"; import { censorNameWithClanTag } from "./validations/username"; +export interface CalibrationData { + numPlayers: number; + profileA: { id: string; name: string; params: AIBehaviorParams }; + profileB: { id: string; name: string; params: AIBehaviorParams }; +} + export async function createGameRunner( gameStart: GameStartInfo, clientID: ClientID, callBack: (gu: GameUpdateViewData | ErrorUpdate) => void, + calibration?: CalibrationData, ): Promise { const config = await getConfig(gameStart.config, null); const gameMap = await loadGameMap(gameStart.config.gameMap); @@ -59,22 +76,62 @@ export async function createGameRunner( ); }); - const nations = gameStart.config.disableNPCs - ? [] - : gameMap.nationMap.nations.map( - (n) => - new Nation( - new Cell(n.coordinates[0], n.coordinates[1]), - n.strength, - new PlayerInfo( - n.flag || "", - n.name, - PlayerType.FakeHuman, - null, - random.nextID(), - ), - ), + let nations: Nation[]; + let aiProfileMap: Map | undefined; + + if (calibration) { + // Calibration mode: generate uniformly distributed AI players + const spawnPoints = generateCalibrationSpawnPoints( + gameMap.gameMap, + calibration.numPlayers, + random, + ); + + nations = []; + aiProfileMap = new Map(); + const half = Math.floor(calibration.numPlayers / 2); + + for (let i = 0; i < calibration.numPlayers; i++) { + const isProfileA = i < half; + const profile = isProfileA ? calibration.profileA : calibration.profileB; + const playerName = `${profile.name} #${isProfileA ? i + 1 : i - half + 1}`; + const playerID = random.nextID(); + + const playerInfo = new PlayerInfo( + "", + playerName, + PlayerType.AI, + null, + playerID, + ); + + const ref = spawnPoints[i]; + const nation = new Nation( + new Cell(gameMap.gameMap.x(ref), gameMap.gameMap.y(ref)), + 1, + playerInfo, ); + nations.push(nation); + aiProfileMap.set(playerID, profile.params); + } + } else { + nations = gameStart.config.disableNPCs + ? [] + : gameMap.nationMap.nations.map( + (n) => + new Nation( + new Cell(n.coordinates[0], n.coordinates[1]), + n.strength, + new PlayerInfo( + n.flag || "", + n.name, + PlayerType.AI, + null, + random.nextID(), + ), + ), + ); + } const game: Game = createGame( humans, @@ -89,11 +146,90 @@ export async function createGameRunner( new Executor(game, gameStart.gameID, clientID), callBack, clientID, + aiProfileMap, ); gr.init(); return gr; } +/** + * Generate N uniformly distributed spawn points on land tiles using + * farthest-point sampling for calibration matches. + */ +function generateCalibrationSpawnPoints( + gameMap: { + width(): number; + height(): number; + isLand(ref: number): boolean; + ref(x: number, y: number): number; + manhattanDist(a: number, b: number): number; + }, + numPlayers: number, + random: PseudoRandom, +): number[] { + const width = gameMap.width(); + const height = gameMap.height(); + + // Collect all land tiles + const landTiles: number[] = []; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const ref = gameMap.ref(x, y); + if (gameMap.isLand(ref)) { + landTiles.push(ref); + } + } + } + + if (landTiles.length < numPlayers) { + throw new Error( + `Not enough land tiles (${landTiles.length}) for ${numPlayers} players`, + ); + } + + // Use greedy farthest-point sampling for uniform distribution + const selected: number[] = []; + const minDistances = new Float32Array(landTiles.length).fill(Infinity); + + const firstIndex = random.nextInt(0, landTiles.length); + selected.push(landTiles[firstIndex]); + + for (let i = 0; i < landTiles.length; i++) { + const dist = gameMap.manhattanDist(landTiles[i], landTiles[firstIndex]); + if (dist < minDistances[i]) { + minDistances[i] = dist; + } + } + + while (selected.length < numPlayers) { + let bestIndex = -1; + let bestDist = -1; + + for (let i = 0; i < landTiles.length; i++) { + if (minDistances[i] > bestDist) { + bestDist = minDistances[i]; + bestIndex = i; + } + } + + if (bestIndex === -1) break; + + selected.push(landTiles[bestIndex]); + minDistances[bestIndex] = 0; + + for (let i = 0; i < landTiles.length; i++) { + if (minDistances[i] > 0) { + const dist = gameMap.manhattanDist(landTiles[i], landTiles[bestIndex]); + if (dist < minDistances[i]) { + minDistances[i] = dist; + } + } + } + } + + return selected; +} + function toAllianceViewData( alliance: AllianceImpl, me: Player, @@ -122,13 +258,19 @@ export class GameRunner { private lastKnownPosBySub: Map = new Map(); private ghostActiveUntilBySub: Map = new Map(); + // Optional profile map: maps playerInfo.id → AIBehaviorParams for calibration + private aiProfileMap?: Map; + private tradeManager?: TradeManagerExecution; + constructor( public game: Game, private execManager: Executor, private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void, clientID: ClientID, + aiProfileMap?: Map, ) { this.clientID = clientID; + this.aiProfileMap = aiProfileMap; } /** @@ -278,14 +420,17 @@ export class GameRunner { ); } if (this.game.config().spawnNPCs()) { - this.game.addExecution(...this.execManager.fakeHumanExecutions()); + this.game.addExecution( + ...this.execManager.aiPlayerExecutions(this.aiProfileMap), + ); } this.game.addExecution(new WinCheckExecution()); this.game.addExecution(new AllianceExpireCheckExecution()); // Background: periodically compute player capitals (geographic centers) this.game.addExecution(new CapitalRecalculationExecution()); // Trade rework: central trade manager for demand/supply/assignment - this.game.addExecution(new TradeManagerExecution()); + this.tradeManager = new TradeManagerExecution(); + this.game.addExecution(this.tradeManager); } public addTurn(turn: Turn): void { @@ -327,8 +472,7 @@ export class GameRunner { this.game .players() .filter( - (p) => - p.type() === PlayerType.Human || p.type() === PlayerType.FakeHuman, + (p) => p.type() === PlayerType.Human || p.type() === PlayerType.AI, ) .forEach( (p) => (this.playerViewData[p.id()] = placeName(this.game, p)), @@ -396,15 +540,14 @@ export class GameRunner { canTarget: player.canTarget(other), canSendAllianceRequest: player.canSendAllianceRequest(other), canBreakAlliance: player.isAlliedWith(other), - // Only show Peace when at war - canRequestPeace: player.isAtWarWith(other), - // Only show Declare War when not at war and not allied, and target is human/fakehuman + // Only show Peace when at war and can send (no pending/cooldown) + canRequestPeace: player.canSendPeaceRequest(other), + // Only show Declare War when not at war and not allied, and target is human/AI (not bots) canDeclareWar: !player.isAtWarWith(other) && !player.isAlliedWith(other) && other !== player && - (other.type() === PlayerType.Human || - other.type() === PlayerType.FakeHuman), + (other.type() === PlayerType.Human || other.type() === PlayerType.AI), canDonate: player.canDonate(other), canEmbargo: !player.hasEmbargoAgainst(other), }; @@ -463,4 +606,165 @@ export class GameRunner { } return player.bestTransportShipSpawn(targetTile); } + + public warScoreDebug(): WarScoreDebugData[] { + return AIDiplomacyHandler.getAllWarScoreBreakdowns( + this.game, + this.game.ticks(), + ); + } + + public attackDebug(): AttackDebugData[] { + return AIAttackHandler.getAllAttackDebugData(this.game); + } + + public constructionDebug(): ConstructionDebugData[] { + return AIPlayerExecution.getAllConstructionDebugData(this.game); + } + + public tradeDebug(): TradeDebugPayload { + const g = this.game; + const allShips = [...g.units(UnitType.TradeShip)]; + const allPorts = [...g.units(UnitType.Port)]; + + // Group ships by owner + const byOwner = new Map< + string, + { player: Player; ships: typeof allShips } + >(); + for (const ship of allShips) { + const owner = ship.owner(); + const pid = owner.id(); + if (!byOwner.has(pid)) { + byOwner.set(pid, { player: owner, ships: [] }); + } + byOwner.get(pid)!.ships.push(ship); + } + + // The queue length is set on the game object by TradeManagerExecution + const queueLength: number = (g as any).tradeDemandQueueLength?.() ?? 0; + + const playerDebugList: TradePlayerDebug[] = []; + + for (const [pid, { player, ships }] of byOwner) { + const shipDebugList: TradeShipDebug[] = []; + let idleCount = 0; + let toStartCount = 0; + let toEndCount = 0; + let returningCount = 0; + let stuckAtPortCount = 0; + let stationaryCount = 0; + + for (const ship of ships) { + const tile = ship.tile(); + const lastTile = ship.lastTile(); + const x = g.x(tile); + const y = g.y(tile); + const isOnOcean = g.isOcean(tile); + const portsHere = g + .unitsAt(tile) + .filter((u) => u.type() === UnitType.Port); + const isAtPort = portsHere.length > 0; + const dockedPortId = isAtPort ? portsHere[0].id() : null; + const phase = ship.tradePhase(); + const returning = ship.returning(); + const target = ship.targetUnit(); + const targetUnitId = target?.id() ?? null; + const targetX = target ? g.x(target.tile()) : null; + const targetY = target ? g.y(target.tile()) : null; + const distToTarget = target + ? g.manhattanDist(tile, target.tile()) + : null; + const startOwner = ship.tradeRouteStartOwner(); + const endOwner = ship.tradeRouteEndOwner(); + const stationaryThisTick = tile === lastTile; + const adjacentOceanCount = g + .neighbors(tile) + .filter((t) => g.isOcean(t)).length; + + const phaseStr = phase ?? "idle"; + + if (phase === null) idleCount++; + else if (phase === "toStart") toStartCount++; + else if (phase === "toEnd") toEndCount++; + if (returning) returningCount++; + if (stationaryThisTick) stationaryCount++; + // Heuristic for stuck at port: at a port, has a target, stationary, and very close to target + if ( + isAtPort && + target && + stationaryThisTick && + distToTarget !== null && + distToTarget <= 2 + ) { + stuckAtPortCount++; + } + + shipDebugList.push({ + shipId: ship.id(), + ownerName: player.displayName(), + ownerId: pid, + x, + y, + isOnOcean, + isAtPort, + dockedPortId, + phase: phaseStr, + returning, + targetUnitId, + targetX, + targetY, + distToTarget, + startOwner: startOwner?.displayName() ?? null, + endOwner: endOwner?.displayName() ?? null, + cargoGold: ship.cargoGold().toString(), + stationaryThisTick, + adjacentOceanCount, + }); + } + + // Sort ships: stuck-looking first, then by phase + shipDebugList.sort((a, b) => { + // Stuck at port first + const aStuck = + a.isAtPort && a.stationaryThisTick && a.targetUnitId !== null ? 0 : 1; + const bStuck = + b.isAtPort && b.stationaryThisTick && b.targetUnitId !== null ? 0 : 1; + if (aStuck !== bStuck) return aStuck - bStuck; + // Then by phase + const phaseOrder = { toStart: 0, toEnd: 1, idle: 2 }; + return phaseOrder[a.phase] - phaseOrder[b.phase]; + }); + + const portCount = allPorts.filter( + (p) => p.owner() === player && p.isActive(), + ).length; + + playerDebugList.push({ + playerId: pid, + playerName: player.displayName(), + totalShips: ships.length, + idleShips: idleCount, + toStartShips: toStartCount, + toEndShips: toEndCount, + returningShips: returningCount, + stuckAtPort: stuckAtPortCount, + stationaryShips: stationaryCount, + goldPerMinute: player.tradeShipGoldPerMinute(), + portCount, + ships: shipDebugList, + }); + } + + // Sort players by total ships descending + playerDebugList.sort((a, b) => b.totalShips - a.totalShips); + + return { + tick: g.ticks(), + queueLength, + totalTradeShips: allShips.length, + players: playerDebugList, + demands: this.tradeManager?.getDemandDebug() ?? [], + }; + } } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index f91e799a7..29352e860 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -37,6 +37,7 @@ export type Intent = | AllianceExtensionIntent | BreakAllianceIntent | PeaceRequestIntent + | PeaceRequestReplyIntent | DeclareWarIntent | TargetPlayerIntent | EmojiIntent @@ -61,8 +62,8 @@ export type Intent = | SetAutoBombingIntent | KickPlayerIntent | UpgradeStructureIntent - | UpgradeBomberIntent - | UpdateGameConfigIntent; + | UpdateGameConfigIntent + | UpgradeStructureIntent; export type AttackIntent = z.infer; export type CancelAttackIntent = z.infer; @@ -75,6 +76,9 @@ export type AllianceRequestReplyIntent = z.infer< >; export type BreakAllianceIntent = z.infer; export type PeaceRequestIntent = z.infer; +export type PeaceRequestReplyIntent = z.infer< + typeof PeaceRequestReplyIntentSchema +>; export type DeclareWarIntent = z.infer; export type TargetPlayerIntent = z.infer; export type EmojiIntent = z.infer; @@ -118,8 +122,6 @@ export type UpdateGameConfigIntent = z.infer< export type UpgradeStructureIntent = z.infer< typeof UpgradeStructureIntentSchema >; -export type UpgradeBomberIntent = z.infer; - export type Turn = z.infer; export enum PeaceTimerDuration { None = 0, @@ -369,6 +371,12 @@ export const PeaceRequestIntentSchema = BaseIntentSchema.extend({ recipient: ID, }); +export const PeaceRequestReplyIntentSchema = BaseIntentSchema.extend({ + type: z.literal("peaceRequestReply"), + requestor: ID, // The one who made the original peace request + accept: z.boolean(), +}); + export const DeclareWarIntentSchema = BaseIntentSchema.extend({ type: z.literal("declareWar"), recipient: ID, @@ -431,10 +439,7 @@ export const BuildUnitIntentSchema = BaseIntentSchema.extend({ tile: z.number(), // Optional desired starting level for upgradeable structures. // Server will clamp based on type and game rules. - targetLevel: z.number().int().min(1).max(25).optional(), - // Optional desired bomber upgrade level for airfields. - // Server will clamp based on maxUnitLevel(UnitType.Bomber). - bomberLevel: z.number().int().min(1).max(99).optional(), + targetLevel: z.number().int().min(1).max(99).optional(), }); export const UpgradeStructureIntentSchema = BaseIntentSchema.extend({ @@ -443,11 +448,6 @@ export const UpgradeStructureIntentSchema = BaseIntentSchema.extend({ unitType: z.enum(UnitType), }); -export const UpgradeBomberIntentSchema = BaseIntentSchema.extend({ - type: z.literal("upgrade_bomber"), - airfieldId: z.number(), -}); - export const ResearchTreeSelectIntentSchema = BaseIntentSchema.extend({ type: z.literal("research_tree_select"), techId: z.string().max(128), @@ -544,6 +544,7 @@ const IntentSchema = z.discriminatedUnion("type", [ AllianceExtensionIntentSchema, BreakAllianceIntentSchema, PeaceRequestIntentSchema, + PeaceRequestReplyIntentSchema, DeclareWarIntentSchema, TargetPlayerIntentSchema, EmojiIntentSchema, @@ -555,7 +556,6 @@ const IntentSchema = z.discriminatedUnion("type", [ ResearchInvestmentIntentSchema, BuildUnitIntentSchema, UpgradeStructureIntentSchema, - UpgradeBomberIntentSchema, ResearchTreeSelectIntentSchema, EmbargoIntentSchema, MoveWarshipIntentSchema, diff --git a/src/core/ai/AIAttackHandler.ts b/src/core/ai/AIAttackHandler.ts new file mode 100644 index 000000000..a532827b8 --- /dev/null +++ b/src/core/ai/AIAttackHandler.ts @@ -0,0 +1,613 @@ +import { AttackExecution } from "../execution/AttackExecution"; +import { TransportShipExecution } from "../execution/TransportShipExecution"; +import { Game, Player, PlayerID, PlayerType, UnitType } from "../game/Game"; +import { TileRef } from "../game/GameMap"; +import { canBuildTransportShip } from "../game/TransportShipUtils"; +import { PseudoRandom } from "../PseudoRandom"; +import { AIBehaviorParams } from "./AIBehaviorParams"; + +// ─── Debug overlay types ───────────────────────────────────────────────────── + +/** Per-enemy breakdown for a single AI player's attack evaluation. */ +export interface AttackTargetBreakdown { + targetId: PlayerID; + targetName: string; + isAtWar: boolean; + sharesBorder: boolean; + /** Which path was selected: "land", "boat", or "none". */ + attackPath: "land" | "boat" | "none"; + /** Why the attack was blocked, if it was. */ + blockReason: string; + /** Manhattan distance to nearest enemy shore (boat targeting). 0 if N/A. */ + boatDistance: number; + /** Does the enemy border ocean? */ + enemyBordersOcean: boolean; +} + +/** All attack debug data for one AI player. */ +export interface AttackDebugData { + playerId: PlayerID; + playerName: string; + /** Whether handleAttack() is being reached (not suppressed by TN/bot). */ + handleAttackReached: boolean; + /** Last tick handleAttack() was called. */ + lastHandleAttackTick: number; + /** Troop ratio vs threshold. */ + troopRatio: number; + attackThreshold: number; + /** Defending troop ratio vs target. */ + defendingRatio: number; + defendingTarget: number; + /** Does this player border ocean? */ + bordersOcean: boolean; + /** Number of ocean shore tiles. */ + oceanShoreTileCount: number; + /** Current transport ship count / max. */ + boatCount: number; + boatMax: number; + /** Ticks since last boat attack. */ + ticksSinceLastBoat: number; + /** Boat cooldown threshold. */ + boatCooldown: number; + /** Current boat search range (Manhattan distance). */ + boatSearchRange: number; + /** Per-enemy breakdown. */ + targets: AttackTargetBreakdown[]; +} + +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Handles attack behavior against AI and Human players. + * Only attacks players we are at war with. + * Bot and TerraNullius attacks are handled separately. + */ +export class AIAttackHandler { + // Static registry for debug overlay access + private static readonly registry = new Map(); + // Number of random shore tiles to sample (in addition to extrema) + private static readonly RANDOM_SHORE_SAMPLE_SIZE = 4; + + // Cooldown between boat attacks (ticks) + private static readonly BOAT_ATTACK_COOLDOWN = 50; + + // Best non-extremum tile found per enemy player (for boat targeting) + // Maps enemy PlayerID -> their best shore tile we've found + private closestRandomEnemy = new Map(); + + // Last tick we sent a boat attack + private lastBoatAttackTick = 0; + + // Growing boat search range (Manhattan distance) + private currentBoatSearchRange: number | null = null; + + // Debug: last tick handleAttack() was actually called + private _lastHandleAttackTick = 0; + // Debug: whether handleAttack was reached last tick cycle + private _handleAttackReached = false; + + constructor( + private mg: Game, + private playerId: PlayerID, + private random: PseudoRandom, + private params: AIBehaviorParams, + private readonly thresholdOffset: number, + ) { + AIAttackHandler.registry.set(playerId, this); + } + + private getPlayer(): Player | null { + if (!this.mg.hasPlayer(this.playerId)) { + return null; + } + return this.mg.player(this.playerId); + } + + handleAttack(): boolean { + this._handleAttackReached = true; + this._lastHandleAttackTick = this.mg.ticks(); + + const player = this.getPlayer(); + if (!player || !player.isAlive()) { + return false; + } + + const attackThreshold = + (this.params.attackTroopThreshold ?? 0.5) + this.thresholdOffset; + const maxPop = this.mg.config().maxPopulation(player); + const maxTroops = maxPop * player.targetTroopRatio(); + const totalTroops = player.troops() + player.attackingTroops(); + const troopRatio = player.troops() / maxTroops; + + // Only attack if we have enough troops + if (troopRatio < attackThreshold) { + return false; + } + + // Check if we have enough defending troops at home + const defendingTroopTarget = this.params.defendingTroopTarget ?? 0.5; + const defendingRatio = player.troops() / totalTroops; + if (defendingRatio < defendingTroopTarget) { + return false; + } + + // Find land target: enemy we're at war with, that borders us, with lowest troop density + const landTarget = this.findLandTarget(player); + if (landTarget !== null) { + this.launchLandAttack(player, landTarget); + return true; + } + + // No land target found, try boat attack + // Rate-limit boat attacks to prevent sending multiple ships in quick succession + const currentTick = this.mg.ticks(); + if ( + currentTick - this.lastBoatAttackTick < + AIAttackHandler.BOAT_ATTACK_COOLDOWN + ) { + return false; + } + + const boatTarget = this.findBoatTarget(player); + if (boatTarget !== null) { + return this.launchBoatAttack(player, boatTarget.target, boatTarget.tile); + } + + return false; + } + + private findLandTarget(player: Player): Player | null { + let bestTarget: Player | null = null; + let lowestDensity = Infinity; + + for (const other of this.mg.players()) { + if (other.id() === player.id()) continue; + if (!other.isAlive()) continue; + + // Only attack AI and Human players + if (other.type() !== PlayerType.Human && other.type() !== PlayerType.AI) { + continue; + } + + // Must be at war with them + if (!player.isAtWarWith(other)) { + continue; + } + + // Must share a border (no boating for now) + if (!player.sharesBorderWith(other)) { + continue; + } + + // Calculate troop density (troops per tile) + const numTiles = other.numTilesOwned(); + if (numTiles === 0) continue; + + const troopDensity = other.troops() / numTiles; + if (troopDensity < lowestDensity) { + lowestDensity = troopDensity; + bestTarget = other; + } + } + + return bestTarget; + } + + /** + * Finds a boat attack target: enemy at war with us, reachable by boat, + * doesn't share a border. Returns the target and the nearest tile to attack. + */ + private findBoatTarget( + player: Player, + ): { target: Player; tile: TileRef } | null { + // Fast path: skip if we don't border ocean + if (!player.bordersOcean()) { + return null; + } + + // Get our ocean shore sample (extrema + closestRandom + random) + const playerSample = this.getOceanShoreSample(player, true); + if (playerSample.length === 0) { + return null; + } + + // Use first tile as reference point for finding enemy shores + const refShore = playerSample[0]; + + let bestTarget: Player | null = null; + let bestTile: TileRef | null = null; + let shortestDistance = Infinity; + + for (const other of this.mg.players()) { + if (other.id() === player.id()) continue; + if (!other.isAlive()) continue; + + // Only attack AI and Human players + if (other.type() !== PlayerType.Human && other.type() !== PlayerType.AI) { + continue; + } + + // Must be at war with them + if (!player.isAtWarWith(other)) { + continue; + } + + // Skip if we share a border (should use land attack) + if (player.sharesBorderWith(other)) { + continue; + } + + // Fast path: skip if enemy doesn't border ocean + if (!other.bordersOcean()) { + continue; + } + + // Get enemy's ocean shore sample + const otherSample = this.getOceanShoreSample(other); + if (otherSample.length === 0) { + continue; + } + + // Find closest enemy tile to our reference shore + let closestTile: TileRef | null = null; + let closestToRef = Infinity; + for (const tile of otherSample) { + const dist = this.mg.manhattanDist(refShore, tile); + if (dist < closestToRef) { + closestToRef = dist; + closestTile = tile; + } + } + + if (closestTile === null) { + continue; + } + + // Find distance from closest enemy tile to our nearest sample tile + let minDist = Infinity; + let bestShoreTile: TileRef | null = null; + for (const shore of playerSample) { + const dist = this.mg.manhattanDist(shore, closestTile); + if (dist < minDist) { + minDist = dist; + bestShoreTile = shore; + } + } + + // Remember best non-extremum tile for this enemy + if (bestShoreTile !== null) { + const extremaSet = new Set(player.oceanShoreExtrema()); + if (!extremaSet.has(bestShoreTile)) { + this.closestRandomEnemy.set(other.id(), bestShoreTile); + } + } + + if (minDist < shortestDistance) { + shortestDistance = minDist; + bestTarget = other; + bestTile = closestTile; + } + } + + if (bestTarget === null || bestTile === null) { + // No target found at all — grow range for next attempt + this.growBoatSearchRange(); + return null; + } + + // Check if the best target is within the current search range + const range = this.getBoatSearchRange(); + if (shortestDistance > range) { + // Target exists but too far — grow range for next attempt + this.growBoatSearchRange(); + return null; + } + + return { target: bestTarget, tile: bestTile }; + } + + /** + * Gets ocean shore sample for a player: extrema + closestRandom + random tiles. + * For our own player, uses closestRandomEnemy values. + * For enemy players, just uses extrema + random. + */ + private getOceanShoreSample( + player: Player, + isOwn: boolean = false, + ): TileRef[] { + const extrema = player.oceanShoreExtrema(); + const allShores = player.oceanShoreTiles(); + + if (allShores.length === 0) { + return []; + } + + // Start with extrema + const result = [...extrema]; + const usedSet = new Set(extrema); + + // For our own player, include remembered best tiles from previous evaluations + if (isOwn) { + for (const tile of this.closestRandomEnemy.values()) { + // Verify tile still belongs to us + if ( + this.mg.isValidRef(tile) && + this.mg.owner(tile).id() === player.id() && + !usedSet.has(tile) + ) { + result.push(tile); + usedSet.add(tile); + } + } + } + + // Add random samples + const availableForSampling = allShores.filter((t) => !usedSet.has(t)); + const randomSample = this.sampleTiles( + availableForSampling, + AIAttackHandler.RANDOM_SHORE_SAMPLE_SIZE, + ); + result.push(...randomSample); + + return result; + } + + /** + * Randomly samples n tiles from the array. + */ + private sampleTiles(tiles: readonly TileRef[], n: number): TileRef[] { + if (tiles.length <= n) { + return [...tiles]; + } + const result: TileRef[] = []; + const indices = new Set(); + while (result.length < n) { + const idx = this.random.nextInt(0, tiles.length); + if (!indices.has(idx)) { + indices.add(idx); + result.push(tiles[idx]); + } + } + return result; + } + + private getBoatSearchRange(): number { + if (this.currentBoatSearchRange === null) { + this.currentBoatSearchRange = this.params.attackBoatInitialRange ?? 50; + } + return this.currentBoatSearchRange; + } + + private growBoatSearchRange(): void { + const growth = this.params.attackBoatRangeGrowth ?? 0.5; + this.currentBoatSearchRange = this.getBoatSearchRange() + growth; + } + + private launchLandAttack(player: Player, target: Player): void { + // Verify the border actually exists (cache may be stale) + const targetSmallID = target.smallID(); + let hasConquerableTile = false; + for (const tile of player.borderTiles()) { + for (const n of this.mg.neighbors(tile)) { + if (!this.mg.isWater(n) && this.mg.ownerID(n) === targetSmallID) { + hasConquerableTile = true; + break; + } + } + if (hasConquerableTile) break; + } + if (!hasConquerableTile) { + return; + } + + const alpha = this.params.attackOwnTroopPercent ?? 0.2; + const beta = this.params.attackEnemyTroopMultiplier ?? 1.5; + + const troopsFromOwn = player.troops() * alpha; + const troopsFromEnemy = target.troops() * beta; + const troops = Math.min(troopsFromOwn, troopsFromEnemy); + + if (troops < 1) { + return; + } + + this.mg.addExecution(new AttackExecution(troops, player, target.id())); + } + + private launchBoatAttack( + player: Player, + target: Player, + targetTile: TileRef, + ): boolean { + // Validate that we can actually build a transport ship to this destination + if (canBuildTransportShip(this.mg, player, targetTile) === false) { + return false; + } + + const boatTroopPercent = this.params.attackBoatTroopPercent ?? 0.1; + const troops = player.troops() * boatTroopPercent; + + if (troops < 1) { + return false; + } + + this.lastBoatAttackTick = this.mg.ticks(); + this.mg.addExecution( + new TransportShipExecution(player, targetTile, troops), + ); + return true; + } + + // ─── Debug overlay support ────────────────────────────────────────────────── + + /** + * Collects debug data for all registered AI attack handlers. + */ + public static getAllAttackDebugData(game: Game): AttackDebugData[] { + const results: AttackDebugData[] = []; + for (const [playerId, handler] of AIAttackHandler.registry) { + if (!game.hasPlayer(playerId)) continue; + const player = game.player(playerId); + if (!player.isPlayer() || !player.isAlive()) continue; + results.push(handler.collectDebugData(player)); + } + return results; + } + + private collectDebugData(player: Player): AttackDebugData { + const attackThreshold = + (this.params.attackTroopThreshold ?? 0.5) + this.thresholdOffset; + const maxPop = this.mg.config().maxPopulation(player); + const maxTroops = maxPop * player.targetTroopRatio(); + const totalTroops = player.troops() + player.attackingTroops(); + const troopRatio = totalTroops / maxTroops; + + const defendingTroopTarget = this.params.defendingTroopTarget ?? 0.5; + const defendingRatio = totalTroops > 0 ? player.troops() / totalTroops : 1; + + const currentTick = this.mg.ticks(); + const boatMax = this.mg.config().boatMaxNumber(); + const boatCount = player.unitCount(UnitType.TransportShip); + + const targets: AttackTargetBreakdown[] = []; + for (const other of this.mg.players()) { + if (other.id() === player.id()) continue; + if (!other.isAlive()) continue; + if (other.type() !== PlayerType.Human && other.type() !== PlayerType.AI) + continue; + if (!player.isAtWarWith(other)) continue; + + const sharesBorder = player.sharesBorderWith(other); + const enemyBordersOcean = other.bordersOcean(); + + let attackPath: "land" | "boat" | "none" = "none"; + let blockReason = ""; + let boatDistance = 0; + + if (sharesBorder) { + // Would go through land attack path + attackPath = "land"; + // Check if land attack would actually succeed + if (troopRatio < attackThreshold) { + blockReason = `troopRatio ${troopRatio.toFixed(2)} < threshold ${attackThreshold.toFixed(2)}`; + } else if (defendingRatio < defendingTroopTarget) { + blockReason = `defendingRatio ${defendingRatio.toFixed(2)} < target ${defendingTroopTarget.toFixed(2)}`; + } else { + // Check if conquerable tiles exist + const targetSmallID = other.smallID(); + let hasConquerable = false; + for (const tile of player.borderTiles()) { + for (const n of this.mg.neighbors(tile)) { + if (!this.mg.isWater(n) && this.mg.ownerID(n) === targetSmallID) { + hasConquerable = true; + break; + } + } + if (hasConquerable) break; + } + if (!hasConquerable) { + blockReason = "no conquerable land tiles at border"; + } else { + blockReason = "OK (land attack active)"; + } + } + } else { + // Would go through boat attack path + attackPath = "boat"; + if (troopRatio < attackThreshold) { + blockReason = `troopRatio ${troopRatio.toFixed(2)} < threshold ${attackThreshold.toFixed(2)}`; + } else if (defendingRatio < defendingTroopTarget) { + blockReason = `defendingRatio ${defendingRatio.toFixed(2)} < target ${defendingTroopTarget.toFixed(2)}`; + } else if (!player.bordersOcean()) { + blockReason = "player does not border ocean"; + } else if (player.oceanShoreTiles().length === 0) { + blockReason = "player has no ocean shore tiles"; + } else if ( + currentTick - this.lastBoatAttackTick < + AIAttackHandler.BOAT_ATTACK_COOLDOWN + ) { + blockReason = `boat cooldown (${currentTick - this.lastBoatAttackTick}/${AIAttackHandler.BOAT_ATTACK_COOLDOWN} ticks)`; + } else if (!enemyBordersOcean) { + blockReason = "enemy does not border ocean"; + } else if (other.oceanShoreTiles().length === 0) { + blockReason = "enemy has no ocean shore tiles"; + } else if (boatCount >= boatMax) { + blockReason = `boat cap (${boatCount}/${boatMax})`; + } else { + // Check distance — simulate findBoatTarget + const playerSample = this.getOceanShoreSample(player, true); + const otherSample = this.getOceanShoreSample(other); + if (playerSample.length === 0) { + blockReason = "no player ocean shore sample"; + } else if (otherSample.length === 0) { + blockReason = "no enemy ocean shore sample"; + } else { + let minDist = Infinity; + for (const s of playerSample) { + for (const t of otherSample) { + const d = this.mg.manhattanDist(s, t); + if (d < minDist) minDist = d; + } + } + boatDistance = minDist; + const currentRange = this.getBoatSearchRange(); + if (minDist > currentRange) { + blockReason = `out of range (dist=${minDist}, range=${currentRange.toFixed(1)})`; + } else { + // Try canBuildTransportShip + const bestEnemyTile = otherSample.reduce((best, t) => { + const dBest = this.mg.manhattanDist(playerSample[0], best); + const dT = this.mg.manhattanDist(playerSample[0], t); + return dT < dBest ? t : best; + }); + if ( + canBuildTransportShip(this.mg, player, bestEnemyTile) === false + ) { + blockReason = `canBuildTransportShip failed (dist=${minDist})`; + } else { + const boatTroopPercent = + this.params.attackBoatTroopPercent ?? 0.1; + const troops = player.troops() * boatTroopPercent; + if (troops < 1) { + blockReason = `boat troops too low (${troops.toFixed(0)})`; + } else { + blockReason = `OK (boat ready, dist=${minDist})`; + } + } + } + } + } + } + + targets.push({ + targetId: other.id(), + targetName: other.displayName(), + isAtWar: true, + sharesBorder, + attackPath, + blockReason, + boatDistance, + enemyBordersOcean, + }); + } + + return { + playerId: this.playerId, + playerName: player.displayName(), + handleAttackReached: this._handleAttackReached, + lastHandleAttackTick: this._lastHandleAttackTick, + troopRatio, + attackThreshold, + defendingRatio, + defendingTarget: defendingTroopTarget, + bordersOcean: player.bordersOcean(), + oceanShoreTileCount: player.oceanShoreTiles().length, + boatCount, + boatMax, + ticksSinceLastBoat: currentTick - this.lastBoatAttackTick, + boatCooldown: AIAttackHandler.BOAT_ATTACK_COOLDOWN, + boatSearchRange: this.getBoatSearchRange(), + targets, + }; + } +} diff --git a/src/core/ai/AIBehaviorParams.ts b/src/core/ai/AIBehaviorParams.ts new file mode 100644 index 000000000..018380f84 --- /dev/null +++ b/src/core/ai/AIBehaviorParams.ts @@ -0,0 +1,383 @@ +import aiProfilesData from "../../../resources/ai-profiles.json" with { type: "json" }; + +/** + * Behavior parameters that define how an AI player acts. + */ +export interface AIBehaviorParams { + // === Spawn Behavior === + /** Whether to move spawn point around during spawn phase */ + spawnHopping?: boolean; + /** How often (in ticks) to move spawn point when spawnHopping is enabled */ + spawnHopRate?: number; + /** Whether to wait until end of spawn phase and spawn near another player */ + spawnSniping?: boolean; + /** Whether to move away when another player spawns nearby */ + spawnAvoidance?: boolean; + /** Minimum distance to maintain from other players when spawnAvoidance is enabled */ + spawnAvoidanceDistance?: number; + + // === Terra Nullius Expansion === + /** Minimum troop ratio (troops / maxTroops) before expanding into unclaimed land (0-1) */ + terraNulliusTroopThreshold?: number; + /** Percent of own troops to use when expanding into Terra Nullius by land */ + terraNulliusOwnTroopPercent?: number; + /** Percent of own troops to use when boating to Terra Nullius */ + terraNulliusBoatTroopPercent?: number; + /** Maximum distance from capital to attack TN (bypassed if shares land border) */ + terraNulliusMaxDistance?: number; + /** Minimum spacing between TN boat targets to prevent clustering */ + terraNulliusBoatSpacing?: number; + /** Search range for opportunistic boat attacks (random probes for nearby TN across water). Default 20. */ + terraNulliusOpportunisticBoatRange?: number; + + // === Bot Attack Behavior === + /** Minimum troop ratio (troops / maxTroops) before attacking bots (0-1) */ + botAttackTroopThreshold?: number; + /** Maximum distance (in tiles) to consider a bot target */ + botAttackMaxDistance?: number; + /** Percent of own troops to use in bot attack (alpha) */ + botAttackOwnTroopPercent?: number; + /** Multiplier of bot troops to cap attack size (beta) */ + botAttackEnemyTroopMultiplier?: number; + /** + * How much the boat search range grows per failed attempt. + * Default 0.5 (tiles per attempt). + */ + botAttackBoatSearchRangeGrowth?: number; + + /** + * Initial maximum Manhattan distance for bot boat attacks. + * Grows over time when no target is found within range. + * Default 50. + */ + botAttackBoatInitialRange?: number; + + // === AI/Human Attack Behavior === + /** Minimum troop ratio (troops / maxTroops) before attacking AI/Human players (0-1) */ + attackTroopThreshold?: number; + /** Percent of own troops to use in attack (alpha) */ + attackOwnTroopPercent?: number; + /** Multiplier of enemy troops to cap attack size (beta) */ + attackEnemyTroopMultiplier?: number; + + // === Defense === + /** Minimum ratio of defending troops (player.troops() / totalTroops) required before attacking (0-1) */ + defendingTroopTarget?: number; + + // === Investment Rates === + /** Productivity investment rate (0-1), set at game start */ + productivityInvestmentRate?: number; + /** Research investment rate (0-1), set at game start */ + researchInvestmentRate?: number; + /** Road investment rate (0-1), set once roads are researched */ + roadInvestmentRate?: number; + /** Target troop ratio (0-1), troops share out of workers and troops, set at game start */ + targetTroopRatio?: number; + /** If true, cap road investment to maintenance cost (or exact maintenance if at max quality) */ + roadInvestmentCapToMaintenance?: boolean; + /** Extra investment above maintenance when road network is incomplete (0-1), default 0.1 */ + roadBuildBoost?: number; + /** Investment adjustment above/below maintenance based on quality vs target (0-1), default 0.01 */ + roadQualityAdjust?: number; + /** Target road quality (0-150), invest more when below, less when above, default 100 */ + targetRoadQuality?: number; + + // === Construction === + /** Whether to build cities */ + buildCities?: boolean; + /** Whether to build factories */ + buildFactories?: boolean; + /** Whether to build ports */ + buildPorts?: boolean; + /** Whether to build hospitals */ + buildHospitals?: boolean; + /** Whether to build academies */ + buildAcademies?: boolean; + /** Whether to build airfields */ + buildAirfields?: boolean; + /** Whether to build research labs */ + buildResearchLabs?: boolean; + /** Whether to build missile silos */ + buildMissileSilos?: boolean; + /** Whether to build SAM launchers */ + buildSAMLaunchers?: boolean; + /** Whether to build defense posts */ + buildDefensePosts?: boolean; + /** + * Percentage penalty (0-1) applied to defense post tile score when + * water is found within otherTileWaterCheckDistance of the tile. + * Applied as score *= (1 - penalty). Default 0 (no penalty). + */ + defensePostNearWaterPenalty?: number; + /** Whether to build doomsday devices */ + buildDoomsdayDevices?: boolean; + + // === Unit Construction === + /** Whether to build warships */ + buildWarships?: boolean; + /** Whether to build submarines */ + buildSubmarines?: boolean; + /** Whether to build fighter jets */ + buildFighterJets?: boolean; + /** Whether to build artillery */ + buildArtillery?: boolean; + + // === Unit Build Weights === + /** Multiplicative weight for warship build scoring. Default 1. */ + weightWarship?: number; + /** + * Weight applied to the global moving-average trade-ship income + * (sum of tradeShipGoldPerMinute across all players) when computing + * the warship score numerator. + * numerator += warshipTradeIncomeWeight * globalTradeShipGoldPerMinute. + * Default 0. + */ + warshipTradeIncomeWeight?: number; + /** + * Bonus added to the warship score numerator when ALL enemies the player + * is at war with are naval-only (no shared land border). + * numerator += warshipCoastalThreatWeight * indicator (0 or 1). + * Default 0. + */ + warshipCoastalThreatWeight?: number; + + /** + * If the best construction target score is less than this multiplier times + * the best nuke score, skip construction in favor of saving for nukes. + * Set to 0 to disable. Default 0 (disabled). + */ + nukeScoreConstructionThreshold?: number; + + /** + * Weight multiplier for collateral damage to non-enemy player structures + * when evaluating nuke targets. Higher values make the AI more cautious + * about hitting friendly/neutral structures. Default 1.0. + */ + nukeFriendlyDamageWeight?: number; + + /** + * Multiplier applied to the nuke score when comparing against construction + * scores to decide whether to start a nuke sequence. Default 1.0. + */ + nukeScoreMultiplier?: number; + + /** + * Scale parameter inside the sigmoid that modulates nuke enemy-value by + * war score (without dominance). Each enemy structure contribution is + * multiplied by sigmoid(nukeWarScoreSigmoidScale * (warScore - 4)). + * Higher values make the sigmoid steeper; 0 disables the modulation. + * Default 1/50 = 0.02. + */ + nukeWarScoreSigmoidScale?: number; + + /** + * Minimum distance (in tiles) to keep away from other players when placing + * non-defense-post structures. + */ + aiAvoidPlayerDistance?: number; + + // === Structure Build Weights === + // Multipliers for structure build scoring (default 1 for all) + /** Weight for city build priority */ + weightCity?: number; + /** Weight for factory build priority */ + weightFactory?: number; + /** Weight for port build priority */ + weightPort?: number; + /** Weight for hospital build priority */ + weightHospital?: number; + /** Weight for academy build priority */ + weightAcademy?: number; + /** Weight for airfield build priority */ + weightAirfield?: number; + /** Weight for research lab build priority */ + weightResearchLab?: number; + /** Weight for missile silo build priority */ + weightMissileSilo?: number; + /** Weight for SAM launcher build priority */ + weightSAMLauncher?: number; + /** + * Decay rate for SAM coverage penalty sigmoid. + * score *= sigmoid(-samCoverageDecay * existingCoverage). + * Higher values penalize redundant SAM coverage more sharply. + * Default 0.05. + */ + samCoverageDecay?: number; + /** Weight for defense post build priority */ + weightDefensePost?: number; + /** Weight for doomsday device build priority */ + weightDoomsdayDevice?: number; + + /** + * Fraction of total income/min used as the perpetual income stream when + * scoring the first port (portCount === 0). Default 0.5 (50%). + */ + firstPortIncomeShare?: number; + + // === Structure Scoring === + /** + * Assumed population percentage of max pop when scoring structures. + * Default 0.7 (70%). + */ + aiAssumedPopPercent?: number; + + // === Tile Scoring (port, other/land, and SAM structures) === + /** + * Sigmoid weight for enemy proximity penalty on port/other/SAM tiles. + * Higher values penalize tiles near enemy borders more strongly. Default 2.0. + */ + tileNearPlayerPenalty?: number; + /** + * Penalty weight for nearby own structures within zone 1 (≤25 tiles). + * Applied to structure value / 1M in the sigmoid. Default 0.3. + */ + tileNearStructurePenalty?: number; + /** + * Multiplier for zone 2 penalty relative to zone 1 (25-61 tiles). Default 0.5. + */ + nearStructureZone2Multiplier?: number; + /** + * Multiplier for zone 3 penalty relative to zone 2 (61-161 tiles). Default 0.5. + */ + nearStructureZone3Multiplier?: number; + /** + * Multiplier for zone 4 penalty relative to zone 3 (161-201 tiles). Default 0.5. + */ + nearStructureZone4Multiplier?: number; + /** + * Sigmoid weight for capital distance penalty on port/other tiles. + * Higher values penalize tiles far from capital more strongly. Default 1.0. + */ + tileCapitalDistancePenalty?: number; + /** + * Distance (in tiles) to check for nearby water. + * Default 5. + */ + otherTileWaterCheckDistance?: number; + /** + * Percentage penalty (0-1) if water is within the water check distance. + * Default 0.2 (20% penalty). + */ + otherTileNearWaterPenalty?: number; + + /** + * Bonus added to the logistic z-score per own structure stack count + * within 120 tiles. Applied to both port and other (land) tile scores. + * Default 0.01. + */ + tileNearbyStructureStackBonus?: number; + + // === Diplomacy === + /** + * War score threshold above which the AI will declare war on another player. + * Higher values make the AI less aggressive. Default 1.0. + */ + warDeclarationThreshold?: number; + + /** + * Weight for shared border length ratio in war score calculation. + * Score contribution = weight * (sharedBorderLength / ownTotalBorderLength). + * Default 0. + */ + warScoreSharedBorderWeight?: number; + + /** + * Weight for military strength ratio in war score calculation. + * Score contribution = weight * (ownMilitaryStrength / totalEnemyStrength). + * totalEnemyStrength includes target plus current war enemies (weighted by border). + * Default 0. + */ + warScoreMilitaryStrengthWeight?: number; + + /** + * Weight multiplier for non-reachable enemies in military strength calculation. + * Enemies that can't reach us are less threatening (can't attack directly). + * Value between 0-1; default 0.2 means non-reachable enemies count as 20% threat. + */ + warScoreNonReachableEnemyWeight?: number; + + /** + * Discount factor applied to the sum of co-belligerent contributions in the + * military strength numerator. Value between 0-1; default 0.9 means + * co-belligerents' effective contribution is multiplied by 0.9. + */ + warScoreCoBelligerentDiscount?: number; + + /** + * Penalty applied to war score if target is an ally. + * Score contribution = -penalty (subtracted from total). + * Default 0. + */ + warScoreAllyPenalty?: number; + + /** + * Weight for distance penalty when target is only reachable by boat. + * Penalty = weight * (shoreDistance / sqrt(mapWidth * mapHeight))^2. + * Higher values discourage attacking distant ocean targets. + * Default 0. + */ + warScoreDistancePenaltyWeight?: number; + + /** + * Weight for dominance bonus when target is the strongest player in the game. + * Only applies if the target has the highest military strength. + * Score contribution = weight * gapPercent / (0.8 - targetShare), where: + * gapPercent = (targetStrength - secondHighestStrength) / secondHighestStrength + * targetShare = targetStrength / totalGameStrength + * Encourages AI to gang up on runaway leaders. Default 0. + */ + warScoreDominanceWeight?: number; + + /** + * Percentage of troops to send on boat attacks against AI/Human players. + * Lower than land attacks since boats are riskier. + * Default 0.1 (10%). + */ + attackBoatTroopPercent?: number; + + /** + * Initial maximum Manhattan distance for boat attacks against AI/Human + * players. Grows over time when no target is found within range. + * Default 50. + */ + attackBoatInitialRange?: number; + + /** + * How much the boat search range grows per failed attempt (no target + * within range). Never resets, no cap. + * Default 0.5 (tiles per attempt). + */ + attackBoatRangeGrowth?: number; + + /** + * Gap below warDeclarationThreshold at which the AI will seek/accept peace. + * Peace threshold = warDeclarationThreshold - peaceThresholdGap. + * If a war score for an enemy falls below this threshold, the AI is willing + * to make peace. Default 30. + */ + peaceThresholdGap?: number; + + // === General === + /** + * Discount factor applied to future rewards when evaluating decisions. + * Lower values make the AI more short-sighted; higher values make it + * plan further ahead. Default 0.1. + */ + discountFactor?: number; +} + +export interface AIProfile { + id: string; + name: string; + params: AIBehaviorParams; +} + +const aiProfiles = aiProfilesData.profiles as AIProfile[]; + +export function getAIProfile(id: string): AIProfile | undefined { + return aiProfiles.find((p) => p.id === id); +} + +export function getAllAIProfiles(): AIProfile[] { + return [...aiProfiles]; +} diff --git a/src/core/ai/AIBotAttackHandler.ts b/src/core/ai/AIBotAttackHandler.ts new file mode 100644 index 000000000..a6adc4678 --- /dev/null +++ b/src/core/ai/AIBotAttackHandler.ts @@ -0,0 +1,317 @@ +import { AttackExecution } from "../execution/AttackExecution"; +import { TransportShipExecution } from "../execution/TransportShipExecution"; +import { closestTwoTiles } from "../execution/Util"; +import { Game, Player, PlayerID, PlayerType } from "../game/Game"; +import { TileRef } from "../game/GameMap"; +import { canBuildTransportShip } from "../game/TransportShipUtils"; +import { PseudoRandom } from "../PseudoRandom"; +import { AIBehaviorParams } from "./AIBehaviorParams"; + +/** + * Handles attack behavior against Bot players only. + * Player attacks (Human, AI, etc.) are handled separately. + */ +export class AIBotAttackHandler { + private currentBotTarget: Player | null = null; + private unreachableBots: Map = new Map(); // PlayerID -> tick when marked unreachable + private allNeighborsCache: { neighbors: Set; tick: number } | null = + null; + private playerShoreCache: { tiles: TileRef[]; tick: number } | null = null; + private targetShoreCache: Map = + new Map(); + private lastBoatAttackTick: number = 0; + private currentBoatSearchRange: number | null = null; + private static readonly UNREACHABLE_RECHECK_INTERVAL = 100; + private static readonly NEIGHBOR_CACHE_INTERVAL = 10; + private static readonly SHORE_CACHE_INTERVAL = 10; + private static readonly BOAT_ATTACK_COOLDOWN = 100; // ticks between boat attacks + private static readonly MAX_BOAT_SEARCH_RANGE = 270; + + constructor( + private mg: Game, + private playerId: PlayerID, + private random: PseudoRandom, + private params: AIBehaviorParams, + private readonly thresholdOffset: number, + ) {} + + private getPlayer(): Player | null { + if (!this.mg.hasPlayer(this.playerId)) { + return null; + } + return this.mg.player(this.playerId); + } + + private getBoatSearchRange(): number { + if (this.currentBoatSearchRange === null) { + this.currentBoatSearchRange = this.params.botAttackBoatInitialRange ?? 50; + } + return this.currentBoatSearchRange; + } + + handleBotAttack(): boolean { + const player = this.getPlayer(); + if (!player || !player.isAlive()) { + return false; + } + + const attackThreshold = + (this.params.botAttackTroopThreshold ?? 0.5) + this.thresholdOffset; + const maxPop = this.mg.config().maxPopulation(player); + const maxTroops = maxPop * player.targetTroopRatio(); + const totalTroops = player.troops() + player.attackingTroops(); + const troopRatio = player.troops() / maxTroops; + + // Only attack bots if we have enough troops + if (troopRatio < attackThreshold) { + return false; + } + + // Check if we have enough defending troops at home + const defendingTroopTarget = this.params.defendingTroopTarget ?? 0.5; + const defendingRatio = player.troops() / totalTroops; + if (defendingRatio < defendingTroopTarget) { + return false; + } + + // If no bot target, or target is dead, or target became unreachable, find a new one + if ( + this.currentBotTarget === null || + !this.currentBotTarget.isAlive() || + !this.isReachable(player, this.currentBotTarget) + ) { + this.currentBotTarget = this.findBotTarget(player); + } + + if (this.currentBotTarget === null) { + return false; + } + return this.launchBotAttack(player, this.currentBotTarget); + } + + private findBotTarget(player: Player): Player | null { + const maxDistance = this.params.botAttackMaxDistance ?? 200; + const playerCapital = player.capital(); + + if (playerCapital === null) { + return null; + } + + // Get all bots sorted by distance to our capital + const candidates: { player: Player; distanceSq: number }[] = []; + const currentTick = this.mg.ticks(); + + // Clean up expired unreachable entries + for (const [botId, markedTick] of this.unreachableBots) { + if ( + currentTick - markedTick >= + AIBotAttackHandler.UNREACHABLE_RECHECK_INTERVAL + ) { + this.unreachableBots.delete(botId); + } + } + + for (const other of this.mg.players()) { + if (other.id() === player.id()) continue; + if (other.type() !== PlayerType.Bot) continue; + if (!other.isAlive()) continue; + // Skip bots marked as unreachable (not yet expired) + if (this.unreachableBots.has(other.id())) continue; + + const otherCapital = other.capital(); + if (otherCapital === null) continue; + + // Use squared distance to avoid expensive sqrt + const distanceSq = + (playerCapital.x - otherCapital.x) ** 2 + + (playerCapital.y - otherCapital.y) ** 2; + + // Check cached neighbor status or compute if expired/missing + const isNeighbor = this.isNeighborCached(player, other, currentTick); + if (isNeighbor || distanceSq <= maxDistance * maxDistance) { + candidates.push({ player: other, distanceSq }); + } + } + + // Sort by distance (nearest first) + candidates.sort((a, b) => a.distanceSq - b.distanceSq); + + // Find the first reachable target, marking unreachable ones + for (const candidate of candidates) { + if (this.isReachable(player, candidate.player)) { + return candidate.player; + } else { + // Mark as unreachable for 100 ticks + this.unreachableBots.set(candidate.player.id(), currentTick); + } + } + + return null; + } + + /** + * Get all neighboring player IDs with caching. + * This is more efficient than checking each target individually since + * we only iterate border tiles once. + */ + private getAllNeighborsCached( + player: Player, + currentTick: number, + ): Set { + if ( + this.allNeighborsCache && + currentTick - this.allNeighborsCache.tick < + AIBotAttackHandler.NEIGHBOR_CACHE_INTERVAL + ) { + return this.allNeighborsCache.neighbors; + } + + // Compute all neighbors in one pass through border tiles + const neighbors = new Set(); + const myId = player.smallID(); + + for (const border of player.borderTiles()) { + for (const neighbor of this.mg.neighbors(border)) { + if (this.mg.isLand(neighbor)) { + const ownerId = this.mg.ownerID(neighbor); + if (ownerId !== 0 && ownerId !== myId) { + const owner = this.mg.owner(neighbor); + const ownersId = owner.id(); + if (ownersId !== null) { + neighbors.add(ownersId); + } + } + } + } + } + + this.allNeighborsCache = { neighbors, tick: currentTick }; + return neighbors; + } + + /** + * Check if player shares border with target using the all-neighbors cache. + */ + private isNeighborCached( + player: Player, + target: Player, + currentTick: number, + ): boolean { + const allNeighbors = this.getAllNeighborsCached(player, currentTick); + return allNeighbors.has(target.id()); + } + + private isReachable(player: Player, target: Player): boolean { + // Check if shares land border (use cache) + const currentTick = this.mg.ticks(); + if (this.isNeighborCached(player, target, currentTick)) { + return true; + } + + // Check if reachable by boat (both have ocean shore tiles) + const playerShore = this.getPlayerShoreCached(player, currentTick); + const targetShore = this.getTargetShoreCached(target, currentTick); + + // If both have ocean shore, they're reachable by boat + return playerShore.length > 0 && targetShore.length > 0; + } + + /** + * Get target player's ocean shore tiles with caching. + */ + private getTargetShoreCached(target: Player, currentTick: number): TileRef[] { + const cached = this.targetShoreCache.get(target.id()); + if ( + cached && + currentTick - cached.tick < AIBotAttackHandler.SHORE_CACHE_INTERVAL + ) { + return cached.tiles; + } + + const tiles = Array.from(target.borderTiles()).filter((t) => + this.mg.isOceanShore(t), + ); + this.targetShoreCache.set(target.id(), { tiles, tick: currentTick }); + return tiles; + } + + /** + * Get player's ocean shore tiles with caching. + */ + private getPlayerShoreCached(player: Player, currentTick: number): TileRef[] { + if ( + this.playerShoreCache && + currentTick - this.playerShoreCache.tick < + AIBotAttackHandler.SHORE_CACHE_INTERVAL + ) { + return this.playerShoreCache.tiles; + } + + const tiles = Array.from(player.borderTiles()).filter((t) => + this.mg.isOceanShore(t), + ); + this.playerShoreCache = { tiles, tick: currentTick }; + return tiles; + } + + private launchBotAttack(player: Player, target: Player): boolean { + const alpha = this.params.botAttackOwnTroopPercent ?? 0.2; + const beta = this.params.botAttackEnemyTroopMultiplier ?? 1.5; + + const troopsFromOwn = player.troops() * alpha; + const troopsFromEnemy = target.troops() * beta; + const troops = Math.min(troopsFromOwn, troopsFromEnemy); + + if (troops < 1) { + return false; + } + + const currentTick = this.mg.ticks(); + + // Check if we share a land border - if so, use land attack + if (this.isNeighborCached(player, target, currentTick)) { + this.mg.addExecution(new AttackExecution(troops, player, target.id())); + return true; + } + + // Otherwise, try boat attack against the bot + // Rate-limit boat attacks to prevent sending multiple ships in quick succession + if ( + currentTick - this.lastBoatAttackTick < + AIBotAttackHandler.BOAT_ATTACK_COOLDOWN + ) { + return false; + } + + const playerShore = this.getPlayerShoreCached(player, currentTick); + const targetShore = this.getTargetShoreCached(target, currentTick); + + const closest = closestTwoTiles(this.mg, playerShore, targetShore); + if (closest !== null) { + // Check if the closest shore pair is within the current growing search range + const dist = + Math.abs(this.mg.x(closest.x) - this.mg.x(closest.y)) + + Math.abs(this.mg.y(closest.x) - this.mg.y(closest.y)); + if (dist > this.getBoatSearchRange()) { + // Too far — grow the range for next attempt + const growth = this.params.botAttackBoatSearchRangeGrowth ?? 0.5; + this.currentBoatSearchRange = Math.min( + this.getBoatSearchRange() + growth, + AIBotAttackHandler.MAX_BOAT_SEARCH_RANGE, + ); + return false; + } + + // Validate that we can actually build a transport ship to this destination + if (canBuildTransportShip(this.mg, player, closest.y) === false) { + return false; + } + this.lastBoatAttackTick = currentTick; + this.mg.addExecution( + new TransportShipExecution(player, closest.y, troops), + ); + return true; + } + return false; + } +} diff --git a/src/core/ai/AIConstructionHandler.ts b/src/core/ai/AIConstructionHandler.ts new file mode 100644 index 000000000..54523e197 --- /dev/null +++ b/src/core/ai/AIConstructionHandler.ts @@ -0,0 +1,2574 @@ +import { ConstructionExecution } from "../execution/ConstructionExecution"; +import { UpgradeStructureExecution } from "../execution/UpgradeStructureExecution"; +import { computeUpgradeStepCost } from "../game/Costs"; +import { + Game, + isStructureType, + Player, + PlayerID, + PlayerType, + Unit, + UnitType, + UpgradeType, +} from "../game/Game"; +import { TileRef } from "../game/GameMap"; +import { + isStackableStructure, + maxStackCount, + playerMaxStructureTechLevel, +} from "../game/Upgradeables"; +import { PseudoRandom } from "../PseudoRandom"; +import { tradeIncomeModifiers } from "../tech/TechEffects"; +import { AIBehaviorParams } from "./AIBehaviorParams"; +import { AINukeEvaluator } from "./AINukeEvaluator"; + +/** + * Handles structure construction for AI players. + * Builds cities, factories, and ports based on density parameters. + */ +export class AIConstructionHandler { + private target: UnitType | null = null; + + // Cached tile array to avoid allocation every tick + private _cachedTiles: TileRef[] | null = null; + private _cachedTilesLastRebuildTick: number = -Infinity; + + // Whether construction is paused (e.g. during a nuke sequence) + private _paused: boolean = false; + + // Structure types blocked from consideration until another structure is built/upgraded + private _blockedStructures: Set = new Set(); + + private static readonly PORT_SCORE_MULTIPLIER = 5e4; + private static readonly HOSPITAL_BASE_SCORE = 5; + private static readonly ACADEMY_BASE_SCORE = 7; + private static readonly RESEARCH_LAB_BASE_SCORE = 4e3; + private static readonly AIRFIELD_SCORE_MULTIPLIER = 4e3; + private static readonly SAM_BASE_SCORE = 2e-1; + private static readonly DEFENSE_POST_BASE_SCORE = 7e4; + private static readonly MIN_TILE_EVALUATIONS_BEFORE_BUILD = 30; + private static readonly TILE_EVALUATION_INTERVAL = 1; + private static readonly TILE_CACHE_REBUILD_INTERVAL = 200; // Rebuild tile cache every ~10s (200 ticks at 20 tps) + + private static readonly PORT_STATS_CACHE_INTERVAL = 50; + + // Cached global port statistics (refreshed every PORT_STATS_CACHE_INTERVAL ticks) + private _cachedGlobalPortCount = 0; + private _cachedGlobalShipsUnderConstruction = 0; + private _cachedPortStatsTick = -Infinity; + + // Lazy-computed map dimension (√(width×height), never changes) + private _cachedMapDim = 0; + + // Lazy-computed zone weights (derived from params, never change) + private _cachedZoneWeights: number[] | null = null; + + // Cached airfield enemy structure count (refreshed periodically) + private _cachedAirfieldEnemyStructures = 0; + private _cachedAirfieldTick = -Infinity; + private static readonly AIRFIELD_STATS_CACHE_INTERVAL = 50; + + // Cached structure base costs per type (refreshed once per scoring cycle) + private _cachedStructureCosts: Map = new Map(); + private _cachedCostsTick = -Infinity; + + // scoreTarget cache for external callers (bestConstructionScore, constructionScoreBreakdown) + private _scoreTargetCache: Map = new Map(); + private _scoreTargetCacheDirty = true; + + // Tile evaluation state (ports, defense posts, SAMs, others) + private _portTileScore: number = 0; + private _portTile: TileRef | null = null; + private _portEvalCount: number = 0; + private _defensePostTileScore: number = 0; + private _defensePostTile: TileRef | null = null; + private _defensePostEvalCount: number = 0; + private _samTileScore: number = 0; + private _samTile: TileRef | null = null; + private _samEvalCount: number = 0; + private _otherTileScore: number = 0; + private _otherTile: TileRef | null = null; + private _otherEvalCount: number = 0; + + // Upgrade evaluation state for each stackable structure type + // Maps UnitType -> { score: number, unit: Unit | null } + private _upgradeScores: Map = + new Map(); + + // Set by scoreTarget: whether upgrade composite (baseScore*tileScore) beats new-build composite. + private _upgradePreferred: Map = new Map(); + + // Tracks the last tick each structure (by ID) was evaluated for upgrade. + // Structures not in this map have never been evaluated and are prioritised. + private _upgradeLastEvalTick: Map = new Map(); + + // Tracks the tick of the last successful build or upgrade for each structure type. + // Used to require all upgrade candidates of that type to be re-evaluated before the next action. + private _lastBuildOrUpgradeTick: Map = new Map(); + + private static readonly ALL_STRUCTURE_TYPES: UnitType[] = Object.values( + UnitType, + ).filter((t) => isStructureType(t)); + + private static readonly NON_DEFENSE_STRUCTURE_TYPES: UnitType[] = + Object.values(UnitType).filter( + (t) => isStructureType(t) && t !== UnitType.DefensePost, + ); + + // Structure types to consider for distance checks (excludes defense posts and SAMs) + private static readonly DISTANCE_CHECK_STRUCTURE_TYPES: UnitType[] = + Object.values(UnitType).filter( + (t) => + isStructureType(t) && + t !== UnitType.DefensePost && + t !== UnitType.SAMLauncher, + ); + + // Types excluded from distance checks — used to skip Construction units building these + private static readonly DISTANCE_CHECK_EXCLUDED_CONSTRUCTION_TYPES: Set = + new Set([UnitType.DefensePost, UnitType.SAMLauncher]); + + // Tiered zone boundaries for structure proximity penalty (squared for fast comparison) + // Diameters: atom inner 2×12=24, atom outer 2×30=60, hydro inner 2×80=160, hydro outer 2×100=200 + private static readonly ZONE_BOUNDARIES = [24, 60, 160, 200] as const; + private static readonly ZONE_BOUNDARIES_SQ = ( + AIConstructionHandler.ZONE_BOUNDARIES as readonly number[] + ).map((d) => d * d); + private static readonly ZONE_SEARCH_RADIUS = + AIConstructionHandler.ZONE_BOUNDARIES[3]; // outermost zone + + // Phase seed for spreading periodic actions across AIs + private readonly phaseSeed: number; + + /** Optional callback that returns the current naval unit score (max of warship, submarine). */ + private _navalScoreProvider: (() => number) | null = null; + + /** Internal multiplier applied to nuke scores in shouldDeferToNukes. */ + private static readonly NUKE_SCORE_CONSTRUCTION_INTERNAL_MULTIPLIER = 1; + + constructor( + private mg: Game, + private playerId: PlayerID, + private random: PseudoRandom, + private params: AIBehaviorParams, + private nukeEvaluator: AINukeEvaluator | null = null, + ) { + // Stagger periodic actions across AIs using random offset + this.phaseSeed = random.nextInt(0, 0x7fffffff); + } + + /** + * Set a callback that provides the current naval unit score + * (max of warship and submarine scores from AIUnitHandler). + * Used by scorePort to boost port priority when the AI has no ports + * but wants to build naval units. + */ + setNavalScoreProvider(provider: () => number): void { + this._navalScoreProvider = provider; + } + + private periodicOffset(period: number): number { + const p = Math.max(1, Math.floor(period)); + return this.phaseSeed % p; + } + + private shouldRunPeriodic(ticks: number, period: number): boolean { + const p = Math.max(1, Math.floor(period)); + return ticks % p === this.periodicOffset(p); + } + + private getPlayer(): Player | null { + if (!this.mg.hasPlayer(this.playerId)) { + return null; + } + return this.mg.player(this.playerId); + } + + /** Lazy getter for √(width×height), computed once. */ + private getMapDim(): number { + if (this._cachedMapDim === 0) { + this._cachedMapDim = Math.sqrt(this.mg.width() * this.mg.height()); + } + return this._cachedMapDim; + } + + /** Lazy getter for tiered zone weights, computed once from params. */ + private getZoneWeights(): number[] { + if (this._cachedZoneWeights === null) { + const basePenalty = this.params.tileNearStructurePenalty ?? 0.3; + const mul2 = this.params.nearStructureZone2Multiplier ?? 0.5; + const mul3 = this.params.nearStructureZone3Multiplier ?? 0.5; + const mul4 = this.params.nearStructureZone4Multiplier ?? 0.5; + this._cachedZoneWeights = [ + basePenalty, + basePenalty * mul2, + basePenalty * mul2 * mul3, + basePenalty * mul2 * mul3 * mul4, + ]; + } + return this._cachedZoneWeights; + } + + /** + * Returns the cached base cost (as number) for a unit type, refreshing + * once per tick to avoid repeated BigInt→Number conversions. + */ + private getCachedStructureCost(player: Player, unitType: UnitType): number { + const tick = this.mg.ticks(); + if (tick !== this._cachedCostsTick) { + this._cachedStructureCosts.clear(); + this._cachedCostsTick = tick; + } + let cost = this._cachedStructureCosts.get(unitType); + if (cost === undefined) { + cost = Number(this.mg.unitInfo(unitType).cost(player)); + this._cachedStructureCosts.set(unitType, cost); + } + return cost; + } + + /** + * Build the SAM + under-construction-SAM list and range data for a player. + * Call once per tile evaluation cycle and pass the result to tile score functions. + * Always returns range data (needed to evaluate coverage of a potential new SAM) + * even when the player owns no SAMs yet. + */ + private buildSAMData(player: Player): { units: Unit[]; rangeSq: number } { + const sams = player.units(UnitType.SAMLauncher).filter((u) => u.isActive()); + for (const u of player.units(UnitType.Construction)) { + if (u.isActive() && u.constructionType() === UnitType.SAMLauncher) { + sams.push(u); + } + } + const techLevel = playerMaxStructureTechLevel(player, UnitType.SAMLauncher); + const samRange = this.getEffectiveSAMRange(techLevel); + return { units: sams, rangeSq: samRange * samRange }; + } + + tickConstruction( + ticks: number, + shouldRecalculate: boolean, + allowSpending: boolean = true, + ): void { + // Invalidate scoreTarget cache every tick since base scores depend on + // live game state (income, unit counts, trade demand, etc.) + this._scoreTargetCacheDirty = true; + + const player = this.getPlayer(); + if (!player || !player.isAlive()) { + return; + } + + const numTiles = player.numTilesOwned(); + if (numTiles === 0) { + return; + } + + // Periodically evaluate a random tile for structures (spread across AIs) + if ( + this.shouldRunPeriodic( + ticks, + AIConstructionHandler.TILE_EVALUATION_INTERVAL, + ) + ) { + this.tickTileEvaluation(player, ticks); + } + + // Periodically re-score and potentially retarget. + // Only switches if there's a strictly better target than the current. + if (shouldRecalculate) { + this.recalculateTarget(player); + } + + if (this.target === null) { + this.target = this.pickTarget(null, player); + return; + } + + // If spending is not allowed (e.g. nuke sequence active, or unit score is higher), skip + if (!allowSpending) { + return; + } + + // If nuke score threshold is set, skip construction when nuke value is higher + if (this.shouldDeferToNukes(player)) { + return; + } + + // Only attempt placement if we can afford the target structure + if (!this.canAffordTarget(player, this.target)) { + return; + } + + // Require minimum tile evaluations before attempting construction + const evalCount = this.getEvalCountForStructure(this.target); + if (evalCount < AIConstructionHandler.MIN_TILE_EVALUATIONS_BEFORE_BUILD) { + return; + } + + // Check if upgrade is preferred over building new + const { isUpgrade } = this.getEffectiveScoreAndMode(this.target); + + if (isUpgrade && isStackableStructure(this.target)) { + // Upgrade path: stack an existing structure + const result = this.tryStructureUpgrade(player, this.target); + if (result === "success") { + this._lastBuildOrUpgradeTick.set(this.target, ticks); + this.clearBlockedStructures(); + this.target = null; + return; + } else if (result === "blocked") { + // Permanent failure - clear upgrade state and try again + this.clearUpgradeScoreForStructure(this.target); + this.target = null; + return; + } + // result === "retry" means temporary failure, just return and try again later + return; + } + + // Build new path: construct at the saved tile + // Get the saved tile for this structure type + const savedTile = this.getSavedTileForStructure(this.target); + if (savedTile === null) { + // No tile evaluated yet, wait for tile evaluation + return; + } + + // Re-validate the tile at build time to catch any changes since evaluation + if (!this.validateTileForConstruction(player, savedTile, this.target)) { + // Tile no longer valid - clear it and wait for fresh evaluation + this.clearTileScoresForTile( + savedTile, + `validation failed for ${this.target}`, + ); + this.target = null; + return; + } + + // Score was updated by validation — re-check if this target is still the best + const previousTarget = this.target; + this.recalculateTarget(player); + if (this.target !== previousTarget) { + // A different target now scores higher, switch to it instead of building + return; + } + + // Attempt to build at the saved tile + const spawnTile = player.canBuild(this.target, savedTile); + + if (spawnTile !== false && this.canAffordTarget(player, this.target)) { + this.mg.addExecution( + new ConstructionExecution(player, this.target, spawnTile), + ); + this._lastBuildOrUpgradeTick.set(this.target, ticks); + this.clearBlockedStructures(); + } else { + // Failed to place - block this structure until another is built + this._blockedStructures.add(this.target); + } + + // Clear the score and tile for this structure type, and any others sharing the same tile + this.clearTileScoresForTile( + savedTile, + `build attempted for ${this.target} (success=${spawnTile !== false})`, + ); + this.target = null; + } + + /** + * Attempts to upgrade (stack) an existing structure. + * Returns "success" if upgrade was initiated, + * "blocked" if there's a permanent failure (should clear upgrade state), + * "retry" if there's a temporary failure (should try again later). + */ + private tryStructureUpgrade( + player: Player, + unitType: UnitType, + ): "success" | "blocked" | "retry" { + const upgradeUnit = this.getUpgradeUnitForStructure(unitType); + + if (upgradeUnit === null) { + return "blocked"; + } + + // Validate the unit is still valid for upgrade + if (!upgradeUnit.isActive()) { + return "blocked"; + } + + if (upgradeUnit.owner().id() !== player.id()) { + return "blocked"; + } + + const currentStack = upgradeUnit.stackCount?.() ?? 1; + const maxStack = maxStackCount(unitType); + if (currentStack >= maxStack) { + return "blocked"; + } + + // Check if we can afford the upgrade + const baseCost = this.mg.unitInfo(unitType).cost(player); + const multiplier = this.mg + .config() + .structureUpgradeCostMultiplier(unitType); + const upgradeCost = computeUpgradeStepCost(baseCost, multiplier); + if (player.gold() < upgradeCost) { + return "retry"; // Can't afford yet, try again later + } + + // Execute the upgrade + this.mg.addExecution(new UpgradeStructureExecution(player, upgradeUnit)); + this.clearUpgradeScoreForStructure(unitType); + return "success"; + } + + /** + * Clears the upgrade score and unit for a given structure type. + */ + private clearUpgradeScoreForStructure(unitType: UnitType): void { + // Defense posts cannot be stacked + if (unitType === UnitType.DefensePost) { + return; + } + this._upgradeScores.delete(unitType); + this._upgradePreferred.delete(unitType); + this._scoreTargetCacheDirty = true; + } + + /** + * Gets the evaluation count for a structure type. + * Returns MIN_TILE_EVALUATIONS_BEFORE_BUILD if: + * - Tile eval count >= MIN_TILE_EVALUATIONS_BEFORE_BUILD, AND + * - All stackable structures of this type have been evaluated (or none exist) + * Otherwise returns a value less than MIN_TILE_EVALUATIONS_BEFORE_BUILD. + */ + private getEvalCountForStructure(unitType: UnitType): number { + // Get tile evaluation count + let tileEvalCount: number; + if (unitType === UnitType.Port) { + tileEvalCount = this._portEvalCount; + } else if (unitType === UnitType.DefensePost) { + tileEvalCount = this._defensePostEvalCount; + } else if (unitType === UnitType.SAMLauncher) { + tileEvalCount = this._samEvalCount; + } else { + tileEvalCount = this._otherEvalCount; + } + + // If tile eval count is below threshold, return it directly + if ( + tileEvalCount < AIConstructionHandler.MIN_TILE_EVALUATIONS_BEFORE_BUILD + ) { + return tileEvalCount; + } + + // Check if all stackable structures of this type have been evaluated + if (isStackableStructure(unitType)) { + const allEvaluated = this.allStructuresEvaluatedForType(unitType); + if (!allEvaluated) { + // Return a value below threshold to block construction until all evaluated + return AIConstructionHandler.MIN_TILE_EVALUATIONS_BEFORE_BUILD - 1; + } + } + + // Both conditions met + return tileEvalCount; + } + + /** + * Checks if all upgradeable structures of a given type have been evaluated + * since the last build or upgrade of that type. + * Returns true if there are no upgradeable structures or all have been evaluated. + */ + private allStructuresEvaluatedForType(unitType: UnitType): boolean { + const player = this.getPlayer(); + if (!player) return true; + + // Get all upgradeable structures of this type + const upgradeableUnits = player.units(unitType).filter((u) => { + if (!u.isActive()) return false; + const currentStack = u.stackCount?.() ?? 1; + const maxStack = maxStackCount(unitType); + return currentStack < maxStack; + }); + + // If no upgradeable structures, consider all evaluated + if (upgradeableUnits.length === 0) return true; + + // All candidates must have been evaluated after the last build/upgrade of this type + const lastBuildTick = + this._lastBuildOrUpgradeTick.get(unitType) ?? -Infinity; + for (const unit of upgradeableUnits) { + const lastEval = this._upgradeLastEvalTick.get(unit.id()); + if (lastEval === undefined || lastEval <= lastBuildTick) { + return false; + } + } + return true; + } + + /** + * Recalculates the saved tile scores for each category by re-scoring + * the currently saved best tile against the current game state. + * If a tile's score dropped to 0, it's cleared. + */ + private refreshTileScores(player: Player): void { + if (this._portTile !== null) { + const newScore = this.calculatePortTileScore(player, this._portTile); + if (newScore <= 0) { + this._portTileScore = 0; + this._portTile = null; + } else { + this._portTileScore = newScore; + } + } + + if (this._defensePostTile !== null) { + const newScore = this.calculateDefensePostTileScore( + player, + this._defensePostTile, + ); + if (newScore <= 0) { + this._defensePostTileScore = 0; + this._defensePostTile = null; + } else { + this._defensePostTileScore = newScore; + } + } + + if (this._samTile !== null) { + const newScore = this.calculateSAMTileScore(player, this._samTile); + if (newScore <= 0) { + this._samTileScore = 0; + this._samTile = null; + } else { + this._samTileScore = newScore; + } + } + + if (this._otherTile !== null) { + const newScore = this.calculateOtherTileScore(player, this._otherTile); + if (newScore <= 0) { + this._otherTileScore = 0; + this._otherTile = null; + } else { + this._otherTileScore = newScore; + } + } + + // Tile scores may have changed, invalidate scoreTarget cache + this._scoreTargetCacheDirty = true; + } + + private recalculateTarget(player: Player): void { + // Refresh saved tile scores by recalculating against current game state + this.refreshTileScores(player); + + const candidates = this.candidateTargets(); + if (candidates.length === 0) { + this.target = null; + return; + } + + // If current target is no longer a candidate, drop it so we can repick. + if (this.target !== null && !candidates.includes(this.target)) { + this.target = null; + } + + let bestScore = -Infinity; + let best: UnitType[] = []; + + for (const t of candidates) { + const s = this.scoreTarget(player, t); + if (s > bestScore) { + bestScore = s; + best = [t]; + } else if (s === bestScore) { + best.push(t); + } + } + + if (best.length === 0) { + this.target = null; + return; + } + + if (this.target === null) { + this.target = this.random.randElement(best); + return; + } + + const currentScore = this.scoreTarget(player, this.target); + // Switch only if a new target has a strictly higher score, or if the current + // target is somehow not in the best set. + if (bestScore > currentScore || !best.includes(this.target)) { + this.target = this.random.randElement(best); + } + } + + private candidateTargets(): UnitType[] { + const candidates: UnitType[] = []; + if (this.params.buildCities ?? true) candidates.push(UnitType.City); + if (this.params.buildFactories ?? true) candidates.push(UnitType.Factory); + if (this.params.buildPorts ?? true) candidates.push(UnitType.Port); + if (this.params.buildHospitals ?? false) candidates.push(UnitType.Hospital); + if (this.params.buildAcademies ?? false) candidates.push(UnitType.Academy); + if (this.params.buildAirfields ?? false) candidates.push(UnitType.Airfield); + if (this.params.buildResearchLabs ?? false) + candidates.push(UnitType.ResearchLab); + if (this.params.buildSAMLaunchers ?? false) + candidates.push(UnitType.SAMLauncher); + if (this.params.buildDefensePosts ?? false) + candidates.push(UnitType.DefensePost); + if (this.params.buildDoomsdayDevices ?? false) + candidates.push(UnitType.DoomsdayDevice); + // Exclude blocked structures until another structure is successfully built + return candidates.filter((t) => !this._blockedStructures.has(t)); + } + + private scoreTarget(player: Player, unitType: UnitType): number { + const weight = this.getStructureWeight(unitType); + let baseScore = 0; + + if (unitType === UnitType.City) { + baseScore = this.scoreCity(player); + } else if (unitType === UnitType.Factory) { + baseScore = this.scoreFactory(player); + } else if (unitType === UnitType.Port) { + baseScore = this.scorePort(player); + } else if (unitType === UnitType.Hospital) { + baseScore = this.scoreHospital(player); + } else if (unitType === UnitType.Academy) { + baseScore = this.scoreAcademy(player); + } else if (unitType === UnitType.ResearchLab) { + baseScore = this.scoreResearchLab(player); + } else if (unitType === UnitType.Airfield) { + baseScore = this.scoreAirfield(player); + } else if (unitType === UnitType.SAMLauncher) { + baseScore = this.scoreSAMLauncher(player); + } else if (unitType === UnitType.DefensePost) { + baseScore = this.scoreDefensePost(player); + } + + // For other structures, base score remains 0 (uses weight only) + const newBuildScore = baseScore * weight; + + // Get the upgrade score for this structure type (if stackable) + const upgradeData = this._upgradeScores.get(unitType); + const upgradeScore = upgradeData?.score ?? 0; + + // Recompute base score using upgrade cost for T (all stackable structures) + let upgradeBaseScore = baseScore; + if (upgradeScore > 0 && unitType !== UnitType.DefensePost) { + const baseCost = this.mg.unitInfo(unitType).cost(player); + const upgMultiplier = this.mg + .config() + .structureUpgradeCostMultiplier(unitType); + const upgCost = computeUpgradeStepCost(baseCost, upgMultiplier); + if (unitType === UnitType.City) { + upgradeBaseScore = this.scoreCity(player, upgCost); + } else if (unitType === UnitType.Factory) { + upgradeBaseScore = this.scoreFactory(player, upgCost); + } else if (unitType === UnitType.Port) { + upgradeBaseScore = this.scorePort(player, upgCost); + } else if (unitType === UnitType.Hospital) { + upgradeBaseScore = this.scoreHospital(player, upgCost); + } else if (unitType === UnitType.Academy) { + upgradeBaseScore = this.scoreAcademy(player, upgCost); + } else if (unitType === UnitType.ResearchLab) { + upgradeBaseScore = this.scoreResearchLab(player, upgCost); + } else if (unitType === UnitType.Airfield) { + upgradeBaseScore = this.scoreAirfield(player, upgCost); + } else if (unitType === UnitType.SAMLauncher) { + upgradeBaseScore = this.scoreSAMLauncher(player, upgCost); + } + } + const upgradeStructureScore = upgradeBaseScore * weight; + + // Multiply by the max of (newBuild * tileScore) vs (upgrade * upgradeScore) + // Compute new-build and upgrade composites, record which wins. + let newComposite: number; + let upgComposite: number; + + if (unitType === UnitType.Port) { + const effectivePortTileScore = + this._portEvalCount < + AIConstructionHandler.MIN_TILE_EVALUATIONS_BEFORE_BUILD && + this._portTileScore === 0 + ? 1 + : this._portTileScore; + newComposite = newBuildScore * effectivePortTileScore; + upgComposite = upgradeStructureScore * upgradeScore; + + // When the AI has no ports, the naval unit score acts as a floor + // on the final composite so it isn't diluted by weight × tileScore. + if (player.unitsOwned(UnitType.Port) === 0) { + const navalScore = this._navalScoreProvider?.() ?? 0; + newComposite = Math.max(newComposite, navalScore); + } + } else if (unitType === UnitType.DefensePost) { + newComposite = newBuildScore * this._defensePostTileScore; + upgComposite = 0; // Defense posts cannot be stacked + } else if (unitType === UnitType.SAMLauncher) { + newComposite = newBuildScore * this._samTileScore; + upgComposite = upgradeStructureScore * upgradeScore; + } else { + newComposite = newBuildScore * this._otherTileScore; + upgComposite = upgradeStructureScore * upgradeScore; + } + + // Record which mode the composite comparison favors. + // Use >= so that ties (equal tile scores) are broken by the cheaper + // upgrade cost (higher base score due to smaller T). + this._upgradePreferred.set( + unitType, + upgradeScore > 0 && upgComposite >= newComposite, + ); + + return Math.max(newComposite, upgComposite); + } + + /** + * Gets the base score for a structure type (without weight or tile multiplier). + * Used for debugging/logging purposes. + */ + private getBaseScoreForType(player: Player, unitType: UnitType): number { + if (unitType === UnitType.City) { + return this.scoreCity(player); + } else if (unitType === UnitType.Factory) { + return this.scoreFactory(player); + } else if (unitType === UnitType.Port) { + return this.scorePort(player); + } else if (unitType === UnitType.Hospital) { + return this.scoreHospital(player); + } else if (unitType === UnitType.Academy) { + return this.scoreAcademy(player); + } else if (unitType === UnitType.ResearchLab) { + return this.scoreResearchLab(player); + } else if (unitType === UnitType.Airfield) { + return this.scoreAirfield(player); + } else if (unitType === UnitType.SAMLauncher) { + return this.scoreSAMLauncher(player); + } else if (unitType === UnitType.DefensePost) { + return this.scoreDefensePost(player); + } + return 0; + } + + /** + * Gets the effective tile/upgrade score for a structure type, and whether upgrade is preferred. + * Returns { score, isUpgrade } where isUpgrade is true if the upgrade score is higher. + */ + private getEffectiveScoreAndMode(unitType: UnitType): { + score: number; + isUpgrade: boolean; + } { + // Defense posts cannot be stacked + if (unitType === UnitType.DefensePost) { + return { score: this._defensePostTileScore, isUpgrade: false }; + } + + const upgradeData = this._upgradeScores.get(unitType); + const upgradeScore = upgradeData?.score ?? 0; + + let tileScore: number; + if (unitType === UnitType.Port) { + tileScore = this._portTileScore; + } else if (unitType === UnitType.SAMLauncher) { + tileScore = this._samTileScore; + } else { + tileScore = this._otherTileScore; + } + + // Read the decision that scoreTarget already computed using the full + // composite (baseScore × weight × tileScore) for both modes. + const isUpgrade = this._upgradePreferred.get(unitType) ?? false; + return { score: Math.max(tileScore, upgradeScore), isUpgrade }; + } + + /** + * Gets the upgrade unit for a structure type, if upgrade is the preferred mode. + */ + private getUpgradeUnitForStructure(unitType: UnitType): Unit | null { + // Defense posts cannot be stacked + if (unitType === UnitType.DefensePost) { + return null; + } + const upgradeData = this._upgradeScores.get(unitType); + return upgradeData?.unit ?? null; + } + + /** + * Computes the city base score as present value of perpetual income gain: + * incomeGain/min / discountRate / (1 + discountRate)^T + * where T = minutes to earn the city cost at current income. + * @param costOverride - If provided, use this cost instead of base cost (e.g. upgrade cost). + */ + private scoreCity(player: Player, costOverride?: bigint): number { + const config = this.mg.config(); + const cost = costOverride ?? this.mg.unitInfo(UnitType.City).cost(player); + if (cost <= 0n) { + return 0; + } + + // Get assumed pop percentage (default 70%) + const assumedPopPercent = this.params.aiAssumedPopPercent ?? 0.7; + const targetTroopRatio = player.targetTroopRatio(); + + // Compute current max pop and projected max pop with +1 city + const currentMaxPop = config.maxPopulation(player); + const cityPopBonus = config.cityPopulationIncrease(); + // Adding one city increases effective units by 1 (at level 1) + const projectedMaxPop = currentMaxPop + cityPopBonus; + + // Compute workers under assumed pop scenario + // totalPop = maxPop * assumedPopPercent + // troops = totalPop * targetTroopRatio + // workers = totalPop - troops = totalPop * (1 - targetTroopRatio) + const currentTotalPop = currentMaxPop * assumedPopPercent; + const currentWorkers = currentTotalPop * (1 - targetTroopRatio); + + const projectedTotalPop = projectedMaxPop * assumedPopPercent; + const projectedWorkers = projectedTotalPop * (1 - targetTroopRatio); + + // Compute factory factor (unchanged by city construction) + const k = player.unitsOwned(UnitType.Factory); + const factoryFactor = Math.pow(1 + k, 0.35); + + // Compute productivity and multiplier (unchanged) + const productivity = player.productivity(); + const multiplier = config.gameConfig().goldMultiplier ?? 1; + + const currentGrossGold = + 0.11 * + Math.pow(currentWorkers, 0.65) * + productivity * + factoryFactor * + multiplier; + const projectedGrossGold = + 0.11 * + Math.pow(projectedWorkers, 0.65) * + productivity * + factoryFactor * + multiplier; + + const incomeGain = projectedGrossGold - currentGrossGold; + + const costNum = Number(cost); + if (costNum <= 0 || !Number.isFinite(incomeGain) || incomeGain <= 0) { + return 0; + } + + const TICKS_PER_MINUTE = 600; + const grossGoldPerMinute = player.estimatedGoldIncomePerMinute(); + if (grossGoldPerMinute <= 0) { + return 0; + } + + // T = minutes to earn the cost of the city (or city stack upgrade) + const T = costNum / grossGoldPerMinute; + const discountRate = this.params.discountFactor ?? 0.1; + const incomeGainPerMinute = incomeGain * TICKS_PER_MINUTE; + + // PV of perpetuity delayed by T minutes: + // incomeGainPerMinute / discountRate / (1 + discountRate)^T + return incomeGainPerMinute / discountRate / Math.pow(1 + discountRate, T); + } + + /** + * Computes the factory base score as present value of perpetual income gain: + * incomeGain/min / discountRate / (1 + discountRate)^T + * where T = minutes to earn the factory cost at current income. + * @param costOverride - If provided, use this cost instead of base cost (e.g. upgrade cost). + */ + private scoreFactory(player: Player, costOverride?: bigint): number { + const config = this.mg.config(); + const cost = + costOverride ?? this.mg.unitInfo(UnitType.Factory).cost(player); + if (cost <= 0n) { + return 0; + } + + const assumedPopPercent = this.params.aiAssumedPopPercent ?? 0.7; + const targetTroopRatio = player.targetTroopRatio(); + + const currentMaxPop = config.maxPopulation(player); + const currentTotalPop = currentMaxPop * assumedPopPercent; + const workers = currentTotalPop * (1 - targetTroopRatio); + + // Factory factor changes with +1 factory + const k = player.unitsOwned(UnitType.Factory); + const currentFactoryFactor = Math.pow(1 + k, 0.35); + const projectedFactoryFactor = Math.pow(1 + k + 1, 0.35); + + const productivity = player.productivity(); + const multiplier = config.gameConfig().goldMultiplier ?? 1; + + const base = 0.11 * Math.pow(workers, 0.65) * productivity * multiplier; + const currentGrossGold = base * currentFactoryFactor; + const projectedGrossGold = base * projectedFactoryFactor; + + const incomeGain = projectedGrossGold - currentGrossGold; + + const costNum = Number(cost); + if (costNum <= 0 || !Number.isFinite(incomeGain) || incomeGain <= 0) { + return 0; + } + + const TICKS_PER_MINUTE = 600; + const grossGoldPerMinute = player.estimatedGoldIncomePerMinute(); + if (grossGoldPerMinute <= 0) { + return 0; + } + + // T = minutes to earn the cost of the factory (or factory stack upgrade) + const T = costNum / grossGoldPerMinute; + const discountRate = this.params.discountFactor ?? 0.1; + const incomeGainPerMinute = incomeGain * TICKS_PER_MINUTE; + + // PV of perpetuity delayed by T minutes: + // incomeGainPerMinute / discountRate / (1 + discountRate)^T + return incomeGainPerMinute / discountRate / Math.pow(1 + discountRate, T); + } + + /** + * Computes the port base score based on trade demand. + */ + private scorePort(player: Player, costOverride?: bigint): number { + const portCount = player.unitsOwned(UnitType.Port); + + // When the AI has 0 ports, score the first port as a discounted + // present value of a share of total income: + // score = d / r / (1 + r)^(T + 1) + // where d = incomeShare * grossGoldPerMinute, r = discount factor, + // T = minutes to afford the port at current income. + // The naval-score boost is applied later in scoreTarget (on the + // final composite) so it isn't diluted by weight × tileScore. + if (portCount === 0) { + const grossGoldPerMinute = player.estimatedGoldIncomePerMinute(); + const r = this.params.discountFactor ?? 0.1; + let base = 0; + if (grossGoldPerMinute > 0) { + const portCost = Number( + costOverride ?? this.mg.unitInfo(UnitType.Port).cost(player), + ); + const T = portCost / grossGoldPerMinute; + const incomeShare = this.params.firstPortIncomeShare ?? 0.5; + const d = incomeShare * grossGoldPerMinute; + base = d / r / Math.pow(1 + r, T + 1); + } + return base; + } + + // Get global trade demand queue length + const queueLen = (this.mg as any).tradeDemandQueueLength?.() ?? 0; + + // Use player method for metrics calculation + const metrics = player.tradeDemandMetrics(queueLen); + + // Get trade income multipliers + const tradeMods = tradeIncomeModifiers(player); + const tradeIncomeMul = tradeMods.incomeMul * tradeMods.tradeShipIncomeMul; + + // Calculate global ships under construction vs global ports multiplier + // Uses cached values refreshed every PORT_STATS_CACHE_INTERVAL ticks + const currentTick = this.mg.ticks(); + if ( + currentTick - this._cachedPortStatsTick >= + AIConstructionHandler.PORT_STATS_CACHE_INTERVAL + ) { + this._cachedPortStatsTick = currentTick; + const allPorts = this.mg.units(UnitType.Port).filter((p) => p.isActive()); + this._cachedGlobalPortCount = allPorts.reduce( + (sum, port) => sum + (port.stackCount?.() ?? 1), + 0, + ); + this._cachedGlobalShipsUnderConstruction = allPorts.reduce( + (sum, port) => sum + (port as any).pendingTradeShipDueTicks().length, + 0, + ); + } + const globalPortCount = this._cachedGlobalPortCount; + const globalShipsUnderConstruction = + this._cachedGlobalShipsUnderConstruction; + const constructionRatioMul = + globalPortCount > 0 + ? 1 - globalShipsUnderConstruction / globalPortCount + : 1; + + // Time-discount: (1+r)^T where T = minutes to fund the port + const portCost = Number( + costOverride ?? this.mg.unitInfo(UnitType.Port).cost(player), + ); + const grossGoldPerMinute = player.estimatedGoldIncomePerMinute(); + const r = this.params.discountFactor ?? 0.1; + let timeDiscount = 1; + if (grossGoldPerMinute > 0 && portCost > 0) { + const T = portCost / grossGoldPerMinute; + timeDiscount = Math.pow(1 + r, T); + } + + // Base score = multiplier * (1 + queueRatio) * (1 - availableRatio) * tradeIncomeMods * constructionRatioMul * productivity / (1+r)^T + return ( + (AIConstructionHandler.PORT_SCORE_MULTIPLIER * + (1 + metrics.queueRatio) * + (1 - metrics.availableRatio) * + tradeIncomeMul * + Math.max(0, constructionRatioMul) * + player.productivity()) / + timeDiscount + ); + } + + /** + * Computes the hospital base score based on troop ratio and pop growth bonus. + */ + private scoreHospital(player: Player, costOverride?: bigint): number { + const config = this.mg.config(); + const assumedPopPercent = this.params.aiAssumedPopPercent ?? 0.7; + const targetTroopRatio = player.targetTroopRatio(); + const maxPop = config.maxPopulation(player); + + // Calculate the bonus from constructing one additional hospital + // Death multiplier formula: 0.6 + 0.4 * Math.pow(0.75, hospitals) + const currentHospitals = player.unitsOwned(UnitType.Hospital); + const currentDeathMul = 0.6 + 0.4 * Math.pow(0.75, currentHospitals); + const projectedDeathMul = 0.6 + 0.4 * Math.pow(0.75, currentHospitals + 1); + const hospitalBonus = currentDeathMul - projectedDeathMul; + + // Time-discount: (1+r)^T where T = minutes to fund the hospital + const hospitalCost = Number( + costOverride ?? this.mg.unitInfo(UnitType.Hospital).cost(player), + ); + const grossGoldPerMinute = player.estimatedGoldIncomePerMinute(); + const discountRate = this.params.discountFactor ?? 0.1; + let timeDiscount = 1; + if (grossGoldPerMinute > 0 && hospitalCost > 0) { + const T = hospitalCost / grossGoldPerMinute; + timeDiscount = Math.pow(1 + discountRate, T); + } + + return ( + (AIConstructionHandler.HOSPITAL_BASE_SCORE * + maxPop * + targetTroopRatio * + assumedPopPercent * + hospitalBonus) / + timeDiscount + ); + } + + /** + * Computes the academy base score based on troop ratio and combat bonus. + */ + private scoreAcademy(player: Player, costOverride?: bigint): number { + const config = this.mg.config(); + const assumedPopPercent = this.params.aiAssumedPopPercent ?? 0.7; + const targetTroopRatio = player.targetTroopRatio(); + const maxPop = config.maxPopulation(player); + + // Calculate the bonus from constructing one additional academy + // Academy modifier formula: 1.2 - 0.2 * 0.5^(academies) + // Higher modifier = more enemy casualties in combat + const currentAcademies = player.unitsOwned(UnitType.Academy); + const currentModifier = 1.2 - 0.2 * Math.pow(0.5, currentAcademies); + const projectedModifier = 1.2 - 0.2 * Math.pow(0.5, currentAcademies + 1); + const academyBonus = projectedModifier - currentModifier; + + // Time-discount: (1+r)^T where T = minutes to fund the academy + const academyCost = Number( + costOverride ?? this.mg.unitInfo(UnitType.Academy).cost(player), + ); + const grossGoldPerMinute = player.estimatedGoldIncomePerMinute(); + const discountRate = this.params.discountFactor ?? 0.1; + let timeDiscount = 1; + if (grossGoldPerMinute > 0 && academyCost > 0) { + const T = academyCost / grossGoldPerMinute; + timeDiscount = Math.pow(1 + discountRate, T); + } + + return ( + (AIConstructionHandler.ACADEMY_BASE_SCORE * + maxPop * + targetTroopRatio * + assumedPopPercent * + academyBonus) / + timeDiscount + ); + } + + /** + * Computes the research lab base score based on research spending and lab bonus. + */ + private scoreResearchLab(player: Player, costOverride?: bigint): number { + const config = this.mg.config(); + + // Calculate total effective research spending + const grossGold = config.grossGoldAdditionRate(player); + const investRate = player.researchInvestmentRate?.() ?? 0; + const researchSpending = grossGold * investRate; + + if (researchSpending <= 0) { + return 0; + } + + // Calculate the bonus from constructing one additional research lab + // Lab multiplier formula: 1 + (0.4 * (1 - 0.5^labs)) / 0.5 + // This is a geometric series that caps at 1.8 as labs -> infinity + const currentLabs = player.unitsOwned(UnitType.ResearchLab); + const currentBoostSum = + currentLabs > 0 ? (0.4 * (1 - Math.pow(0.5, currentLabs))) / 0.5 : 0; + const projectedBoostSum = + (0.4 * (1 - Math.pow(0.5, currentLabs + 1))) / 0.5; + const labBonus = projectedBoostSum - currentBoostSum; + + // Time-discount: (1+r)^T where T = minutes to fund the research lab + const labCost = Number( + costOverride ?? this.mg.unitInfo(UnitType.ResearchLab).cost(player), + ); + const grossGoldPerMinute = player.estimatedGoldIncomePerMinute(); + const discountRate = this.params.discountFactor ?? 0.1; + let timeDiscount = 1; + if (grossGoldPerMinute > 0 && labCost > 0) { + const T = labCost / grossGoldPerMinute; + timeDiscount = Math.pow(1 + discountRate, T); + } + + return ( + (AIConstructionHandler.RESEARCH_LAB_BASE_SCORE * + researchSpending * + labBonus) / + timeDiscount + ); + } + + /** + * Computes the airfield base score based on enemy structures. + * Score = multiplier * (total non-self structures / (airfields owned + 1)) + */ + private scoreAirfield(player: Player, costOverride?: bigint): number { + // Use cached enemy structure count (refreshed every AIRFIELD_STATS_CACHE_INTERVAL ticks) + const currentTick = this.mg.ticks(); + if ( + currentTick - this._cachedAirfieldTick >= + AIConstructionHandler.AIRFIELD_STATS_CACHE_INTERVAL + ) { + this._cachedAirfieldTick = currentTick; + let total = 0; + for (const other of this.mg.players()) { + if (other.id() === player.id()) continue; + if (!other.isAlive()) continue; + for (const structureType of AIConstructionHandler.NON_DEFENSE_STRUCTURE_TYPES) { + total += other.unitsOwned(structureType); + } + } + this._cachedAirfieldEnemyStructures = total; + } + const totalNonSelfStructures = this._cachedAirfieldEnemyStructures; + + const airfieldCount = player.unitsOwned(UnitType.Airfield); + + // Time-discount: (1+r)^T where T = minutes to fund the airfield + const airfieldCost = Number( + costOverride ?? this.mg.unitInfo(UnitType.Airfield).cost(player), + ); + const grossGoldPerMinute = player.estimatedGoldIncomePerMinute(); + const discountRate = this.params.discountFactor ?? 0.1; + let timeDiscount = 1; + if (grossGoldPerMinute > 0 && airfieldCost > 0) { + const T = airfieldCost / grossGoldPerMinute; + timeDiscount = Math.pow(1 + discountRate, T); + } + + return ( + (AIConstructionHandler.AIRFIELD_SCORE_MULTIPLIER * + (totalNonSelfStructures / (airfieldCount + 1))) / + timeDiscount + ); + } + + /** + * Computes the SAM launcher base score. + * Uses SAM_BASE_SCORE as a fixed multiplier, similar to other structures. + */ + private scoreSAMLauncher(player: Player, costOverride?: bigint): number { + // Time-discount: (1+r)^T where T = minutes to fund the SAM launcher + const samCost = Number( + costOverride ?? this.mg.unitInfo(UnitType.SAMLauncher).cost(player), + ); + const grossGoldPerMinute = player.estimatedGoldIncomePerMinute(); + const discountRate = this.params.discountFactor ?? 0.1; + let timeDiscount = 1; + if (grossGoldPerMinute > 0 && samCost > 0) { + const T = samCost / grossGoldPerMinute; + timeDiscount = Math.pow(1 + discountRate, T); + } + + return AIConstructionHandler.SAM_BASE_SCORE / timeDiscount; + } + + /** + * Computes the defense post base score as baseScoreParam / ((1+r)^T * ownMilitaryStrength). + * T = minutes to earn the defense post cost at current gross gold income. + * r = discount rate (from AI profile discountFactor, default 0.1). + */ + private scoreDefensePost(player: Player): number { + const cost = this.mg.unitInfo(UnitType.DefensePost).cost(player); + const costNum = Number(cost); + if (costNum <= 0) return 0; + + const grossGoldPerMinute = player.estimatedGoldIncomePerMinute(); + if (grossGoldPerMinute <= 0) return 0; + + // T = minutes to earn the defense post cost + const T = costNum / grossGoldPerMinute; + const discountRate = this.params.discountFactor ?? 0.1; + + return ( + AIConstructionHandler.DEFENSE_POST_BASE_SCORE / + Math.pow(1 + discountRate, T) + ); + } + + /** + * Clears all blocked structures after a successful build/upgrade. + */ + private clearBlockedStructures(): void { + this._blockedStructures.clear(); + } + + /** + * Gets the saved tile for a given structure type. + */ + private getSavedTileForStructure(unitType: UnitType): TileRef | null { + if (unitType === UnitType.Port) { + return this._portTile; + } else if (unitType === UnitType.DefensePost) { + return this._defensePostTile; + } else if (unitType === UnitType.SAMLauncher) { + return this._samTile; + } else { + return this._otherTile; + } + } + + /** + * Clears the tile score for a given structure type and any other scores sharing the same tile. + * Also resets the tile evaluation counter to require fresh evaluations. + */ + private clearTileScoresForTile(tile: TileRef, reason: string): void { + if (this._portTile === tile) { + this._portTileScore = 0; + this._portTile = null; + this._portEvalCount = 0; + } + if (this._defensePostTile === tile) { + this._defensePostTileScore = 0; + this._defensePostTile = null; + this._defensePostEvalCount = 0; + } + if (this._samTile === tile) { + this._samTileScore = 0; + this._samTile = null; + this._samEvalCount = 0; + } + if (this._otherTile === tile) { + this._otherTileScore = 0; + this._otherTile = null; + this._otherEvalCount = 0; + } + this._scoreTargetCacheDirty = true; + } + + /** + * Logistic (sigmoid) function: σ(z) = 1 / (1 + e^(-z)) + * Maps any real number z ∈ (-∞, +∞) to (0, 1). + */ + private static sigmoid(z: number): number { + return 1 / (1 + Math.exp(-z)); + } + + /** + * Calculates the port tile score for a given tile using a logistic function. + * + * ## Mathematical Model + * + * The tile score is computed as: + * + * score = σ(z) = 1 / (1 + e^(-z)) + * + * where z is a linear combination of features: + * + * z = w₀ + w₁·x₁ + w₂·x₂ + w₃·x₃ + * + * Features (xᵢ) and their weights (wᵢ): + * - w₀ = bias term (default 0, so σ(0) = 0.5 as baseline) + * - x₁ = (max(0, (maxDist - closestPlayerDist) / maxDist))² ∈ [0, 1], quadratic + * w₁ = -tileNearPlayerPenalty (negative = penalty for being close to enemies) + * - x₂ = tiered structure value penalty: Σ(structureValue * zoneWeight) / 1M + * Zones: ≤25, 25-61, 61-161, 161-201 tiles; each zone's weight is + * basePenalty * Π(zone multipliers) cascading from inner to outer. + * - x₃ = dist / maxMapDim, normalized by larger map dimension + * w₃ = -tileCapitalDistancePenalty (negative = penalty for being far from capital) + * + * Returns 0 if port cannot be built (hard constraint), otherwise score ∈ (0, 1). + */ + private calculatePortTileScore( + player: Player, + tile: TileRef, + skipSpacingCheck: boolean = false, + precomputedClosestPlayerDist?: number | null | undefined, + precomputedNearbyStructures?: Array<{ unit: Unit; distSquared: number }>, + precomputedSAMs?: { units: Unit[]; rangeSq: number }, + ): number { + // Early terrain check: ports must be on ocean shore + if (!this.mg.isOceanShore(tile)) { + return 0; + } + + // Check ownership and structure spacing (skip for upgrade evaluation) + if ( + !skipSpacingCheck && + player.canBuildAtTile(UnitType.Port, tile) === false + ) { + return 0; + } + + // For upgrades, still check basic ownership + if (skipSpacingCheck && this.mg.owner(tile) !== player) { + return 0; + } + + // Initialize linear combination: z = w₀ (bias) + let z = 0; + + // Feature 1: Enemy proximity penalty (quadratic) + // x₁ = (max(0, (maxDist - dist) / maxDist))², bounded below at 0 + // x₁ = 1 when dist = 0 (very close), x₁ = 0 when dist >= maxDist + // Quadratic makes penalty more severe when enemies are very close + const avoidPlayerDist = this.avoidPlayerDistanceFor(UnitType.Port); + if (avoidPlayerDist > 0) { + // Use precomputed value if provided (undefined = not precomputed; null = no enemy found) + const closestPlayerDist = + precomputedClosestPlayerDist !== undefined + ? precomputedClosestPlayerDist + : this.closestOtherPlayerDistance(player, tile, avoidPlayerDist); + if (closestPlayerDist !== null) { + const linearX1 = Math.max( + 0, + (avoidPlayerDist - closestPlayerDist) / avoidPlayerDist, + ); + const x1 = linearX1 * linearX1; // Quadratic + const w1 = -(this.params.tileNearPlayerPenalty ?? 2.0); + z += w1 * x1; + } + } + + // Feature 2: Own structure proximity penalty (tiered zones) + { + const nearbyStructures = + precomputedNearbyStructures ?? + this.mg.nearbyUnits( + tile, + AIConstructionHandler.ZONE_SEARCH_RADIUS, + AIConstructionHandler.DISTANCE_CHECK_STRUCTURE_TYPES, + ); + const zoneWeights = this.getZoneWeights(); + const zoneSq = AIConstructionHandler.ZONE_BOUNDARIES_SQ; + const pid = player.id(); + let weightedValue = 0; + for (const { unit, distSquared } of nearbyStructures) { + if (unit.owner().id() !== pid) continue; + // Skip Construction units building excluded types (SAM, DefensePost) + if ( + unit.type() === UnitType.Construction && + AIConstructionHandler.DISTANCE_CHECK_EXCLUDED_CONSTRUCTION_TYPES.has( + unit.constructionType()!, + ) + ) + continue; + // Determine zone index (0-3) based on distance² + let zi = 0; + if (distSquared > zoneSq[0]) { + if (distSquared > zoneSq[2]) zi = 3; + else if (distSquared > zoneSq[1]) zi = 2; + else zi = 1; + } + weightedValue += this.getStructureValue(player, unit) * zoneWeights[zi]; + } + if (weightedValue > 0) { + z -= weightedValue / 1_000_000; + } + } + + // Feature 3: Capital distance penalty + // x₃ = dist / mapDim, normalized by geometric mean of map dimensions + const capital = player.capital(); + if (capital !== null) { + const capitalTile = this.mg.ref(capital.x, capital.y); + const dist = Math.sqrt(this.mg.euclideanDistSquared(tile, capitalTile)); + const mapDim = this.getMapDim(); + const x3 = dist / mapDim; + const w3 = -(this.params.tileCapitalDistancePenalty ?? 1.0); + z += w3 * x3; + } + + // Feature 4: Nearby own structure bonus (flat if any structure within road range) + { + const roadRangeSq = 120 * 120; + const bonus = this.params.tileNearbyStructureStackBonus ?? 0.01; + const pid = player.id(); + let hasNearby = false; + const structures = + precomputedNearbyStructures ?? + this.mg.nearbyUnits( + tile, + AIConstructionHandler.ZONE_SEARCH_RADIUS, + AIConstructionHandler.DISTANCE_CHECK_STRUCTURE_TYPES, + ); + for (const { unit, distSquared } of structures) { + if (distSquared > roadRangeSq) continue; + if (unit.owner().id() !== pid) continue; + // Skip Construction units building excluded types (SAM, DefensePost) + if ( + unit.type() === UnitType.Construction && + AIConstructionHandler.DISTANCE_CHECK_EXCLUDED_CONSTRUCTION_TYPES.has( + unit.constructionType()!, + ) + ) + continue; + hasNearby = true; + break; + } + if (hasNearby) z += bonus; + } + + // Feature 5: SAM coverage bonus (flat +0.01 if within range of an existing or under-construction SAM) + { + const samData = + precomputedSAMs !== undefined + ? precomputedSAMs + : this.buildSAMData(player); + for (const sam of samData.units) { + if (this.mg.euclideanDistSquared(tile, sam.tile()) <= samData.rangeSq) { + z += 0.01; + break; + } + } + } + + return AIConstructionHandler.sigmoid(z); + } + + /** + * Calculates the other tile score for land structures using a logistic function. + * + * ## Mathematical Model + * + * The tile score is computed as: + * + * score = σ(z) = 1 / (1 + e^(-z)) + * + * where z is a linear combination of features: + * + * z = w₀ + w₁·x₁ + w₂·x₂ + w₃·x₃ + w₄·x₄ + * + * Features (xᵢ) and their weights (wᵢ): + * - w₀ = bias term (default 0, so σ(0) = 0.5 as baseline) + * - x₁ = (max(0, (maxDist - closestPlayerDist) / maxDist))² ∈ [0, 1], quadratic + * w₁ = -tileNearPlayerPenalty (negative = penalty for being close to enemies) + * - x₂ = tiered structure value penalty: Σ(structureValue * zoneWeight) / 1M + * Zones: ≤25, 25-61, 61-161, 161-201 tiles; each zone's weight is + * basePenalty * Π(zone multipliers) cascading from inner to outer. + * - x₃ = dist / maxMapDim, normalized by larger map dimension + * w₃ = -tileCapitalDistancePenalty (negative = penalty for being far from capital) + * - x₄ = nearby water indicator: 1 if water within distance, 0 otherwise + * w₄ = -otherTileNearWaterPenalty (negative = penalty for being near coast) + * + * Returns 0 if structure cannot be built (hard constraint), otherwise score ∈ (0, 1). + */ + private calculateOtherTileScore( + player: Player, + tile: TileRef, + skipSpacingCheck: boolean = false, + precomputedClosestPlayerDist?: number | null | undefined, + precomputedNearbyStructures?: Array<{ unit: Unit; distSquared: number }>, + precomputedSAMs?: { units: Unit[]; rangeSq: number }, + ): number { + // Early terrain check: land structures cannot be on ocean + if (this.mg.isOcean(tile)) { + return 0; + } + + // Check ownership and structure spacing (skip for upgrade evaluation) + if ( + !skipSpacingCheck && + player.canBuildAtTile(UnitType.City, tile) === false + ) { + return 0; + } + + // For upgrades, still check basic ownership + if (skipSpacingCheck && this.mg.owner(tile) !== player) { + return 0; + } + + // Initialize linear combination: z = w₀ (bias) + let z = 0; + + // Feature 1: Enemy proximity penalty (quadratic) + // x₁ = (max(0, (maxDist - dist) / maxDist))², bounded below at 0 + // x₁ = 1 when dist = 0 (very close), x₁ = 0 when dist >= maxDist + // Quadratic makes penalty more severe when enemies are very close + const avoidPlayerDist = this.avoidPlayerDistanceFor(UnitType.City); + if (avoidPlayerDist > 0) { + // Use precomputed value if provided (undefined = not precomputed; null = no enemy found) + const closestPlayerDist = + precomputedClosestPlayerDist !== undefined + ? precomputedClosestPlayerDist + : this.closestOtherPlayerDistance(player, tile, avoidPlayerDist); + if (closestPlayerDist !== null) { + const linearX1 = Math.max( + 0, + (avoidPlayerDist - closestPlayerDist) / avoidPlayerDist, + ); + const x1 = linearX1 * linearX1; // Quadratic + const w1 = -(this.params.tileNearPlayerPenalty ?? 2.0); + z += w1 * x1; + } + } + + // Feature 2: Own structure proximity penalty (tiered zones) + { + const nearbyStructures = + precomputedNearbyStructures ?? + this.mg.nearbyUnits( + tile, + AIConstructionHandler.ZONE_SEARCH_RADIUS, + AIConstructionHandler.DISTANCE_CHECK_STRUCTURE_TYPES, + ); + const zoneWeights = this.getZoneWeights(); + const zoneSq = AIConstructionHandler.ZONE_BOUNDARIES_SQ; + const pid = player.id(); + let weightedValue = 0; + for (const { unit, distSquared } of nearbyStructures) { + if (unit.owner().id() !== pid) continue; + // Skip Construction units building excluded types (SAM, DefensePost) + if ( + unit.type() === UnitType.Construction && + AIConstructionHandler.DISTANCE_CHECK_EXCLUDED_CONSTRUCTION_TYPES.has( + unit.constructionType()!, + ) + ) + continue; + let zi = 0; + if (distSquared > zoneSq[0]) { + if (distSquared > zoneSq[2]) zi = 3; + else if (distSquared > zoneSq[1]) zi = 2; + else zi = 1; + } + weightedValue += this.getStructureValue(player, unit) * zoneWeights[zi]; + } + if (weightedValue > 0) { + z -= weightedValue / 1_000_000; + } + } + + // Feature 3: Capital distance penalty + // x₃ = dist / mapDim, normalized by geometric mean of map dimensions + const capital = player.capital(); + if (capital !== null) { + const capitalTile = this.mg.ref(capital.x, capital.y); + const dist = Math.sqrt(this.mg.euclideanDistSquared(tile, capitalTile)); + const mapDim = this.getMapDim(); + const x3 = dist / mapDim; + const w3 = -(this.params.tileCapitalDistancePenalty ?? 1.0); + z += w3 * x3; + } + + // Feature 4: Nearby water penalty (binary feature) + // x₄ = 1 if water is within distance, 0 otherwise + const waterCheckDist = this.params.otherTileWaterCheckDistance ?? 5; + if (waterCheckDist > 0) { + const hasNearbyWater = this.tileHasNearbyWater(tile, waterCheckDist); + if (hasNearbyWater) { + const x4 = 1; + const w4 = -(this.params.otherTileNearWaterPenalty ?? 0.8); + z += w4 * x4; + } + } + + // Feature 5: Nearby own structure bonus (flat if any structure within road range) + { + const roadRangeSq = 120 * 120; + const bonus = this.params.tileNearbyStructureStackBonus ?? 0.01; + const pid = player.id(); + let hasNearby = false; + const structures = + precomputedNearbyStructures ?? + this.mg.nearbyUnits( + tile, + AIConstructionHandler.ZONE_SEARCH_RADIUS, + AIConstructionHandler.DISTANCE_CHECK_STRUCTURE_TYPES, + ); + for (const { unit, distSquared } of structures) { + if (distSquared > roadRangeSq) continue; + if (unit.owner().id() !== pid) continue; + // Skip Construction units building excluded types (SAM, DefensePost) + if ( + unit.type() === UnitType.Construction && + AIConstructionHandler.DISTANCE_CHECK_EXCLUDED_CONSTRUCTION_TYPES.has( + unit.constructionType()!, + ) + ) + continue; + hasNearby = true; + break; + } + if (hasNearby) z += bonus; + } + + // Feature 6: SAM coverage bonus (flat +0.01 if within range of an existing or under-construction SAM) + { + const samData = + precomputedSAMs !== undefined + ? precomputedSAMs + : this.buildSAMData(player); + for (const sam of samData.units) { + if (this.mg.euclideanDistSquared(tile, sam.tile()) <= samData.rangeSq) { + z += 0.01; + break; + } + } + } + + return AIConstructionHandler.sigmoid(z); + } + + /** + * Calculates the defense post tile score based on nearby enemy threat. + * + * Score = Σ over each nearby enemy player: + * min(militaryStrength(enemy) / ownMilitaryStrength, 4) * distanceFactor + * + * where: + * x = closestEnemyBorderDist / defensePostRadius, clamped to [0, 1] + * distanceFactor = -x² + 2x (peaks at 1.0 when x=1, so enemy border at radius edge) + * + * Returns 0 if tile is ocean or not owned by the player. + */ + private calculateDefensePostTileScore(player: Player, tile: TileRef): number { + if (this.mg.isOcean(tile)) return 0; + if (!this.mg.hasOwner(tile) || this.mg.owner(tile).id() !== player.id()) + return 0; + + const defensePostRadius = this.mg.config().defensePostRange(); + if (defensePostRadius <= 0) return 0; + + const radiusSquared = defensePostRadius * defensePostRadius; + + // Area scan: find closest distance² to each enemy player within radius. + // This is O(radius²) instead of O(numEnemies × borderTiles). + const playerSmallID = player.smallID(); + const cx = this.mg.x(tile); + const cy = this.mg.y(tile); + const closestDistSqByOwner = new Map(); + + // Piggyback water check onto the same area scan to avoid a second pass + const waterCheckDist = this.params.otherTileWaterCheckDistance ?? 5; + const waterCheckDistSq = waterCheckDist * waterCheckDist; + let hasNearbyWater = false; + + for (let dy = -defensePostRadius; dy <= defensePostRadius; dy++) { + for (let dx = -defensePostRadius; dx <= defensePostRadius; dx++) { + const distSq = dx * dx + dy * dy; + if (distSq > radiusSquared) continue; + const nx = cx + dx; + const ny = cy + dy; + if (!this.mg.isValidCoord(nx, ny)) continue; + const t = this.mg.ref(nx, ny); + // Check for nearby water within the water check distance + if ( + !hasNearbyWater && + waterCheckDist > 0 && + distSq <= waterCheckDistSq && + this.mg.isOcean(t) + ) { + hasNearbyWater = true; + } + if (!this.mg.hasOwner(t)) continue; + const oid = this.mg.ownerID(t); + if (oid === playerSmallID || oid === 0) continue; + const prev = closestDistSqByOwner.get(oid); + if (prev === undefined || distSq < prev) { + closestDistSqByOwner.set(oid, distSq); + } + } + } + + if (closestDistSqByOwner.size === 0) return 0; + + const ownStrength = Math.max(1, player.militaryStrength()); + let score = 0; + + for (const [ownerSmallID, closestDistSq] of closestDistSqByOwner) { + const other = this.mg.playerBySmallID(ownerSmallID); + if (!other.isPlayer()) continue; + if (!other.isAlive()) continue; + if (other.type() === PlayerType.Bot) continue; + + // x = closestDist / radius, clamped to [0, 1] + const x = Math.min(1, Math.sqrt(closestDistSq) / defensePostRadius); + // distanceFactor = -x² + 2x (parabola peaking at 1.0 when x = 1) + const distanceFactor = -x * x + 2 * x; + + const strengthRatio = Math.min(4, other.militaryStrength() / ownStrength); + score += strengthRatio * distanceFactor; + } + + if (score <= 0) return 0; + + // Penalize overlap with existing and under-construction defense posts (same radius circles) + const existingDPs = player + .units(UnitType.DefensePost) + .filter((u) => u.isActive()); + // Include defense posts under construction + for (const u of player.units(UnitType.Construction)) { + if (u.isActive() && u.constructionType() === UnitType.DefensePost) { + existingDPs.push(u); + } + } + if (existingDPs.length > 0) { + const r = defensePostRadius; + const circleArea = Math.PI * r * r; + let totalOverlapArea = 0; + + for (const dp of existingDPs) { + const distSq = this.mg.euclideanDistSquared(tile, dp.tile()); + const d = Math.sqrt(distSq); + + if (d >= 2 * r) { + // No overlap + continue; + } else if (d <= 0) { + // Full overlap + totalOverlapArea += circleArea; + } else { + // Partial overlap of two equal-radius circles: + // area = 2r²·arccos(d/(2r)) - (d/2)·√(4r²-d²) + const halfD = d / 2; + const overlapArea = + 2 * r * r * Math.acos(halfD / r) - + halfD * Math.sqrt(4 * r * r - d * d); + totalOverlapArea += overlapArea; + } + } + + const overlapFraction = Math.min(1, totalOverlapArea / circleArea); + score *= 1 - overlapFraction; + } + + // Apply water avoidance penalty (same convention as otherTileNearWaterPenalty) + if (hasNearbyWater) { + score *= 1 - (this.params.defensePostNearWaterPenalty ?? 0); + } + + return score; + } + + /** + * Checks if there is water (ocean) within the given distance of a tile. + */ + private tileHasNearbyWater(tile: TileRef, maxDist: number): boolean { + const cx = this.mg.x(tile); + const cy = this.mg.y(tile); + const maxDistSq = maxDist * maxDist; + + // Sample tiles in the area to check for water + for (let dy = -maxDist; dy <= maxDist; dy++) { + for (let dx = -maxDist; dx <= maxDist; dx++) { + if (dx * dx + dy * dy > maxDistSq) continue; + const x = cx + dx; + const y = cy + dy; + if (!this.mg.isValidCoord(x, y)) continue; + const t = this.mg.ref(x, y); + if (this.mg.isOcean(t)) { + return true; + } + } + } + return false; + } + + /** + * Calculates the SAM tile score for a given tile. + * + * Score = Σ over each owned structure: + * structureValue * weight + * + * where weight = 1 / (1 + e^(decay * existingCoverage)) + * and existingCoverage = number of existing SAMs already covering the structure. + * + * Returns 0 if the tile is ocean, not owned, or too close to enemies. + * The raw score is structure-value-weighted and NOT normalized to (0,1). + */ + private calculateSAMTileScore( + player: Player, + tile: TileRef, + precomputedClosestPlayerDist?: number | null | undefined, + skipSpacingCheck: boolean = false, + precomputedSAMs?: { units: Unit[]; rangeSq: number }, + ): number { + if (this.mg.isOcean(tile)) return 0; + if (!this.mg.hasOwner(tile) || this.mg.owner(tile).id() !== player.id()) + return 0; + + // Check if a SAM can be built here (skip for upgrade evaluation) + if ( + !skipSpacingCheck && + player.canBuildAtTile(UnitType.SAMLauncher, tile) === false + ) + return 0; + + // Enemy proximity penalty via sigmoid (only the distance term) + let z = 0; + const avoidPlayerDist = this.avoidPlayerDistanceFor(UnitType.SAMLauncher); + if (avoidPlayerDist > 0) { + // Use precomputed value if provided (undefined = not precomputed; null = no enemy found) + const closestPlayerDist = + precomputedClosestPlayerDist !== undefined + ? precomputedClosestPlayerDist + : this.closestOtherPlayerDistance(player, tile, avoidPlayerDist); + if (closestPlayerDist !== null) { + const linearX = Math.max( + 0, + (avoidPlayerDist - closestPlayerDist) / avoidPlayerDist, + ); + const x = linearX * linearX; // Quadratic + const w = -(this.params.tileNearPlayerPenalty ?? 2.0); + z += w * x; + } + } + const proximityMultiplier = AIConstructionHandler.sigmoid(z); + + // Use precomputed SAM data or build it + const samData = + precomputedSAMs !== undefined + ? precomputedSAMs + : this.buildSAMData(player); + const sams = samData.units; + const rangeSquared = samData.rangeSq; + + const rawScore = this.evaluateSAMPlacementScore( + player, + tile, + sams, + rangeSquared, + ); + + return rawScore * proximityMultiplier; + } + + /** + * Evaluates a random owned tile or existing structure and updates the saved scores. + * Randomly decides between evaluating a new tile and evaluating an existing structure for upgrade. + */ + private tickTileEvaluation(player: Player, ticks: number): void { + const numTiles = player.numTilesOwned(); + if (numTiles === 0) return; + + // Rebuild cached tile array periodically (~every 10s) so newly conquered tiles + // are included. Between rebuilds, stale entries are skipped via ownership check. + if ( + this._cachedTiles === null || + this._cachedTiles.length === 0 || + ticks - this._cachedTilesLastRebuildTick >= + AIConstructionHandler.TILE_CACHE_REBUILD_INTERVAL + ) { + this._cachedTiles = Array.from(player.tiles()); + this._cachedTilesLastRebuildTick = ticks; + } + + if (this._cachedTiles.length === 0) return; + + // Evaluate both a new tile and an existing structure for upgrade every tick + this.evaluateNewTile(player); + this.evaluateUpgradeCandidate(player, ticks); + } + + /** + * Evaluates a random owned tile for building new structures. + */ + private evaluateNewTile(player: Player): void { + if (this._cachedTiles === null || this._cachedTiles.length === 0) return; + + // Pick a random owned tile, skipping stale entries (tiles no longer owned) + let tile: TileRef | null = null; + for (let attempt = 0; attempt < 4; attempt++) { + const candidate = this.random.randElement(this._cachedTiles); + if (this.mg.owner(candidate) === player) { + tile = candidate; + break; + } + } + if (tile === null) return; + + // Early terrain classification to avoid redundant expensive checks + const isOceanTile = this.mg.isOcean(tile); + + // Precompute shared expensive values once for all score functions: + // closestOtherPlayerDistance is used by port, other, and SAM (all with same radius) + // nearbyUnits is used by port and other (same tile, same params) + // SAM list + range is used by port, other, and SAM tile scoring + const avoidPlayerDist = this.avoidPlayerDistanceFor(UnitType.Port); // Same for Port, City, SAMLauncher + const closestPlayerDist = + avoidPlayerDist > 0 + ? this.closestOtherPlayerDistance(player, tile, avoidPlayerDist) + : null; + + const nearbyStructures = this.mg.nearbyUnits( + tile, + AIConstructionHandler.ZONE_SEARCH_RADIUS, + AIConstructionHandler.DISTANCE_CHECK_STRUCTURE_TYPES, + ); + + const samData = this.buildSAMData(player); + + // Calculate port score with penalties and bonuses (only for ocean shore tiles) + const portScore = this.calculatePortTileScore( + player, + tile, + false, + closestPlayerDist, + nearbyStructures, + samData, + ); + + // Land structures (defense post, SAM, and other) can only be built on non-ocean tiles + const otherScore = isOceanTile + ? 0 + : this.calculateOtherTileScore( + player, + tile, + false, + closestPlayerDist, + nearbyStructures, + samData, + ); + const defensePostScore = isOceanTile + ? 0 + : this.calculateDefensePostTileScore(player, tile); + const samScore = isOceanTile + ? 0 + : this.calculateSAMTileScore( + player, + tile, + closestPlayerDist, + false, + samData, + ); + + // Increment evaluation counts for each type + this._portEvalCount++; + this._defensePostEvalCount++; + this._samEvalCount++; + this._otherEvalCount++; + + // Update port tile if this score is strictly greater + if (portScore > this._portTileScore) { + this._portTileScore = portScore; + this._portTile = tile; + this._scoreTargetCacheDirty = true; + } + + // Update defense post tile if this score is strictly greater + if (defensePostScore > this._defensePostTileScore) { + this._defensePostTileScore = defensePostScore; + this._defensePostTile = tile; + this._scoreTargetCacheDirty = true; + } + + // Update SAM tile if this score is strictly greater + if (samScore > this._samTileScore) { + this._samTileScore = samScore; + this._samTile = tile; + this._scoreTargetCacheDirty = true; + } + + // Update other structures tile if this score is strictly greater + if (otherScore > this._otherTileScore) { + this._otherTileScore = otherScore; + this._otherTile = tile; + this._scoreTargetCacheDirty = true; + } + } + + /** + * Evaluates the least-recently-checked existing structure for potential upgrade/stacking. + * Structures that have never been evaluated are checked first. + * Uses the same scoring as new tiles but divides by UPGRADE_SCORE_DIVISOR. + * Returns true if a structure was evaluated, false if no upgradeable structures exist. + */ + private evaluateUpgradeCandidate(player: Player, ticks: number): boolean { + // Get all stackable structures owned by this player + const stackableTypes = [ + UnitType.City, + UnitType.Port, + UnitType.Airfield, + UnitType.Hospital, + UnitType.Academy, + UnitType.ResearchLab, + UnitType.Factory, + UnitType.SAMLauncher, + ]; + + // Collect all upgradeable structures + const upgradeableStructures: Unit[] = []; + for (const unitType of stackableTypes) { + const units = player.units(unitType).filter((u) => { + if (!u.isActive()) return false; + const currentStack = u.stackCount?.() ?? 1; + const maxStack = maxStackCount(unitType); + return currentStack < maxStack; + }); + upgradeableStructures.push(...units); + } + + if (upgradeableStructures.length === 0) return false; + + // Pick the structure that was evaluated the longest ago (never-evaluated first) + let structure = upgradeableStructures[0]; + let oldestTick = this._upgradeLastEvalTick.get(structure.id()) ?? -Infinity; + for (let i = 1; i < upgradeableStructures.length; i++) { + const candidate = upgradeableStructures[i]; + const lastTick = + this._upgradeLastEvalTick.get(candidate.id()) ?? -Infinity; + if (lastTick < oldestTick) { + oldestTick = lastTick; + structure = candidate; + } + } + + // Record this evaluation tick + this._upgradeLastEvalTick.set(structure.id(), ticks); + const tile = structure.tile(); + const unitType = structure.type(); + + // Calculate the score based on structure type (skip spacing check for upgrades) + let score: number; + if (unitType === UnitType.Port) { + score = this.calculatePortTileScore(player, tile, true); + } else if (unitType === UnitType.SAMLauncher) { + score = this.calculateSAMTileScore(player, tile, undefined, true); + } else { + score = this.calculateOtherTileScore(player, tile, true); + } + + // Get current upgrade data for this specific structure type + const currentData = this._upgradeScores.get(unitType); + const currentScore = currentData?.score ?? 0; + + // Update the upgrade score/unit for this structure type if this score is strictly greater + if (score > currentScore) { + this._upgradeScores.set(unitType, { + score, + unit: structure, + }); + this._scoreTargetCacheDirty = true; + } + + return true; + } + + /** + * Computes the effective SAM range for a given tech level. + */ + private getEffectiveSAMRange(techLevel: number): number { + const baseRange = this.mg.config().defaultSamRange(); + const rangeBonus = this.mg.config().samRangeUpgradePercent(); + if (techLevel <= 1) return baseRange; + return baseRange * Math.pow(1 + rangeBonus, techLevel - 1); + } + + /** + * Computes the value of a structure based on its type and level. + * Uses base construction cost + upgrade costs for each level. + */ + private getStructureValue(player: Player, structure: Unit): number { + let unitType = structure.type(); + // For construction units, value by the type being built + if (unitType === UnitType.Construction) { + const ct = structure.constructionType(); + if (ct === null) return 0; + unitType = ct; + } + const baseCost = this.getCachedStructureCost(player, unitType); + const level = structure.stackCount?.() ?? 1; + + if (level <= 1) { + return baseCost; + } + + // Add upgrade costs for each level beyond 1 + // Upgrade cost is typically 80% of base cost per level + const upgradeMultiplier = 0.8; + let totalValue = baseCost; + for (let i = 2; i <= level; i++) { + totalValue += baseCost * upgradeMultiplier; + } + return totalValue; + } + + /** + * Evaluates the score of placing a SAM at a given tile. + * Returns weighted sum of structure values where weight = 1/(1 + existing SAM coverage). + */ + private evaluateSAMPlacementScore( + player: Player, + tile: TileRef, + sams: Unit[], + rangeSquared: number, + ): number { + let score = 0; + + for (const structureType of AIConstructionHandler.ALL_STRUCTURE_TYPES) { + // Exclude SAMs and SAM constructions — their value shouldn't inflate + // the protected-value score (circular incentive). + if (structureType === UnitType.SAMLauncher) continue; + + const structures = player.units(structureType).filter((u) => { + if (!u.isActive()) return false; + // Exclude Construction units that are building a SAM + if ( + u.type() === UnitType.Construction && + u.constructionType() === UnitType.SAMLauncher + ) { + return false; + } + return true; + }); + + for (const structure of structures) { + const structureTile = structure.tile(); + const distSq = this.mg.euclideanDistSquared(tile, structureTile); + + // Check if this structure would be covered by a SAM at the given tile + if (distSq > rangeSquared) { + continue; // Structure not in range + } + + // Count existing SAM coverage + let existingCoverage = 0; + for (const sam of sams) { + if ( + this.mg.euclideanDistSquared(sam.tile(), structureTile) <= + rangeSquared + ) { + existingCoverage++; + } + } + + const structureValue = this.getStructureValue(player, structure); + const decay = this.params.samCoverageDecay ?? 0.05; + const weight = 1 / (1 + Math.exp(decay * existingCoverage)); + const contribution = structureValue * weight; + score += contribution; + } + } + + return score; + } + + private getStructureWeight(unitType: UnitType): number { + switch (unitType) { + case UnitType.City: + return this.params.weightCity ?? 1; + case UnitType.Factory: + return this.params.weightFactory ?? 1; + case UnitType.Port: + return this.params.weightPort ?? 1; + case UnitType.Hospital: + return this.params.weightHospital ?? 1; + case UnitType.Academy: + return this.params.weightAcademy ?? 1; + case UnitType.Airfield: + return this.params.weightAirfield ?? 1; + case UnitType.ResearchLab: + return this.params.weightResearchLab ?? 1; + case UnitType.MissileSilo: + return this.params.weightMissileSilo ?? 1; + case UnitType.SAMLauncher: + return this.params.weightSAMLauncher ?? 1; + case UnitType.DefensePost: + return this.params.weightDefensePost ?? 1; + case UnitType.DoomsdayDevice: + return this.params.weightDoomsdayDevice ?? 1; + default: + return 1; + } + } + + private pickTarget( + exclude: UnitType | null, + player: Player, + ): UnitType | null { + const candidates = this.candidateTargets().filter((t) => + exclude === null ? true : t !== exclude, + ); + + if (candidates.length === 0) { + return null; + } + + let bestScore = -Infinity; + let best: UnitType[] = []; + for (const t of candidates) { + const s = this.scoreTarget(player, t); + if (s > bestScore) { + bestScore = s; + best = [t]; + } else if (s === bestScore) { + best.push(t); + } + } + + return this.random.randElement(best); + } + + private canAffordTarget(player: Player, unitType: UnitType): boolean { + // Check if we're upgrading or building new + const { isUpgrade } = this.getEffectiveScoreAndMode(unitType); + + if (isUpgrade && isStackableStructure(unitType)) { + // Upgrade cost is based on structure upgrade multiplier + const baseCost = this.mg.unitInfo(unitType).cost(player); + const multiplier = this.mg + .config() + .structureUpgradeCostMultiplier(unitType); + const upgradeCost = computeUpgradeStepCost(baseCost, multiplier); + return player.gold() >= upgradeCost; + } else { + // New construction cost + const cost = this.mg.unitInfo(unitType).cost(player); + return player.gold() >= cost; + } + } + + /** + * Returns true if construction should be deferred because nuke value + * exceeds the construction target score (scaled by threshold param). + * Only considers hydrogen bomb score if the player has ThermonuclearStaging. + */ + private shouldDeferToNukes(player: Player): boolean { + const threshold = this.params.nukeScoreConstructionThreshold ?? 0; + if (threshold <= 0 || !this.nukeEvaluator || this.target === null) + return false; + + // Get the best nuke scores + const atomTarget = this.nukeEvaluator.bestAtomTarget(); + let bestNukeScore = atomTarget?.score ?? 0; + + // Only consider hydrogen bomb if player has researched ThermonuclearStaging + if (player.hasUpgrade(UpgradeType.ThermonuclearStaging)) { + const hydrogenTarget = this.nukeEvaluator.bestHydrogenTarget(); + if (hydrogenTarget && hydrogenTarget.score > bestNukeScore) { + bestNukeScore = hydrogenTarget.score; + } + } + + if (bestNukeScore <= 0) return false; + + // Apply internal multiplier + bestNukeScore *= + AIConstructionHandler.NUKE_SCORE_CONSTRUCTION_INTERNAL_MULTIPLIER; + + const constructionScore = this.scoreTarget(player, this.target); + + return constructionScore < threshold * bestNukeScore; + } + + /** + * Returns the best construction score across all candidate structure types. + */ + bestConstructionScore(): number { + const player = this.getPlayer(); + if (!player) return 0; + // Use cached scores if available; they're invalidated by tile/upgrade score changes + if (!this._scoreTargetCacheDirty && this._scoreTargetCache.size > 0) { + let best = 0; + for (const s of this._scoreTargetCache.values()) { + if (s > best) best = s; + } + return best; + } + // Rebuild cache + this._scoreTargetCache.clear(); + const candidates = this.candidateTargets(); + let best = 0; + for (const t of candidates) { + const s = this.scoreTarget(player, t); + this._scoreTargetCache.set(t, s); + if (s > best) best = s; + } + this._scoreTargetCacheDirty = false; + return best; + } + + /** + * Returns a map of candidate structure type → score for debugging/logging. + */ + constructionScoreBreakdown(): Map { + const player = this.getPlayer(); + if (!player) return new Map(); + // Use cached scores if available + if (!this._scoreTargetCacheDirty && this._scoreTargetCache.size > 0) { + return new Map(this._scoreTargetCache); + } + // Rebuild cache + this._scoreTargetCache.clear(); + const candidates = this.candidateTargets(); + for (const t of candidates) { + this._scoreTargetCache.set(t, this.scoreTarget(player, t)); + } + this._scoreTargetCacheDirty = false; + return new Map(this._scoreTargetCache); + } + + /** + * Returns whether the upgrade path is preferred over new-build for a given type. + */ + isUpgradePreferred(unitType: UnitType): boolean { + return this._upgradePreferred.get(unitType) ?? false; + } + + /** + * Returns detailed component breakdowns for city and factory base scores. + * Used for debugging/logging. + */ + cityFactoryScoreBreakdown(player: Player): { + city: { + cost: number; + currentMaxPop: number; + projectedMaxPop: number; + currentWorkers: number; + projectedWorkers: number; + factoryCount: number; + factoryFactor: number; + productivity: number; + goldMultiplier: number; + currentGrossGold: number; + projectedGrossGold: number; + incomeGain: number; + T: number; + discountRate: number; + finalScore: number; + }; + factory: { + cost: number; + workers: number; + factoryCount: number; + currentFactoryFactor: number; + projectedFactoryFactor: number; + productivity: number; + goldMultiplier: number; + currentGrossGold: number; + projectedGrossGold: number; + incomeGain: number; + T: number; + discountRate: number; + finalScore: number; + }; + } { + const config = this.mg.config(); + const assumedPopPercent = this.params.aiAssumedPopPercent ?? 0.7; + const targetTroopRatio = player.targetTroopRatio(); + const discountRate = this.params.discountFactor ?? 0.1; + const TICKS_PER_MINUTE = 600; + + // --- City --- + const cityCost = this.mg.unitInfo(UnitType.City).cost(player); + const cityCostNum = Number(cityCost); + const currentMaxPop = config.maxPopulation(player); + const cityPopBonus = config.cityPopulationIncrease(); + const projectedMaxPop = currentMaxPop + cityPopBonus; + const currentWorkers = + currentMaxPop * assumedPopPercent * (1 - targetTroopRatio); + const projectedWorkers = + projectedMaxPop * assumedPopPercent * (1 - targetTroopRatio); + const factoryCount = player.unitsOwned(UnitType.Factory); + const factoryFactor = Math.pow(1 + factoryCount, 0.35); + const cityProductivity = player.productivity(); + const cityMultiplier = config.gameConfig().goldMultiplier ?? 1; + const cityCurrentGross = + 0.11 * + Math.pow(currentWorkers, 0.65) * + cityProductivity * + factoryFactor * + cityMultiplier; + const cityProjectedGross = + 0.11 * + Math.pow(projectedWorkers, 0.65) * + cityProductivity * + factoryFactor * + cityMultiplier; + const cityIncomeGain = cityProjectedGross - cityCurrentGross; + const cityGrossPerMin = cityCurrentGross * TICKS_PER_MINUTE; + const cityT = cityGrossPerMin > 0 ? cityCostNum / cityGrossPerMin : 0; + const cityIncomeGainPerMin = cityIncomeGain * TICKS_PER_MINUTE; + const cityFinalScore = + cityGrossPerMin > 0 && cityIncomeGain > 0 + ? cityIncomeGainPerMin / + discountRate / + Math.pow(1 + discountRate, cityT) + : 0; + + // --- Factory --- + const factoryCost = this.mg.unitInfo(UnitType.Factory).cost(player); + const factoryCostNum = Number(factoryCost); + const fWorkers = currentMaxPop * assumedPopPercent * (1 - targetTroopRatio); + const currentFactoryFactor = Math.pow(1 + factoryCount, 0.35); + const projectedFactoryFactor = Math.pow(1 + factoryCount + 1, 0.35); + const factoryProductivity = player.productivity(); + const factoryMultiplier = config.gameConfig().goldMultiplier ?? 1; + const factoryBase = + 0.11 * Math.pow(fWorkers, 0.65) * factoryProductivity * factoryMultiplier; + const factoryCurrentGross = factoryBase * currentFactoryFactor; + const factoryProjectedGross = factoryBase * projectedFactoryFactor; + const factoryIncomeGain = factoryProjectedGross - factoryCurrentGross; + const factoryGrossPerMin = factoryCurrentGross * TICKS_PER_MINUTE; + const factoryT = + factoryGrossPerMin > 0 ? factoryCostNum / factoryGrossPerMin : 0; + const factoryIncomeGainPerMin = factoryIncomeGain * TICKS_PER_MINUTE; + const factoryFinalScore = + factoryGrossPerMin > 0 && factoryIncomeGain > 0 + ? factoryIncomeGainPerMin / + discountRate / + Math.pow(1 + discountRate, factoryT) + : 0; + + return { + city: { + cost: cityCostNum, + currentMaxPop, + projectedMaxPop, + currentWorkers, + projectedWorkers, + factoryCount, + factoryFactor, + productivity: cityProductivity, + goldMultiplier: cityMultiplier, + currentGrossGold: cityCurrentGross, + projectedGrossGold: cityProjectedGross, + incomeGain: cityIncomeGain, + T: cityT, + discountRate, + finalScore: cityFinalScore, + }, + factory: { + cost: factoryCostNum, + workers: fWorkers, + factoryCount, + currentFactoryFactor, + projectedFactoryFactor, + productivity: factoryProductivity, + goldMultiplier: factoryMultiplier, + currentGrossGold: factoryCurrentGross, + projectedGrossGold: factoryProjectedGross, + incomeGain: factoryIncomeGain, + T: factoryT, + discountRate, + finalScore: factoryFinalScore, + }, + }; + } + + /** + * Consume the current "other" tile for silo placement during a nuke sequence. + * Returns the tile and clears it (same as after a normal build). + */ + consumeOtherTile(): TileRef | null { + const tile = this._otherTile; + if (tile !== null) { + this.clearTileScoresForTile(tile, "consumed for nuke silo"); + } + return tile; + } + + private avoidPlayerDistanceFor(unitType: UnitType): number { + if (unitType === UnitType.DefensePost) return 0; + return Math.max(0, Math.floor(this.params.aiAvoidPlayerDistance ?? 8)); // Reduced from 10 + } + + /** + * Finds the distance to the closest other player's territory within the given radius. + * Returns null if no other player territory is found within radius. + * Exhaustively checks all tiles within the radius. + */ + private closestOtherPlayerDistance( + player: Player, + center: TileRef, + radius: number, + ): number | null { + if (radius <= 0) return null; + + const radiusSq = radius * radius; + const cx = this.mg.x(center); + const cy = this.mg.y(center); + let closestDistSq: number | null = null; + + // Check all tiles within the radius + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const distSq = dx * dx + dy * dy; + if (distSq > radiusSq) continue; + const x = cx + dx; + const y = cy + dy; + if (!this.mg.isValidCoord(x, y)) continue; + const t = this.mg.ref(x, y); + if (!this.mg.hasOwner(t)) continue; + const owner = this.mg.owner(t); + if (!owner.isPlayer?.() || !owner.isPlayer()) continue; + if (owner.id() !== player.id()) { + if (closestDistSq === null || distSq < closestDistSq) { + closestDistSq = distSq; + } + } + } + } + + return closestDistSq !== null ? Math.sqrt(closestDistSq) : null; + } + + /** + * Re-validates a tile at build time by recalculating its score. + * Updates the saved score to the fresh value. If score dropped to 0, + * rejects the tile. Even if the score decreased, the tile may still be the + * best option so we keep it rather than doing a full reset. + */ + private validateTileForConstruction( + player: Player, + tile: TileRef, + unitType: UnitType, + ): boolean { + // Recalculate the score for the current best tile + if (unitType === UnitType.Port) { + const newScore = this.calculatePortTileScore(player, tile); + // Update saved score to current value (tile may still be the best even if score dropped) + this._portTileScore = newScore; + return newScore > 0; + } else if (unitType === UnitType.DefensePost) { + const newScore = this.calculateDefensePostTileScore(player, tile); + this._defensePostTileScore = newScore; + return newScore > 0; + } else if (unitType === UnitType.SAMLauncher) { + const newScore = this.calculateSAMTileScore(player, tile); + this._samTileScore = newScore; + return newScore > 0; + } else { + const newScore = this.calculateOtherTileScore(player, tile); + this._otherTileScore = newScore; + return newScore > 0; + } + } +} diff --git a/src/core/ai/AIDiplomacyHandler.ts b/src/core/ai/AIDiplomacyHandler.ts new file mode 100644 index 000000000..ecc4e20e0 --- /dev/null +++ b/src/core/ai/AIDiplomacyHandler.ts @@ -0,0 +1,1074 @@ +import { Game, Player, PlayerID, PlayerType } from "../game/Game"; +import { TileRef } from "../game/GameMap"; +import { PseudoRandom } from "../PseudoRandom"; +import { AIBehaviorParams } from "./AIBehaviorParams"; + +/** + * War score breakdown for a single AI → target pair (debug overlay). + */ +export interface WarScoreBreakdown { + targetId: PlayerID; + targetName: string; + total: number; + threshold: number; + borderScore: number; + militaryScore: number; + allyPenalty: number; + distancePenalty: number; + dominanceBonus: number; + militaryStrengthShare: number; + movingAverage: number; + isAtWar: boolean; + isFriendly: boolean; + unreachable: boolean; +} + +/** + * All war score breakdowns for one AI player (debug overlay). + */ +export interface WarScoreDebugData { + playerId: PlayerID; + playerName: string; + breakdowns: WarScoreBreakdown[]; +} + +/** + * Cached ocean shore sample for a player. + * Contains extremum tiles (min/max X/Y) plus a random sample. + */ +interface OceanShoreSample { + extrema: TileRef[]; // Up to 4 tiles: minX, maxX, minY, maxY + randomSample: TileRef[]; // Small random sample + closestRandom: TileRef | null; // Best random tile from last calculation + lastUpdate: number; // Tick when extrema were last refreshed +} + +/** + * Handles AI diplomacy decisions: war declarations, peace requests, etc. + */ +export class AIDiplomacyHandler { + // 10 ticks/second * 5 seconds = 50 ticks between evaluations + private static readonly WAR_SCORE_EVALUATION_INTERVAL = 50; + // 30 seconds / 5 seconds per sample = 6 samples for moving average + private static readonly WAR_SCORE_HISTORY_LENGTH = 6; + // Minimum number of history samples before the AI may declare war (warmup period) + private static readonly WAR_SCORE_MIN_SAMPLES = 3; + // Invalidate shore sample cache every 100 ticks (10 seconds) + private static readonly SHORE_SAMPLE_CACHE_TTL = 100; + // Number of random shore tiles to sample (in addition to 4 extrema) + private static readonly RANDOM_SHORE_SAMPLE_SIZE = 4; + // Peace score evaluation interval (50 ticks = 5 seconds, same as war score) + private static readonly PEACE_SCORE_EVALUATION_INTERVAL = 50; + // 30 seconds / 5 seconds per sample = 6 samples for peace moving average + private static readonly PEACE_SCORE_HISTORY_LENGTH = 6; + + // Static registry of all active handlers for cross-AI peace request evaluation + private static readonly registry = new Map(); + + // Phase seed for spreading periodic actions across AIs + private readonly phaseSeed: number; + + // Current war scores for each player (keyed by PlayerID) + private _warScores: Map = new Map(); + + // War scores without dominance bonus, cached for at-war players (keyed by PlayerID) + private _warScoresNoDominance: Map = new Map(); + + // Historical war scores for moving average (keyed by PlayerID -> circular buffer of scores) + private _warScoreHistory: Map = new Map(); + + // Cache for shore distances between player pairs (keyed by "fromId:toId") + private _shoreDistanceCache: Map = new Map(); + + // Cache for ocean shore samples per player (keyed by PlayerID) + private _oceanShoreSampleCache: Map = new Map(); + + // Current peace scores for each player we're at war with (keyed by PlayerID) + private _peaceScores: Map = new Map(); + + // Historical peace scores for moving average (keyed by PlayerID -> buffer of scores) + private _peaceScoreHistory: Map = new Map(); + + // Ordered list of peace candidate PlayerIDs (sorted by peace score ascending) + private _pendingPeaceCandidates: PlayerID[] = []; + // Current index into the pending peace candidates list + private _currentPeaceCandidateIndex = 0; + // Whether peace was successfully made this evaluation cycle + private _peaceCompletedThisCycle = false; + + // Ticks at peace without any active wars (for threshold decay) + private _ticksAtPeace = 0; + // Ticks at war (for gradual threshold recovery) + private _ticksAtWar = 0; + // Whether the AI was at war last tick (to detect war start/end transitions) + private _wasAtWar = false; + // Accumulated threshold reduction from peaceful ticks + private _warThresholdDecay = 0; + // How many peaceful ticks before threshold drops by 1 + private static readonly PEACE_DECAY_INTERVAL = 200; + // How many ticks at war before threshold recovers by 1 toward baseline + private static readonly WAR_RECOVERY_INTERVAL = 200; + + constructor( + private mg: Game, + private playerId: PlayerID, + private random: PseudoRandom, + private params: AIBehaviorParams, + ) { + // Stagger periodic actions across AIs using random offset + this.phaseSeed = random.nextInt(0, 0x7fffffff); + // Register this handler for cross-AI peace request evaluation + AIDiplomacyHandler.registry.set(this.playerId, this); + } + + private periodicOffset(period: number): number { + const p = Math.max(1, Math.floor(period)); + return this.phaseSeed % p; + } + + private shouldRunPeriodic(ticks: number, period: number): boolean { + const p = Math.max(1, Math.floor(period)); + return ticks % p === this.periodicOffset(p); + } + + private getPlayer(): Player | null { + if (!this.mg.hasPlayer(this.playerId)) { + return null; + } + return this.mg.player(this.playerId); + } + + /** + * Determines if one player can reach another for military purposes. + * Players are reachable if they share a border OR both border the ocean. + */ + private isReachable(from: Player, to: Player): boolean { + if (from.sharesBorderWith(to)) { + return true; + } + // Check ocean reachability: both must border ocean (uses cached values) + return from.bordersOcean() && to.bordersOcean(); + } + + /** + * Gets the closest manhattan distance between ocean shore tiles of two players. + * Returns null if either player doesn't border the ocean. + * Uses extremum tiles + random sampling for efficiency. + */ + private closestOceanShoreDistance( + from: Player, + to: Player, + currentTick: number, + ): number | null { + // Check cache first + const cacheKey = `${from.id()}:${to.id()}`; + if (this._shoreDistanceCache.has(cacheKey)) { + return this._shoreDistanceCache.get(cacheKey)!; + } + + // Fast path: check if either doesn't border ocean + if (!from.bordersOcean() || !to.bordersOcean()) { + this._shoreDistanceCache.set(cacheKey, null); + return null; + } + + // Get shore samples for both players + const fromSample = this.getOceanShoreSample(from, currentTick); + const toSample = this.getOceanShoreSample(to, currentTick); + + if (fromSample === null || toSample === null) { + this._shoreDistanceCache.set(cacheKey, null); + return null; + } + + // Combine extrema + closestRandom + randomSample for each player + const fromTiles = this.getSampleTiles(fromSample); + const toTiles = this.getSampleTiles(toSample); + + if (fromTiles.length === 0 || toTiles.length === 0) { + this._shoreDistanceCache.set(cacheKey, null); + return null; + } + + // Find minimum distance and track closest random tiles + let minDist = Infinity; + let closestFromRandom: TileRef | null = null; + let closestToRandom: TileRef | null = null; + + for (const fromTile of fromTiles) { + const isFromRandom = fromSample.randomSample.includes(fromTile); + for (const toTile of toTiles) { + const dist = this.mg.manhattanDist(fromTile, toTile); + if (dist < minDist) { + minDist = dist; + if (isFromRandom) closestFromRandom = fromTile; + if (toSample.randomSample.includes(toTile)) closestToRandom = toTile; + } + } + } + + // Update closestRandom for future iterations + if (closestFromRandom !== null) { + fromSample.closestRandom = closestFromRandom; + } + if (closestToRandom !== null) { + toSample.closestRandom = closestToRandom; + } + + this._shoreDistanceCache.set(cacheKey, minDist); + return minDist; + } + + /** + * Gets combined sample tiles: extrema + closestRandom (if any) + randomSample. + */ + private getSampleTiles(sample: OceanShoreSample): TileRef[] { + const tiles = [...sample.extrema]; + if (sample.closestRandom !== null) { + tiles.push(sample.closestRandom); + } + tiles.push(...sample.randomSample); + return tiles; + } + + /** + * Gets or creates an ocean shore sample for a player. + * Refreshes extrema if TTL expired, keeps closestRandom, replaces random sample. + */ + private getOceanShoreSample( + player: Player, + currentTick: number, + ): OceanShoreSample | null { + const cached = this._oceanShoreSampleCache.get(player.id()); + const needsRefresh = + !cached || + currentTick - cached.lastUpdate > + AIDiplomacyHandler.SHORE_SAMPLE_CACHE_TTL; + + if (!needsRefresh && cached) { + return cached; + } + + // Use cached ocean shore tiles from Player + const oceanShores = player.oceanShoreTiles(); + if (oceanShores.length === 0) { + this._oceanShoreSampleCache.delete(player.id()); + return null; + } + + // Use cached extrema from Player + const extrema = [...player.oceanShoreExtrema()]; + + // Create set of extrema tiles to exclude from random sampling + const extremaSet = new Set(extrema); + + // Get random sample (excluding extrema and closestRandom) + const closestRandom = cached?.closestRandom ?? null; + const availableForSampling = oceanShores.filter( + (t) => !extremaSet.has(t) && t !== closestRandom, + ); + + const randomSample = this.sampleTiles( + availableForSampling, + AIDiplomacyHandler.RANDOM_SHORE_SAMPLE_SIZE, + ); + + const sample: OceanShoreSample = { + extrema, + randomSample, + closestRandom, + lastUpdate: currentTick, + }; + + this._oceanShoreSampleCache.set(player.id(), sample); + return sample; + } + + /** + * Randomly samples n tiles from the array. + */ + private sampleTiles(tiles: readonly TileRef[], n: number): TileRef[] { + if (tiles.length <= n) { + return [...tiles]; + } + const result: TileRef[] = []; + const indices = new Set(); + while (result.length < n) { + const idx = this.random.nextInt(0, tiles.length); + if (!indices.has(idx)) { + indices.add(idx); + result.push(tiles[idx]); + } + } + return result; + } + + /** + * Main tick function for diplomacy handling. + */ + tickDiplomacy(ticks: number): void { + const player = this.getPlayer(); + if (!player || !player.isAlive()) { + return; + } + + // Track war/peace state for threshold decay + this.updateWarThresholdDecay(player); + + // Periodically evaluate war scores + if ( + this.shouldRunPeriodic( + ticks, + AIDiplomacyHandler.WAR_SCORE_EVALUATION_INTERVAL, + ) + ) { + this.evaluateWarScores(player, ticks); + this.updateWarScoreHistory(); + this.maybeDeclarWars(player); + } + + // Periodically evaluate peace scores and rebuild candidate list + if ( + this.shouldRunPeriodic( + ticks, + AIDiplomacyHandler.PEACE_SCORE_EVALUATION_INTERVAL, + ) + ) { + this.evaluatePeaceScores(player, ticks); + } + + // Try peace negotiation each tick (advances through candidate list) + this.tryPeaceNegotiation(player, ticks); + } + + /** + * Evaluates war scores for all other human and AI players. + */ + private evaluateWarScores(player: Player, ticks: number): void { + this._warScores.clear(); + // Clear distance cache so new samples can affect results + this._shoreDistanceCache.clear(); + + for (const other of this.mg.players()) { + // Skip self + if (other.id() === player.id()) { + continue; + } + + // Only consider Human and AI players (not Bots) + if (other.type() !== PlayerType.Human && other.type() !== PlayerType.AI) { + continue; + } + + // Skip dead players + if (!other.isAlive()) { + continue; + } + + // Skip players we're already at war with + if (player.isAtWarWith(other)) { + continue; + } + + // Skip allies and team members + if (player.isFriendly(other)) { + continue; + } + + const score = this.calculateWarScore(player, other, ticks); + this._warScores.set(other.id(), score); + } + } + + /** + * Calculates the war score against a specific player. + * Higher score = more likely to declare war. + * Returns a linear combination of weighted factors. + */ + private calculateWarScore( + player: Player, + other: Player, + ticks: number, + ): number { + const base = this.calculateWarScoreBase(player, other, ticks); + return base + this.calculateDominanceBonus(other); + } + + /** + * War score factors 1-4 (border, military, ally penalty, distance). + * Excludes the dominance bonus so it can be cached separately. + */ + private calculateWarScoreBase( + player: Player, + other: Player, + ticks: number, + ): number { + // No point declaring war on someone we can't reach + if (!this.isReachable(player, other)) { + return 0; + } + + let score = 0; + + // Factor 1: Shared border length ratio + // sharedBorderLength / ownTotalBorderLength + const sharedBorderWeight = this.params.warScoreSharedBorderWeight ?? 0; + if (sharedBorderWeight !== 0) { + const ownTotalBorderLength = player.borderTiles().size; + if (ownTotalBorderLength > 0) { + const sharedBorderLength = player.sharedBorderLength(other); + const borderRatio = sharedBorderLength / ownTotalBorderLength; + score += sharedBorderWeight * borderRatio; + } + } + + // Factor 2: Military strength ratio + // Numerator: ownStrength weighted by target's share of all enemies + // Denominator: target's military strength only + const militaryStrengthWeight = + this.params.warScoreMilitaryStrengthWeight ?? 0; + if (militaryStrengthWeight !== 0) { + const ownStrength = player.militaryStrength(); + const targetStrength = other.militaryStrength(); + + // Sum military strength of countries we are already at war with + let existingEnemiesStrength = 0; + for (const enemy of this.mg.players()) { + if ( + enemy.id() !== player.id() && + enemy.id() !== other.id() && + enemy.isAlive() && + enemy.type() !== PlayerType.Bot && + player.isAtWarWith(enemy) + ) { + existingEnemiesStrength += enemy.militaryStrength(); + } + } + + if (targetStrength > 0) { + // Weight own strength by target's share of total enemy burden + const targetShare = + targetStrength / (targetStrength + existingEnemiesStrength); + const weightedOwnStrength = ownStrength * targetShare; + const strengthRatio = Math.min(weightedOwnStrength / targetStrength, 4); + score += militaryStrengthWeight * strengthRatio; + } + } + + // Factor 3: Ally penalty (negative contribution) + const allyPenalty = this.params.warScoreAllyPenalty ?? 0; + if (allyPenalty !== 0 && player.isAlliedWith(other)) { + score -= allyPenalty; + } + + // Factor 4: Distance penalty for non-bordering players + // Penalizes distant ocean-only targets, normalized by geometric mean of map dimensions + const distancePenaltyWeight = + this.params.warScoreDistancePenaltyWeight ?? 0; + if (distancePenaltyWeight !== 0 && !player.sharesBorderWith(other)) { + const shoreDist = this.closestOceanShoreDistance(player, other, ticks); + if (shoreDist !== null && shoreDist > 0) { + const mapDim = Math.sqrt(this.mg.width() * this.mg.height()); + if (mapDim > 0) { + const normalizedDist = shoreDist / mapDim; + score -= distancePenaltyWeight * normalizedDist; + } + } + } + + return score; + } + + /** + * Factor 5: Dominance bonus – incentivise attacking the strongest player. + * Separated so calculateWarScoreBase can be cached independently. + */ + private calculateDominanceBonus(other: Player): number { + const dominanceWeight = this.params.warScoreDominanceWeight ?? 0; + if (dominanceWeight === 0) return 0; + + let totalGameStrength = 0; + let highestStrength = 0; + let secondHighestStrength = 0; + + for (const p of this.mg.players()) { + if (!p.isAlive() || p.type() === PlayerType.Bot) continue; + const s = p.militaryStrength(); + totalGameStrength += s; + if (s > highestStrength) { + secondHighestStrength = highestStrength; + highestStrength = s; + } else if (s > secondHighestStrength) { + secondHighestStrength = s; + } + } + + const targetStrength = other.militaryStrength(); + if ( + totalGameStrength > 0 && + targetStrength >= highestStrength && + targetStrength > 0 + ) { + const targetShare = targetStrength / totalGameStrength; + const denominator = 0.8 - targetShare; + // Only apply when target share is below 80% (denominator > 0) + if (denominator > 0 && secondHighestStrength > 0) { + const gapPercent = + (targetStrength - secondHighestStrength) / secondHighestStrength; + return dominanceWeight * (gapPercent / denominator); + } + } + return 0; + } + + /** + * Debug: returns per-factor breakdown of war score for a specific target. + * Used only by the debug overlay. + */ + public calculateWarScoreBreakdown( + other: Player, + ticks: number, + ): WarScoreBreakdown | null { + const player = this.getPlayer(); + if (!player || !player.isAlive()) return null; + if (!other.isAlive()) return null; + if (!this.isReachable(player, other)) { + return { + targetId: other.id(), + targetName: other.displayName(), + total: 0, + threshold: this.effectiveWarThreshold, + borderScore: 0, + militaryScore: 0, + allyPenalty: 0, + distancePenalty: 0, + dominanceBonus: 0, + militaryStrengthShare: 0, + movingAverage: this.getMovingAverageWarScore(other.id()) ?? 0, + isAtWar: player.isAtWarWith(other), + isFriendly: player.isFriendly(other), + unreachable: true, + }; + } + + // Factor 1: Border + let borderScore = 0; + const sharedBorderWeight = this.params.warScoreSharedBorderWeight ?? 0; + if (sharedBorderWeight !== 0) { + const ownTotalBorderLength = player.borderTiles().size; + if (ownTotalBorderLength > 0) { + const sharedBorderLength = player.sharedBorderLength(other); + const borderRatio = sharedBorderLength / ownTotalBorderLength; + borderScore = sharedBorderWeight * borderRatio; + } + } + + // Factor 2: Military + let militaryScore = 0; + const militaryStrengthWeight = + this.params.warScoreMilitaryStrengthWeight ?? 0; + if (militaryStrengthWeight !== 0) { + const ownStrength = player.militaryStrength(); + const targetStrength = other.militaryStrength(); + + // Sum military strength of countries we are already at war with + let existingEnemiesStrength = 0; + for (const enemy of this.mg.players()) { + if ( + enemy.id() !== player.id() && + enemy.id() !== other.id() && + enemy.isAlive() && + enemy.type() !== PlayerType.Bot && + player.isAtWarWith(enemy) + ) { + existingEnemiesStrength += enemy.militaryStrength(); + } + } + + if (targetStrength > 0) { + // Weight own strength by target's share of total enemy burden + const targetShare = + targetStrength / (targetStrength + existingEnemiesStrength); + const weightedOwnStrength = ownStrength * targetShare; + const strengthRatio = Math.min(weightedOwnStrength / targetStrength, 4); + militaryScore = militaryStrengthWeight * strengthRatio; + } + } + + // Factor 3: Ally penalty + let allyPenaltyVal = 0; + const allyPenalty = this.params.warScoreAllyPenalty ?? 0; + if (allyPenalty !== 0 && player.isAlliedWith(other)) { + allyPenaltyVal = allyPenalty; + } + + // Factor 4: Distance penalty + let distancePenaltyVal = 0; + const distancePenaltyWeight = + this.params.warScoreDistancePenaltyWeight ?? 0; + if (distancePenaltyWeight !== 0 && !player.sharesBorderWith(other)) { + const shoreDist = this.closestOceanShoreDistance(player, other, ticks); + if (shoreDist !== null && shoreDist > 0) { + const mapDim = Math.sqrt(this.mg.width() * this.mg.height()); + if (mapDim > 0) { + const normalizedDist = shoreDist / mapDim; + distancePenaltyVal = distancePenaltyWeight * normalizedDist; + } + } + } + + // Factor 5: Dominance bonus + let dominanceBonusVal = 0; + const dominanceWeight = this.params.warScoreDominanceWeight ?? 0; + if (dominanceWeight !== 0) { + let totalGameStrength = 0; + let highestStrength = 0; + let secondHighestStrength = 0; + for (const p of this.mg.players()) { + if (!p.isAlive() || p.type() === PlayerType.Bot) continue; + const s = p.militaryStrength(); + totalGameStrength += s; + if (s > highestStrength) { + secondHighestStrength = highestStrength; + highestStrength = s; + } else if (s > secondHighestStrength) { + secondHighestStrength = s; + } + } + const targetStrength = other.militaryStrength(); + if ( + totalGameStrength > 0 && + targetStrength >= highestStrength && + targetStrength > 0 + ) { + const targetShare = targetStrength / totalGameStrength; + const denominator = 0.8 - targetShare; + if (denominator > 0 && secondHighestStrength > 0) { + const gapPercent = + (targetStrength - secondHighestStrength) / secondHighestStrength; + dominanceBonusVal = dominanceWeight * (gapPercent / denominator); + } + } + } + + const total = + borderScore + + militaryScore - + allyPenaltyVal - + distancePenaltyVal + + dominanceBonusVal; + + return { + targetId: other.id(), + targetName: other.displayName(), + total, + threshold: this.effectiveWarThreshold, + borderScore, + militaryScore, + allyPenalty: allyPenaltyVal, + distancePenalty: distancePenaltyVal, + dominanceBonus: dominanceBonusVal, + militaryStrengthShare: (() => { + let totalGameStrength = 0; + for (const p of this.mg.players()) { + if (!p.isAlive() || p.type() === PlayerType.Bot) continue; + totalGameStrength += p.militaryStrength(); + } + return totalGameStrength > 0 + ? other.militaryStrength() / totalGameStrength + : 0; + })(), + movingAverage: this.getMovingAverageWarScore(other.id()) || total, + isAtWar: player.isAtWarWith(other), + isFriendly: player.isFriendly(other), + unreachable: false, + }; + } + + /** + * Debug: returns war score breakdowns for all AI players against all others. + */ + public static getAllWarScoreBreakdowns( + game: Game, + ticks: number, + ): WarScoreDebugData[] { + const results: WarScoreDebugData[] = []; + for (const [playerId, handler] of AIDiplomacyHandler.registry) { + const player = game.player(playerId); + if (!player.isPlayer() || !player.isAlive()) continue; + + const breakdowns: WarScoreBreakdown[] = []; + for (const other of game.players()) { + if (other.id() === playerId) continue; + if (!other.isAlive()) continue; + if (other.type() === PlayerType.Bot) continue; + const bd = handler.calculateWarScoreBreakdown(other, ticks); + if (bd) breakdowns.push(bd); + } + results.push({ + playerId, + playerName: player.displayName(), + breakdowns, + }); + } + return results; + } + + /** + * Updates the war score history for moving average calculation. + * Adds current scores to history and removes old entries. + */ + private updateWarScoreHistory(): void { + // Add current scores to history + for (const [otherId, score] of this._warScores) { + let history = this._warScoreHistory.get(otherId); + if (!history) { + history = []; + this._warScoreHistory.set(otherId, history); + } + history.push(score); + // Keep only the last N samples + if (history.length > AIDiplomacyHandler.WAR_SCORE_HISTORY_LENGTH) { + history.shift(); + } + } + + // Clean up history for players no longer in war scores (e.g., died, allied, at war) + for (const otherId of this._warScoreHistory.keys()) { + if (!this._warScores.has(otherId)) { + this._warScoreHistory.delete(otherId); + } + } + } + + /** + * Calculates the moving average war score for a player. + */ + private getMovingAverageWarScore(otherId: PlayerID): number { + const history = this._warScoreHistory.get(otherId); + if (!history || history.length === 0) { + return 0; + } + const sum = history.reduce((acc, score) => acc + score, 0); + return sum / history.length; + } + + /** + * Returns the effective war declaration threshold, accounting for peaceful decay. + */ + private get effectiveWarThreshold(): number { + return ( + (this.params.warDeclarationThreshold ?? 1.0) - this._warThresholdDecay + ); + } + + /** + * Checks if the AI is currently at war with any non-bot player. + */ + private isAtWar(player: Player): boolean { + for (const other of this.mg.players()) { + if ( + other.id() !== player.id() && + other.isAlive() && + other.type() !== PlayerType.Bot && + player.isAtWarWith(other) + ) { + return true; + } + } + return false; + } + + /** + * Updates the war threshold decay based on peace/war state transitions. + * + * At peace: every PEACE_DECAY_INTERVAL ticks, threshold drops by 1 + * (making the AI more aggressive over time). + * At war: every WAR_RECOVERY_INTERVAL ticks, threshold recovers by 1 + * back toward the baseline (undoing accumulated decay). + */ + private updateWarThresholdDecay(player: Player): void { + const atWar = this.isAtWar(player); + + if (atWar) { + // Entering war: reset peace counter + if (!this._wasAtWar) { + this._ticksAtPeace = 0; + } + this._wasAtWar = true; + + // Gradually recover threshold toward baseline while at war + if (this._warThresholdDecay > 0) { + this._ticksAtWar++; + if (this._ticksAtWar >= AIDiplomacyHandler.WAR_RECOVERY_INTERVAL) { + this._ticksAtWar -= AIDiplomacyHandler.WAR_RECOVERY_INTERVAL; + this._warThresholdDecay--; + } + } + } else { + // Entering peace: reset war recovery counter + if (this._wasAtWar) { + this._ticksAtWar = 0; + } + this._wasAtWar = false; + + // Decay threshold while at peace + this._ticksAtPeace++; + if (this._ticksAtPeace >= AIDiplomacyHandler.PEACE_DECAY_INTERVAL) { + this._ticksAtPeace -= AIDiplomacyHandler.PEACE_DECAY_INTERVAL; + this._warThresholdDecay++; + } + } + } + + /** + * Declares war on players whose moving average war score exceeds the threshold. + */ + private maybeDeclarWars(player: Player): void { + const threshold = this.effectiveWarThreshold; + + for (const [otherId] of this._warScores) { + // Require enough history samples before declaring war so + // a single spike right after spawn doesn't trigger it. + const history = this._warScoreHistory.get(otherId); + if ( + !history || + history.length < AIDiplomacyHandler.WAR_SCORE_MIN_SAMPLES + ) { + continue; + } + const avgScore = this.getMovingAverageWarScore(otherId); + if (avgScore > threshold) { + const other = this.mg.player(otherId); + if (other && other.isAlive() && !player.isAtWarWith(other)) { + // Declare war (mutual) + player.setWarWith(other); + other.setWarWith(player); + // Clear history after declaring war + this._warScoreHistory.delete(otherId); + } + } + } + } + + /** + * Gets the current war score against a specific player. + * Returns 0 if no score has been calculated. + */ + getWarScore(otherId: PlayerID): number { + return this._warScores.get(otherId) ?? 0; + } + + /** + * Returns the cached war score without dominance bonus for a target. + * Populated during evaluatePeaceScores for at-war players. + * Returns 0 if no cached value exists. + */ + warScoreWithoutDominance(otherId: PlayerID): number { + return this._warScoresNoDominance.get(otherId) ?? 0; + } + + /** + * Gets all current war scores. + */ + getAllWarScores(): Map { + return new Map(this._warScores); + } + + // --------------------------------------------------------------------------- + // Peace handling + // --------------------------------------------------------------------------- + + /** + * Returns the peace threshold for this AI. + * Peace threshold = warDeclarationThreshold - peaceThresholdGap. + * A war score below this value means the AI is willing to make peace. + */ + private get peaceThreshold(): number { + const warThreshold = this.effectiveWarThreshold; + const gap = this.params.peaceThresholdGap ?? 30; + return warThreshold - gap; + } + + /** + * Evaluates peace scores for all players we are currently at war with. + * Updates peace score history and builds a sorted candidate list of enemies + * whose moving-average peace score is below the peace threshold. + * Resets the negotiation state for this cycle. + */ + private evaluatePeaceScores(player: Player, ticks: number): void { + // Clear distance cache so fresh samples are used + this._shoreDistanceCache.clear(); + + this._peaceScores.clear(); + + for (const other of this.mg.players()) { + if (other.id() === player.id()) continue; + if (other.type() === PlayerType.Bot) continue; + if (!other.isAlive()) continue; + if (!player.isAtWarWith(other)) continue; + + // Peace score is calculated identically to war score, treating the + // target as if we are NOT currently at war with them. + // calculateWarScore already does this: it counts the target as the + // primary enemy and adds OTHER current enemies separately (excluding + // the target via the enemy.id() !== other.id() check). + const base = this.calculateWarScoreBase(player, other, ticks); + this._warScoresNoDominance.set(other.id(), base); + const score = base + this.calculateDominanceBonus(other); + this._peaceScores.set(other.id(), score); + } + + // Update peace score history with current scores + this.updatePeaceScoreHistory(); + + // Build candidate list using moving average + const peaceScores: { id: PlayerID; score: number }[] = []; + for (const [otherId] of this._peaceScores) { + const avgScore = this.getMovingAveragePeaceScore(otherId); + if (avgScore < this.peaceThreshold) { + peaceScores.push({ id: otherId, score: avgScore }); + } + } + + // Sort ascending: lowest score = most desirable peace partner + peaceScores.sort((a, b) => a.score - b.score); + + this._pendingPeaceCandidates = peaceScores.map((e) => e.id); + this._currentPeaceCandidateIndex = 0; + this._peaceCompletedThisCycle = false; + } + + /** + * Updates the peace score history for moving average calculation. + * Adds current scores to history and removes entries for players + * no longer at war. + */ + private updatePeaceScoreHistory(): void { + for (const [otherId, score] of this._peaceScores) { + let history = this._peaceScoreHistory.get(otherId); + if (!history) { + history = []; + this._peaceScoreHistory.set(otherId, history); + } + history.push(score); + if (history.length > AIDiplomacyHandler.PEACE_SCORE_HISTORY_LENGTH) { + history.shift(); + } + } + + // Clean up history for players no longer in peace scores (e.g., died, peace made) + for (const otherId of this._peaceScoreHistory.keys()) { + if (!this._peaceScores.has(otherId)) { + this._peaceScoreHistory.delete(otherId); + } + } + } + + /** + * Calculates the moving average peace score for a player. + */ + private getMovingAveragePeaceScore(otherId: PlayerID): number { + const history = this._peaceScoreHistory.get(otherId); + if (!history || history.length === 0) { + return this._peaceScores.get(otherId) ?? 0; + } + const sum = history.reduce((acc, score) => acc + score, 0); + return sum / history.length; + } + + /** + * Attempts peace negotiation with the current candidate. Called each tick. + * For AI targets: pre-checks acceptance, then creates the request (auto-accepted). + * For human targets: creates a pending request (human will reply via UI). + * If declined or cannot send, advances to the next candidate on the next tick. + */ + private tryPeaceNegotiation(player: Player, ticks: number): void { + // Already made peace this cycle, or no candidates left + if (this._peaceCompletedThisCycle) return; + if (this._currentPeaceCandidateIndex >= this._pendingPeaceCandidates.length) + return; + + const candidateId = + this._pendingPeaceCandidates[this._currentPeaceCandidateIndex]; + + // Validate candidate is still a valid target + if (!this.mg.hasPlayer(candidateId)) { + this._currentPeaceCandidateIndex++; + return; + } + + const candidate = this.mg.player(candidateId); + if (!candidate.isAlive() || !player.isAtWarWith(candidate)) { + this._currentPeaceCandidateIndex++; + return; + } + + // Can't send if there's already a pending request or cooldown + if (!player.canSendPeaceRequest(candidate)) { + this._currentPeaceCandidateIndex++; + return; + } + + // For AI targets: pre-check if they would accept before creating request + if (candidate.type() === PlayerType.AI) { + const otherHandler = AIDiplomacyHandler.registry.get(candidateId); + if ( + otherHandler && + !otherHandler.evaluateIncomingPeaceRequest(player, ticks) + ) { + // Declined – advance to next candidate on next tick + this._currentPeaceCandidateIndex++; + return; + } + } + + // Create the peace request (for humans it stays pending; for AI it will be auto-accepted by handleIncomingPeaceRequests) + player.createPeaceRequest(candidate); + this._peaceCompletedThisCycle = true; + + // Clear score histories so fresh evaluation starts if relations worsen again + this._warScoreHistory.delete(candidateId); + this._peaceScoreHistory.delete(candidateId); + } + + /** + * Handles incoming peace requests for this AI player. + * Evaluates each request and accepts/rejects based on peace score threshold. + * Waits a short delay before responding to feel more natural. + * Called each tick by the AI execution loop. + */ + handleIncomingPeaceRequests(ticks: number): void { + const player = this.getPlayer(); + if (!player || !player.isAlive()) return; + + const RESPONSE_DELAY = 10; // ticks before AI responds to a peace request + + for (const request of player.incomingPeaceRequests()) { + if (ticks - request.createdAt() < RESPONSE_DELAY) { + continue; + } + const sender = request.requestor(); + if (this.evaluateIncomingPeaceRequest(sender, ticks)) { + request.accept(); + } else { + request.reject(); + } + } + } + + /** + * Evaluates whether this AI should accept an incoming peace request + * from the given player. Returns true if the peace score for the sender + * is below this AI's peace threshold. + */ + evaluateIncomingPeaceRequest(sender: Player, ticks: number): boolean { + const player = this.getPlayer(); + if (!player || !player.isAlive()) return false; + if (!player.isAtWarWith(sender)) return false; + + // Calculate the war score for the sender as if not at war with them + const score = this.calculateWarScore(player, sender, ticks); + return score < this.peaceThreshold; + } +} diff --git a/src/core/ai/AINukeEvaluator.ts b/src/core/ai/AINukeEvaluator.ts new file mode 100644 index 000000000..bd5f79381 --- /dev/null +++ b/src/core/ai/AINukeEvaluator.ts @@ -0,0 +1,295 @@ +import { NukeMagnitude } from "../configuration/Config"; +import { Game, isStructureType, Player, Unit, UnitType } from "../game/Game"; +import { TileRef } from "../game/GameMap"; +import { playerMaxStructureTechLevel } from "../game/Upgradeables"; +import { PseudoRandom } from "../PseudoRandom"; +import { GameID } from "../Schemas"; + +/** + * Best nuke target info for a given bomb type. + */ +export interface NukeBestTarget { + tile: TileRef; + score: number; +} + +/** + * Shared AI handler that evaluates potential nuclear strike targets. + * + * Every tick, picks a random tile and calculates two scores (atom bomb and + * hydrogen bomb) based on the value of all structures within the bomb's inner + * blast range, minus the bomb cost and SAM interception penalties. + * + * Scores are shared across all AI players in the same game. + * Every 100 ticks, the currently saved best tiles are reevaluated. + */ +export class AINukeEvaluator { + // One shared instance per game, keyed by GameID + private static _instances: Map = new Map(); + + private static readonly REEVALUATE_INTERVAL = 100; + private static readonly UPGRADE_MULTIPLIER = 0.8; + + private static readonly ALL_STRUCTURE_TYPES: UnitType[] = Object.values( + UnitType, + ).filter((t) => isStructureType(t)); + + // Best atom bomb target + private _bestAtomScore: number = 0; + private _bestAtomTile: TileRef | null = null; + + // Best hydrogen bomb target + private _bestHydrogenScore: number = 0; + private _bestHydrogenTile: TileRef | null = null; + + // Tick tracking for reevaluation + private _lastReevalTick: number = -1; + + // Dedup guard: only evaluate once per game tick even if multiple AI players call tick() + private _lastTickProcessed: number = -1; + + // Precomputed max SAM range (level 3) for spatial queries + private _maxSAMRange: number = 0; + + private constructor(private mg: Game) { + // Precompute worst-case SAM range (max tech level = 3) for spatial queries + const baseRange = mg.config().defaultSamRange(); + const rangeBonus = mg.config().samRangeUpgradePercent(); + const maxTechLevel = 3; // SAMLauncher max stack count + this._maxSAMRange = baseRange * Math.pow(1 + rangeBonus, maxTechLevel - 1); + } + + /** + * Get or create the shared NukeHandler instance for this game. + */ + static getInstance(gameID: GameID, mg: Game): AINukeEvaluator { + let instance = AINukeEvaluator._instances.get(gameID); + if (!instance) { + instance = new AINukeEvaluator(mg); + AINukeEvaluator._instances.set(gameID, instance); + } + return instance; + } + + /** + * Remove the shared instance for a game (call on game end). + */ + static removeInstance(gameID: GameID): void { + AINukeEvaluator._instances.delete(gameID); + } + + /** + * Called each tick by any AI player. Only evaluates once per game tick; + * subsequent calls within the same tick are no-ops. + */ + tick(random: PseudoRandom, ticks: number): void { + // Only evaluate once per game tick + if (ticks === this._lastTickProcessed) return; + this._lastTickProcessed = ticks; + + // Every 100 ticks, reevaluate the saved best tiles + if ( + this._lastReevalTick < 0 || + ticks - this._lastReevalTick >= AINukeEvaluator.REEVALUATE_INTERVAL + ) { + this.reevaluateBest(); + this._lastReevalTick = ticks; + } + + // Pick a random tile on the map + const w = this.mg.width(); + const h = this.mg.height(); + const rx = random.nextInt(0, w); + const ry = random.nextInt(0, h); + const tile = this.mg.ref(rx, ry); + + // Score for atom bomb + const atomScore = this.calculateNukeScore(tile, UnitType.AtomBomb); + if (atomScore > this._bestAtomScore) { + this._bestAtomScore = atomScore; + this._bestAtomTile = tile; + } + + // Score for hydrogen bomb + const hydrogenScore = this.calculateNukeScore(tile, UnitType.HydrogenBomb); + if (hydrogenScore > this._bestHydrogenScore) { + this._bestHydrogenScore = hydrogenScore; + this._bestHydrogenTile = tile; + } + } + + /** + * Returns the best atom bomb target found so far (or null if none). + */ + bestAtomTarget(): NukeBestTarget | null { + if (this._bestAtomTile === null) return null; + return { tile: this._bestAtomTile, score: this._bestAtomScore }; + } + + /** + * Returns the best hydrogen bomb target found so far (or null if none). + */ + bestHydrogenTarget(): NukeBestTarget | null { + if (this._bestHydrogenTile === null) return null; + return { tile: this._bestHydrogenTile, score: this._bestHydrogenScore }; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Reevaluate the saved best tiles. If the tile is no longer valuable, + * reset it so future sampling can find a better one. + */ + private reevaluateBest(): void { + if (this._bestAtomTile !== null) { + const newScore = this.calculateNukeScore( + this._bestAtomTile, + UnitType.AtomBomb, + ); + if (newScore <= 0) { + this._bestAtomScore = 0; + this._bestAtomTile = null; + } else { + this._bestAtomScore = newScore; + } + } + + if (this._bestHydrogenTile !== null) { + const newScore = this.calculateNukeScore( + this._bestHydrogenTile, + UnitType.HydrogenBomb, + ); + if (newScore <= 0) { + this._bestHydrogenScore = 0; + this._bestHydrogenTile = null; + } else { + this._bestHydrogenScore = newScore; + } + } + } + + /** + * Calculate the nuke score for a given tile and bomb type. + * Uses spatial grid query (nearbyUnits) instead of iterating all structures. + * + * Score = (total value of all structures within inner blast range) + * / (cost of the bomb + atom bomb cost × SAM levels within SAM range) + */ + private calculateNukeScore(tile: TileRef, bombType: UnitType): number { + const magnitude: NukeMagnitude = this.mg.config().nukeMagnitudes(bombType); + const innerRange = magnitude.inner; + + // Spatial query: only checks nearby grid cells, not all structures on the map + const nearby = this.mg.nearbyUnits( + tile, + innerRange, + AINukeEvaluator.ALL_STRUCTURE_TYPES, + ); + + let totalValue = 0; + for (const { unit: structure } of nearby) { + totalValue += this.getStructureValue(structure); + } + + // Compute total cost: bomb + SAM interception + const bombCost = Number( + this.mg.unitInfo(bombType).cost(this.dummyPlayer()), + ); + const atomBombCost = Number( + this.mg.unitInfo(UnitType.AtomBomb).cost(this.dummyPlayer()), + ); + const samPenalty = this.calculateSAMPenalty(tile) * atomBombCost; + const totalCost = Math.max(bombCost + samPenalty, 1); + + return totalValue / totalCost; + } + + /** + * Count total SAM levels within SAM range of the tile. + * Uses spatial query with max possible SAM range, then filters + * by each SAM's actual effective range based on owner tech level. + */ + private calculateSAMPenalty(tile: TileRef): number { + const nearbySAMs = this.mg.nearbyUnits( + tile, + this._maxSAMRange, + UnitType.SAMLauncher, + ); + let totalSAMLevels = 0; + + for (const { unit: sam, distSquared } of nearbySAMs) { + // Get the SAM's effective range based on its owner's tech level + const owner = sam.owner(); + const samRange = this.getEffectiveSAMRange(owner); + const samRangeSquared = samRange * samRange; + + // Check if the tile is within this SAM's actual range + if (distSquared <= samRangeSquared) { + totalSAMLevels += sam.stackCount(); + } + } + + return totalSAMLevels; + } + + /** + * Compute the value of a structure: base cost + 80% per upgrade level. + * Same calculation as AIConstructionHandler.getStructureValue. + */ + private getStructureValue(structure: Unit): number { + const unitType = structure.type(); + const owner = structure.owner(); + const baseCost = Number(this.mg.unitInfo(unitType).cost(owner)); + const level = structure.stackCount?.() ?? 1; + + if (level <= 1) { + return baseCost; + } + + let totalValue = baseCost; + for (let i = 2; i <= level; i++) { + totalValue += baseCost * AINukeEvaluator.UPGRADE_MULTIPLIER; + } + return totalValue; + } + + /** + * Compute the effective SAM range for a player's tech level. + */ + private getEffectiveSAMRange(player: Player): number { + const baseRange = this.mg.config().defaultSamRange(); + const rangeBonus = this.mg.config().samRangeUpgradePercent(); + const techLevel = this.getPlayerSAMTechLevel(player); + if (techLevel <= 1) return baseRange; + return baseRange * Math.pow(1 + rangeBonus, techLevel - 1); + } + + /** + * Get a player's SAM tech level. + */ + private getPlayerSAMTechLevel(player: Player): number { + return playerMaxStructureTechLevel(player, UnitType.SAMLauncher); + } + + /** + * Get a dummy player reference for cost lookups. + * unitInfo().cost() requires a Player, but for base cost we use the first alive player. + * Falls back to any player if none alive. + */ + private _dummyCached: Player | null = null; + private dummyPlayer(): Player { + if (this._dummyCached && this._dummyCached.isAlive()) { + return this._dummyCached; + } + const players = this.mg.players(); + this._dummyCached = + players.find((p) => p.isAlive()) ?? + (players.length > 0 ? players[0] : null); + if (!this._dummyCached) { + throw new Error("No players available for cost lookup"); + } + return this._dummyCached; + } +} diff --git a/src/core/ai/AINukeHandler.ts b/src/core/ai/AINukeHandler.ts new file mode 100644 index 000000000..1c0588ed7 --- /dev/null +++ b/src/core/ai/AINukeHandler.ts @@ -0,0 +1,658 @@ +import { NukeMagnitude } from "../configuration/Config"; +import { + Game, + isStructureType, + Player, + PlayerID, + PlayerType, + Unit, + UnitType, +} from "../game/Game"; +import { TileRef } from "../game/GameMap"; +import { playerMaxStructureTechLevel } from "../game/Upgradeables"; +import { PseudoRandom } from "../PseudoRandom"; +import { AIBehaviorParams } from "./AIBehaviorParams"; + +/** + * Best nuke target info for a given bomb type (per-player). + */ +export interface NukeHandlerBestTarget { + tile: TileRef; + score: number; +} + +/** + * Per-AI-player handler that evaluates potential nuclear strike targets + * against players the AI is currently at war with. + * + * Every tick, picks a random tile and calculates two scores (atom bomb and + * hydrogen bomb) based on the value of enemy structures within the bomb's + * inner blast range, minus the bomb cost, SAM penalties, and a penalty for + * collateral damage to non-enemy player structures. + * + * Unlike the shared AINukeEvaluator, each AI player has its own instance + * so scores reflect that player's specific war relationships. + */ +export class AINukeHandler { + private static readonly REEVALUATE_INTERVAL = 100; + private static readonly UPGRADE_MULTIPLIER = 0.8; + /** Expected number of nukes launched per silo built; amortises silo cost in score. */ + private static readonly EXPECTED_NUKES_PER_SILO = 2; + + private static readonly ALL_STRUCTURE_TYPES: UnitType[] = Object.values( + UnitType, + ).filter((t) => isStructureType(t)); + + // Best atom bomb target for this AI player + private _bestAtomScore: number = 0; + private _bestAtomTile: TileRef | null = null; + + // Best hydrogen bomb target for this AI player + private _bestHydrogenScore: number = 0; + private _bestHydrogenTile: TileRef | null = null; + + // Tick tracking for reevaluation + private _lastReevalTick: number = -1; + + private player: Player | null = null; + + /** Maximum possible SAM range (base × (1 + upgrade%)^maxLevel). */ + private readonly _maxSAMRange: number; + + // Phase seed for spreading periodic actions across AIs + private readonly phaseSeed: number; + + /** + * Optional callback that returns the war-score (without dominance) for a + * given target player. Set via `setWarScoreProvider`. + */ + private _warScoreProvider: ((targetId: PlayerID) => number) | null = null; + + // --- Per-tick caches (refreshed at the start of each tick()) --- + private _cachedTickNumber: number = -1; + private _cachedStrongestEnemyId: PlayerID | null = null; + private _cachedSigmoids: Map = new Map(); + private _cachedSiloCapacity: number = 0; + /** Per-tick cache for unitInfo().cost() results, keyed by "unitType:playerId". */ + private _cachedUnitCosts: Map = new Map(); + + constructor( + private mg: Game, + private playerId: PlayerID, + private random: PseudoRandom, + private params: AIBehaviorParams, + ) { + // Precompute worst-case SAM range (max tech level = 3) for spatial queries + const baseRange = this.mg.config().defaultSamRange(); + const rangeBonus = this.mg.config().samRangeUpgradePercent(); + const maxTechLevel = 3; // SAMLauncher max stack count + this._maxSAMRange = baseRange * Math.pow(1 + rangeBonus, maxTechLevel - 1); + + // Stagger periodic actions across AIs using random offset + this.phaseSeed = random.nextInt(0, 0x7fffffff); + } + + private shouldRunPeriodic(ticks: number, period: number): boolean { + const p = Math.max(1, Math.floor(period)); + return ticks % p === this.phaseSeed % p; + } + + /** + * Set the provider that returns war-score (without dominance) for a target. + */ + setWarScoreProvider(provider: (targetId: PlayerID) => number): void { + this._warScoreProvider = provider; + } + + /** Sigmoid helper: 1 / (1 + exp(-x)). */ + private static sigmoid(x: number): number { + return 1 / (1 + Math.exp(-x)); + } + + // warScoreSigmoid is now served by getCachedSigmoid / computeWarScoreSigmoid + + /** + * Called each tick by the owning AI player. Evaluates every other tick + * (phased across players) to reduce work. + */ + tick(ticks: number): void { + this.player = this.mg.player(this.playerId); + if (!this.player || !this.player.isAlive()) return; + + // Refresh per-tick caches + this.refreshTickCaches(ticks); + + // Periodic reevaluation of saved best tiles (always check, independent of skip) + if ( + this._lastReevalTick < 0 || + ticks - this._lastReevalTick >= AINukeHandler.REEVALUATE_INTERVAL + ) { + this.reevaluateBest(); + this._lastReevalTick = ticks; + } + + // Only evaluate a new tile every other tick, phased across players + if (!this.shouldRunPeriodic(ticks, 2)) return; + + // Pick a random tile near a random enemy structure + const tile = this.pickTileNearEnemyStructure(); + if (tile === null) return; + + // Score both bomb types in a single pass (one spatial query) + const { atomScore, hydrogenScore } = this.scoreTileBothBombs(tile); + + if (atomScore > this._bestAtomScore) { + this._bestAtomScore = atomScore; + this._bestAtomTile = tile; + } + if (hydrogenScore > this._bestHydrogenScore) { + this._bestHydrogenScore = hydrogenScore; + this._bestHydrogenTile = tile; + } + } + + /** + * Returns the best atom bomb target found so far (or null if none). + */ + bestAtomTarget(): NukeHandlerBestTarget | null { + if (this._bestAtomTile === null) return null; + return { tile: this._bestAtomTile, score: this._bestAtomScore }; + } + + /** + * Returns the best hydrogen bomb target found so far (or null if none). + */ + bestHydrogenTarget(): NukeHandlerBestTarget | null { + if (this._bestHydrogenTile === null) return null; + return { tile: this._bestHydrogenTile, score: this._bestHydrogenScore }; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Refresh all per-tick caches. Called once at the start of each tick(). + */ + private refreshTickCaches(ticks: number): void { + if (ticks === this._cachedTickNumber) return; + this._cachedTickNumber = ticks; + + // Strongest enemy + this._cachedStrongestEnemyId = this.computeStrongestEnemyId(); + + // Sigmoid cache + this._cachedSigmoids.clear(); + + // Silo capacity + this._cachedSiloCapacity = this.computeSiloCapacity(); + + // Unit cost cache + this._cachedUnitCosts.clear(); + } + + /** + * Pick a random tile within a hydrogen bomb's inner radius of a random + * enemy structure (owned by an AI or Human player we're at war with). + * + * Picks a random enemy player first, then a random structure from that + * player, avoiding a full iteration over every structure on the map. + * Returns null if no enemy structures exist. + */ + private pickTileNearEnemyStructure(): TileRef | null { + // Collect enemy players we're at war with + const enemyPlayers: Player[] = []; + for (const p of this.mg.players()) { + if (!p.isAlive()) continue; + if (p.id() === this.playerId) continue; + if (p.type() !== PlayerType.Human && p.type() !== PlayerType.AI) continue; + if (!this.player!.isAtWarWith(p)) continue; + enemyPlayers.push(p); + } + if (enemyPlayers.length === 0) return null; + + // Pick a random enemy player + const enemy = enemyPlayers[this.random.nextInt(0, enemyPlayers.length)]; + + // Get that player's structures + const structures = enemy.units(...AINukeHandler.ALL_STRUCTURE_TYPES); + if (structures.length === 0) return null; + + // Pick a random structure from that player + const target = structures[this.random.nextInt(0, structures.length)]; + const structureTile = target.tile(); + + // Random offset within hydrogen bomb inner radius + const hRadius = this.mg + .config() + .nukeMagnitudes(UnitType.HydrogenBomb).inner; + const sx = this.mg.x(structureTile); + const sy = this.mg.y(structureTile); + const ox = this.random.nextInt(-hRadius, hRadius + 1); + const oy = this.random.nextInt(-hRadius, hRadius + 1); + const tx = Math.max(0, Math.min(this.mg.width() - 1, sx + ox)); + const ty = Math.max(0, Math.min(this.mg.height() - 1, sy + oy)); + + return this.mg.ref(tx, ty); + } + + /** + * Reevaluate the saved best tiles. If the tile is no longer valuable, + * reset it so future sampling can find a better one. + */ + private reevaluateBest(): void { + if (this._bestAtomTile !== null) { + const newScore = this.calculateNukeScore( + this._bestAtomTile, + UnitType.AtomBomb, + ); + if (newScore <= 0) { + this._bestAtomScore = 0; + this._bestAtomTile = null; + } else { + this._bestAtomScore = newScore; + } + } + + if (this._bestHydrogenTile !== null) { + const newScore = this.calculateNukeScore( + this._bestHydrogenTile, + UnitType.HydrogenBomb, + ); + if (newScore <= 0) { + this._bestHydrogenScore = 0; + this._bestHydrogenTile = null; + } else { + this._bestHydrogenScore = newScore; + } + } + } + + /** + * Score both atom and hydrogen bombs for a tile in a single pass. + * Uses one spatial query (hydrogen has the larger radius) and one + * SAM/silo cost computation. + * + * Denominator uses (1 + discountRate)^T where T = minutes to afford + * the total cost at current income. + */ + private scoreTileBothBombs(tile: TileRef): { + atomScore: number; + hydrogenScore: number; + } { + const atomMagnitude = this.mg.config().nukeMagnitudes(UnitType.AtomBomb); + const hydrogenMagnitude = this.mg + .config() + .nukeMagnitudes(UnitType.HydrogenBomb); + const atomInnerRange = atomMagnitude.inner; + const hydrogenInnerRange = hydrogenMagnitude.inner; + const atomInnerRangeSq = atomInnerRange * atomInnerRange; + + const friendlyDamageWeight = this.params.nukeFriendlyDamageWeight ?? 1.0; + + const strongestEnemyId = this._cachedStrongestEnemyId; + + let atomEnemyValue = 0; + let atomFriendlyValue = 0; + let hydrogenEnemyValue = 0; + let hydrogenFriendlyValue = 0; + + // Single spatial query using the larger hydrogen radius + const nearby = this.mg.nearbyUnits( + tile, + hydrogenInnerRange, + AINukeHandler.ALL_STRUCTURE_TYPES, + ); + + for (const { unit: structure, distSquared } of nearby) { + const owner = structure.owner(); + if (owner.type() !== PlayerType.Human && owner.type() !== PlayerType.AI) { + continue; + } + + const value = this.getStructureValue(structure); + const isEnemy = + owner.id() !== this.playerId && this.player!.isAtWarWith(owner); + + if (isEnemy) { + const bonus = owner.id() === strongestEnemyId ? 1000 : 0; + const sig = this.getCachedSigmoid(owner.id()); + hydrogenEnemyValue += (value + bonus) * sig; + if (distSquared <= atomInnerRangeSq) + atomEnemyValue += (value + bonus) * sig; + } else { + hydrogenFriendlyValue += value; + if (distSquared <= atomInnerRangeSq) atomFriendlyValue += value; + } + } + + // Shared cost components (SAM penalty + silo capacity) + const samLevels = this.calculateSAMPenalty(tile); + const atomBombCost = this.getCachedUnitCost( + UnitType.AtomBomb, + this.player!, + ); + const siloCapacity = this._cachedSiloCapacity; + + // Use moving-average income estimate for time-to-fund + const grossGoldPerMinute = this.player!.estimatedGoldIncomePerMinute(); + const discountRate = this.params.discountFactor ?? 0.1; + + // Atom score + const atomNumerator = + atomEnemyValue - friendlyDamageWeight * atomFriendlyValue; + const atomTotalCost = atomBombCost + samLevels * atomBombCost; + const atomT = + grossGoldPerMinute > 0 ? atomTotalCost / grossGoldPerMinute : Infinity; + const atomScore = atomNumerator / Math.pow(1 + discountRate, atomT); + + // Hydrogen score + const hydrogenNumerator = + hydrogenEnemyValue - friendlyDamageWeight * hydrogenFriendlyValue; + const hydrogenBombCost = this.getCachedUnitCost( + UnitType.HydrogenBomb, + this.player!, + ); + const hydrogenTotalCost = hydrogenBombCost + samLevels * atomBombCost; + const hydrogenT = + grossGoldPerMinute > 0 + ? hydrogenTotalCost / grossGoldPerMinute + : Infinity; + const hydrogenScore = + hydrogenNumerator / Math.pow(1 + discountRate, hydrogenT); + + return { atomScore, hydrogenScore }; + } + + /** + * Look up the cached war-score sigmoid for `targetId`, computing and + * storing it on first access within this tick. + */ + private getCachedSigmoid(targetId: PlayerID): number { + let val = this._cachedSigmoids.get(targetId); + if (val === undefined) { + val = this.computeWarScoreSigmoid(targetId); + this._cachedSigmoids.set(targetId, val); + } + return val; + } + + /** + * Raw sigmoid computation (not cached). Use `getCachedSigmoid` instead. + */ + private computeWarScoreSigmoid(targetId: PlayerID): number { + const scale = this.params.nukeWarScoreSigmoidScale ?? 1 / 50; + if (scale === 0 || !this._warScoreProvider) return 1; + const ws = this._warScoreProvider(targetId); + return AINukeHandler.sigmoid(scale * (ws - 4)); + } + + /** + * Find the enemy at war with this AI player that has the highest + * military strength. Returns its PlayerID, or null if none. + */ + private computeStrongestEnemyId(): PlayerID | null { + let strongestId: PlayerID | null = null; + let highestStrength = -Infinity; + for (const p of this.mg.players()) { + if (!p.isAlive()) continue; + if (p.id() === this.playerId) continue; + if (p.type() !== PlayerType.Human && p.type() !== PlayerType.AI) continue; + if (!this.player!.isAtWarWith(p)) continue; + const strength = p.militaryStrength(); + if (strength > highestStrength) { + highestStrength = strength; + strongestId = p.id(); + } + } + return strongestId; + } + + /** + * Compute extra silo cost needed to support (1 + samLevels) bombs. + */ + private computeSiloCost(samLevels: number, siloCapacity: number): number { + const bombsNeeded = 1 + samLevels; + if (siloCapacity >= bombsNeeded) return 0; + + const siloCost = this.getCachedUnitCost(UnitType.MissileSilo, this.player!); + const levelsNeeded = bombsNeeded - siloCapacity; + + if (siloCapacity > 0) { + return levelsNeeded * siloCost * AINukeHandler.UPGRADE_MULTIPLIER; + } + // No silo — first level at full cost, rest at upgrade cost + let cost = siloCost; + for (let i = 1; i < levelsNeeded; i++) { + cost += siloCost * AINukeHandler.UPGRADE_MULTIPLIER; + } + return cost; + } + + /** + * Calculate the nuke score for a given tile and bomb type. + * Uses spatial grid query (nearbyUnits) instead of iterating all structures. + * + * Score = (value of enemy structures - friendly damage weight × friendly structures) + * / (1 + discountRate)^T + * where T = minutes to afford (bombCost + SAM penalty + silo penalty) at current income. + */ + private calculateNukeScore(tile: TileRef, bombType: UnitType): number { + const magnitude: NukeMagnitude = this.mg.config().nukeMagnitudes(bombType); + const innerRange = magnitude.inner; + + const friendlyDamageWeight = this.params.nukeFriendlyDamageWeight ?? 1.0; + + const strongestEnemyId = this._cachedStrongestEnemyId; + + let enemyValue = 0; + let friendlyValue = 0; + + // Spatial query: only checks nearby grid cells, not all structures + const nearby = this.mg.nearbyUnits( + tile, + innerRange, + AINukeHandler.ALL_STRUCTURE_TYPES, + ); + + for (const { unit: structure } of nearby) { + const owner = structure.owner(); + + if (owner.type() !== PlayerType.Human && owner.type() !== PlayerType.AI) { + continue; + } + + if (owner.id() === this.playerId) { + friendlyValue += this.getStructureValue(structure); + continue; + } + + if (this.player!.isAtWarWith(owner)) { + const bonus = owner.id() === strongestEnemyId ? 1000 : 0; + const sig = this.getCachedSigmoid(owner.id()); + enemyValue += (this.getStructureValue(structure) + bonus) * sig; + } else { + friendlyValue += this.getStructureValue(structure); + } + } + + const numerator = enemyValue - friendlyDamageWeight * friendlyValue; + + const bombCost = this.getCachedUnitCost(bombType, this.player!); + const atomBombCost = this.getCachedUnitCost( + UnitType.AtomBomb, + this.player!, + ); + const samLevels = this.calculateSAMPenalty(tile); + const siloCapacity = this._cachedSiloCapacity; + const totalCost = + bombCost + + samLevels * atomBombCost + + this.computeSiloCost(samLevels, siloCapacity) / + AINukeHandler.EXPECTED_NUKES_PER_SILO; + + // T = minutes to afford totalCost at current income + const grossGoldPerMinute = this.player!.estimatedGoldIncomePerMinute(); + const discountRate = this.params.discountFactor ?? 0.1; + const T = + grossGoldPerMinute > 0 ? totalCost / grossGoldPerMinute : Infinity; + + return numerator / Math.pow(1 + discountRate, T); + } + + /** + * Count total SAM levels within SAM range of the tile. + * Uses spatial query with max possible SAM range, then filters + * by each SAM's actual effective range based on owner tech level. + */ + calculateSAMPenalty(tile: TileRef): number { + const nearbySAMs = this.mg.nearbyUnits( + tile, + this._maxSAMRange, + UnitType.SAMLauncher, + ); + let totalSAMLevels = 0; + + for (const { unit: sam, distSquared } of nearbySAMs) { + const owner = sam.owner(); + const samRange = this.getEffectiveSAMRange(owner); + const samRangeSquared = samRange * samRange; + + if (distSquared <= samRangeSquared) { + totalSAMLevels += sam.stackCount(); + } + } + + return totalSAMLevels; + } + + /** + * Get the cached cost (as number) for a unit type owned by a player. + * Avoids repeated unitInfo() object allocation + cost() calls. + */ + private getCachedUnitCost(unitType: UnitType, owner: Player): number { + const key = `${unitType}:${owner.id()}`; + let cost = this._cachedUnitCosts.get(key); + if (cost === undefined) { + cost = Number(this.mg.unitInfo(unitType).cost(owner)); + this._cachedUnitCosts.set(key, cost); + } + return cost; + } + + /** + * Compute the value of a structure: base cost + 80% per upgrade level. + */ + private getStructureValue(structure: Unit): number { + const baseCost = this.getCachedUnitCost( + structure.type(), + structure.owner(), + ); + const level = structure.stackCount?.() ?? 1; + + if (level <= 1) { + return baseCost; + } + + let totalValue = baseCost; + for (let i = 2; i <= level; i++) { + totalValue += baseCost * AINukeHandler.UPGRADE_MULTIPLIER; + } + return totalValue; + } + + /** + * Get the silo launch capacity for this AI player (cached per tick). + * Returns the stack count of the player's largest silo, or 0 if none exist. + */ + getPlayerSiloCapacity(): number { + if (this._cachedTickNumber === -1) { + return this.computeSiloCapacity(); + } + return this._cachedSiloCapacity; + } + + /** + * Compute the silo capacity from scratch (called once per tick). + */ + private computeSiloCapacity(): number { + let maxCapacity = 0; + for (const silo of this.mg.units(UnitType.MissileSilo)) { + if (!silo.isActive()) continue; + if (silo.owner().id() !== this.playerId) continue; + if (silo.stackCount() > maxCapacity) { + maxCapacity = silo.stackCount(); + } + } + return maxCapacity; + } + + /** + * Compute the effective SAM range for a player's tech level. + */ + getEffectiveSAMRange(player: Player): number { + const baseRange = this.mg.config().defaultSamRange(); + const rangeBonus = this.mg.config().samRangeUpgradePercent(); + const techLevel = this.getPlayerSAMTechLevel(player); + if (techLevel <= 1) return baseRange; + return baseRange * Math.pow(1 + rangeBonus, techLevel - 1); + } + + /** + * Get a player's SAM tech level. + */ + getPlayerSAMTechLevel(player: Player): number { + return playerMaxStructureTechLevel(player, UnitType.SAMLauncher); + } + + /** + * Returns the list of SAM units (with their tiles) that are in range of + * the given tile. Each SAM appears once; the caller should use + * stackCount() to determine how many atom bombs to target at each. + */ + getSAMsInRange(tile: TileRef): Unit[] { + const nearbySAMs = this.mg.nearbyUnits( + tile, + this._maxSAMRange, + UnitType.SAMLauncher, + ); + const result: Unit[] = []; + for (const { unit: sam, distSquared } of nearbySAMs) { + const owner = sam.owner(); + const samRange = this.getEffectiveSAMRange(owner); + if (distSquared <= samRange * samRange) { + result.push(sam); + } + } + return result; + } + + /** + * Reset all cached best-target scores and tiles. Call after a nuke + * sequence completes so the handler starts fresh. + */ + resetScores(): void { + this._bestAtomScore = 0; + this._bestAtomTile = null; + this._bestHydrogenScore = 0; + this._bestHydrogenTile = null; + } + + /** + * Compute the nuke score for an arbitrary tile and bomb type. + * Used for a final validation before committing to a launch. + */ + scoreForTile(tile: TileRef, bombType: UnitType): number { + this.player = this.mg.player(this.playerId); + if (!this.player || !this.player.isAlive()) return 0; + return this.calculateNukeScore(tile, bombType); + } + + /** + * How many bomb launches are needed for a strike at the given tile: + * 1 (main bomb) + total SAM levels in range. + */ + bombsNeeded(tile: TileRef): number { + return 1 + this.calculateSAMPenalty(tile); + } +} diff --git a/src/core/ai/AIPlayerExecution.ts b/src/core/ai/AIPlayerExecution.ts new file mode 100644 index 000000000..b01a16399 --- /dev/null +++ b/src/core/ai/AIPlayerExecution.ts @@ -0,0 +1,1276 @@ +import { ConstructionExecution } from "../execution/ConstructionExecution"; +import { UpgradeStructureExecution } from "../execution/UpgradeStructureExecution"; +import { + Execution, + Game, + Nation, + Player, + PlayerID, + Unit, + UnitType, + UpgradeType, +} from "../game/Game"; +import { TileRef } from "../game/GameMap"; +import { PseudoRandom } from "../PseudoRandom"; +import { GameID } from "../Schemas"; +import { NukeType } from "../StatsSchemas"; +import { simpleHash } from "../Util"; +import { AIAttackHandler } from "./AIAttackHandler"; +import { AIBehaviorParams } from "./AIBehaviorParams"; +import { AIBotAttackHandler } from "./AIBotAttackHandler"; +import { AIConstructionHandler } from "./AIConstructionHandler"; +import { AIDiplomacyHandler } from "./AIDiplomacyHandler"; +import { AINukeEvaluator } from "./AINukeEvaluator"; +import { AINukeHandler } from "./AINukeHandler"; +import { AISpawnHandler } from "./AISpawnHandler"; +import { AITerraNulliusHandler } from "./AITerraNulliusHandler"; +import { AIUnitHandler } from "./AIUnitHandler"; +import { + ConstructionDebugData, + ConstructionScoreEntry, + NukeScoreDebugInfo, + NukeSequenceDebugInfo, + UnitScoreEntry, +} from "./ConstructionDebugData"; + +/** + * Phases for the nuke launch state machine. + * + * idle – no nuke sequence active; normal construction runs. + * waitForFunds – nuke score beat construction; waiting until we can afford + * all bombs + any silos we need to build. + * buildSilo – building / upgrading silo; waiting for it to complete. + * launchSAMs – launching one atom bomb per tick at each SAM level in range. + * waitForMain – 30-tick gap between last SAM bomb and main bomb. + * launchMain – fire the main bomb. + */ +type NukeSequencePhase = + | "idle" + | "waitForFunds" + | "buildSilo" + | "launchSAMs" + | "waitForMain" + | "launchMain"; + +/** + * Mutable state for an in-progress nuke sequence. + */ +interface NukeSequenceState { + phase: NukeSequencePhase; + /** The bomb type to use for the main strike. */ + bombType: NukeType; + /** Target tile for the main bomb. */ + targetTile: TileRef; + /** SAM units in range of the target, with one atom bomb per stack level. */ + samTargets: { sam: Unit; levelsRemaining: number }[]; + /** Tick when we entered waitForMain phase. */ + waitStartTick: number; + /** + * True after we call addExecution for a silo ConstructionExecution but + * before the Construction unit actually appears on the map (one-tick gap). + * Prevents queuing a duplicate silo build. + */ + siloConstructionQueued: boolean; +} + +/** + * AI Player Execution - A configurable AI player with behavior parameters. + */ +export class AIPlayerExecution implements Execution { + // Static registry for debug overlay access + private static readonly registry = new Map(); + + private active = true; + private mg: Game; + private player: Player | undefined; + private random: PseudoRandom; + private phaseSeed: number; + private spawnHandler: AISpawnHandler | null = null; + private terraNulliusHandler: AITerraNulliusHandler | null = null; + private botAttackHandler: AIBotAttackHandler | null = null; + private attackHandler: AIAttackHandler | null = null; + private constructionHandler: AIConstructionHandler | null = null; + private diplomacyHandler: AIDiplomacyHandler | null = null; + private nukeEvaluator: AINukeEvaluator | null = null; + private nukeHandler: AINukeHandler | null = null; + private unitHandler: AIUnitHandler | null = null; + private initialInvestmentSet = false; + private roadInvestmentSet = false; + + // Nuke launch state machine + private nukeState: NukeSequenceState | null = null; + private static readonly MAIN_BOMB_DELAY_TICKS = 15; + /** How often (in ticks) to check for redundant nukes during an active sequence. */ + private static readonly NUKE_REDUNDANCY_CHECK_INTERVAL = 10; + + /** Internal multiplier applied to nuke scores when comparing against construction scores. */ + private static readonly NUKE_SCORE_INTERNAL_MULTIPLIER = 7e-1; + + // Wall-clock perf logging (shared across all AI instances) + private static readonly PERF_LOG_INTERVAL_MS = 10_000; + private static _lastPerfLogTime = 0; + + constructor( + private gameID: GameID, + private nation: Nation, + private params: AIBehaviorParams = {}, + ) { + this.random = new PseudoRandom( + simpleHash(nation.playerInfo.id) + simpleHash(gameID), + ); + // Stagger periodic actions across AIs. + // For any period P, use (phaseSeed % P) as the per-AI offset. + this.phaseSeed = this.random.nextInt(0, 0x7fffffff); + } + + private periodicOffset(period: number): number { + const p = Math.max(1, Math.floor(period)); + return this.phaseSeed % p; + } + + private shouldRunPeriodic(ticks: number, period: number): boolean { + const p = Math.max(1, Math.floor(period)); + return ticks % p === this.periodicOffset(p); + } + + init(mg: Game): void { + this.mg = mg; + // Calculate threshold offset once and share between attack handlers + // Random offset in range [-0.025, 0.025] for threshold variation + const thresholdOffset = (this.random.next() - 0.5) * 0.05; + + this.spawnHandler = new AISpawnHandler( + mg, + this.nation, + this.random, + this.params, + ); + this.terraNulliusHandler = new AITerraNulliusHandler( + mg, + this.nation.playerInfo.id, + this.random, + this.params, + thresholdOffset, + ); + this.botAttackHandler = new AIBotAttackHandler( + mg, + this.nation.playerInfo.id, + this.random, + this.params, + thresholdOffset, + ); + this.attackHandler = new AIAttackHandler( + mg, + this.nation.playerInfo.id, + this.random, + this.params, + thresholdOffset, + ); + this.constructionHandler = new AIConstructionHandler( + mg, + this.nation.playerInfo.id, + this.random, + this.params, + AINukeEvaluator.getInstance(this.gameID, mg), + ); + this.diplomacyHandler = new AIDiplomacyHandler( + mg, + this.nation.playerInfo.id, + this.random, + this.params, + ); + this.nukeEvaluator = AINukeEvaluator.getInstance(this.gameID, mg); + this.nukeHandler = new AINukeHandler( + mg, + this.nation.playerInfo.id, + this.random, + this.params, + ); + this.unitHandler = new AIUnitHandler( + mg, + this.nation.playerInfo.id, + this.random, + this.params, + ); + + // Wire war-score (without dominance) into nuke scoring so enemy value + // is modulated by how much the AI wants to fight each target. + this.nukeHandler.setWarScoreProvider( + (targetId) => + this.diplomacyHandler?.warScoreWithoutDominance(targetId) ?? 0, + ); + + // Wire naval scores into port scoring so the AI builds a port + // when it wants warships/submarines but has none. + this.constructionHandler.setNavalScoreProvider( + () => this.unitHandler?.bestNavalScore() ?? 0, + ); + + // Register for debug overlay access + AIPlayerExecution.registry.set(this.nation.playerInfo.id, this); + } + + isActive(): boolean { + return this.active; + } + + activeDuringSpawnPhase(): boolean { + return true; + } + + tick(ticks: number): void { + if (this.mg.inSpawnPhase()) { + this.spawnHandler?.handleSpawnPhase(ticks); + return; + } + + // Find player if not found yet + this.player ??= this.mg + .players() + .find((p) => p.id() === this.nation.playerInfo.id); + + if (!this.player || !this.player.isAlive()) { + this.active = false; + return; + } + + const sliderPeriod = 100; + const constructionRescorePeriod = 100; + + // Update shared nuke target evaluation + performance.mark("ai-nukeEval"); + this.nukeEvaluator?.tick(this.random, ticks); + performance.measure("nukeEval", "ai-nukeEval"); + + // Update per-player nuke target evaluation (must run before tickNukeSequence + // so scores are fresh when the nuke sequence reads them) + performance.mark("ai-nukeHandler"); + this.nukeHandler?.tick(ticks); + performance.measure("nukeHandler", "ai-nukeHandler"); + + // --- Nuke orchestration --- + performance.mark("ai-nukeSequence"); + this.tickNukeSequence(ticks); + performance.measure("nukeSequence", "ai-nukeSequence"); + + // --- Spending priority --- + // After nuke orchestration, determine which handler is allowed to spend. + // If a nuke sequence is active, neither construction nor units may spend. + // Otherwise, the highest score wins. + + // Refresh unit caches before scoring so data is always fresh + performance.mark("ai-unitRefreshCaches"); + this.unitHandler?.refreshCaches(ticks); + performance.measure("unitRefreshCaches", "ai-unitRefreshCaches"); + + // Compute spending scores fresh every tick + performance.mark("ai-scoreCache"); + const constructionScore = + this.constructionHandler?.bestConstructionScore() ?? 0; + const unitScore = this.unitHandler?.bestUnitScore() ?? 0; + performance.measure("scoreCache", "ai-scoreCache"); + + const nukeSequenceActive = + this.nukeState !== null && this.nukeState.phase !== "idle"; + + let allowConstructionSpending = false; + let allowUnitSpending = false; + + if (!nukeSequenceActive) { + if (constructionScore >= unitScore) { + allowConstructionSpending = true; + } else { + allowUnitSpending = true; + } + } + + // Construction always ticks (tile evaluation), but spending is gated + performance.mark("ai-construction"); + this.constructionHandler?.tickConstruction( + ticks, + this.shouldRunPeriodic(ticks, constructionRescorePeriod), + allowConstructionSpending, + ); + performance.measure("construction", "ai-construction"); + + // Unit purchases only run when unit score wins + performance.mark("ai-unitPurchase"); + if (allowUnitSpending) { + this.unitHandler?.tickUnitPurchase(ticks); + } + performance.measure("unitPurchase", "ai-unitPurchase"); + + // Unit movement decisions always run + performance.mark("ai-unitMovement"); + this.unitHandler?.tickUnitMovement(ticks); + performance.measure("unitMovement", "ai-unitMovement"); + + // Handle slider updates every 100 ticks + if (this.shouldRunPeriodic(ticks, sliderPeriod)) { + this.updateSliders(ticks); + } + + // Handle Terra Nullius expansion every tick + performance.mark("ai-terraNullius"); + const tnAttacked = + this.terraNulliusHandler?.handleTerraNulliusAttack() ?? false; + performance.measure("terraNullius", "ai-terraNullius"); + + // Handle bot attacks every tick (skip if TN already attacked) + performance.mark("ai-botAttack"); + let botAttacked = false; + if (!tnAttacked) { + botAttacked = this.botAttackHandler?.handleBotAttack() ?? false; + } + performance.measure("botAttack", "ai-botAttack"); + + // Handle attacks against AI/Human players we're at war with (skip if already attacked) + performance.mark("ai-attack"); + if (!tnAttacked && !botAttacked) { + this.attackHandler?.handleAttack(); + } + performance.measure("attack", "ai-attack"); + + // Handle diplomacy (war declarations, peace requests, etc.) + performance.mark("ai-diplomacy"); + this.diplomacyHandler?.tickDiplomacy(ticks); + this.diplomacyHandler?.handleIncomingPeaceRequests(ticks); + performance.measure("diplomacy", "ai-diplomacy"); + + // Periodic wall-clock perf log (every 10 real seconds, one AI triggers it) + AIPlayerExecution.maybeDumpPerfLog(); + } + + /** + * If 10 real-time seconds have elapsed since the last dump, log all + * performance.measure() entries grouped by name with percentage shares, + * then clear the entries. + */ + private static maybeDumpPerfLog(): void { + const now = performance.now(); + if ( + AIPlayerExecution._lastPerfLogTime !== 0 && + now - AIPlayerExecution._lastPerfLogTime < + AIPlayerExecution.PERF_LOG_INTERVAL_MS + ) { + return; + } + // On the very first call just set the baseline and return + if (AIPlayerExecution._lastPerfLogTime === 0) { + AIPlayerExecution._lastPerfLogTime = now; + performance.clearMeasures(); + performance.clearMarks(); + return; + } + AIPlayerExecution._lastPerfLogTime = now; + + const entries = performance.getEntriesByType( + "measure", + ) as PerformanceMeasure[]; + if (entries.length === 0) return; + + const totals = new Map(); + for (const e of entries) { + totals.set(e.name, (totals.get(e.name) ?? 0) + e.duration); + } + const grand = [...totals.values()].reduce((a, b) => a + b, 0); + + const lines = [...totals.entries()] + .sort((a, b) => b[1] - a[1]) + .map( + ([name, ms]) => + ` ${name.padEnd(20)} ${ms.toFixed(1).padStart(8)}ms ${((ms / grand) * 100).toFixed(1).padStart(5)}%`, + ); + + console.log( + `\n[AI Perf – last 10 s] total ${grand.toFixed(1)}ms\n${lines.join("\n")}`, + ); + + performance.clearMeasures(); + performance.clearMarks(); + } + + // --------------------------------------------------------------------------- + // Nuke launch state machine + // --------------------------------------------------------------------------- + + /** + * Drives the nuke sequence each tick. Transitions: + * + * idle → waitForFunds (when nuke score > all construction scores) + * waitForFunds → buildSilo (when player can afford everything) + * buildSilo → launchSAMs (when silo capacity is sufficient) + * launchSAMs → waitForMain (when all SAM-targeting atom bombs launched) + * waitForMain → launchMain (after 30 ticks) + * launchMain → idle (after main bomb launched) + */ + private tickNukeSequence(ticks: number): void { + if (!this.player || !this.nukeHandler || !this.constructionHandler) return; + + // If no active sequence, check whether to start one + if (this.nukeState === null || this.nukeState.phase === "idle") { + this.maybeStartNukeSequence(); + return; + } + + const state = this.nukeState; + + // Periodically re-evaluate the entire nuke plan: score, SAMs, + // redundancy, construction comparison, and retargeting. + // Only run during pre-launch phases so we don't detect our own + // in-flight SAM-suppression nukes once launching has started. + const isPreLaunch = + state.phase === "waitForFunds" || state.phase === "buildSilo"; + if ( + this.shouldRunPeriodic( + ticks, + AIPlayerExecution.NUKE_REDUNDANCY_CHECK_INTERVAL, + ) + ) { + if (isPreLaunch && this.isNukeAlreadyInbound(state)) { + this.resetNukeSequence(); + return; + } + const currentScore = this.nukeHandler.scoreForTile( + state.targetTile, + state.bombType, + ); + if (currentScore <= 0) { + this.resetNukeSequence(); + return; + } + + // Fully refresh SAM list from scratch: picks up new SAMs, removes + // destroyed ones, and updates stack counts on surviving ones. + const freshSAMs = this.nukeHandler.getSAMsInRange(state.targetTile); + const oldTotalLevels = state.samTargets.reduce( + (sum, s) => sum + s.levelsRemaining, + 0, + ); + state.samTargets = freshSAMs.map((s) => ({ + sam: s, + levelsRemaining: s.stackCount(), + })); + const newTotalLevels = state.samTargets.reduce( + (sum, s) => sum + s.levelsRemaining, + 0, + ); + if (newTotalLevels > 5 || oldTotalLevels > 5) { + const tileX = this.mg.x(state.targetTile); + const tileY = this.mg.y(state.targetTile); + console.warn( + `[NUKE-DIAG] REFRESH SAMs: player=${this.player.id()} ` + + `phase=${state.phase} target=(${tileX},${tileY}) ` + + `oldTotalLevels=${oldTotalLevels} newSAMs=${freshSAMs.length} ` + + `newTotalLevels=${newTotalLevels} ` + + `SAM details=[${freshSAMs + .map((s) => { + const ox = this.mg.x(s.tile()); + const oy = this.mg.y(s.tile()); + const dist = Math.sqrt( + this.mg.euclideanDistSquared(state.targetTile, s.tile()), + ); + const ownerRange = this.nukeHandler!.getEffectiveSAMRange( + s.owner(), + ); + return `{id=${s.id()} pos=(${ox},${oy}) owner=${s.owner().id()} stack=${s.stackCount()} dist=${dist.toFixed(1)} ownerRange=${ownerRange.toFixed(1)} isActive=${s.isActive()}}`; + }) + .join(", ")}]`, + ); + } + + // During pre-launch phases, perform additional checks + if (state.phase === "waitForFunds" || state.phase === "buildSilo") { + // Abort if construction is now more valuable than this nuke + const profileMultiplier = this.params.nukeScoreMultiplier ?? 1; + const adjustedScore = + currentScore * + profileMultiplier * + AIPlayerExecution.NUKE_SCORE_INTERNAL_MULTIPLIER; + const constructionScore = + this.constructionHandler?.bestConstructionScore() ?? 0; + const unitScore = this.unitHandler?.bestUnitScore() ?? 0; + if (adjustedScore <= constructionScore || adjustedScore <= unitScore) { + this.resetNukeSequence(); + return; + } + + // Check if a better target has appeared + this.maybeRetargetNukeSequence(state, currentScore); + } + } + + switch (state.phase) { + case "waitForFunds": + this.tickWaitForFunds(); + break; + case "buildSilo": + this.tickBuildSilo(); + break; + case "launchSAMs": + this.tickLaunchSAMs(); + break; + case "waitForMain": + this.tickWaitForMain(ticks); + break; + case "launchMain": + this.tickLaunchMain(); + break; + } + } + + /** + * Check whether to begin a nuke sequence: the best nuke score must exceed + * every construction score. + */ + private maybeStartNukeSequence(): void { + if (!this.player || !this.nukeHandler || !this.constructionHandler) return; + + // Determine best nuke target + const atomTarget = this.nukeHandler.bestAtomTarget(); + let bestScore = atomTarget?.score ?? 0; + let bestTile = atomTarget?.tile ?? null; + let bombType: UnitType = UnitType.AtomBomb; + + // Consider hydrogen bomb only if researched + if (this.player.hasUpgrade(UpgradeType.ThermonuclearStaging)) { + const hydrogenTarget = this.nukeHandler.bestHydrogenTarget(); + if (hydrogenTarget && hydrogenTarget.score > bestScore) { + bestScore = hydrogenTarget.score; + bestTile = hydrogenTarget.tile; + bombType = UnitType.HydrogenBomb; + } + } + + if (bestScore <= 0 || bestTile === null) return; + + // Apply multipliers + const profileMultiplier = this.params.nukeScoreMultiplier ?? 1; + bestScore *= + profileMultiplier * AIPlayerExecution.NUKE_SCORE_INTERNAL_MULTIPLIER; + + // Compare against fresh construction and unit scores + const constructionScore = + this.constructionHandler?.bestConstructionScore() ?? 0; + const unitScore = this.unitHandler?.bestUnitScore() ?? 0; + if (bestScore <= constructionScore || bestScore <= unitScore) return; + + // Start the nuke sequence + const sams = this.nukeHandler.getSAMsInRange(bestTile); + const samTargets = sams.map((s) => ({ + sam: s, + levelsRemaining: s.stackCount(), + })); + const totalSAMLevelsAtStart = samTargets.reduce( + (sum, s) => sum + s.levelsRemaining, + 0, + ); + if (totalSAMLevelsAtStart > 5) { + const tileX = this.mg.x(bestTile); + const tileY = this.mg.y(bestTile); + console.warn( + `[NUKE-DIAG] START sequence: player=${this.player.id()} ` + + `target=(${tileX},${tileY}) bombType=${bombType} ` + + `SAMs found=${sams.length} totalSAMLevels=${totalSAMLevelsAtStart} ` + + `maxSAMRange=${this.nukeHandler["_maxSAMRange"].toFixed(1)} ` + + `SAM details=[${sams + .map((s) => { + const ox = this.mg.x(s.tile()); + const oy = this.mg.y(s.tile()); + const dist = Math.sqrt( + this.mg.euclideanDistSquared(bestTile, s.tile()), + ); + const ownerRange = this.nukeHandler.getEffectiveSAMRange( + s.owner(), + ); + return `{id=${s.id()} pos=(${ox},${oy}) owner=${s.owner().id()} stack=${s.stackCount()} dist=${dist.toFixed(1)} ownerRange=${ownerRange.toFixed(1)} isActive=${s.isActive()}}`; + }) + .join(", ")}]`, + ); + } + this.nukeState = { + phase: "waitForFunds", + bombType, + targetTile: bestTile, + samTargets, + waitStartTick: 0, + siloConstructionQueued: false, + }; + } + + /** + * During waitForFunds / buildSilo, check if the nuke handler has found a + * better target than our current one. If so, switch the sequence to the + * new target (updating bomb type, target tile, and SAM list). + */ + private maybeRetargetNukeSequence( + state: NukeSequenceState, + currentScore: number, + ): void { + if (!this.player || !this.nukeHandler) return; + + // Find the handler's current best target across bomb types + const atomTarget = this.nukeHandler.bestAtomTarget(); + let bestScore = atomTarget?.score ?? 0; + let bestTile = atomTarget?.tile ?? null; + let bombType: UnitType = UnitType.AtomBomb; + + if (this.player.hasUpgrade(UpgradeType.ThermonuclearStaging)) { + const hydrogenTarget = this.nukeHandler.bestHydrogenTarget(); + if (hydrogenTarget && hydrogenTarget.score > bestScore) { + bestScore = hydrogenTarget.score; + bestTile = hydrogenTarget.tile; + bombType = UnitType.HydrogenBomb; + } + } + + if (bestScore <= 0 || bestTile === null) return; + + // Only switch if the new target is strictly better than the current one + if (bestScore <= currentScore) return; + + // Switch to the better target + const sams = this.nukeHandler.getSAMsInRange(bestTile); + state.bombType = bombType; + state.targetTile = bestTile; + state.samTargets = sams.map((s) => ({ + sam: s, + levelsRemaining: s.stackCount(), + })); + // Reset to waitForFunds so silo requirements are re-evaluated + state.phase = "waitForFunds"; + } + + /** + * Wait until the player can afford all bombs + any silos needed. + */ + private tickWaitForFunds(): void { + if (!this.player || !this.nukeState || !this.nukeHandler) return; + const state = this.nukeState; + + const totalCost = this.calculateNukeSequenceCost(state); + if (this.player.gold() < BigInt(Math.ceil(totalCost))) return; + + // Player can afford it — check silo capacity + const bombsNeeded = this.nukeHandler.bombsNeeded(state.targetTile); + const siloCapacity = this.nukeHandler.getPlayerSiloCapacity(); + + if (siloCapacity >= bombsNeeded) { + // Silo capacity already sufficient — skip straight to launching + state.phase = "launchSAMs"; + } else { + // Need to build/upgrade silo + state.phase = "buildSilo"; + } + } + + /** + * Build or upgrade a missile silo to the required capacity. + */ + private tickBuildSilo(): void { + if ( + !this.player || + !this.nukeState || + !this.nukeHandler || + !this.constructionHandler + ) + return; + const state = this.nukeState; + + const bombsNeeded = this.nukeHandler.bombsNeeded(state.targetTile); + const siloCapacity = this.nukeHandler.getPlayerSiloCapacity(); + + if (siloCapacity >= bombsNeeded) { + // Silo is ready — move to launching + state.phase = "launchSAMs"; + return; + } + + // If a silo is already under construction, wait for it to finish + // (or be destroyed/captured) before attempting another build. + if (this.hasSiloUnderConstruction()) { + // The real Construction unit now exists — the queued flag is no longer + // needed; clear it so it doesn't become stale. + state.siloConstructionQueued = false; + return; + } + + // Guard against the one-tick gap: the ConstructionExecution was queued + // last tick but its Construction unit hasn't been created yet (it runs + // after the AI in the execution list). Wait one more tick. + if (state.siloConstructionQueued) { + return; + } + + // Find the player's largest existing silo + let largestSilo: Unit | null = null; + let largestStack = 0; + for (const silo of this.mg.units(UnitType.MissileSilo)) { + if (!silo.isActive()) continue; + if (silo.owner().id() !== this.player.id()) continue; + if (silo.stackCount() > largestStack) { + largestStack = silo.stackCount(); + largestSilo = silo; + } + } + + if (largestSilo !== null) { + // Upgrade the existing largest silo (instant one-shot; no Construction + // unit is created, so no need to set the siloConstructionQueued guard). + this.mg.addExecution( + new UpgradeStructureExecution(this.player, largestSilo), + ); + } else { + // No silo exists — build a new one at the construction handler's other tile + const tile = this.constructionHandler.consumeOtherTile(); + if (tile === null) { + // No tile available yet — wait for tile evaluation to find one + return; + } + const spawnTile = this.player.canBuild(UnitType.MissileSilo, tile); + if (spawnTile === false) { + // Can't build here — abort the sequence + this.resetNukeSequence(); + return; + } + // Build a silo at the level needed + this.mg.addExecution( + new ConstructionExecution( + this.player, + UnitType.MissileSilo, + spawnTile, + bombsNeeded, + ), + ); + // Mark that we've queued a silo build so the next tick doesn't duplicate + // it before the Construction unit appears on the map. + state.siloConstructionQueued = true; + } + // Stay in buildSilo phase; next tick will re-check capacity + } + + /** + * Returns true if this player has a Construction unit that is building + * a MissileSilo. Used to avoid queueing duplicate silo builds while + * one is already in progress. + */ + private hasSiloUnderConstruction(): boolean { + if (!this.player) return false; + for (const unit of this.player.units(UnitType.Construction)) { + if (!unit.isActive()) continue; + if (unit.constructionType() === UnitType.MissileSilo) { + return true; + } + } + return false; + } + + /** + * Launch one atom bomb per tick targeting SAMs in range of the nuke target. + * Each SAM gets one atom bomb per stack level. + */ + private tickLaunchSAMs(): void { + if (!this.player || !this.nukeState || !this.nukeHandler) return; + const state = this.nukeState; + + // Before the first launch, do a score check, redundancy check, and + // ensure we can afford ALL SAM atom bombs so we don't start launching + // and then stall mid-sequence. + const isFirstLaunch = state.samTargets.every( + (s) => s.levelsRemaining === s.sam.stackCount(), + ); + if (isFirstLaunch) { + // Abort if another player's nuke is already heading to this target + if (this.isNukeAlreadyInbound(state)) { + this.resetNukeSequence(); + return; + } + const freshScore = this.nukeHandler.scoreForTile( + state.targetTile, + state.bombType, + ); + if (freshScore <= 0) { + this.resetNukeSequence(); + return; + } + + // Total cost of all SAM atom bombs + the main nuke + const atomCost = this.mg.unitInfo(UnitType.AtomBomb).cost(this.player); + const mainCost = this.mg.unitInfo(state.bombType).cost(this.player); + const totalSAMLevels = state.samTargets.reduce( + (sum, s) => sum + s.levelsRemaining, + 0, + ); + const totalCost = atomCost * BigInt(totalSAMLevels) + mainCost; + if (this.player.gold() < totalCost) return; // Wait until we can afford all nukes + } + + // Find next SAM that still needs atom bombs + const nextSam = state.samTargets.find((s) => s.levelsRemaining > 0); + if (!nextSam) { + // All SAM-targeting bombs launched (or there were none) + // If there were no SAMs at all, check for inbound nukes before + // going directly to launchMain (the only launch in this sequence). + const hadSAMs = state.samTargets.length > 0; + + // Log SAM phase completion + const totalBombsLaunched = state.samTargets.reduce( + (sum, s) => sum + (s.sam.stackCount() - s.levelsRemaining), + 0, + ); + if (totalBombsLaunched > 5 || state.samTargets.length > 5) { + const tileX = this.mg.x(state.targetTile); + const tileY = this.mg.y(state.targetTile); + const currentSAMs = this.nukeHandler.getSAMsInRange(state.targetTile); + const currentTotalLevels = currentSAMs.reduce( + (sum, s) => sum + s.stackCount(), + 0, + ); + console.warn( + `[NUKE-DIAG] SAM-PHASE DONE: player=${this.player.id()} ` + + `target=(${tileX},${tileY}) totalBombsLaunched=${totalBombsLaunched} ` + + `trackedSAMs=${state.samTargets.length} hadSAMs=${hadSAMs} ` + + `currentSAMsStillInRange=${currentSAMs.length} currentTotalLevels=${currentTotalLevels} ` + + `deficit=${currentTotalLevels - totalBombsLaunched} ` + + `tracked=[${state.samTargets.map((s) => `{id=${s.sam.id()} stack=${s.sam.stackCount()} launched=${s.sam.stackCount() - s.levelsRemaining} active=${s.sam.isActive()}}`).join(", ")}]`, + ); + } + + if (!hadSAMs) { + if (this.isNukeAlreadyInbound(state)) { + this.resetNukeSequence(); + return; + } + state.phase = "launchMain"; + } else { + state.phase = "waitForMain"; + state.waitStartTick = this.mg.ticks(); + } + return; + } + + // Check if we can afford an atom bomb (mid-sequence, e.g. after retargeting added SAMs) + const atomCost = this.mg.unitInfo(UnitType.AtomBomb).cost(this.player); + if (this.player.gold() < atomCost) return; // Wait for funds + + // Check if we have a silo not on cooldown + if (!this.player.canBuild(UnitType.AtomBomb, nextSam.sam.tile())) { + return; // Wait for silo cooldown + } + + // Launch atom bomb at this SAM's tile + const totalLevelsBeforeLaunch = state.samTargets.reduce( + (sum, s) => sum + s.levelsRemaining, + 0, + ); + if (totalLevelsBeforeLaunch > 5) { + const tileX = this.mg.x(state.targetTile); + const tileY = this.mg.y(state.targetTile); + const samX = this.mg.x(nextSam.sam.tile()); + const samY = this.mg.y(nextSam.sam.tile()); + console.warn( + `[NUKE-DIAG] LAUNCH SAM-bomb: player=${this.player.id()} ` + + `target=(${tileX},${tileY}) samTarget=(${samX},${samY}) ` + + `samId=${nextSam.sam.id()} samOwner=${nextSam.sam.owner().id()} ` + + `samStack=${nextSam.sam.stackCount()} samActive=${nextSam.sam.isActive()} ` + + `levelsRemaining=${nextSam.levelsRemaining} ` + + `totalLevelsRemaining=${totalLevelsBeforeLaunch} ` + + `allSamTargets=[${state.samTargets.map((s) => `{id=${s.sam.id()} stack=${s.sam.stackCount()} remaining=${s.levelsRemaining} active=${s.sam.isActive()}}`).join(", ")}]`, + ); + } + this.mg.addExecution( + new ConstructionExecution( + this.player, + UnitType.AtomBomb, + nextSam.sam.tile(), + ), + ); + nextSam.levelsRemaining--; + } + + /** + * Wait 30 ticks after the last SAM bomb before launching the main bomb. + */ + private tickWaitForMain(ticks: number): void { + if (!this.nukeState) return; + const elapsed = ticks - this.nukeState.waitStartTick; + if (elapsed >= AIPlayerExecution.MAIN_BOMB_DELAY_TICKS) { + this.nukeState.phase = "launchMain"; + } + } + + /** + * Launch the main bomb at the target tile. + */ + private tickLaunchMain(): void { + if (!this.player || !this.nukeState || !this.nukeHandler) return; + const state = this.nukeState; + + // Final score recheck before committing the main bomb + const freshScore = this.nukeHandler.scoreForTile( + state.targetTile, + state.bombType, + ); + if (freshScore <= 0) { + this.resetNukeSequence(); + return; + } + + // Check cost + const bombCost = this.mg.unitInfo(state.bombType).cost(this.player); + if (this.player.gold() < bombCost) return; // Wait for funds + + // Check silo availability + if (!this.player.canBuild(state.bombType, state.targetTile)) { + return; // Wait for silo cooldown + } + + // Fire the main bomb + { + const totalSAMLevelsAtLaunch = state.samTargets.reduce( + (sum, s) => sum + s.levelsRemaining, + 0, + ); + const totalSAMBombsLaunched = state.samTargets.reduce( + (sum, s) => sum + (s.sam.stackCount() - s.levelsRemaining), + 0, + ); + if (totalSAMBombsLaunched > 5 || state.samTargets.length > 5) { + const tileX = this.mg.x(state.targetTile); + const tileY = this.mg.y(state.targetTile); + // Re-query SAMs at main launch time to compare with what we tracked + const currentSAMs = this.nukeHandler.getSAMsInRange(state.targetTile); + const currentTotalLevels = currentSAMs.reduce( + (sum, s) => sum + s.stackCount(), + 0, + ); + console.warn( + `[NUKE-DIAG] LAUNCH MAIN: player=${this.player.id()} ` + + `target=(${tileX},${tileY}) bombType=${state.bombType} ` + + `SAM-bombs launched=${totalSAMBombsLaunched} ` + + `levelsStillRemaining=${totalSAMLevelsAtLaunch} ` + + `trackedSAMs=${state.samTargets.length} ` + + `currentSAMsInRange=${currentSAMs.length} currentTotalLevels=${currentTotalLevels} ` + + `tracked=[${state.samTargets.map((s) => `{id=${s.sam.id()} stack=${s.sam.stackCount()} remaining=${s.levelsRemaining} active=${s.sam.isActive()}}`).join(", ")}] ` + + `current=[${currentSAMs.map((s) => `{id=${s.id()} stack=${s.stackCount()} pos=(${this.mg.x(s.tile())},${this.mg.y(s.tile())}) active=${s.isActive()}}`).join(", ")}]`, + ); + } + } + this.mg.addExecution( + new ConstructionExecution(this.player, state.bombType, state.targetTile), + ); + + // Sequence complete — reset + this.resetNukeSequence(); + } + + /** + * Calculate the total cost of the nuke sequence: main bomb + atom bombs + * for SAMs + any silo construction/upgrade costs. + */ + private calculateNukeSequenceCost(state: NukeSequenceState): number { + if (!this.player || !this.nukeHandler) return Infinity; + + // Main bomb cost + const mainCost = Number(this.mg.unitInfo(state.bombType).cost(this.player)); + + // Atom bomb cost per SAM level + const atomCost = Number( + this.mg.unitInfo(UnitType.AtomBomb).cost(this.player), + ); + const totalSAMLevels = state.samTargets.reduce( + (sum, s) => sum + s.levelsRemaining, + 0, + ); + const samBombsCost = totalSAMLevels * atomCost; + + // Silo cost if capacity is insufficient. + // The first silo costs the full base price; each upgrade level after that + // costs base * structureUpgradeCostMultiplier (currently 0.8). + const bombsNeeded = 1 + totalSAMLevels; + const siloCapacity = this.nukeHandler.getPlayerSiloCapacity(); + let siloCost = 0; + if (siloCapacity < bombsNeeded) { + const siloBaseCost = Number( + this.mg.unitInfo(UnitType.MissileSilo).cost(this.player), + ); + const upgradeMultiplier = this.mg + .config() + .structureUpgradeCostMultiplier(UnitType.MissileSilo); + const levelsNeeded = bombsNeeded - siloCapacity; + + if (siloCapacity === 0) { + // No silo exists: first level is full cost, rest are upgrades + siloCost = siloBaseCost; + for (let i = 1; i < levelsNeeded; i++) { + siloCost += siloBaseCost * upgradeMultiplier; + } + } else { + // Silo exists: all additional levels are upgrades + siloCost = levelsNeeded * siloBaseCost * upgradeMultiplier; + } + } + + return mainCost + samBombsCost + siloCost; + } + + /** + * Reset the nuke sequence state. + */ + private resetNukeSequence(): void { + this.nukeState = null; + this.nukeHandler?.resetScores(); + } + + /** + * Check whether any nuke (from any player, including ourselves) is + * already in flight toward the blast radius of our planned target. + * Returns true if we should abort because the target will already be hit. + */ + private isNukeAlreadyInbound(state: NukeSequenceState): boolean { + const magnitude = this.mg.config().nukeMagnitudes(state.bombType); + const rangeSquared = magnitude.inner * magnitude.inner; + + const inFlightNukes = this.mg.units( + UnitType.AtomBomb, + UnitType.HydrogenBomb, + UnitType.MIRVWarhead, + ); + + for (const nuke of inFlightNukes) { + if (!nuke.isActive()) continue; + const target = nuke.targetTile(); + if (target === undefined) continue; + const dist2 = this.mg.euclideanDistSquared(state.targetTile, target); + if (dist2 <= rangeSquared) return true; + } + + return false; + } + + private updateSliders(ticks: number): void { + if (!this.player) return; + + // Set initial investment rates once + if (!this.initialInvestmentSet) { + const productivityRate = this.params.productivityInvestmentRate ?? 0.1; + const researchRate = this.params.researchInvestmentRate ?? 0.1; + const troopRatio = this.params.targetTroopRatio ?? 0.6; + this.player.setInvestmentRate(productivityRate); + this.player.setResearchInvestmentRate(researchRate); + this.player.setRoadInvestmentRate(0); + this.player.setTargetTroopRatio(troopRatio); + this.initialInvestmentSet = true; + } + + // Set road investment once roads are researched + if (!this.roadInvestmentSet && this.player.hasUpgrade(UpgradeType.Roads)) { + this.updateRoadInvestment(this.player); + this.roadInvestmentSet = true; + } else if ( + this.roadInvestmentSet && + this.params.roadInvestmentCapToMaintenance + ) { + // Continuously update road investment when capping to maintenance + this.updateRoadInvestment(this.player); + } + } + + private updateRoadInvestment(player: Player): void { + const baseRate = this.params.roadInvestmentRate ?? 0.1; + const capToMaintenance = + this.params.roadInvestmentCapToMaintenance ?? false; + + if (!capToMaintenance) { + player.setRoadInvestmentRate(baseRate); + return; + } + + // New parameters + const buildBoost = this.params.roadBuildBoost ?? 0.1; // X + const qualityAdjust = this.params.roadQualityAdjust ?? 0.01; // Y + const targetQuality = this.params.targetRoadQuality ?? 100; + + // Get maintenance rate from authoritative source + const maintenanceRate = this.mg.getRoadMaintenanceRateForPlayer(player); + const roadLength = player.roadNetworkLength(); + const quality = player.roadNetworkQuality(); + const completion = player.roadNetworkCompletion(); + + let finalRate: number; + if (roadLength === 0) { + // No roads built yet: invest buildBoost to start building + finalRate = buildBoost; + } else if (completion < 100) { + // Road network incomplete: invest maintenance + buildBoost to build more roads + finalRate = maintenanceRate + buildBoost; + } else { + // Road network complete: adjust based on quality vs target + if (quality < targetQuality) { + finalRate = maintenanceRate + qualityAdjust; + } else { + finalRate = maintenanceRate - qualityAdjust; + } + } + + // Clamp to [0, 1] + finalRate = Math.max(0, Math.min(1, finalRate)); + + player.setRoadInvestmentRate(finalRate); + } + + // ─── Debug overlay support ────────────────────────────────────────────────── + + /** + * Collects construction/spending debug data for all registered AI players. + */ + public static getAllConstructionDebugData( + game: Game, + ): ConstructionDebugData[] { + const results: ConstructionDebugData[] = []; + for (const [playerId, exec] of AIPlayerExecution.registry) { + if (!game.hasPlayer(playerId)) continue; + const player = game.player(playerId); + if (!player.isPlayer() || !player.isAlive()) continue; + const data = exec.collectConstructionDebugData(player); + if (data) results.push(data); + } + return results; + } + + private collectConstructionDebugData( + player: Player, + ): ConstructionDebugData | null { + if (!this.constructionHandler || !this.unitHandler || !this.nukeHandler) + return null; + + const gold = Number(player.gold()); + const goldPerMinute = player.estimatedGoldIncomePerMinute(); + + // Construction scores + const constructionBreakdown = + this.constructionHandler.constructionScoreBreakdown(); + const constructionScores: ConstructionScoreEntry[] = []; + for (const [unitType, score] of constructionBreakdown) { + constructionScores.push({ + unitType, + score, + upgradePreferred: this.constructionHandler.isUpgradePreferred(unitType), + }); + } + constructionScores.sort((a, b) => b.score - a.score); + + // Unit scores + const unitBreakdown = this.unitHandler.unitScoreBreakdown(); + const unitScores: UnitScoreEntry[] = []; + for (const [unitType, score] of unitBreakdown) { + unitScores.push({ unitType, score }); + } + unitScores.sort((a, b) => b.score - a.score); + + const bestConstructionScore = + this.constructionHandler.bestConstructionScore(); + const bestUnitScore = this.unitHandler.bestUnitScore(); + + // Nuke scores + const atomTarget = this.nukeHandler.bestAtomTarget(); + const hydrogenTarget = this.nukeHandler.bestHydrogenTarget(); + const profileMultiplier = this.params.nukeScoreMultiplier ?? 1; + + let bestRawNukeScore = atomTarget?.score ?? 0; + let bestNukeBombType: UnitType = UnitType.AtomBomb; + if ( + hydrogenTarget && + hydrogenTarget.score > bestRawNukeScore && + player.hasUpgrade(UpgradeType.ThermonuclearStaging) + ) { + bestRawNukeScore = hydrogenTarget.score; + bestNukeBombType = UnitType.HydrogenBomb; + } + const adjustedBestNukeScore = + bestRawNukeScore * + profileMultiplier * + AIPlayerExecution.NUKE_SCORE_INTERNAL_MULTIPLIER; + + // Identify target player for nuke tiles + const atomTargetPlayer = atomTarget + ? this.identifyTileOwner(atomTarget.tile) + : null; + const hydrogenTargetPlayer = hydrogenTarget + ? this.identifyTileOwner(hydrogenTarget.tile) + : null; + + const nukeScores: NukeScoreDebugInfo = { + bestAtomScore: atomTarget?.score ?? 0, + bestAtomTargetPlayerName: atomTargetPlayer?.displayName() ?? "—", + bestHydrogenScore: hydrogenTarget?.score ?? 0, + bestHydrogenTargetPlayerName: hydrogenTargetPlayer?.displayName() ?? "—", + adjustedBestNukeScore, + }; + + // Spending winner + const nukeSequenceActive = + this.nukeState !== null && this.nukeState.phase !== "idle"; + let spendingWinner: "construction" | "unit" | "nuke" | "none" = "none"; + if (nukeSequenceActive) { + spendingWinner = "nuke"; + } else if (bestConstructionScore >= bestUnitScore) { + spendingWinner = "construction"; + } else { + spendingWinner = "unit"; + } + + // Nuke sequence info + let nukeSequence: NukeSequenceDebugInfo | null = null; + if (this.nukeState && this.nukeState.phase !== "idle") { + const state = this.nukeState; + const targetPlayer = this.identifyTileOwner(state.targetTile); + const totalSAMLevels = state.samTargets.reduce( + (sum, s) => sum + s.levelsRemaining, + 0, + ); + const siloCapacity = this.nukeHandler.getPlayerSiloCapacity(); + const bombsNeeded = 1 + totalSAMLevels; + const totalCost = this.calculateNukeSequenceCost(state); + const currentScore = this.nukeHandler.scoreForTile( + state.targetTile, + state.bombType, + ); + + nukeSequence = { + phase: state.phase, + bombType: state.bombType, + targetPlayerName: targetPlayer?.displayName() ?? "—", + targetPlayerId: targetPlayer?.id() ?? "—", + samNukesNeeded: totalSAMLevels, + siloCapacity, + bombsNeeded, + estimatedTotalCost: totalCost, + currentScore, + }; + } + + return { + playerId: player.id(), + playerName: player.displayName(), + gold, + goldPerMinute, + spendingWinner, + bestConstructionScore, + bestUnitScore, + constructionScores, + unitScores, + nukeScores, + nukeSequence, + }; + } + + /** + * Identify which player owns the tile (or the strongest enemy structure on it). + */ + private identifyTileOwner(tile: TileRef): Player | null { + const owner = this.mg.owner(tile); + if (owner.isPlayer()) return owner; + return null; + } +} diff --git a/src/core/ai/AISpawnHandler.ts b/src/core/ai/AISpawnHandler.ts new file mode 100644 index 000000000..37f3ff414 --- /dev/null +++ b/src/core/ai/AISpawnHandler.ts @@ -0,0 +1,253 @@ +import { SpawnExecution } from "../execution/SpawnExecution"; +import { Game, Nation, Player, PlayerType, TerrainType } from "../game/Game"; +import { TileRef } from "../game/GameMap"; +import { PseudoRandom } from "../PseudoRandom"; +import { AIBehaviorParams } from "./AIBehaviorParams"; + +/** + * Handles all spawn-related behavior for AI players. + */ +export class AISpawnHandler { + private snipeSpawnTick: number | null = null; + private currentSpawnTile: TileRef | null = null; + private spawnTick: number; + + constructor( + private mg: Game, + private nation: Nation, + private random: PseudoRandom, + private params: AIBehaviorParams, + ) { + const hopRate = this.params.spawnHopRate ?? 40; + this.spawnTick = this.random.nextInt(0, hopRate); + } + + handleSpawnPhase(ticks: number): void { + const sniping = this.params.spawnSniping ?? false; + const avoidance = this.params.spawnAvoidance ?? false; + + if (sniping) { + this.handleSnipingSpawn(ticks); + } else if (avoidance) { + this.handleAvoidanceSpawn(ticks); + } else { + this.handleNormalSpawn(ticks); + } + } + + private handleNormalSpawn(ticks: number): void { + const hopping = this.params.spawnHopping ?? true; + const hopRate = this.params.spawnHopRate ?? 40; + + if (hopping) { + if (ticks % hopRate !== this.spawnTick) { + return; + } + } else { + if (ticks !== this.spawnTick) { + return; + } + } + + const tile = this.randomLand(); + if (tile === null) { + console.warn(`cannot spawn ${this.nation.playerInfo.name}`); + return; + } + this.mg.addExecution(new SpawnExecution(this.nation.playerInfo, tile)); + } + + private handleSnipingSpawn(ticks: number): void { + const spawnPhaseEnd = this.mg.config().numSpawnPhaseTurns(); + + this.snipeSpawnTick ??= this.random.nextInt( + spawnPhaseEnd - 10, + spawnPhaseEnd, + ); + + if (ticks !== this.snipeSpawnTick) { + return; + } + + const targets = this.mg + .players() + .filter( + (p) => + p.id() !== this.nation.playerInfo.id && + (p.type() === PlayerType.Human || p.type() === PlayerType.AI), + ); + + if (targets.length === 0) { + const tile = this.randomLand(); + if (tile !== null) { + this.mg.addExecution(new SpawnExecution(this.nation.playerInfo, tile)); + } + return; + } + + const target = this.random.randElement(targets); + const tile = this.randomLandNearPlayer(target, 10); + if (tile === null) { + const fallbackTile = this.randomLand(); + if (fallbackTile !== null) { + this.mg.addExecution( + new SpawnExecution(this.nation.playerInfo, fallbackTile), + ); + } + return; + } + this.mg.addExecution(new SpawnExecution(this.nation.playerInfo, tile)); + } + + private randomLandNearPlayer(target: Player, radius: number): TileRef | null { + const targetTiles = Array.from(target.tiles()); + if (targetTiles.length === 0) { + return null; + } + const centerTile = this.random.randElement(targetTiles); + const centerX = this.mg.x(centerTile); + const centerY = this.mg.y(centerTile); + + for (let tries = 0; tries < 50; tries++) { + const x = this.random.nextInt(centerX - radius, centerX + radius); + const y = this.random.nextInt(centerY - radius, centerY + radius); + if (!this.mg.isValidCoord(x, y)) { + continue; + } + const tile = this.mg.ref(x, y); + if (this.mg.isLand(tile) && !this.mg.hasOwner(tile)) { + if ( + this.mg.terrainType(tile) === TerrainType.Mountain && + this.random.chance(2) + ) { + continue; + } + return tile; + } + } + return null; + } + + private handleAvoidanceSpawn(ticks: number): void { + const hopping = this.params.spawnHopping ?? true; + const hopRate = this.params.spawnHopRate ?? 40; + const avoidanceDistance = this.params.spawnAvoidanceDistance ?? 50; + + const needsToMove = this.shouldAvoidCurrentPosition(avoidanceDistance); + + if (needsToMove) { + const tile = this.findAvoidanceTile(avoidanceDistance); + if (tile !== null) { + this.currentSpawnTile = tile; + this.mg.addExecution(new SpawnExecution(this.nation.playerInfo, tile)); + } + return; + } + + if (hopping) { + if (ticks % hopRate !== this.spawnTick) { + return; + } + } else { + if (ticks !== this.spawnTick) { + return; + } + } + + const tile = this.randomLand(); + if (tile === null) { + console.warn(`cannot spawn ${this.nation.playerInfo.name}`); + return; + } + this.currentSpawnTile = tile; + this.mg.addExecution(new SpawnExecution(this.nation.playerInfo, tile)); + } + + private shouldAvoidCurrentPosition(avoidanceDistance: number): boolean { + if (this.currentSpawnTile === null) { + return false; + } + + const nearestDist = this.distanceToNearestPlayer(this.currentSpawnTile); + return nearestDist !== null && nearestDist < avoidanceDistance; + } + + private distanceToNearestPlayer(tile: TileRef): number | null { + const x = this.mg.x(tile); + const y = this.mg.y(tile); + let nearestDist: number | null = null; + + for (const player of this.mg.players()) { + if (player.id() === this.nation.playerInfo.id) continue; + + const playerTiles = Array.from(player.tiles()); + if (playerTiles.length === 0) continue; + + for (const pTile of playerTiles.slice(0, 10)) { + const px = this.mg.x(pTile); + const py = this.mg.y(pTile); + const dist = Math.sqrt((x - px) ** 2 + (y - py) ** 2); + if (nearestDist === null || dist < nearestDist) { + nearestDist = dist; + } + } + } + + return nearestDist; + } + + private findAvoidanceTile(initialDistance: number): TileRef | null { + let requiredDistance = initialDistance; + const currentNearestDist = this.currentSpawnTile + ? this.distanceToNearestPlayer(this.currentSpawnTile) + : null; + + while (requiredDistance > 0) { + if ( + currentNearestDist !== null && + requiredDistance < currentNearestDist + ) { + return null; + } + + for (let tries = 0; tries < 50; tries++) { + const tile = this.randomLand(); + if (tile === null) continue; + + const nearestDist = this.distanceToNearestPlayer(tile); + if (nearestDist === null || nearestDist >= requiredDistance) { + return tile; + } + } + + requiredDistance -= 5; + } + + return null; + } + + private randomLand(): TileRef | null { + const delta = 25; + let tries = 0; + while (tries < 50) { + tries++; + const cell = this.nation.spawnCell; + const x = this.random.nextInt(cell.x - delta, cell.x + delta); + const y = this.random.nextInt(cell.y - delta, cell.y + delta); + if (!this.mg.isValidCoord(x, y)) { + continue; + } + const tile = this.mg.ref(x, y); + if (this.mg.isLand(tile) && !this.mg.hasOwner(tile)) { + if ( + this.mg.terrainType(tile) === TerrainType.Mountain && + this.random.chance(2) + ) { + continue; + } + return tile; + } + } + return null; + } +} diff --git a/src/core/ai/AITerraNulliusHandler.ts b/src/core/ai/AITerraNulliusHandler.ts new file mode 100644 index 000000000..f8e658579 --- /dev/null +++ b/src/core/ai/AITerraNulliusHandler.ts @@ -0,0 +1,397 @@ +import { AttackExecution } from "../execution/AttackExecution"; +import { TransportShipExecution } from "../execution/TransportShipExecution"; +import { Game, Player, PlayerID } from "../game/Game"; +import { TileRef } from "../game/GameMap"; +import { canBuildTransportShip } from "../game/TransportShipUtils"; +import { PseudoRandom } from "../PseudoRandom"; +import { AIBehaviorParams } from "./AIBehaviorParams"; + +/** + * Handles expansion attacks against Terra Nullius (unclaimed land). + */ +export class AITerraNulliusHandler { + private pendingBoatTargets: Set = new Set(); + private currentSearchRange: number = 50; + private tnExpansionDisabled: boolean = false; + private boatExpansionDisabled: boolean = false; + private lastTNCheckTick: number = 0; + private lastBoatCheckTick: number = 0; + private lastBoatAttemptTick: number = 0; + private playerShoreCache: { tiles: TileRef[]; tick: number } | null = null; + private tnBorderCache: { borders: boolean; tick: number } | null = null; + private static readonly MAX_SEARCH_RANGE = 270; + private static readonly TN_RECHECK_INTERVAL = 100; // ticks between re-checking if TN exists + private static readonly BOAT_RECHECK_INTERVAL = 100; // ticks between re-checking if boat TN reachable + private static readonly BOAT_ATTEMPT_INTERVAL = 10; // only attempt boat attacks every N ticks + private static readonly TN_BORDER_CACHE_INTERVAL = 20; // cache sharesBorderWith(tn) result + private static readonly SHORE_CACHE_INTERVAL = 10; + private static readonly RANDOM_SHORE_MAX_ITERATIONS = 150; + private static readonly OPPORTUNISTIC_BOAT_SAMPLES = 1; // random tiles to check for opportunistic boat attacks + + constructor( + private mg: Game, + private playerId: PlayerID, + private random: PseudoRandom, + private params: AIBehaviorParams, + private readonly thresholdOffset: number, + ) {} + + private getPlayer(): Player | null { + if (!this.mg.hasPlayer(this.playerId)) { + return null; + } + return this.mg.player(this.playerId); + } + + handleTerraNulliusAttack(): boolean { + const player = this.getPlayer(); + if (!player || !player.isAlive()) { + return false; + } + + // Check if TN exists at all (cheap arithmetic check) - re-check periodically + const currentTick = this.mg.ticks(); + if (this.tnExpansionDisabled) { + if ( + currentTick - this.lastTNCheckTick >= + AITerraNulliusHandler.TN_RECHECK_INTERVAL + ) { + this.lastTNCheckTick = currentTick; + if (this.hasTNLandTiles()) { + this.tnExpansionDisabled = false; + } + } + if (this.tnExpansionDisabled) { + return false; + } + } else if ( + currentTick - this.lastTNCheckTick >= + AITerraNulliusHandler.TN_RECHECK_INTERVAL + ) { + // Periodically verify TN still exists before expensive sharesBorderWith check + this.lastTNCheckTick = currentTick; + if (!this.hasTNLandTiles()) { + this.tnExpansionDisabled = true; + return false; + } + } + + // Clean up pending targets (tiles we now own) + this.cleanupPendingTargets(player); + + const attackThreshold = + (this.params.terraNulliusTroopThreshold ?? 0.3) + this.thresholdOffset; + const maxPop = this.mg.config().maxPopulation(player); + const maxTroops = maxPop * player.targetTroopRatio(); + const totalTroops = player.troops() + player.attackingTroops(); + const troopRatio = totalTroops / maxTroops; + + if (troopRatio < attackThreshold) { + return false; + } + + // Check if we have enough defending troops at home + const defendingTroopTarget = this.params.defendingTroopTarget ?? 0.5; + const defendingRatio = player.troops() / totalTroops; + if (defendingRatio < defendingTroopTarget) { + return false; + } + + const tn = this.mg.terraNullius(); + + // Try opportunistic boat attack first (finds TN across rivers/water that land attack can't reach) + if (this.tryOpportunisticBoatAttack(player)) { + return true; + } + + // Try land attack if we border Terra Nullius (cached check) + if (this.bordersTNCached(player, tn, currentTick)) { + return this.launchLandAttack( + player, + troopRatio, + maxPop, + maxTroops, + totalTroops, + ); + } + + // Otherwise, try boat attack (rate-limited to avoid expensive shore searches) + // Check if boat expansion is disabled (no reachable TN ocean shore) + if (this.boatExpansionDisabled) { + if ( + currentTick - this.lastBoatCheckTick >= + AITerraNulliusHandler.BOAT_RECHECK_INTERVAL + ) { + this.lastBoatCheckTick = currentTick; + // Re-enable to try again - if TN land tiles changed, there might be new boat targets + this.boatExpansionDisabled = false; + this.currentSearchRange = 50; // Reset search range for fresh attempt + } else { + return false; + } + } + + if ( + currentTick - this.lastBoatAttemptTick < + AITerraNulliusHandler.BOAT_ATTEMPT_INTERVAL + ) { + // Rate limited - skip without expanding search range + return false; + } + this.lastBoatAttemptTick = currentTick; + + const boatAttacked = this.launchBoatAttack(player); + if (boatAttacked) { + return true; + } + + // No valid TN attack available - increase search range + // (increase by 1 since we only check every BOAT_ATTEMPT_INTERVAL ticks) + this.currentSearchRange = Math.min( + this.currentSearchRange + 1, + AITerraNulliusHandler.MAX_SEARCH_RANGE, + ); + + // If we've maxed out search range and still can't find TN, disable boat expansion + if (this.currentSearchRange >= AITerraNulliusHandler.MAX_SEARCH_RANGE) { + if (!this.hasTNLandTiles()) { + this.tnExpansionDisabled = true; + this.lastTNCheckTick = currentTick; + } + // Disable boat expansion specifically - no reachable TN ocean shore found + this.boatExpansionDisabled = true; + this.lastBoatCheckTick = currentTick; + } + + return false; + } + + /** + * Check if any Terra Nullius land tiles exist in the game. + * TN tiles = total land tiles - all player-owned tiles + */ + private hasTNLandTiles(): boolean { + const totalLand = this.mg.numLandTiles(); + const playerOwned = this.mg + .players() + .reduce((sum, p) => sum + p.numTilesOwned(), 0); + const tnTiles = totalLand - playerOwned; + return tnTiles > 0; + } + + /** + * Cached check for whether player borders Terra Nullius. + * Invalidates when player successfully attacks TN (acquires new land). + */ + private bordersTNCached( + player: Player, + tn: ReturnType, + currentTick: number, + ): boolean { + if ( + this.tnBorderCache && + currentTick - this.tnBorderCache.tick < + AITerraNulliusHandler.TN_BORDER_CACHE_INTERVAL + ) { + return this.tnBorderCache.borders; + } + + const borders = player.sharesBorderWith(tn); + this.tnBorderCache = { borders, tick: currentTick }; + return borders; + } + + private launchLandAttack( + player: Player, + troopRatio: number, + maxPop: number, + maxTroops: number, + totalTroops: number, + ): boolean { + const ownTroopPercent = this.params.terraNulliusOwnTroopPercent ?? 0.1; + const troops = player.troops() * ownTroopPercent; + + if (troops < 1) { + return false; + } + + this.mg.addExecution(new AttackExecution(troops, player, null)); + return true; + } + + /** + * Opportunistic boat attack: picks random tiles within search range and checks + * if any are TN ocean shore tiles that can be boat attacked. This finds TN + * across rivers/water that land attacks can't reach. + */ + private tryOpportunisticBoatAttack(player: Player): boolean { + const tn = this.mg.terraNullius(); + const minSpacing = this.params.terraNulliusBoatSpacing ?? 30; + const boatTroopPercent = this.params.terraNulliusBoatTroopPercent ?? 0.05; + + // Get a random border tile as our search origin + const borderTiles = Array.from(player.borderTiles()); + if (borderTiles.length === 0) { + return false; + } + const origin = borderTiles[this.random.nextInt(0, borderTiles.length - 1)]; + const originX = this.mg.x(origin); + const originY = this.mg.y(origin); + const range = this.params.terraNulliusOpportunisticBoatRange ?? 20; + + for (let i = 0; i < AITerraNulliusHandler.OPPORTUNISTIC_BOAT_SAMPLES; i++) { + // Pick a random tile within opportunistic boat range + const randX = this.random.nextInt(originX - range, originX + range); + const randY = this.random.nextInt(originY - range, originY + range); + + if (!this.mg.isValidCoord(randX, randY)) { + continue; + } + + const tile = this.mg.ref(randX, randY); + + // Must be TN-owned ocean shore + if (!this.mg.isOceanShore(tile)) { + continue; + } + if (this.mg.owner(tile) !== tn) { + continue; + } + + // Check spacing from pending targets + if (this.isTooCloseToExisting(tile, minSpacing)) { + continue; + } + + // Check if we can actually boat attack this tile + if (canBuildTransportShip(this.mg, player, tile) === false) { + continue; + } + + const troops = player.troops() * boatTroopPercent; + if (troops < 1) { + return false; + } + + this.pendingBoatTargets.add(tile); + this.mg.addExecution(new TransportShipExecution(player, tile, troops)); + return true; + } + + return false; + } + + private launchBoatAttack(player: Player): boolean { + const currentTick = this.mg.ticks(); + const minSpacing = this.params.terraNulliusBoatSpacing ?? 30; + const boatTroopPercent = this.params.terraNulliusBoatTroopPercent ?? 0.05; + + // Get player's ocean shore tiles (cached) + const playerShore = this.getPlayerShoreCached(player, currentTick); + if (playerShore.length === 0) { + return false; + } + + const shoreSample = this.random.sampleArray(playerShore, 8); + + for (const tile of shoreSample) { + const dst = this.findRandomTNShore(tile, this.currentSearchRange); + if (dst === null) { + continue; + } + + // Check spacing from pending targets + if (this.isTooCloseToExisting(dst, minSpacing)) { + continue; + } + + // Validate boat attack is possible + if (canBuildTransportShip(this.mg, player, dst) === false) { + continue; + } + + const troops = player.troops() * boatTroopPercent; + if (troops < 1) { + return false; + } + + this.pendingBoatTargets.add(dst); + this.mg.addExecution(new TransportShipExecution(player, dst, troops)); + return true; + } + return false; + } + + /** + * Get player's ocean shore tiles with caching. + */ + private getPlayerShoreCached(player: Player, currentTick: number): TileRef[] { + if ( + this.playerShoreCache && + currentTick - this.playerShoreCache.tick < + AITerraNulliusHandler.SHORE_CACHE_INTERVAL + ) { + return this.playerShoreCache.tiles; + } + + const tiles = Array.from(player.borderTiles()).filter((t) => + this.mg.isOceanShore(t), + ); + this.playerShoreCache = { tiles, tick: currentTick }; + return tiles; + } + + private findRandomTNShore( + fromTile: TileRef, + maxDistance: number, + ): TileRef | null { + const tn = this.mg.terraNullius(); + const x = this.mg.x(fromTile); + const y = this.mg.y(fromTile); + + for ( + let i = 0; + i < AITerraNulliusHandler.RANDOM_SHORE_MAX_ITERATIONS; + i++ + ) { + const randX = this.random.nextInt(x - maxDistance, x + maxDistance); + const randY = this.random.nextInt(y - maxDistance, y + maxDistance); + + if (!this.mg.isValidCoord(randX, randY)) { + continue; + } + + const randTile = this.mg.ref(randX, randY); + + if (!this.mg.isOceanShore(randTile)) { + continue; + } + + if (this.mg.owner(randTile) === tn) { + return randTile; + } + } + + return null; + } + + private isTooCloseToExisting(tile: TileRef, minSpacing: number): boolean { + const minSpacingSq = minSpacing * minSpacing; + for (const pending of this.pendingBoatTargets) { + const dx = this.mg.x(tile) - this.mg.x(pending); + const dy = this.mg.y(tile) - this.mg.y(pending); + if (dx * dx + dy * dy < minSpacingSq) { + return true; + } + } + return false; + } + + private cleanupPendingTargets(player: Player): void { + for (const tile of this.pendingBoatTargets) { + if (this.mg.owner(tile) === player) { + this.pendingBoatTargets.delete(tile); + } + } + } +} diff --git a/src/core/ai/AIUnitHandler.ts b/src/core/ai/AIUnitHandler.ts new file mode 100644 index 000000000..7cb8b74e0 --- /dev/null +++ b/src/core/ai/AIUnitHandler.ts @@ -0,0 +1,1164 @@ +import { ConstructionExecution } from "../execution/ConstructionExecution"; +import { + Game, + Gold, + Player, + PlayerID, + PlayerType, + Unit, + UnitType, +} from "../game/Game"; +import { TileRef } from "../game/GameMap"; +import { getUnitLevelCost } from "../game/UnitUpgrades"; +import { playerMaxUnitLevel } from "../game/Upgradeables"; +import { PseudoRandom } from "../PseudoRandom"; +import { AIBehaviorParams } from "./AIBehaviorParams"; + +/** + * Candidate unit types the AI can build, with their scoring functions. + */ +type UnitCandidate = + | UnitType.Warship + | UnitType.Submarine + | UnitType.FighterJet + | UnitType.Artillery; + +const UNIT_CANDIDATES: UnitCandidate[] = [ + UnitType.Warship, + UnitType.Submarine, + UnitType.FighterJet, + UnitType.Artillery, +]; + +/** + * Handles AI decisions about building and moving military units + * (warships, submarines, fighter jets, artillery). + * + * Scoring is analogous to AIConstructionHandler: each unit type gets a score, + * and the best score is surfaced so AIPlayerExecution can compare it against + * nuke and construction scores. + */ +export class AIUnitHandler { + /** The unit type the AI is currently saving up to build (or null). */ + private _target: UnitCandidate | null = null; + + // --- Warship scoring cache (refreshed every WARSHIP_SCAN_INTERVAL ticks) --- + private _cachedEnemyMaxWarships = 0; + private _cachedEnemyWarshipsTick = -Infinity; + + // --- Naval share EMA (refreshed every NAVAL_SHARE_SCAN_INTERVAL ticks) --- + /** EMA of the military-strength-weighted share of enemies that are naval. [0, 1] */ + private _navalShareEMA = 0; + private _lastNavalShareTick = -Infinity; + + // --- Global trade income cache (refreshed alongside warship count) --- + private _cachedGlobalTradeIncome = 0; + + // --- Warship patrol state --- + /** Set of warship IDs currently on default (coast) patrol. */ + private _availableWarshipIds: Set = new Set(); + /** Enemy transport ID → own warship ID assigned to intercept it. */ + private _transportAssignments: Map = new Map(); + /** Enemy warship ID → list of own warship IDs assigned to engage it (max 2). */ + private _warshipAssignments: Map = new Map(); + /** Tick when default patrol positions were last refreshed. */ + private _lastDefaultPatrolTick: number = -Infinity; + /** Tick when assigned (unavailable) warship patrol tiles were last refreshed. */ + private _lastAssignedPatrolTick: number = -Infinity; + /** Tick when the assignment scan last ran. */ + private _lastAssignmentScanTick: number = -Infinity; + + /** How often (in ticks) to scan for enemy threats and (re)assign warships. */ + private static readonly ASSIGNMENT_SCAN_INTERVAL = 10; + /** How often (in ticks) to refresh default patrol positions for available warships. */ + private static readonly DEFAULT_PATROL_INTERVAL = 600; + /** How often (in ticks) to refresh patrol tiles for assigned (unavailable) warships. */ + private static readonly ASSIGNED_PATROL_INTERVAL = 300; + /** How often (in ticks) to rescan enemy warship counts. */ + private static readonly WARSHIP_SCAN_INTERVAL = 50; + /** How often (in ticks) to recompute the naval-share EMA. */ + private static readonly NAVAL_SHARE_SCAN_INTERVAL = 10; + /** + * EMA smoothing factor for naval share. + * Window ≈ 600 ticks (1 minute), updated every 10 ticks → 60 samples. + * alpha = 2 / (60 + 1) ≈ 0.0328. + */ + private static readonly NAVAL_SHARE_EMA_ALPHA = 2 / 61; + /** Internal base constant for warship score numerator. */ + private static readonly WARSHIP_BASE_SCORE = 2e5; + + constructor( + private mg: Game, + private playerId: PlayerID, + private random: PseudoRandom, + private params: AIBehaviorParams, + ) {} + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** + * Returns the best score across all candidate unit types. + * Called by AIPlayerExecution to compare against nuke and construction scores. + */ + bestUnitScore(): number { + const player = this.getPlayer(); + if (!player) return 0; + + const hasPorts = player.unitsOwned(UnitType.Port) > 0; + + let best = 0; + for (const unitType of UNIT_CANDIDATES) { + if (!this.isUnitEnabled(unitType)) continue; + // Naval units require at least one port to build + if ( + !hasPorts && + (unitType === UnitType.Warship || unitType === UnitType.Submarine) + ) + continue; + const s = this.scoreUnit(player, unitType); + if (s > best) best = s; + } + return best; + } + + /** + * Returns the best score among naval unit types (warship, submarine). + * Used to boost port priority when the AI has no ports. + */ + bestNavalScore(): number { + const player = this.getPlayer(); + if (!player) return 0; + + let best = 0; + for (const unitType of [UnitType.Warship, UnitType.Submarine] as const) { + if (!this.isUnitEnabled(unitType)) continue; + const s = this.scoreUnit(player, unitType); + if (s > best) best = s; + } + return best; + } + + /** + * Returns a breakdown of scores per unit type (for debugging). + */ + unitScoreBreakdown(): Map { + const result = new Map(); + const player = this.getPlayer(); + if (!player) return result; + + for (const unitType of UNIT_CANDIDATES) { + if (!this.isUnitEnabled(unitType)) continue; + result.set(unitType, this.scoreUnit(player, unitType)); + } + return result; + } + + /** + * Refresh cached data (e.g. enemy warship counts) that scoring depends on. + * Called every tick from AIPlayerExecution so scores are always fresh, + * even before tickUnitPurchase runs. + */ + refreshCaches(ticks: number): void { + const player = this.getPlayer(); + if (!player || !player.isAlive()) return; + + if ( + ticks - this._cachedEnemyWarshipsTick >= + AIUnitHandler.WARSHIP_SCAN_INTERVAL + ) { + this.refreshEnemyWarshipCount(player); + this.refreshGlobalTradeIncome(); + this._cachedEnemyWarshipsTick = ticks; + } + + if ( + ticks - this._lastNavalShareTick >= + AIUnitHandler.NAVAL_SHARE_SCAN_INTERVAL + ) { + this.refreshNavalShareEMA(player); + this._lastNavalShareTick = ticks; + } + } + + /** + * Force the next default patrol refresh to run immediately (e.g. after + * spawning warships, or when war/peace state changes externally). + */ + markPatrolDirty(): void { + this._lastDefaultPatrolTick = -Infinity; + } + + /** + * Main tick for unit purchase decisions. + * Called every tick by AIPlayerExecution (skipped when a nuke sequence is active). + */ + tickUnitPurchase(ticks: number): void { + const player = this.getPlayer(); + if (!player || !player.isAlive()) return; + + // Pick best target if we don't have one + this._target ??= this.pickTarget(player); + if (this._target === null) return; + + // Naval units require at least one port + if ( + (this._target === UnitType.Warship || + this._target === UnitType.Submarine) && + player.unitsOwned(UnitType.Port) === 0 + ) { + this._target = null; + return; + } + + // Warships use batch purchasing: save for N+1 then spawn them all + if (this._target === UnitType.Warship) { + this.tickWarshipBatchPurchase(player); + return; + } + + // Other units: single purchase + const cost = this.unitCostAtLevel(player, this._target); + if (player.gold() < cost) return; + + const placed = this.tryBuildUnit(player, this._target); + if (placed) { + this._target = null; + } + } + + /** + * Warship batch purchase: save up for (enemyMax + 1) warships, then + * spawn them all at once near the closest enemy warship to our capital. + * If no enemy warships exist, spawn a single warship near a random port. + */ + private tickWarshipBatchPurchase(player: Player): void { + const enemyMax = this._cachedEnemyMaxWarships; + const ownWarships = player.unitCount(UnitType.Warship); + const targetCount = enemyMax - ownWarships + 1; + const unitCost = this.unitCostAtLevel(player, UnitType.Warship); + const totalCost = unitCost * BigInt(targetCount); + + // Wait until we can afford the whole batch + if (player.gold() < totalCost) return; + + // Determine spawn tile: near closest enemy warship to our capital, + // or near a random port if no enemy warships exist. + const spawnTile = this.findWarshipPlacementTile(player); + if (spawnTile === null) { + // No valid placement — clear target and retry later + this._target = null; + return; + } + + // Spawn the full batch + let spawned = 0; + for (let i = 0; i < targetCount; i++) { + const tile = player.canBuild(UnitType.Warship, spawnTile); + if (tile === false) { + break; + } + if (player.gold() < unitCost) break; + this.mg.addExecution( + new ConstructionExecution(player, UnitType.Warship, tile), + ); + spawned++; + } + + // Clear target regardless — either we spawned or we failed + this._target = null; + + // Immediately update patrol targets for all warships (including newly spawned) + if (spawned > 0) { + this.markPatrolDirty(); + } + } + + /** + * Main tick for unit movement decisions. + * Called every tick by AIPlayerExecution. + * + * Every ASSIGNMENT_SCAN_INTERVAL ticks: + * - Cleans up stale assignments (dead targets / own warships). + * - Assigns 1 available warship per enemy transport targeting us (priority). + * - Assigns up to 2 available warships per enemy warship. + * - Freed warships return to available pool. + * + * Default patrol (every DEFAULT_PATROL_INTERVAL ticks): + * - own ships < enemy max → spread along own coast. + * - own ships >= enemy max → spread along enemy coast(s), fallback own coast. + * + * Assigned warships refresh patrol tiles every ASSIGNED_PATROL_INTERVAL ticks. + */ + tickUnitMovement(ticks: number): void { + const player = this.getPlayer(); + if (!player || !player.isAlive()) return; + + const ownWarships = this.getOwnActiveWarships(player); + if (ownWarships.length === 0) { + this._availableWarshipIds.clear(); + this._transportAssignments.clear(); + this._warshipAssignments.clear(); + return; + } + + const ownWarshipIds = new Set(ownWarships.map((ws) => ws.id())); + + // --- Assignment scan (every 10 ticks) --- + const scanDue = + ticks - this._lastAssignmentScanTick >= + AIUnitHandler.ASSIGNMENT_SCAN_INTERVAL; + if (scanDue) { + this._lastAssignmentScanTick = ticks; + this.updateAssignments(player, ownWarships, ownWarshipIds); + } + + // --- Default patrol for available warships (every 600 ticks) --- + const defaultPatrolDue = + ticks - this._lastDefaultPatrolTick >= + AIUnitHandler.DEFAULT_PATROL_INTERVAL; + if (defaultPatrolDue) { + this._lastDefaultPatrolTick = ticks; + const available = ownWarships.filter((ws) => + this._availableWarshipIds.has(ws.id()), + ); + if (available.length > 0) { + this.assignDefaultPatrol(player, available); + } + } + + // --- Refresh assigned warship patrol tiles (every 300 ticks) --- + const assignedPatrolDue = + ticks - this._lastAssignedPatrolTick >= + AIUnitHandler.ASSIGNED_PATROL_INTERVAL; + if (assignedPatrolDue) { + this._lastAssignedPatrolTick = ticks; + this.refreshAssignedPatrolTiles(player, ownWarships); + } + } + + // --------------------------------------------------------------------------- + // Assignment system + // --------------------------------------------------------------------------- + + /** + * Update warship assignments: clean stale, assign to transports (priority), + * then to enemy warships (up to 2 each). Freed warships become available. + */ + private updateAssignments( + player: Player, + ownWarships: Unit[], + ownWarshipIds: Set, + ): void { + // --- 1. Clean up stale transport assignments --- + for (const [enemyId, ownId] of this._transportAssignments) { + if ( + !ownWarshipIds.has(ownId) || + !this.isValidEnemyTransport(player, enemyId) + ) { + this._transportAssignments.delete(enemyId); + // Own warship becomes available again (if still alive) + if (ownWarshipIds.has(ownId)) { + this._availableWarshipIds.add(ownId); + } + } + } + + // --- 2. Clean up stale warship assignments --- + for (const [enemyId, ownIds] of this._warshipAssignments) { + const validOwn = ownIds.filter((id) => ownWarshipIds.has(id)); + if (!this.isValidEnemyWarship(player, enemyId) || validOwn.length === 0) { + // Free all assigned warships + for (const id of validOwn) { + this._availableWarshipIds.add(id); + } + this._warshipAssignments.delete(enemyId); + } else if (validOwn.length < ownIds.length) { + // Some own warships died; keep the survivors assigned + this._warshipAssignments.set(enemyId, validOwn); + } + } + + // Build set of all currently-assigned warship IDs + const assignedIds = new Set(); + for (const ownId of this._transportAssignments.values()) { + assignedIds.add(ownId); + } + for (const ownIds of this._warshipAssignments.values()) { + for (const id of ownIds) assignedIds.add(id); + } + + // Any warship not in an assignment is available + for (const ws of ownWarships) { + if (!assignedIds.has(ws.id())) { + this._availableWarshipIds.add(ws.id()); + } + } + // Remove dead warships from available + for (const id of [...this._availableWarshipIds]) { + if (!ownWarshipIds.has(id)) { + this._availableWarshipIds.delete(id); + } + } + + // --- 3. Assign to enemy transports (priority) --- + const incomingTransports = this.findIncomingEnemyTransports(player); + for (const transport of incomingTransports) { + if (this._transportAssignments.has(transport.id())) continue; // already assigned + + const ws = this.findNearestAvailable(ownWarships, transport.tile()); + if (ws) { + this._transportAssignments.set(transport.id(), ws.id()); + this._availableWarshipIds.delete(ws.id()); + this.setPatrolToTransportTarget(ws, transport); + } else { + // No available — try to steal from warship assignment + const stolen = this.stealFromWarshipAssignment(ownWarships); + if (stolen) { + this._transportAssignments.set(transport.id(), stolen.id()); + this._availableWarshipIds.delete(stolen.id()); + this.setPatrolToTransportTarget(stolen, transport); + } + } + } + + // --- 4. Assign to enemy warships (up to 2 each) --- + const enemyWarships = this.findAllEnemyWarships(player); + for (const enemy of enemyWarships) { + const existing = this._warshipAssignments.get(enemy.id()) ?? []; + const needed = 2 - existing.length; + if (needed <= 0) continue; + + for (let i = 0; i < needed; i++) { + const ws = this.findNearestAvailable(ownWarships, enemy.tile()); + if (!ws) break; + existing.push(ws.id()); + this._availableWarshipIds.delete(ws.id()); + this.setPatrolIfChanged(ws, enemy.tile()); + } + if (existing.length > 0) { + this._warshipAssignments.set(enemy.id(), existing); + } + } + + // --- 5. Newly-available warships get default patrol immediately --- + const newlyAvailable = ownWarships.filter( + (ws) => + this._availableWarshipIds.has(ws.id()) && !assignedIds.has(ws.id()), + ); + // We don't wait for the 600-tick timer here — these just became free + // and the old assignment set didn't have them, so give them a patrol now. + // (This is a no-op if assignDefaultPatrol was just called.) + } + + /** + * Check if an enemy transport (by unit ID) is still a valid target. + */ + private isValidEnemyTransport(player: Player, unitId: number): boolean { + for (const ship of this.mg.units(UnitType.TransportShip)) { + if (ship.id() !== unitId) continue; + if (!ship.isActive()) return false; + if (ship.owner().isFriendly(player)) return false; + const targetPID = (ship as any).boatTargetPlayerID?.() as + | PlayerID + | null + | undefined; + return targetPID === player.id(); + } + return false; + } + + /** + * Check if an enemy warship (by unit ID) is still a valid target. + */ + private isValidEnemyWarship(player: Player, unitId: number): boolean { + for (const ws of this.mg.units(UnitType.Warship)) { + if (ws.id() !== unitId) continue; + if (!ws.isActive() || ws.health() <= 0) return false; + const owner = ws.owner(); + if (owner.id() === player.id()) return false; + if (owner.type() !== PlayerType.Human && owner.type() !== PlayerType.AI) + return false; + return player.isAtWarWith(owner); + } + return false; + } + + /** + * Find all active enemy warships belonging to Human/AI players at war with us. + */ + private findAllEnemyWarships(player: Player): Unit[] { + const result: Unit[] = []; + for (const ws of this.mg.units(UnitType.Warship)) { + if (!ws.isActive() || ws.health() <= 0) continue; + const owner = ws.owner(); + if (owner.id() === player.id()) continue; + if (owner.type() !== PlayerType.Human && owner.type() !== PlayerType.AI) + continue; + if (!player.isAtWarWith(owner)) continue; + result.push(ws); + } + return result; + } + + /** + * Find the nearest available warship to a given tile. + */ + private findNearestAvailable( + ownWarships: Unit[], + tile: TileRef, + ): Unit | null { + let best: Unit | null = null; + let bestDist = Infinity; + for (const ws of ownWarships) { + if (!this._availableWarshipIds.has(ws.id())) continue; + const d = this.mg.euclideanDistSquared(ws.tile(), tile); + if (d < bestDist) { + bestDist = d; + best = ws; + } + } + return best; + } + + /** + * Steal a warship from a warship assignment (not transport — those have priority). + * Picks the assignment with the most warships and removes the last one. + */ + private stealFromWarshipAssignment(ownWarships: Unit[]): Unit | null { + let bestKey: number | null = null; + let bestLen = 0; + for (const [enemyId, ownIds] of this._warshipAssignments) { + if (ownIds.length > bestLen) { + bestLen = ownIds.length; + bestKey = enemyId; + } + } + if (bestKey === null || bestLen === 0) return null; + + const ids = this._warshipAssignments.get(bestKey)!; + const stolenId = ids.pop()!; + if (ids.length === 0) { + this._warshipAssignments.delete(bestKey); + } + return ownWarships.find((ws) => ws.id() === stolenId) ?? null; + } + + /** + * Set a warship's patrol tile to the target tile of an enemy transport. + */ + private setPatrolToTransportTarget(ws: Unit, transport: Unit): void { + const targetTile = (transport as any).boatTargetTile?.() as + | TileRef + | null + | undefined; + if (targetTile !== null && targetTile !== undefined) { + const oceanTile = this.findOceanNearTile(targetTile); + if (oceanTile !== null) { + this.setPatrolIfChanged(ws, oceanTile); + return; + } + } + // Fallback: patrol near the transport itself + this.setPatrolIfChanged(ws, transport.tile()); + } + + /** + * Refresh patrol tiles for all assigned (unavailable) warships + * so they track moving enemies. + */ + private refreshAssignedPatrolTiles( + player: Player, + ownWarships: Unit[], + ): void { + const wsById = new Map(); + for (const ws of ownWarships) wsById.set(ws.id(), ws); + + // Refresh transport-assigned warships + for (const [enemyId, ownId] of this._transportAssignments) { + const ownWs = wsById.get(ownId); + if (!ownWs) continue; + // Find the transport unit to get its current target tile + for (const ship of this.mg.units(UnitType.TransportShip)) { + if (ship.id() === enemyId && ship.isActive()) { + this.setPatrolToTransportTarget(ownWs, ship); + break; + } + } + } + + // Refresh warship-assigned warships + for (const [enemyId, ownIds] of this._warshipAssignments) { + // Find the enemy warship's current position + let enemyTile: TileRef | null = null; + for (const ws of this.mg.units(UnitType.Warship)) { + if (ws.id() === enemyId && ws.isActive()) { + enemyTile = ws.tile(); + break; + } + } + if (enemyTile === null) continue; + for (const ownId of ownIds) { + const ownWs = wsById.get(ownId); + if (ownWs) { + this.setPatrolIfChanged(ownWs, enemyTile); + } + } + } + } + + // --------------------------------------------------------------------------- + // Default patrol + // --------------------------------------------------------------------------- + + /** + * Default patrol for available warships: + * - own ships < enemy max → spread along own coast. + * - own ships >= enemy max → spread along enemy coast(s); fallback own coast. + */ + private assignDefaultPatrol(player: Player, warships: Unit[]): void { + if (warships.length === 0) return; + + const totalOwn = this.getOwnActiveWarships(player).length; + const enemyMax = this._cachedEnemyMaxWarships; + + if (totalOwn < enemyMax) { + // Outnumbered — patrol own coast + const ownCoast = this.getCoastalBorderTiles(player); + if (ownCoast.length > 0) { + this.spreadWarshipsAlongCoast(warships, ownCoast); + } + } else { + // At parity or above — patrol enemy coast(s) + const enemyCoastTiles = this.collectEnemyCoastTiles(player); + if (enemyCoastTiles.length > 0) { + this.spreadWarshipsAlongCoast(warships, enemyCoastTiles); + } else { + // No enemy coast — fallback to own coast + const ownCoast = this.getCoastalBorderTiles(player); + if (ownCoast.length > 0) { + this.spreadWarshipsAlongCoast(warships, ownCoast); + } + } + } + } + + /** + * Collect coastal border tiles of all enemies we're at war with. + * Returns them sorted by position for even distribution. + */ + private collectEnemyCoastTiles(player: Player): TileRef[] { + const tiles: TileRef[] = []; + for (const other of this.mg.players()) { + if (other.id() === player.id()) continue; + if (other.type() !== PlayerType.Human && other.type() !== PlayerType.AI) + continue; + if (!player.isAtWarWith(other)) continue; + for (const tile of this.getCoastalBorderTiles(other)) { + tiles.push(tile); + } + } + tiles.sort((a, b) => { + const dx = this.mg.x(a) - this.mg.x(b); + return dx !== 0 ? dx : this.mg.y(a) - this.mg.y(b); + }); + return tiles; + } + + // --------------------------------------------------------------------------- + // Coastal helpers + // --------------------------------------------------------------------------- + + /** + * Recompute the military-strength-weighted share of enemies that are + * naval-only (no shared land border) and feed it into the EMA. + * + * navalShare = Σ(isNaval_i * milStr_i) / Σ(milStr_i) ∈ [0, 1] + * + * Called every NAVAL_SHARE_SCAN_INTERVAL ticks from refreshCaches. + */ + private refreshNavalShareEMA(player: Player): void { + let totalWeight = 0; + let navalWeight = 0; + + for (const other of this.mg.players()) { + if (other.id() === player.id()) continue; + if (other.type() !== PlayerType.Human && other.type() !== PlayerType.AI) + continue; + if (!player.isAtWarWith(other)) continue; + + const strength = other.militaryStrength(); + totalWeight += strength; + if (!player.sharesBorderWith(other)) { + navalWeight += strength; + } + } + + const sample = totalWeight > 0 ? navalWeight / totalWeight : 0; + const alpha = AIUnitHandler.NAVAL_SHARE_EMA_ALPHA; + this._navalShareEMA = alpha * sample + (1 - alpha) * this._navalShareEMA; + } + + /** + * Set a warship's patrol tile only when it actually changed, to avoid + * clearing in-progress pathfinding unnecessarily. + */ + private setPatrolIfChanged(ws: Unit, newPatrol: TileRef): void { + if (ws.patrolTile() === newPatrol) return; + ws.setPatrolTile(newPatrol); + ws.setTargetTile(undefined); + } + + /** + * Evenly distribute warships along a set of coastal tiles. + * Each warship is assigned an ocean tile near an evenly-spaced shore tile. + */ + private spreadWarshipsAlongCoast( + warships: Unit[], + coastTiles: TileRef[], + ): void { + if (coastTiles.length === 0 || warships.length === 0) return; + const step = Math.max(1, Math.floor(coastTiles.length / warships.length)); + for (let i = 0; i < warships.length; i++) { + const coastIdx = Math.min( + (i * step) % coastTiles.length, + coastTiles.length - 1, + ); + const coastTile = coastTiles[coastIdx]; + const oceanTile = this.findOceanNearTile(coastTile); + if (oceanTile !== null) { + this.setPatrolIfChanged(warships[i], oceanTile); + } + } + } + + /** + * Get shoreline border tiles (land tiles owned by player that are adjacent to ocean). + * Returns them sorted by position for even distribution. + */ + private getCoastalBorderTiles(player: Player): TileRef[] { + const border = player.borderTiles(); + const coastTiles: TileRef[] = []; + for (const tile of border) { + if (this.mg.isShore(tile)) { + coastTiles.push(tile); + } + } + // Sort by x then y for spatial consistency + coastTiles.sort((a, b) => { + const dx = this.mg.x(a) - this.mg.x(b); + return dx !== 0 ? dx : this.mg.y(a) - this.mg.y(b); + }); + return coastTiles; + } + + /** + * Find incoming enemy transport ships targeting this player. + */ + private findIncomingEnemyTransports(player: Player): Unit[] { + const transports: Unit[] = []; + for (const ship of this.mg.units(UnitType.TransportShip)) { + if (!ship.isActive()) continue; + if (ship.owner().id() === player.id()) continue; + if (ship.owner().isFriendly(player)) continue; + const targetPID = (ship as any).boatTargetPlayerID?.() as + | PlayerID + | null + | undefined; + if (targetPID === player.id()) { + transports.push(ship); + } + } + return transports; + } + + /** + * Find a valid ocean tile near a given tile (for patrol/interception points). + * Searches neighbors first, then expanding radius. + */ + private findOceanNearTile(tile: TileRef): TileRef | null { + // Check immediate neighbors + for (const n of this.mg.neighbors(tile)) { + if (this.mg.isOcean(n) && this.mg.isShoreline(n)) return n; + } + // Expand search radius + const radius = 100; + for (let attempts = 0; attempts < 30; attempts++) { + const rx = this.random.nextInt( + this.mg.x(tile) - radius, + this.mg.x(tile) + radius, + ); + const ry = this.random.nextInt( + this.mg.y(tile) - radius, + this.mg.y(tile) + radius, + ); + if (!this.mg.isValidCoord(rx, ry)) continue; + const t = this.mg.ref(rx, ry); + if (this.mg.isOcean(t)) return t; + } + return null; + } + + /** + * Returns all active warships owned by this player. + */ + private getOwnActiveWarships(player: Player): Unit[] { + return player + .units(UnitType.Warship) + .filter((u) => u.isActive() && u.health() > 0); + } + + // --------------------------------------------------------------------------- + // Scoring + // --------------------------------------------------------------------------- + + /** + * Score a unit type for the current game state. + */ + private scoreUnit(player: Player, unitType: UnitCandidate): number { + switch (unitType) { + case UnitType.Warship: + return this.scoreWarship(player); + case UnitType.Submarine: + return 0; // TODO + case UnitType.FighterJet: + return 0; // TODO + case UnitType.Artillery: + return 0; // TODO + default: + return 0; + } + } + + /** + * Warship score: if we already have more warships than the most-armed + * enemy we're at war with, score is 0. Otherwise: + * + * numerator = WARSHIP_BASE_SCORE + * + warshipTradeIncomeWeight * globalTradeShipGoldPerMinute + * + warshipCoastalThreatWeight * navalShareEMA + * score = (numerator * weightWarship) / (1 + r)^T + * + * where T = minutes to fund (enemyMax + 1) warships at current income, + * and navalShareEMA is an exponential moving average [0,1] of the + * military-strength-weighted share of enemies that are naval-only. + */ + private scoreWarship(player: Player): number { + const ownWarships = player.unitCount(UnitType.Warship); + const enemyMax = this._cachedEnemyMaxWarships; + + // Already at parity or above — no need for more + if (ownWarships > enemyMax) return 0; + + const targetCount = enemyMax - ownWarships + 1; + const warshipCost = Number(this.unitCostAtLevel(player, UnitType.Warship)); + const totalCost = warshipCost * targetCount; + + const grossGoldPerMinute = player.estimatedGoldIncomePerMinute(); + if (grossGoldPerMinute <= 0) return 0; + + const T = totalCost / grossGoldPerMinute; + const discountRate = this.params.discountFactor ?? 0.1; + const weight = this.params.weightWarship ?? 1; + + // Build the numerator: base + trade-income component + coastal-threat component + const tradeWeight = this.params.warshipTradeIncomeWeight ?? 0; + const coastalWeight = this.params.warshipCoastalThreatWeight ?? 0; + + // Use cached global trade income (refreshed every WARSHIP_SCAN_INTERVAL ticks) + const globalTradeIncome = this._cachedGlobalTradeIncome; + const tradeComponent = tradeWeight * globalTradeIncome; + const coastalComponent = coastalWeight * this._navalShareEMA; + + const numerator = + AIUnitHandler.WARSHIP_BASE_SCORE + tradeComponent + coastalComponent; + + return (numerator * weight) / Math.pow(1 + discountRate, T); + } + + /** + * Scan all Human/AI players we're at war with and cache the maximum + * warship count among them. + */ + private refreshEnemyWarshipCount(player: Player): void { + let maxWarships = 0; + for (const other of this.mg.players()) { + if (other.id() === player.id()) continue; + if (other.type() !== PlayerType.Human && other.type() !== PlayerType.AI) { + continue; + } + if (!player.isAtWarWith(other)) continue; + const count = other.unitCount(UnitType.Warship); + if (count > maxWarships) maxWarships = count; + } + this._cachedEnemyMaxWarships = maxWarships; + } + + /** + * Cache the sum of tradeShipGoldPerMinute across all players. + * Called alongside refreshEnemyWarshipCount (every WARSHIP_SCAN_INTERVAL ticks). + */ + private refreshGlobalTradeIncome(): void { + let total = 0; + for (const p of this.mg.players()) { + total += p.tradeShipGoldPerMinute(); + } + this._cachedGlobalTradeIncome = total; + } + + // --------------------------------------------------------------------------- + // Target selection + // --------------------------------------------------------------------------- + + /** + * Pick the unit type with the highest score among affordable candidates. + */ + private pickTarget(player: Player): UnitCandidate | null { + let bestScore = 0; + const best: UnitCandidate[] = []; + + for (const unitType of UNIT_CANDIDATES) { + if (!this.isUnitEnabled(unitType)) continue; + if (this.mg.config().isUnitDisabled(unitType)) continue; + + const s = this.scoreUnit(player, unitType); + if (s > bestScore) { + bestScore = s; + best.length = 0; + best.push(unitType); + } else if (s === bestScore && s > 0) { + best.push(unitType); + } + } + + if (best.length === 0) return null; + return this.random.randElement(best); + } + + /** + * Check if a unit type is enabled via AI behavior params. + */ + private isUnitEnabled(unitType: UnitCandidate): boolean { + switch (unitType) { + case UnitType.Warship: + return this.params.buildWarships ?? false; + case UnitType.Submarine: + return this.params.buildSubmarines ?? false; + case UnitType.FighterJet: + return this.params.buildFighterJets ?? false; + case UnitType.Artillery: + return this.params.buildArtillery ?? false; + default: + return false; + } + } + + // --------------------------------------------------------------------------- + // Building + // --------------------------------------------------------------------------- + + /** + * Attempt to build a unit. Returns true if the build was initiated. + */ + private tryBuildUnit(player: Player, unitType: UnitCandidate): boolean { + const tile = this.findPlacementTile(player, unitType); + if (tile === null) return false; + + const spawnTile = player.canBuild(unitType, tile); + if (spawnTile === false) return false; + + // Double-check affordability right before committing + const cost = this.unitCostAtLevel(player, unitType); + if (player.gold() < cost) return false; + + this.mg.addExecution( + new ConstructionExecution(player, unitType, spawnTile), + ); + return true; + } + + /** + * Find a suitable tile to place a unit build order. + * + * - Naval units (Warship, Submarine): pick a random owned ocean-adjacent + * tile near a port, or a random shoreline tile. + * - Air units (FighterJet): pick a tile near an airfield. + * - Land units (Artillery): pick a tile near a factory. + * + * TODO: Improve placement logic with strategic considerations. + */ + private findPlacementTile( + player: Player, + unitType: UnitCandidate, + ): TileRef | null { + switch (unitType) { + case UnitType.Warship: + return this.findWarshipPlacementTile(player); + case UnitType.Submarine: + return this.findNavalPlacementTile(player); + case UnitType.FighterJet: + return this.findAirPlacementTile(player); + case UnitType.Artillery: + return this.findLandPlacementTile(player); + default: + return null; + } + } + + /** + * Find a placement tile for warships. + * + * Strategy: find the enemy warship (belonging to a Human/AI player we're + * at war with) that is closest to our capital, then spawn near the port + * that is closest to that enemy warship. If no enemy warships exist, + * fall back to a random owned port. + */ + private findWarshipPlacementTile(player: Player): TileRef | null { + const capital = player.capital(); + + // Collect owned port tiles + const portTiles: TileRef[] = []; + for (const port of this.mg.units(UnitType.Port)) { + if (!port.isActive()) continue; + if (port.owner().id() !== player.id()) continue; + portTiles.push(port.tile()); + } + if (portTiles.length === 0) return null; + + // Find closest enemy warship to our capital + let closestEnemyWarship: Unit | null = null; + let closestDist = Infinity; + + if (capital) { + const capitalTile = this.mg.ref(capital.x, capital.y); + for (const warship of this.mg.units(UnitType.Warship)) { + if (!warship.isActive()) continue; + const owner = warship.owner(); + if (owner.id() === player.id()) continue; + if ( + owner.type() !== PlayerType.Human && + owner.type() !== PlayerType.AI + ) { + continue; + } + if (!player.isAtWarWith(owner)) continue; + const dist = this.mg.euclideanDistSquared(capitalTile, warship.tile()); + if (dist < closestDist) { + closestDist = dist; + closestEnemyWarship = warship; + } + } + } + + if (closestEnemyWarship) { + // Spawn near the port closest to that enemy warship + let bestPort: TileRef | null = null; + let bestPortDist = Infinity; + for (const portTile of portTiles) { + const d = this.mg.euclideanDistSquared( + portTile, + closestEnemyWarship.tile(), + ); + if (d < bestPortDist) { + bestPortDist = d; + bestPort = portTile; + } + } + return bestPort ? this.findOceanNearPort(bestPort) : null; + } + + // No enemy warships — pick a random port + const port = this.random.randElement(portTiles); + return this.findOceanNearPort(port); + } + + /** + * Find a tile near a port for submarine placement. + */ + private findNavalPlacementTile(player: Player): TileRef | null { + const ports: TileRef[] = []; + for (const port of this.mg.units(UnitType.Port)) { + if (!port.isActive()) continue; + if (port.owner().id() !== player.id()) continue; + ports.push(port.tile()); + } + if (ports.length === 0) return null; + const port = this.random.randElement(ports); + return this.findOceanNearPort(port); + } + + /** + * Find a tile near an airfield for fighter jet placement. + */ + private findAirPlacementTile(player: Player): TileRef | null { + const airfields: TileRef[] = []; + for (const airfield of this.mg.units(UnitType.Airfield)) { + if (!airfield.isActive()) continue; + if (airfield.owner().id() !== player.id()) continue; + airfields.push(airfield.tile()); + } + if (airfields.length === 0) return null; + + return this.random.randElement(airfields); + } + + /** + * Find a tile near a factory for artillery placement. + */ + private findLandPlacementTile(player: Player): TileRef | null { + const factories: TileRef[] = []; + for (const factory of this.mg.units(UnitType.Factory)) { + if (!factory.isActive()) continue; + if (factory.owner().id() !== player.id()) continue; + factories.push(factory.tile()); + } + if (factories.length === 0) return null; + + return this.random.randElement(factories); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /** + * Find an ocean tile near a port for naval unit spawning. + * Searches a random area within a radius of the port for a valid ocean tile. + */ + private findOceanNearPort(portTile: TileRef): TileRef | null { + const radius = 250; + for (let attempts = 0; attempts < 50; attempts++) { + const randX = this.random.nextInt( + this.mg.x(portTile) - radius, + this.mg.x(portTile) + radius, + ); + const randY = this.random.nextInt( + this.mg.y(portTile) - radius, + this.mg.y(portTile) + radius, + ); + if (!this.mg.isValidCoord(randX, randY)) continue; + const tile = this.mg.ref(randX, randY); + if (!this.mg.isOcean(tile)) continue; + return tile; + } + return null; + } + + /** + * Return the actual gold cost for building a unit at the player's current + * tech level. Falls back to the base cost when there are no upgrades. + */ + private unitCostAtLevel(player: Player, unitType: UnitCandidate): Gold { + const level = playerMaxUnitLevel(player, unitType); + if (level > 1) { + const levelCost = getUnitLevelCost(unitType, level); + if (levelCost > 0n) return levelCost; + } + return this.mg.unitInfo(unitType).cost(player); + } + + private getPlayer(): Player | undefined { + return this.mg.players().find((p) => p.id() === this.playerId); + } +} diff --git a/src/core/ai/ConstructionDebugData.ts b/src/core/ai/ConstructionDebugData.ts new file mode 100644 index 000000000..eb9a2faa9 --- /dev/null +++ b/src/core/ai/ConstructionDebugData.ts @@ -0,0 +1,75 @@ +/** + * Per-structure score breakdown for the construction debug overlay. + */ +export interface ConstructionScoreEntry { + unitType: string; + score: number; + upgradePreferred: boolean; +} + +/** + * Per-unit-candidate score entry for the unit debug overlay. + */ +export interface UnitScoreEntry { + unitType: string; + score: number; +} + +/** + * Nuke sequence state snapshot for the debug overlay. + */ +export interface NukeSequenceDebugInfo { + phase: string; + bombType: string; + targetPlayerName: string; + targetPlayerId: string; + samNukesNeeded: number; + siloCapacity: number; + bombsNeeded: number; + estimatedTotalCost: number; + currentScore: number; +} + +/** + * Nuke scoring snapshot (best atom / hydrogen targets). + */ +export interface NukeScoreDebugInfo { + bestAtomScore: number; + bestAtomTargetPlayerName: string; + bestHydrogenScore: number; + bestHydrogenTargetPlayerName: string; + /** The adjusted nuke score used for comparison against construction/unit scores. */ + adjustedBestNukeScore: number; +} + +/** + * Complete debug payload for a single AI player's construction decisions. + */ +export interface ConstructionDebugData { + playerId: string; + playerName: string; + + /** The current gold the AI has. */ + gold: number; + /** Estimated gold income per minute. */ + goldPerMinute: number; + + /** Which spending category is currently winning. */ + spendingWinner: "construction" | "unit" | "nuke" | "none"; + + /** Best construction composite score (the winner among all structure types). */ + bestConstructionScore: number; + /** Best unit composite score. */ + bestUnitScore: number; + + /** Per-structure score breakdown. */ + constructionScores: ConstructionScoreEntry[]; + /** Per-unit score breakdown. */ + unitScores: UnitScoreEntry[]; + + /** Nuke scoring info. */ + nukeScores: NukeScoreDebugInfo; + + /** Active nuke sequence info (null if idle). */ + nukeSequence: NukeSequenceDebugInfo | null; +} diff --git a/src/core/ai/index.ts b/src/core/ai/index.ts new file mode 100644 index 000000000..4b3bbc7c1 --- /dev/null +++ b/src/core/ai/index.ts @@ -0,0 +1,15 @@ +export { + AIBehaviorParams, + AIProfile, + getAIProfile, + getAllAIProfiles, +} from "./AIBehaviorParams"; +export { AIBotAttackHandler } from "./AIBotAttackHandler"; +export { AIConstructionHandler } from "./AIConstructionHandler"; +export { AIDiplomacyHandler } from "./AIDiplomacyHandler"; +export { AINukeEvaluator, NukeBestTarget } from "./AINukeEvaluator"; +export { AINukeHandler, NukeHandlerBestTarget } from "./AINukeHandler"; +export { AIPlayerExecution } from "./AIPlayerExecution"; +export { AISpawnHandler } from "./AISpawnHandler"; +export { AITerraNulliusHandler } from "./AITerraNulliusHandler"; +export { AIUnitHandler } from "./AIUnitHandler"; diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 000cecd36..e7d56cbb4 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -145,9 +145,7 @@ export interface Config { donateCooldown(): Tick; defaultDonationAmount(sender: Player): number; unitInfo(type: UnitType): UnitInfo; - tradeShipShortRangeDebuff(): number; - tradeShipGold(dist: number, numPorts?: number): Gold; - tradeShipSpawnRate(numberOfPorts: number): number; + tradeShipGold(dist: number): Gold; // Trade rework: gravity-based demand and port-supplied ships tradeGravityK(): number; // Coefficient K in K * ip_i * ip_j / distance / world_industrial_production tradeDemandTickInterval(): number; // Ticks between gravity accumulation (default 10) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 2b8790798..aef14eb89 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -39,12 +39,6 @@ import { PlayerTeamAssignments, TeamCountConfig, } from "../Schemas"; -import { - attackCasualtyModifiers, - attackSpeedModifiers, - defenseCasualtyModifiers, - incomeModifiers, -} from "../tech/TechEffects"; import { assertNever, simpleHash, within } from "../Util"; import { Config, GameEnv, NukeMagnitude, ServerConfig, Theme } from "./Config"; import { PastelTheme } from "./PastelTheme"; @@ -372,9 +366,6 @@ export class DefaultConfig implements Config { const bonus = 1 + 2 * (numPortBonus / (numPortBonus + 5)); return BigInt(Math.floor(baseGold * bonus)); } - tradeShipSpawnRate(numberOfPorts: number): number { - return Math.round(10 * Math.pow(numberOfPorts, 0.37)); - } // Trade rework parameters tradeGravityK(): number { // Tunable coefficient for gravity model demand accumulation @@ -387,11 +378,11 @@ export class DefaultConfig implements Config { return 1; } tradeIncomeFixed(): Gold { - return BigInt(10_000); + return BigInt(20_000); } tradeShipReplacementDelayTicks(): number { - // Assume ~10 ticks/sec => 600 ticks ~= 60s - return 600; + // Assume ~10 ticks/sec => 300 ticks ~= 30s + return 300; } // Roads and Cargo Trucks @@ -927,12 +918,10 @@ export class DefaultConfig implements Config { const attackerType = attacker.type(); const defenderType = defender.isPlayer() ? defender.type() : null; - // If both attacker and defender are Human or FakeHuman, block the attack + // If both attacker and defender are Human or AI, block the attack if ( - (attackerType === PlayerType.Human || - attackerType === PlayerType.FakeHuman) && - (defenderType === PlayerType.Human || - defenderType === PlayerType.FakeHuman) + (attackerType === PlayerType.Human || attackerType === PlayerType.AI) && + (defenderType === PlayerType.Human || defenderType === PlayerType.AI) ) { // Display a message to the players gm.displayMessage( @@ -963,17 +952,20 @@ export class DefaultConfig implements Config { if (defenderIsPlayer) { let maxDefensePostHealthRatio = 0; - for (const dp of gm.nearbyUnits( - tileToConquer, - gm.config().defensePostRange(), - UnitType.DefensePost, - ({ unit }) => unit.owner() === defender, - )) { - const ratio = dp.unit.hasHealth() - ? Number(dp.unit.health()) / (dp.unit.info().maxHealth ?? 1) - : 1; - if (ratio > maxDefensePostHealthRatio) { - maxDefensePostHealthRatio = ratio; + // Skip expensive spatial query when defender has no defense posts + if (defender.effectiveUnits(UnitType.DefensePost) > 0) { + for (const dp of gm.nearbyUnits( + tileToConquer, + gm.config().defensePostRange(), + UnitType.DefensePost, + ({ unit }) => unit.owner() === defender, + )) { + const ratio = dp.unit.hasHealth() + ? Number(dp.unit.health()) / (dp.unit.info().maxHealth ?? 1) + : 1; + if (ratio > maxDefensePostHealthRatio) { + maxDefensePostHealthRatio = ratio; + } } } if (maxDefensePostHealthRatio > 0) { @@ -990,8 +982,7 @@ export class DefaultConfig implements Config { if (attacker.isPlayer() && defenderIsPlayer) { if ( - (attackerType === PlayerType.Human || - attackerType === PlayerType.FakeHuman) && + (attackerType === PlayerType.Human || attackerType === PlayerType.AI) && defenderType === PlayerType.Bot ) { mag *= 0.6; @@ -1027,8 +1018,9 @@ export class DefaultConfig implements Config { let defenderLoss = baseDefenderLoss; // Combine attacker-side and defender-side tech modifiers multiplicatively. - const atkMods = attackCasualtyModifiers(attacker as Player); - const defMods = defenseCasualtyModifiers(defender as Player); + // Use cached getters for performance (avoid iterating all techs per tile) + const atkMods = attacker.getAttackCasualtyModifiers(); + const defMods = defender.getDefenseCasualtyModifiers(); attackerLoss *= atkMods.attackerLossMul * defMods.attackerLossMul; defenderLoss *= atkMods.defenderLossMul * defMods.defenderLossMul; @@ -1059,8 +1051,8 @@ export class DefaultConfig implements Config { defender: Player | TerraNullius, numAdjacentTilesWithEnemy: number, ): number { - // Get tech-based speed modifier - const speedMods = attackSpeedModifiers(attacker); + // Get tech-based speed modifier (cached on player) + const speedMods = attacker.getAttackSpeedModifiers(); const baseTiles = defender.isPlayer() ? 10 * numAdjacentTilesWithEnemy : 12 * numAdjacentTilesWithEnemy; @@ -1099,12 +1091,12 @@ export class DefaultConfig implements Config { if (playerInfo.playerType === PlayerType.Bot) { return 6_000; } - if (playerInfo.playerType === PlayerType.FakeHuman) { + if (playerInfo.playerType === PlayerType.AI) { switch (this._gameConfig.difficulty) { case Difficulty.Easy: return 2_500 + 1000 * (playerInfo?.nation?.strength ?? 1); case Difficulty.Medium: - return 5_000 + 2000 * (playerInfo?.nation?.strength ?? 1); + return 8_000 + 4000 * (playerInfo?.nation?.strength ?? 1); case Difficulty.Hard: return 18_000 + 4000 * (playerInfo?.nation?.strength ?? 1); case Difficulty.Impossible: @@ -1133,7 +1125,7 @@ export class DefaultConfig implements Config { case Difficulty.Easy: return maxPop * 0.4; case Difficulty.Medium: - return maxPop * 0.7; + return maxPop * 1.0; case Difficulty.Hard: return maxPop * 1.4; case Difficulty.Impossible: @@ -1162,13 +1154,13 @@ export class DefaultConfig implements Config { toAdd *= 0.7; } - if (player.type() === PlayerType.FakeHuman) { + if (player.type() === PlayerType.AI) { switch (this._gameConfig.difficulty) { case Difficulty.Easy: toAdd *= 0.7; break; case Difficulty.Medium: - toAdd *= 0.8; + toAdd *= 1.0; break; case Difficulty.Hard: toAdd *= 1.0; @@ -1184,20 +1176,8 @@ export class DefaultConfig implements Config { // Gross gold per tick BEFORE any investments are subtracted grossGoldAdditionRate(player: Player | PlayerView): number { - const base = 0.11 * Math.pow(player.workers(), 0.65); - const productivity = player.productivity(); - const k = player.effectiveUnits(UnitType.Factory); - const factoryFactor = Math.pow(1 + k, 0.35); const multiplier = this._gameConfig.goldMultiplier ?? 1; - // Apply tech/policy-based domestic income multiplier - const incomeMods = incomeModifiers(player); - const grossGold = - base * - productivity * - factoryFactor * - multiplier * - incomeMods.domesticIncomeMul; - return Number.isFinite(grossGold) && grossGold >= 0 ? grossGold : 0; + return player.rawIndustrialProduction() * multiplier; } goldAdditionRate(player: Player): bigint { @@ -1277,7 +1257,6 @@ export class DefaultConfig implements Config { } structureMinDist(): number { - // TODO: Increase this to ~15 once upgradable structures are implemented. return 1; } diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts index bea21130b..76d59d955 100644 --- a/src/core/configuration/DevConfig.ts +++ b/src/core/configuration/DevConfig.ts @@ -57,10 +57,6 @@ export class DevConfig extends DefaultConfig { return info; } - // tradeShipSpawnRate(): number { - // return 10; - // } - // percentageTilesOwnedToWin(): number { // return 1 // } diff --git a/src/core/execution/AirfieldExecution.ts b/src/core/execution/AirfieldExecution.ts index 34aaf8fa6..1b11eb06b 100644 --- a/src/core/execution/AirfieldExecution.ts +++ b/src/core/execution/AirfieldExecution.ts @@ -1,6 +1,6 @@ import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; import { TileRef } from "../game/GameMap"; -import { maxUnitLevel } from "../game/Upgradeables"; +import { playerMaxUnitLevel } from "../game/Upgradeables"; import { PseudoRandom } from "../PseudoRandom"; import { BomberExecution } from "./BomberExecution"; import { CargoPlaneExecution } from "./CargoPlaneExecution"; @@ -17,7 +17,6 @@ export class AirfieldExecution implements Execution { constructor( private player: Player, private tile: TileRef, - private initialBomberLevel: number = 1, // Bomber tech upgrade level private stackCount: number = 1, // Stack count (how many bombers to spawn/maintain) ) {} @@ -54,11 +53,8 @@ export class AirfieldExecution implements Execution { } this.lastStackCount = this.stackCount; - // Set initial bomber upgrade level if specified (clamped to max) - const bomberLvl = Math.min( - maxUnitLevel(UnitType.Bomber), - Math.max(1, this.initialBomberLevel), - ); + // Set bomber level based on player's current tech research + const bomberLvl = playerMaxUnitLevel(this.player, UnitType.Bomber); if (bomberLvl > 1) { this.airfield.setBomberLevel?.(bomberLvl); } diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 9dd17cb63..8aab6018f 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -1,7 +1,6 @@ import { renderNumber, renderTroops } from "../../client/Utils"; import { Attack, - ATTACK_SUBTICKS_PER_TICK, Execution, Game, MessageType, @@ -267,85 +266,93 @@ export class AttackExecution implements Execution { // Consolidated: retreats on alliance/peace are now handled centrally via // PlayerImpl.setNeutralWith, which orders retreats on hostile actions. - // Calculate tiles to process - divided by ATTACK_SUBTICKS_PER_TICK since this is called multiple times per game tick - this.tilesToProcessAccumulator += - this.mg - .config() - .attackTilesPerTick( - troopCount, - this._owner, - this.target, - this.attack.borderSize() + this.random.nextInt(0, 5), - ) / ATTACK_SUBTICKS_PER_TICK; + // Calculate tiles to process + this.tilesToProcessAccumulator += this.mg + .config() + .attackTilesPerTick( + troopCount, + this._owner, + this.target, + this.attack.borderSize() + this.random.nextInt(0, 5), + ); let numTilesPerTick = Math.floor(this.tilesToProcessAccumulator + 1e-9); this.tilesToProcessAccumulator -= numTilesPerTick; - while (numTilesPerTick > 0) { - if (troopCount < 1) { - this.attack.delete(); - this.active = false; - return; - } + const ownerSmallID = this._owner.smallID(); + const targetSmallID = this.target.smallID(); - if (this.toConquer.size() === 0) { - if (!this.isDeepStrike) { - this.refreshToConquer(); + this.mg.beginBorderBatch(); + try { + while (numTilesPerTick > 0) { + if (troopCount < 1) { + this.attack.delete(); + this.active = false; + return; } - this.retreat(); - return; - } - const [tileToConquer] = this.toConquer.dequeue(); - this.attack.removeBorderTile(tileToConquer); + if (this.toConquer.size() === 0) { + if (!this.isDeepStrike) { + this.refreshToConquer(); + } + this.retreat(); + return; + } - let onBorder = false; - if (this.isDeepStrike && tileToConquer === this.sourceTile) { - onBorder = true; // The landing tile is always considered "on border" for a deep strike - } else { - for (const n of this.mg.neighbors(tileToConquer)) { - if (this.mg.owner(n) === this._owner) { - onBorder = true; - break; + const [tileToConquer] = this.toConquer.dequeue(); + this.attack.removeBorderTile(tileToConquer); + + let onBorder = false; + if (this.isDeepStrike && tileToConquer === this.sourceTile) { + onBorder = true; // The landing tile is always considered "on border" for a deep strike + } else { + for (const n of this.mg.neighbors(tileToConquer)) { + if (this.mg.ownerID(n) === ownerSmallID) { + onBorder = true; + break; + } } } + if (this.mg.ownerID(tileToConquer) !== targetSmallID || !onBorder) { + continue; + } + this.addNeighbors(tileToConquer); + const { attackerTroopLoss, defenderTroopLoss, tilesPerTickUsed } = + this.mg + .config() + .attackLogic( + this.mg, + troopCount, + this._owner, + this.target, + tileToConquer, + ); + numTilesPerTick -= tilesPerTickUsed; + troopCount -= attackerTroopLoss; + this.attack.setTroops(troopCount); + if (targetPlayer) { + targetPlayer.removeTroops(defenderTroopLoss); + } + const attackerMultiplier = + 0.6 + + 0.4 * Math.pow(0.75, this._owner.effectiveUnits(UnitType.Hospital)); + const defenderMultiplier = targetPlayer + ? 0.6 + + 0.4 * Math.pow(0.75, this._owner.effectiveUnits(UnitType.Hospital)) + : 1; + + const attackerReturns = attackerTroopLoss * (1 - attackerMultiplier); + const defenderReturns = defenderTroopLoss * (1 - defenderMultiplier); + + this._owner.addHospitalReturns(attackerReturns); + if (targetPlayer) { + targetPlayer.addHospitalReturns(defenderReturns); + } + this.mg.conquer(this._owner, tileToConquer); + this.handleDeadDefender(); } - if (this.mg.owner(tileToConquer) !== this.target || !onBorder) { - continue; - } - this.addNeighbors(tileToConquer); - const { attackerTroopLoss, defenderTroopLoss, tilesPerTickUsed } = this.mg - .config() - .attackLogic( - this.mg, - troopCount, - this._owner, - this.target, - tileToConquer, - ); - numTilesPerTick -= tilesPerTickUsed; - troopCount -= attackerTroopLoss; - this.attack.setTroops(troopCount); - if (targetPlayer) { - targetPlayer.removeTroops(defenderTroopLoss); - } - const attackerMultiplier = - 0.6 + - 0.4 * Math.pow(0.75, this._owner.effectiveUnits(UnitType.Hospital)); - const defenderMultiplier = targetPlayer - ? 0.6 + - 0.4 * Math.pow(0.75, this._owner.effectiveUnits(UnitType.Hospital)) - : 1; - - const attackerReturns = attackerTroopLoss * (1 - attackerMultiplier); - const defenderReturns = defenderTroopLoss * (1 - defenderMultiplier); - - this._owner.addHospitalReturns(attackerReturns); - if (targetPlayer) { - targetPlayer.addHospitalReturns(defenderReturns); - } - this.mg.conquer(this._owner, tileToConquer); - this.handleDeadDefender(); + } finally { + this.mg.endBorderBatch(); } } @@ -364,18 +371,20 @@ export class AttackExecution implements Execution { } const tickNow = this.mg.ticks(); // cache tick + const targetSmallID = this.target.smallID(); + const ownerSmallID = this._owner.smallID(); for (const neighbor of this.mg.neighbors(tile)) { if ( this.mg.isWater(neighbor) || - this.mg.owner(neighbor) !== this.target + this.mg.ownerID(neighbor) !== targetSmallID ) { continue; } this.attack.addBorderTile(neighbor); let numOwnedByMe = 0; for (const n of this.mg.neighbors(neighbor)) { - if (this.mg.owner(n) === this._owner) { + if (this.mg.ownerID(n) === ownerSmallID) { numOwnedByMe++; } } @@ -421,7 +430,7 @@ export class AttackExecution implements Execution { this.mg.stats().goldWar(this._owner, this.target, gold); for (let i = 0; i < 10; i++) { - for (const tile of this.target.tiles()) { + for (const tile of Array.from(this.target.tiles())) { const borders = this.mg .neighbors(tile) .some((t) => this.mg.owner(t) === this._owner); diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts index e19f46404..e941c6cec 100644 --- a/src/core/execution/BotExecution.ts +++ b/src/core/execution/BotExecution.ts @@ -1,7 +1,7 @@ -import { Execution, Game, Player } from "../game/Game"; +import { Execution, Game, Player, TerraNullius } from "../game/Game"; import { PseudoRandom } from "../PseudoRandom"; import { simpleHash } from "../Util"; -import { BotBehavior } from "./utils/BotBehavior"; +import { AttackExecution } from "./AttackExecution"; export class BotExecution implements Execution { executionName = "BotExecution"; @@ -10,17 +10,15 @@ export class BotExecution implements Execution { private mg: Game; private neighborsTerraNullius = true; - private behavior: BotBehavior | null = null; + private firstAttackSent = false; private attackRate: number; private attackTick: number; - private triggerRatio: number; private reserveRatio: number; constructor(private bot: Player) { this.random = new PseudoRandom(simpleHash(bot.id())); this.attackRate = this.random.nextInt(40, 80); this.attackTick = this.random.nextInt(0, this.attackRate); - this.triggerRatio = this.random.nextInt(60, 90) / 100; this.reserveRatio = this.random.nextInt(30, 60) / 100; } @@ -42,37 +40,42 @@ export class BotExecution implements Execution { return; } - this.behavior ??= new BotBehavior( - this.random, - this.mg, - this.bot, - this.triggerRatio, - this.reserveRatio, - ); - this.maybeAttack(); } - private maybeAttack() { - if (this.behavior === null) { - throw new Error("not initialized"); - } + private sendAttack(target: Player | TerraNullius) { + if (target.isPlayer() && this.bot.isOnSameTeam(target)) return; + + const maxPop = this.mg.config().maxPopulation(this.bot); + const maxTroops = maxPop * this.bot.targetTroopRatio(); + const targetTroops = maxTroops * this.reserveRatio; - if (this.neighborsTerraNullius) { - if (this.bot.sharesBorderWith(this.mg.terraNullius())) { - this.behavior.sendAttack(this.mg.terraNullius()); - return; - } - this.neighborsTerraNullius = false; + // Don't wait until it has sufficient reserves to send the first attack + // to prevent the bot from waiting too long at the start of the game. + let troops = this.firstAttackSent + ? this.bot.troops() - targetTroops + : this.bot.troops() / 5; + + if (target.isPlayer()) { + troops = Math.min(troops, target.troops() * 3); } + if (troops < 1) return; + this.firstAttackSent = true; - const neighbors = this.bot - .neighbors() - .filter((n): n is Player => n.isPlayer()); + this.mg.addExecution( + new AttackExecution( + troops, + this.bot, + target.isPlayer() ? target.id() : null, + null, + ), + ); + } - if (neighbors.length > 0) { - const target = this.random.randElement(neighbors); - this.behavior.sendAttack(target); + private maybeAttack() { + // Bots only attack terra nullius — no bot-vs-bot combat + if (this.bot.sharesBorderWith(this.mg.terraNullius())) { + this.sendAttack(this.mg.terraNullius()); } } diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts index e4065a9e8..652a06aed 100644 --- a/src/core/execution/ConstructionExecution.ts +++ b/src/core/execution/ConstructionExecution.ts @@ -1,7 +1,4 @@ -import { - aggregateStructureBuildCost, - computeBomberUpgradeCost, -} from "../game/Costs"; +import { aggregateStructureBuildCost } from "../game/Costs"; import { Execution, Game, @@ -52,7 +49,6 @@ export class ConstructionExecution implements Execution { private constructionType: UnitType, private tile: TileRef, private stackCount?: number, // User-selected stack count (renamed from targetLevel) - private bomberLevel?: number, // Bomber upgrade level for airfields ) {} init(mg: Game, ticks: number): void { @@ -106,11 +102,7 @@ export class ConstructionExecution implements Execution { return; } this.player.removeGold(total); - // Refund base before constructing final unit (buildUnit deducts base) - if (this.baseCost > 0n) { - this.player.addGold(this.baseCost); - } - // Immediately complete construction logic + // Gold is fully reserved upfront; buildUnit no longer deducts. this.completeConstruction(); this.active = false; return; @@ -163,11 +155,7 @@ export class ConstructionExecution implements Execution { if (this.ticksUntilComplete === 0) { this.player = this.construction.owner(); this.construction.delete(false); - // Refund only base cost; PlayerImpl.buildUnit will deduct base again. - // Net effect over the flow is total aggregated cost. - if (this.baseCost > 0n) { - this.player.addGold(this.baseCost); - } + // Gold was fully reserved upfront; buildUnit no longer deducts. this.completeConstruction(); this.active = false; return; @@ -253,7 +241,7 @@ export class ConstructionExecution implements Execution { break; case UnitType.SAMLauncher: if ( - player.type() === PlayerType.FakeHuman && + player.type() === PlayerType.AI && player.unitsOwned(UnitType.SAMLauncher) === 0 ) { player.addUpgrade(UpgradeType.CityAntiAir); @@ -292,14 +280,8 @@ export class ConstructionExecution implements Execution { } break; case UnitType.Airfield: - // Airfield uses bomber level for capability AND stack count for multiple bombers this.mg.addExecution( - new AirfieldExecution( - player, - this.tile, - this.bomberLevel ?? this.desiredTechLevel, - this.desiredStackCount, - ), + new AirfieldExecution(player, this.tile, this.desiredStackCount), ); break; default: @@ -373,20 +355,6 @@ export class ConstructionExecution implements Execution { this.mg.config().structureUpgradeCostMultiplier(this.constructionType), ); - // Add bomber upgrade cost for airfields - if (this.constructionType === UnitType.Airfield) { - const bomberLvl = this.bomberLevel ?? this.desiredTechLevel; - return ( - stackCost + - computeBomberUpgradeCost( - this.mg, - this.player, - bomberLvl, - this.desiredStackCount, - ) - ); - } - return stackCost; } diff --git a/src/core/execution/EmojiExecution.ts b/src/core/execution/EmojiExecution.ts index 44a5be61a..4d88548c1 100644 --- a/src/core/execution/EmojiExecution.ts +++ b/src/core/execution/EmojiExecution.ts @@ -44,7 +44,7 @@ export class EmojiExecution implements Execution { if ( emojiString === "🖕" && this.recipient !== AllPlayers && - this.recipient.type() === PlayerType.FakeHuman + this.recipient.type() === PlayerType.AI ) { this.recipient.updateRelation(this.requestor, -100); } diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 103be62b3..1dd80f27d 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -1,3 +1,6 @@ +import { getAIProfile } from "../ai"; +import { AIBehaviorParams } from "../ai/AIBehaviorParams"; +import { AIPlayerExecution } from "../ai/AIPlayerExecution"; import { Execution, Game, UnitType } from "../game/Game"; import { getArtilleryMaxDistance } from "../game/UnitUpgrades"; import { isUpgradeableStructure } from "../game/Upgradeables"; @@ -18,7 +21,6 @@ import { DonateGoldExecution } from "./DonateGoldExecution"; import { DonateTroopsExecution } from "./DonateTroopExecution"; import { EmbargoExecution } from "./EmbargoExecution"; import { EmojiExecution } from "./EmojiExecution"; -import { FakeHumanExecution } from "./FakeHumanExecution"; import { MarkDisconnectedExecution } from "./MarkDisconnectedExecution"; import { MoveArtilleryExecution } from "./MoveArtilleryExecution"; import { MoveFighterJetExecution } from "./MoveFighterJetExecution"; @@ -28,6 +30,7 @@ import { NoOpExecution } from "./NoOpExecution"; import { ParatrooperAttackExecution } from "./ParatrooperAttackExecution"; import { ParatrooperRetreatExecution } from "./ParatrooperRetreatExecution"; import { PeaceRequestExecution } from "./PeaceRequestExecution"; +import { PeaceRequestReplyExecution } from "./PeaceRequestReplyExecution"; import { QuickChatExecution } from "./QuickChatExecution"; import { ResearchTreeSelectExecution } from "./ResearchTreeSelectExecution"; import { RetreatExecution } from "./RetreatExecution"; @@ -39,7 +42,6 @@ import { SetTargetTroopRatioExecution } from "./SetTargetTroopRatioExecution"; import { SpawnExecution } from "./SpawnExecution"; import { TargetPlayerExecution } from "./TargetPlayerExecution"; import { TransportShipExecution } from "./TransportShipExecution"; -import { UpgradeBomberExecution } from "./UpgradeBomberExecution"; import { UpgradeStructureExecution } from "./UpgradeStructureExecution"; export class Executor { @@ -122,6 +124,12 @@ export class Executor { return new BreakAllianceExecution(player, intent.recipient); case "peaceRequest": return new PeaceRequestExecution(player, intent.recipient); + case "peaceRequestReply": + return new PeaceRequestReplyExecution( + intent.requestor, + player, + intent.accept, + ); case "declareWar": return new DeclareWarExecution(player, intent.recipient); case "targetPlayer": @@ -178,7 +186,6 @@ export class Executor { intent.unit, intent.tile, intent.targetLevel, - intent.bomberLevel, ); } case "upgrade_structure": { @@ -223,13 +230,6 @@ export class Executor { return new MarkDisconnectedExecution(player, intent.isDisconnected); case "set_auto_bombing": return new SetAutoBombingExecution(player, intent.enabled); - case "upgrade_bomber": { - const airfield = player - .units(UnitType.Airfield) - .find((u) => u.id() === intent.airfieldId); - if (!airfield) return new NoOpExecution(); - return new UpgradeBomberExecution(player, airfield); - } default: throw new Error(`intent type ${intent} not found`); } @@ -239,10 +239,13 @@ export class Executor { return new BotSpawner(this.mg, this.gameID).spawnBots(numBots); } - fakeHumanExecutions(): Execution[] { + aiPlayerExecutions(profileMap?: Map): Execution[] { + const defaultProfile = getAIProfile("default"); const execs: Execution[] = []; for (const nation of this.mg.nations()) { - execs.push(new FakeHumanExecution(this.gameID, nation)); + const params = + profileMap?.get(nation.playerInfo.id) ?? defaultProfile?.params ?? {}; + execs.push(new AIPlayerExecution(this.gameID, nation, params)); } return execs; } diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts deleted file mode 100644 index 1c43c2f61..000000000 --- a/src/core/execution/FakeHumanExecution.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { - Execution, - Game, - Nation, - Player, - PlayerID, - PlayerType, - Relation, - TerrainType, - Tick, - UpgradeType, -} from "../game/Game"; -import { TileRef } from "../game/GameMap"; -import { PseudoRandom } from "../PseudoRandom"; -import { GameID } from "../Schemas"; -import { RESEARCH_TECH_IDS } from "../tech/TechEffects"; -import { flattenedEmojiTable, simpleHash } from "../Util"; -import { EmojiExecution } from "./EmojiExecution"; -import { NukeExecutionHelper } from "./NukeExecutionHelper"; -import { PeaceRequestExecution } from "./PeaceRequestExecution"; -import { SetResearchInvestmentExecution } from "./SetResearchInvestmentExecution"; -import { SetRoadInvestmentExecution } from "./SetRoadInvestmentExecution"; -import { SpawnExecution } from "./SpawnExecution"; -import { TransportShipExecution } from "./TransportShipExecution"; -import { UnitCreationHelper } from "./UnitCreationHelper"; -import { closestTwoTiles } from "./Util"; -import { BotBehavior } from "./utils/BotBehavior"; - -export class FakeHumanExecution implements Execution { - executionName = "FakeHumanExecution"; - private firstMove = true; - - private active = true; - private random: PseudoRandom; - private behavior: BotBehavior | null = null; - private mg: Game; - private player: Player | null = null; - private nukeHelper: NukeExecutionHelper | null = null; - private unitCreationHelper: UnitCreationHelper | null = null; - - private attackRate: number; - private attackTick: number; - private diplomacyTick: number; - private triggerRatio: number; - private reserveRatio: number; - - private lastEmojiSent = new Map(); - private embargoMalusApplied = new Set(); - private heckleEmoji: number[]; - private hasSetInvestmentRate = false; - - // alongside other private fields - private boatDestinations: TileRef[] = []; - - constructor( - gameID: GameID, - private nation: Nation, - ) { - this.random = new PseudoRandom( - simpleHash(nation.playerInfo.id) + simpleHash(gameID), - ); - this.attackRate = 40; - this.attackTick = this.random.nextInt(0, this.attackRate); - this.diplomacyTick = this.random.nextInt(0, 10); - this.triggerRatio = 70 / 100; - this.reserveRatio = 50 / 100; - this.heckleEmoji = ["≡ƒñí", "≡ƒÿí"].map((e) => - flattenedEmojiTable.indexOf(e), - ); - } - - init(mg: Game) { - this.mg = mg; - } - - private updateRelationsFromEmbargos() { - const player = this.player; - if (player === null) return; - const others = this.mg.players().filter((p) => p.id() !== player.id()); - - others.forEach((other: Player) => { - const embargoMalus = -20; - if ( - other.hasEmbargoAgainst(player) && - !this.embargoMalusApplied.has(other.id()) - ) { - player.updateRelation(other, embargoMalus); - this.embargoMalusApplied.add(other.id()); - } else if ( - !other.hasEmbargoAgainst(player) && - this.embargoMalusApplied.has(other.id()) - ) { - player.updateRelation(other, -embargoMalus); - this.embargoMalusApplied.delete(other.id()); - } - }); - } - - private handleEmbargoesToHostileNations() { - const player = this.player; - if (player === null) return; - const others = this.mg.players().filter((p) => p.id() !== player.id()); - - others.forEach((other: Player) => { - /* When player is hostile starts embargo. Do not stop until neutral again */ - if ( - player.relation(other) <= Relation.Hostile && - !player.hasEmbargoAgainst(other) - ) { - player.addEmbargo(other.id(), false); - } else if ( - player.relation(other) >= Relation.Neutral && - player.hasEmbargoAgainst(other) - ) { - player.stopEmbargo(other.id()); - } - }); - } - - tick(ticks: number) { - if (this.mg.inSpawnPhase()) { - if (ticks % this.attackRate === this.attackTick) { - const rl = this.randomLand(); - if (rl === null) { - console.warn(`cannot spawn ${this.nation.playerInfo.name}`); - } else { - this.mg.addExecution(new SpawnExecution(this.nation.playerInfo, rl)); - } - } - return; - } - - if (this.player === null) { - this.player = - this.mg.players().find((p) => p.id() === this.nation.playerInfo.id) ?? - null; - if (this.player === null) { - return; - } - this.player.addUpgrade(UpgradeType.InternationalTrade); - - // Set research slider to 20% and set road investment to 20% at game start. - // Do NOT set any research priority here so the AI leaves research priority null. - this.mg.addExecution( - new SetResearchInvestmentExecution(this.player, 0.2), - ); - this.mg.addExecution(new SetRoadInvestmentExecution(this.player, 0.2)); - } - - if (!this.player.isAlive()) { - this.active = false; - return; - } - - // Player is unavailable during init() - this.behavior ??= new BotBehavior( - this.random, - this.mg, - this.player, - this.triggerRatio, - this.reserveRatio, - ); - - this.nukeHelper ??= new NukeExecutionHelper( - this.random, - this.mg, - this.player, - ); - - this.unitCreationHelper ??= new UnitCreationHelper( - this.random, - this.mg, - this.player, - ); - - if (this.firstMove) { - this.firstMove = false; - this.behavior.sendAttack(this.mg.terraNullius()); - return; - } - - if (ticks % 100 === this.diplomacyTick) { - if ( - this.player.troops() > 100_000 && - this.player.targetTroopRatio() > 0.6 - ) { - this.player.setTargetTroopRatio(0.6); - } - - if (!this.hasSetInvestmentRate) { - this.player.setInvestmentRate(0.1); - this.hasSetInvestmentRate = true; - } - - this.updateRelationsFromEmbargos(); - this.behavior.handleAllianceRequests(); - this.behavior.handleBombers(); - // Grant Roads via research tech if AI has enough gold and doesn't have it - if ( - this.player.gold() > 1_000_000 && - !this.player.hasUpgrade(UpgradeType.Roads) - ) { - this.player.addResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM, - ); - } - this.unitCreationHelper.handleUnits(); - this.handleEmbargoesToHostileNations(); - - // Auto-peace: if at war but no aggression between sides for 30 seconds, request peace - // NOTE: Only auto-initiated between AIs (FakeHuman/Bot). Do not initiate peace with human players. - const turnMs = this.mg.config().serverConfig().turnIntervalMs(); - const thresholdTicks = Math.ceil(30_000 / Math.max(1, turnMs)); - const me = this.player; - for (const other of this.mg.players()) { - if (!other.isPlayer?.() || other === me) continue; - if (!me.isAtWarWith(other)) continue; - // Skip if the other side is a human; let them initiate peace explicitly. - if (other.type() === PlayerType.Human) continue; - const lastMe = me.lastAggressionTick(other); - const lastOther = other.lastAggressionTick(me); - const last = Math.max(lastMe, lastOther); - if (last >= 0 && this.mg.ticks() - last > thresholdTicks) { - // Immediate peace request (auto-accept via execution) - this.mg.addExecution(new PeaceRequestExecution(me, other.id())); - } - } - } - - if (ticks % this.attackRate === this.attackTick) { - const attackedTN = this.handleTN(); - if (!attackedTN) { - this.handleEnemies(); - } - } - if (ticks % 10 === this.attackTick % 10) { - this.checkOverwhelm(); - } - } - - handleEnemies() { - if ( - this.player === null || - this.behavior === null || - this.nukeHelper === null - ) { - throw new Error("not initialized"); - } - this.behavior.forgetOldEnemies(); - this.behavior.assistAllies(); - const enemy = this.behavior.selectEnemy(); - if (!enemy) return; - this.maybeSendEmoji(enemy); - this.nukeHelper.maybeSendNuke(enemy); - if (this.player.sharesBorderWith(enemy)) { - this.behavior.sendAttack(enemy); - } else { - this.maybeSendBoatAttack(enemy); - } - } - - private maybeSendEmoji(enemy: Player) { - if (this.player === null) throw new Error("not initialized"); - if (enemy.type() !== PlayerType.Human) return; - const lastSent = this.lastEmojiSent.get(enemy) ?? -300; - if (this.mg.ticks() - lastSent <= 300) return; - this.lastEmojiSent.set(enemy, this.mg.ticks()); - this.mg.addExecution( - new EmojiExecution( - this.player, - enemy.id(), - this.random.randElement(this.heckleEmoji), - ), - ); - } - - private maybeSendBoatAttack(other: Player) { - if (this.player === null) throw new Error("not initialized"); - if (this.player.isOnSameTeam(other)) return; - const closest = closestTwoTiles( - this.mg, - Array.from(this.player.borderTiles()).filter((t) => - this.mg.isOceanShore(t), - ), - Array.from(other.borderTiles()).filter((t) => this.mg.isOceanShore(t)), - ); - if (closest === null) { - return; - } - if (this.isTooCloseToExistingBoat(closest.y)) return; - const troopsToSend = this.player.troops() / 5; - this.mg.addExecution( - new TransportShipExecution(this.player, closest.y, troopsToSend), - ); - } - - randomLand(): TileRef | null { - const delta = 25; - let tries = 0; - while (tries < 50) { - tries++; - const cell = this.nation.spawnCell; - const x = this.random.nextInt(cell.x - delta, cell.x + delta); - const y = this.random.nextInt(cell.y - delta, cell.y + delta); - if (!this.mg.isValidCoord(x, y)) { - continue; - } - const tile = this.mg.ref(x, y); - if (this.mg.isLand(tile) && !this.mg.hasOwner(tile)) { - if ( - this.mg.terrainType(tile) === TerrainType.Mountain && - this.random.chance(2) - ) { - continue; - } - return tile; - } - } - return null; - } - - private randOceanShoreTile(tile: TileRef, dist: number): TileRef | null { - if (this.player === null) throw new Error("not initialized"); - const x = this.mg.x(tile); - const y = this.mg.y(tile); - for (let i = 0; i < 500; i++) { - const randX = this.random.nextInt(x - dist, x + dist); - const randY = this.random.nextInt(y - dist, y + dist); - if (!this.mg.isValidCoord(randX, randY)) { - continue; - } - const randTile = this.mg.ref(randX, randY); - if (!this.mg.isOceanShore(randTile)) { - continue; - } - const owner = this.mg.owner(randTile); - if (!owner.isPlayer()) { - return randTile; - } - if (!owner.isFriendly(this.player)) { - return randTile; - } - } - return null; - } - - isActive(): boolean { - return this.active; - } - - activeDuringSpawnPhase(): boolean { - return true; - } - - private handleTN(): boolean { - if (this.player === null || this.behavior === null) - throw new Error("not initialized"); - - const tn = this.mg.terraNullius(); - if (!tn) return false; - - /* ---------- 1. land-border check (unchanged) ---------- */ - const bordersTN = Array.from(this.player.borderTiles()).some((tile) => - this.mg - .neighbors(tile) - .some((n) => this.mg.isLand(n) && this.mg.ownerID(n) === tn.smallID()), - ); - - if (bordersTN) { - this.behavior.sendAttack(tn); - return true; - } - - /* ---------- 2. boat attack: sample a few shore tiles only ---------- */ - - // Use the same expanding radius as BotBehavior (defaults to 100) - const radius = this.behavior.enemySearchRadius ?? 100; - - const shoreSample = this.random.sampleArray( - Array.from(this.player.borderTiles()).filter((t) => - this.mg.isOceanShore(t), - ), - 8, // check at most 8 shore tiles - ); - - for (const tile of shoreSample) { - const dst = this.randOceanShoreTile(tile, radius); - if (dst && this.mg.ownerID(dst) === tn.smallID()) { - this.mg.addExecution( - new TransportShipExecution( - this.player, - dst, - this.player.troops() / 10, - ), - ); - return true; - } - } - return false; - } - private isTooCloseToExistingBoat(dst: TileRef): boolean { - for (const prev of this.boatDestinations) { - const dx = this.mg.x(dst) - this.mg.x(prev); - const dy = this.mg.y(dst) - this.mg.y(prev); - if (dx * dx + dy * dy <= 100 * 100) return true; - } - return false; - } - - private checkOverwhelm() { - if (!this.player || !this.behavior) return; - - const currentEnemy = (this.behavior as any).enemy as Player | null; - if (!currentEnemy) return; - - if ( - currentEnemy.type() === PlayerType.Bot && - this.player.attackingTroops() > currentEnemy.troops() * 2 - ) { - this.behavior.clearEnemy(); - this.handleEnemies(); - } - } -} diff --git a/src/core/execution/MissileSiloExecution.ts b/src/core/execution/MissileSiloExecution.ts index 52d961aac..119cf9970 100644 --- a/src/core/execution/MissileSiloExecution.ts +++ b/src/core/execution/MissileSiloExecution.ts @@ -1,5 +1,6 @@ import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; import { TileRef } from "../game/GameMap"; +import { UnitImpl } from "../game/UnitImpl"; export class MissileSiloExecution implements Execution { executionName = "MissileSiloExecution"; @@ -57,7 +58,8 @@ export class MissileSiloExecution implements Execution { } const cooldown = this.silo.ticksLeftInCooldown(); - if (typeof cooldown === "number" && cooldown >= 0) { + const recovering = (this.silo as UnitImpl).hasRecoveringSlots?.() ?? false; + if ((typeof cooldown === "number" && cooldown >= 0) || recovering) { this.silo.touch(); } } diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 968d3c605..04c211395 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -16,10 +16,6 @@ import { PathStatus } from "../pathfinding/types"; import { PseudoRandom } from "../PseudoRandom"; import { NukeType } from "../StatsSchemas"; import { DoomsdayActivationExecution } from "./DoomsdayActivationExecution"; -import { - attemptNukeInterception, - findEligibleCitiesForNuke, -} from "./utils/CityAntiAirUtils"; const SPRITE_RADIUS = 16; @@ -163,13 +159,6 @@ export class NukeExecution implements Execution { launcher.launch(); } - if ( - this.nuke.type() === UnitType.AtomBomb || - this.nuke.type() === UnitType.HydrogenBomb - ) { - this.eligibleCities = findEligibleCitiesForNuke(this.nuke, this.mg); - } - return; } @@ -195,29 +184,6 @@ export class NukeExecution implements Execution { this.nuke.move(result.node); // Update index so SAM can interpolate future position this.nuke.setTrajectoryIndex(this.pathFinder.currentIndex()); - - // City-based interception: attempt if in range and off cooldown - if (this.nuke !== null && !this.nuke.targetedBySAM()) { - const currentNuke = this.nuke; - const readyInterceptors = this.eligibleCities.filter( - (city) => - (city.ticksLeftInCooldown() ?? 0) <= 0 && - this.mg.euclideanDistSquared(currentNuke.tile(), city.tile()) <= - this.mg.config().citySamLaunchRange() * - this.mg.config().citySamLaunchRange(), - ); - - if (readyInterceptors.length > 0) { - readyInterceptors.sort( - (a, b) => - this.mg.euclideanDistSquared(currentNuke.tile(), a.tile()) - - this.mg.euclideanDistSquared(currentNuke.tile(), b.tile()), - ); - - const closestInterceptor = readyInterceptors[0]; - attemptNukeInterception(currentNuke, this.mg, closestInterceptor); - } - } } } diff --git a/src/core/execution/ParatrooperAttackExecution.ts b/src/core/execution/ParatrooperAttackExecution.ts index 0fd1f67d3..a2699a400 100644 --- a/src/core/execution/ParatrooperAttackExecution.ts +++ b/src/core/execution/ParatrooperAttackExecution.ts @@ -64,10 +64,8 @@ export class ParatrooperAttackExecution implements Execution { const defenderType = target.type(); if ( - (attackerType === PlayerType.Human || - attackerType === PlayerType.FakeHuman) && - (defenderType === PlayerType.Human || - defenderType === PlayerType.FakeHuman) + (attackerType === PlayerType.Human || attackerType === PlayerType.AI) && + (defenderType === PlayerType.Human || defenderType === PlayerType.AI) ) { return; } diff --git a/src/core/execution/PeaceRequestExecution.ts b/src/core/execution/PeaceRequestExecution.ts index d2c6e3f3c..cd2b71236 100644 --- a/src/core/execution/PeaceRequestExecution.ts +++ b/src/core/execution/PeaceRequestExecution.ts @@ -2,12 +2,12 @@ import { Execution, Game, Player, PlayerID } from "../game/Game"; export class PeaceRequestExecution implements Execution { executionName = "PeaceRequestExecution"; - private mg: Game; private active = true; + private recipient: Player | null = null; constructor( - private sender: Player, - private recipientId: PlayerID, + private requestor: Player, + private recipientID: PlayerID, ) {} isActive(): boolean { @@ -17,28 +17,28 @@ export class PeaceRequestExecution implements Execution { return false; } - init(mg: Game): void { - this.mg = mg; - const recipient = this.mg - .players() - .find((p) => p.id() === this.recipientId); - if (!recipient) { + init(mg: Game, ticks: number): void { + if (!mg.hasPlayer(this.recipientID)) { + console.warn( + `PeaceRequestExecution recipient ${this.recipientID} not found`, + ); this.active = false; return; } - if (recipient === this.sender) { - this.active = false; - return; + this.recipient = mg.player(this.recipientID); + } + + tick(ticks: number): void { + if (this.recipient === null) { + throw new Error("Not initialized"); } - // If they are at war, set both to neutral now (immediate accept). Otherwise no-op. - if (this.sender.isAtWarWith(recipient)) { - this.sender.setNeutralWith(recipient); - recipient.setNeutralWith(this.sender); + if (!this.requestor.isAtWarWith(this.recipient)) { + console.warn("not at war"); + } else if (!this.requestor.canSendPeaceRequest(this.recipient)) { + console.warn("recent or pending peace request"); + } else { + this.requestor.createPeaceRequest(this.recipient); } this.active = false; } - - tick(): void { - // no-op - } } diff --git a/src/core/execution/PeaceRequestReplyExecution.ts b/src/core/execution/PeaceRequestReplyExecution.ts new file mode 100644 index 000000000..2d257f996 --- /dev/null +++ b/src/core/execution/PeaceRequestReplyExecution.ts @@ -0,0 +1,53 @@ +import { Execution, Game, Player, PlayerID } from "../game/Game"; + +export class PeaceRequestReplyExecution implements Execution { + private active = true; + private requestor: Player | null = null; + + constructor( + private requestorID: PlayerID, + private recipient: Player, + private accept: boolean, + ) {} + + init(mg: Game, ticks: number): void { + if (!mg.hasPlayer(this.requestorID)) { + console.warn( + `PeaceRequestReplyExecution requester ${this.requestorID} not found`, + ); + this.active = false; + return; + } + this.requestor = mg.player(this.requestorID); + } + + tick(ticks: number): void { + if (this.requestor === null) { + throw new Error("Not initialized"); + } + if (!this.requestor.isAtWarWith(this.recipient)) { + console.warn("not at war, peace request irrelevant"); + } else { + const request = this.requestor + .outgoingPeaceRequests() + .find((pr) => pr.recipient() === this.recipient); + if (request === undefined) { + console.warn("no peace request found"); + } else { + if (this.accept) { + request.accept(); + } else { + request.reject(); + } + } + } + this.active = false; + } + + isActive(): boolean { + return this.active; + } + activeDuringSpawnPhase(): boolean { + return false; + } +} diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index e171f7967..52c466ac5 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -124,6 +124,8 @@ export class PlayerExecution implements Execution { } this.player.addGold(netGold); this.player.updateProductivity(); + // Update income-per-minute EMA trackers every tick + this.player.updateIncomeTracking(); // Record stats // Track net income after investment in stats this.mg.stats().goldWork(this.player, netGold); @@ -394,7 +396,7 @@ export class PlayerExecution implements Execution { /** * Encircle and annex bot clusters that are surrounded by a single enemy player. * Only applicable when this.player is a Bot (PlayerType.Bot). - * Any player type (Human, FakeHuman, Bot) can annex a surrounded Bot. + * Any player type (Human, AI, Bot) can annex a surrounded Bot. */ private removeClusters() { // Only Bots can be encircled and annexed diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index c263cff17..8d0e5b6ef 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -353,6 +353,7 @@ export class SAMLauncherExecution implements Execution { this.targetingSystem ??= new SAMTargetingSystem(this.mg, this.sam); if (this.sam.isInCooldown()) { + this.sam.touch(); // Emit updates for per-slot cooldown recovery return; } @@ -385,7 +386,7 @@ export class SAMLauncherExecution implements Execution { }, ); - // Get a single target - stacked SAMs use launchesRemaining to fire multiple times before cooldown + // Get a single target - stacked SAMs use per-slot independent cooldowns let target: Target | null = null; if (mirvWarheadTargets.length === 0) { target = this.targetingSystem.getSingleTarget(ticks); diff --git a/src/core/execution/SpawnExecution.ts b/src/core/execution/SpawnExecution.ts index 8202264f0..61e96b4ec 100644 --- a/src/core/execution/SpawnExecution.ts +++ b/src/core/execution/SpawnExecution.ts @@ -38,7 +38,7 @@ export class SpawnExecution implements Execution { player = this.mg.addPlayer(this.playerInfo); } - player.tiles().forEach((t) => player.relinquish(t)); + for (const t of Array.from(player.tiles())) player.relinquish(t); getSpawnTiles(this.mg, this.tile).forEach((t) => { player.conquer(t); }); diff --git a/src/core/execution/SubmarineExecution.ts b/src/core/execution/SubmarineExecution.ts index d28e998f8..af187b20d 100644 --- a/src/core/execution/SubmarineExecution.ts +++ b/src/core/execution/SubmarineExecution.ts @@ -179,9 +179,10 @@ export class SubmarineExecution implements Execution { continue; } if (unit.type() === UnitType.TradeShip) { - if (!hasPort || unit.isSafeFromPirates()) { + if (!hasPort || unit.isSafeFromPirates() || this.isDockedAtPort(unit)) { // Submarines only engage enemy trade ships when at war, but still - // respect basic protections like safe-from-pirates and owner having a port. + // respect basic protections like safe-from-pirates, owner having a port, + // and trade ships docked at a port. continue; } if ( @@ -368,4 +369,11 @@ export class SubmarineExecution implements Execution { } return undefined; } + + /** Returns true when a trade ship is sitting on a port tile (docked). */ + private isDockedAtPort(tradeShip: Unit): boolean { + return this.mg + .unitsAt(tradeShip.tile()) + .some((u) => u.type() === UnitType.Port); + } } diff --git a/src/core/execution/TradeDebugData.ts b/src/core/execution/TradeDebugData.ts new file mode 100644 index 000000000..7505cf857 --- /dev/null +++ b/src/core/execution/TradeDebugData.ts @@ -0,0 +1,80 @@ +/** + * Debug data structures for the Trade Debug Overlay (F11). + * Exported from a separate file to keep TradeManagerExecution lean. + */ + +/** Per-ship diagnostic snapshot */ +export interface TradeShipDebug { + shipId: number; + ownerName: string; + ownerId: string; + /** Current tile position */ + x: number; + y: number; + /** Whether the tile the ship is on is ocean */ + isOnOcean: boolean; + /** Whether the ship is co-located with a port unit */ + isAtPort: boolean; + /** Port id if docked, else null */ + dockedPortId: number | null; + /** Trade phase: toStart, toEnd, or idle (null) */ + phase: "toStart" | "toEnd" | "idle"; + /** Whether the ship is flagged as returning */ + returning: boolean; + /** Target unit id (port being navigated to), if any */ + targetUnitId: number | null; + /** Target unit position, if any */ + targetX: number | null; + targetY: number | null; + /** Manhattan distance to target, if target set */ + distToTarget: number | null; + /** Trade route start owner name */ + startOwner: string | null; + /** Trade route end owner name */ + endOwner: string | null; + /** Cargo gold on the ship */ + cargoGold: string; // bigint serialized as string + /** Whether tile === lastTile (ship didn't move this tick) */ + stationaryThisTick: boolean; + /** Number of adjacent ocean tiles from the ship's current position */ + adjacentOceanCount: number; +} + +/** Per-player summary with its ships */ +export interface TradePlayerDebug { + playerId: string; + playerName: string; + totalShips: number; + idleShips: number; + toStartShips: number; + toEndShips: number; + returningShips: number; + stuckAtPort: number; // ships that are at a port AND have a target but distToTarget <= 1 for extended time (heuristic: stationary + at port + has target) + stationaryShips: number; // ships that didn't move this tick + goldPerMinute: number; + portCount: number; + ships: TradeShipDebug[]; +} + +/** Per-pair bilateral demand snapshot */ +export interface TradeDemandDebug { + fromId: string; + fromName: string; + toId: string; + toName: string; + /** Fractional demand accumulated (enqueue threshold = 1.0) */ + fractionalDemand: number; + /** Number of routes currently queued for this pair */ + queuedRoutes: number; + /** Number of active ships currently servicing this pair */ + activeShips: number; +} + +/** Top-level debug payload */ +export interface TradeDebugPayload { + tick: number; + queueLength: number; + totalTradeShips: number; + players: TradePlayerDebug[]; + demands: TradeDemandDebug[]; +} diff --git a/src/core/execution/TradeManagerExecution.ts b/src/core/execution/TradeManagerExecution.ts index 42ac3c646..5a40a8964 100644 --- a/src/core/execution/TradeManagerExecution.ts +++ b/src/core/execution/TradeManagerExecution.ts @@ -8,13 +8,13 @@ import { Tick, Unit, UnitType, - UpgradeType, } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { PathFinding } from "../pathfinding/PathFinder"; import { PathStatus, SteppingPathFinder } from "../pathfinding/types"; import { PseudoRandom } from "../PseudoRandom"; import { roadEffectModifiers, tradeIncomeModifiers } from "../tech/TechEffects"; +import { TradeDemandDebug } from "./TradeDebugData"; type PairKey = string; // `${fromId}->${toId}` @@ -52,10 +52,17 @@ export class TradeManagerExecution implements Execution { private shipHomePortById: Map = new Map(); private knownPortIds: Set = new Set(); + // Debug: throttle logging to once per second + private lastDebugLogTick: Tick = -100; + // Per-tick cache to avoid multiple global iterations private cachedShips: Unit[] = []; private cachedPorts: Unit[] = []; private cacheTickStamp: number = -1; + // Per-processPortSupply cache: portId -> active ship count for that port + private activeSupplyCache: Map = new Map(); + // Per-processPortSupply cache: portId -> Port unit + private portByIdCache: Map = new Map(); // --- Debug helpers (human owners only) --- // Logging removed per request; retain no-op stubs to avoid refactor churn @@ -93,6 +100,69 @@ export class TradeManagerExecution implements Execution { return false; } + /** Expose bilateral demand + queue breakdown for the debug overlay. */ + public getDemandDebug(): TradeDemandDebug[] { + // Collect all players referenced in demand map or queue + const playerById = new Map(); + for (const p of this.mg.players()) { + playerById.set(p.id(), p); + } + + // Count queued routes per pair + const queueCounts = new Map(); + for (const { from, to } of this.queue) { + const k = this.key(from, to); + queueCounts.set(k, (queueCounts.get(k) ?? 0) + 1); + } + + // Count active ships per pair (ships with a tradePhase that are en route) + const activeShipCounts = new Map(); + for (const ship of this.cachedShips) { + if (!ship.isActive()) continue; + const phase = ship.tradePhase?.(); + if (phase === null || phase === undefined) continue; + const startOwner = ship.tradeRouteStartOwner?.(); + const endOwner = ship.tradeRouteEndOwner?.(); + if (startOwner && endOwner) { + const k = this.key(startOwner, endOwner); + activeShipCounts.set(k, (activeShipCounts.get(k) ?? 0) + 1); + } + } + + // Collect all pair keys from demand, queue and active ships + const allKeys = new Set([ + ...this.demand.keys(), + ...queueCounts.keys(), + ...activeShipCounts.keys(), + ]); + + const results: TradeDemandDebug[] = []; + for (const k of allKeys) { + const [fromId, toId] = k.split("->"); + const fromPlayer = playerById.get(fromId); + const toPlayer = playerById.get(toId); + if (!fromPlayer || !toPlayer) continue; + results.push({ + fromId, + fromName: fromPlayer.displayName(), + toId, + toName: toPlayer.displayName(), + fractionalDemand: this.demand.get(k) ?? 0, + queuedRoutes: queueCounts.get(k) ?? 0, + activeShips: activeShipCounts.get(k) ?? 0, + }); + } + + // Sort by queued+active descending, then fractional demand descending + results.sort( + (a, b) => + b.queuedRoutes + b.activeShips - (a.queuedRoutes + a.activeShips) || + b.fractionalDemand - a.fractionalDemand, + ); + + return results; + } + tick(ticks: number): void { if (!this.active) return; @@ -115,6 +185,9 @@ export class TradeManagerExecution implements Execution { // 2) Maintain per-port replacement timers and spawn replacements when due this.processPortSupply(ticks); + // 2.5) Recover stranded idle ships (on ocean, not at a port, no target/phase) + this.recoverStrandedShips(); + // 3) Drop any queued routes that are now embargoed this.pruneEmbargoedRoutes(); @@ -153,29 +226,24 @@ export class TradeManagerExecution implements Execution { private accumulateDemand(): void { const K = this.mg.config().tradeGravityK(); - // World Industrial Production = sum of all alive players' industrialProduction values (bots and humans) - const worldIndustrialProduction = this.mg - .players() - .filter((p) => p.isAlive()) - .reduce((sum, p) => sum + p.industrialProduction(), 0); const players = this.playersForTrade(); + // World Industrial Production = sum of trade-eligible players only (excludes Bots) + // Using the same population as playersForTrade() so bot IP doesn't inflate the + // denominator and suppress demand between actual trading partners. + const worldIndustrialProduction = players.reduce( + (sum, p) => sum + p.industrialProduction(), + 0, + ); + for (let i = 0; i < players.length; i++) { for (let j = 0; j < players.length; j++) { if (i === j) continue; const a = players[i]; const b = players[j]; - // If either side has an embargo against the other, demand is zero + // If either side has an embargo against the other, skip accumulation + // but preserve the existing fractional demand so it resumes where it + // left off once the embargo lifts. if (a.hasEmbargoAgainst(b) || b.hasEmbargoAgainst(a)) { - // Keep fractional demand at 0 for this pair - this.demand.set(this.key(a, b), 0); - continue; - } - // If either side lacks InternationalTrade upgrade (autarky), demand is zero - if ( - !a.hasUpgrade(UpgradeType.InternationalTrade) || - !b.hasUpgrade(UpgradeType.InternationalTrade) - ) { - this.demand.set(this.key(a, b), 0); continue; } const capA = a.capital(); @@ -196,8 +264,21 @@ export class TradeManagerExecution implements Execution { const k = this.key(a, b); // Initialize with a uniform random fractional remainder in [0,1) once per pair let prev = this.demand.get(k); - prev ??= 0.8 + this.rand.next() * 0.2; + prev ??= this.rand.next(); const next = (prev as number) + demandDelta; + + // Debug: log demand between Human and Madagascar (once per second) + const isHumanMadagascar = + (a.type() === PlayerType.Human && b.name().includes("Madagascar")) || + (b.type() === PlayerType.Human && a.name().includes("Madagascar")); + const ticks = this.mg.ticks(); + if (isHumanMadagascar && ticks - this.lastDebugLogTick >= 10) { + this.lastDebugLogTick = ticks; + console.log( + `[TradeDemand] ${a.name()} -> ${b.name()}: prev=${(prev as number).toFixed(3)} delta=${demandDelta.toFixed(5)} next=${next.toFixed(3)} ip_a=${a.industrialProduction()} ip_b=${b.industrialProduction()} dist=${dist.toFixed(0)} worldIP=${worldIndustrialProduction}`, + ); + } + // Enqueue integer demand, keep fractional remainder if (next >= 1) { const count = Math.floor(next); @@ -215,7 +296,11 @@ export class TradeManagerExecution implements Execution { private capitalDistance(a: Cell, b: Cell): number { const refA = this.mg.ref(a.x, a.y); const refB = this.mg.ref(b.x, b.y); - return Math.sqrt(this.mg.euclideanDistSquared(refA, refB)); + const raw = Math.sqrt(this.mg.euclideanDistSquared(refA, refB)); + // Normalize by geometric mean of map dimensions so K behaves + // consistently across different map sizes. + const geomMean = Math.sqrt(this.mg.width() * this.mg.height()); + return raw / geomMean; } // (removed) nearestOceanWithin helper was unused after direct undocking implementation @@ -225,10 +310,12 @@ export class TradeManagerExecution implements Execution { const delay = this.mg.config().tradeShipReplacementDelayTicks(); const targetSupplyFor = (port: Unit) => basePerPort * port.level(); + // Build per-tick caches for O(1) lookups + this.buildPortSupplyCaches(); + // 1) Update current home-port assignments and track current owners const currentShipIds = new Set(); - const shipsSnapshot = [...this.cachedShips]; - for (const ship of shipsSnapshot) { + for (const ship of this.cachedShips) { // Remove trade ships owned by eliminated players if (!ship.owner().isAlive()) { // Delete without messages; considered a consequence of elimination @@ -262,31 +349,14 @@ export class TradeManagerExecution implements Execution { ); const homePortId = this.shipHomePortById.get(sid); if (homePortId !== undefined) { - const port = this.mg - .units(UnitType.Port) - .find((p) => p.id() === homePortId && p.isActive()); - if ( - port && - this.activeHomeSupplyCount(port) < targetSupplyFor(port) - ) { - const list = this.replacementDueAt.get(homePortId) ?? []; - const target = targetSupplyFor(port); - const active = this.activeHomeSupplyCount(port); - const pending = list.length; - const missing = Math.max(0, target - (active + pending)); - if (missing > 0) { - const newList = [...list]; - for (let i = 0; i < missing; i++) newList.push(ticks + delay); - this.replacementDueAt.set(homePortId, newList); - const homePortObj = this.mg - .units(UnitType.Port) - .find((p) => p.id() === homePortId && p.isActive()); - if (homePortObj) - (homePortObj as any).setPendingTradeShipDueTicks(newList); - this.log( - `t=${ticks} schedule replacement after capture homePort=${homePortId} add=${missing} due=${ticks + delay}`, - ); - } + const port = this.portByIdCache.get(homePortId); + if (port) { + this.scheduleReplacementsIfNeeded( + port, + targetSupplyFor, + ticks, + delay, + ); } } // Clear home assignment after capture @@ -317,31 +387,14 @@ export class TradeManagerExecution implements Execution { ); const homePortId = this.shipHomePortById.get(sid); if (homePortId !== undefined) { - const port = this.mg - .units(UnitType.Port) - .find((p) => p.id() === homePortId && p.isActive()); - if ( - port && - this.activeHomeSupplyCount(port) < targetSupplyFor(port) - ) { - const list = this.replacementDueAt.get(homePortId) ?? []; - const target = targetSupplyFor(port); - const active = this.activeHomeSupplyCount(port); - const pending = list.length; - const missing = Math.max(0, target - (active + pending)); - if (missing > 0) { - const newList = [...list]; - for (let i = 0; i < missing; i++) newList.push(ticks + delay); - this.replacementDueAt.set(homePortId, newList); - const homePortObj2 = this.mg - .units(UnitType.Port) - .find((p) => p.id() === homePortId && p.isActive()); - if (homePortObj2) - (homePortObj2 as any).setPendingTradeShipDueTicks(newList); - this.log( - `t=${ticks} schedule replacement (ship lost) homePort=${homePortId} add=${missing} due=${ticks + delay}`, - ); - } + const port = this.portByIdCache.get(homePortId); + if (port) { + this.scheduleReplacementsIfNeeded( + port, + targetSupplyFor, + ticks, + delay, + ); } } this.shipOwnerById.delete(sid); @@ -360,41 +413,11 @@ export class TradeManagerExecution implements Execution { const prevLevel = this.portLevelById.get(port.id()); if (prevOwner && prevOwner !== port.owner()) { // Port captured: ensure new owner can reach level-scaled supply target - if (this.activeHomeSupplyCount(port) < targetSupplyFor(port)) { - const list = this.replacementDueAt.get(port.id()) ?? []; - const target = targetSupplyFor(port); - const active = this.activeHomeSupplyCount(port); - const pending = list.length; - const missing = Math.max(0, target - (active + pending)); - if (missing > 0) { - const newList = [...list]; - for (let i = 0; i < missing; i++) newList.push(ticks + delay); - this.replacementDueAt.set(port.id(), newList); - (port as any).setPendingTradeShipDueTicks(newList); - this.log( - `t=${ticks} portCapture port=${port.id()} level=${currentLevel} add=${missing} due=${ticks + delay}`, - ); - } - } + this.scheduleReplacementsIfNeeded(port, targetSupplyFor, ticks, delay); } // Detect level upgrade if (prevLevel !== undefined && currentLevel > prevLevel) { - if (this.activeHomeSupplyCount(port) < targetSupplyFor(port)) { - const list = this.replacementDueAt.get(port.id()) ?? []; - const target = targetSupplyFor(port); - const active = this.activeHomeSupplyCount(port); - const pending = list.length; - const missing = Math.max(0, target - (active + pending)); - if (missing > 0) { - const newList = [...list]; - for (let i = 0; i < missing; i++) newList.push(ticks + delay); - this.replacementDueAt.set(port.id(), newList); - (port as any).setPendingTradeShipDueTicks(newList); - this.log( - `t=${ticks} portUpgrade port=${port.id()} oldLevel=${prevLevel} newLevel=${currentLevel} add=${missing} due=${ticks + delay}`, - ); - } - } + this.scheduleReplacementsIfNeeded(port, targetSupplyFor, ticks, delay); } // Track current owner this.portOwnerById.set(port.id(), port.owner()); @@ -402,19 +425,7 @@ export class TradeManagerExecution implements Execution { this.portLevelById.set(port.id(), currentLevel); if (!this.knownPortIds.has(port.id())) { // New port detected - if (this.activeHomeSupplyCount(port) < targetSupplyFor(port)) { - const list = this.replacementDueAt.get(port.id()) ?? []; - const target = targetSupplyFor(port); - const active = this.activeHomeSupplyCount(port); - const pending = list.length; - const missing = Math.max(0, target - (active + pending)); - if (missing > 0) { - const newList = [...list]; - for (let i = 0; i < missing; i++) newList.push(ticks + delay); - this.replacementDueAt.set(port.id(), newList); - (port as any).setPendingTradeShipDueTicks(newList); - } - } + this.scheduleReplacementsIfNeeded(port, targetSupplyFor, ticks, delay); this.knownPortIds.add(port.id()); } } @@ -430,9 +441,7 @@ export class TradeManagerExecution implements Execution { for (const [portID, dueList] of Array.from( this.replacementDueAt.entries(), )) { - const port = this.mg - .units(UnitType.Port) - .find((p) => p.id() === portID && p.isActive()); + const port = this.portByIdCache.get(portID); if (!port) { this.replacementDueAt.delete(portID); continue; @@ -441,7 +450,7 @@ export class TradeManagerExecution implements Execution { let remaining = dueList.filter((d) => d > ticks); const ready = dueList.filter((d) => d <= ticks); for (const dueTick of ready) { - if (this.activeHomeSupplyCount(port) >= targetSupplyFor(port)) { + if (this.getCachedActiveSupply(port) >= targetSupplyFor(port)) { remaining = []; break; } @@ -460,6 +469,11 @@ export class TradeManagerExecution implements Execution { ); this.shipOwnerById.set(newShip.id(), newShip.owner()); this.shipHomePortById.set(newShip.id(), portID); + // Update cache to reflect the new ship + this.activeSupplyCache.set( + portID, + (this.activeSupplyCache.get(portID) ?? 0) + 1, + ); this.logShip( newShip, `spawned replacement port=${portID} owner='${owner.displayName()}' due=${dueTick}`, @@ -469,7 +483,7 @@ export class TradeManagerExecution implements Execution { } // After spawns, schedule additional if still missing const target = targetSupplyFor(port); - const active = this.activeHomeSupplyCount(port); + const active = this.getCachedActiveSupply(port); const pending = remaining.length; const missing = Math.max(0, target - (active + pending)); if (missing > 0) { @@ -518,6 +532,11 @@ export class TradeManagerExecution implements Execution { } private activeHomeSupplyCount(port: Unit): number { + // Fallback for any callers outside processPortSupply (e.g., assignRoutes) + // Uses cached value if available, otherwise computes directly + const cached = this.activeSupplyCache.get(port.id()); + if (cached !== undefined) return cached; + let count = 0; const pid = port.id(); for (const ship of this.cachedShips) { @@ -528,6 +547,70 @@ export class TradeManagerExecution implements Execution { return count; } + /** + * Build caches for O(1) lookups during processPortSupply. + * - activeSupplyCache: portId -> count of active ships homed to that port (matching owner) + * - portByIdCache: portId -> Port unit + */ + private buildPortSupplyCaches(): void { + this.activeSupplyCache.clear(); + this.portByIdCache.clear(); + + // Build port lookup cache + for (const port of this.cachedPorts) { + if (port.isActive()) { + this.portByIdCache.set(port.id(), port); + } + } + + // Build active supply cache: count ships per home port (only if owner matches) + for (const ship of this.cachedShips) { + if (!ship.isActive()) continue; + const homePortId = this.shipHomePortById.get(ship.id()); + if (homePortId === undefined) continue; + const port = this.portByIdCache.get(homePortId); + // Only count if ship owner matches port owner + if (port && ship.owner() === port.owner()) { + this.activeSupplyCache.set( + homePortId, + (this.activeSupplyCache.get(homePortId) ?? 0) + 1, + ); + } + } + } + + /** + * Get cached active supply count for a port (O(1) lookup). + */ + private getCachedActiveSupply(port: Unit): number { + return this.activeSupplyCache.get(port.id()) ?? 0; + } + + /** + * Schedule replacement ships if the port is below target supply. + * Uses cached active supply count for efficiency. + */ + private scheduleReplacementsIfNeeded( + port: Unit, + targetSupplyFor: (port: Unit) => number, + ticks: Tick, + delay: number, + ): void { + const active = this.getCachedActiveSupply(port); + const target = targetSupplyFor(port); + if (active >= target) return; + + const list = this.replacementDueAt.get(port.id()) ?? []; + const pending = list.length; + const missing = Math.max(0, target - (active + pending)); + if (missing > 0) { + const newList = [...list]; + for (let i = 0; i < missing; i++) newList.push(ticks + delay); + this.replacementDueAt.set(port.id(), newList); + (port as any).setPendingTradeShipDueTicks(newList); + } + } + private assignRoutes(carryOverMode: boolean, ticks: Tick): void { if (this.queue.length === 0) return; const available = this.availableShips(); @@ -689,6 +772,29 @@ export class TradeManagerExecution implements Execution { public requeueRoute(from: Player, to: Player): void { this.queue.push({ from, to }); } + + /** + * Detect trade ships that are idle (no phase, no target, not returning) but + * stranded on the ocean (not at a port tile). These ships can never be + * assigned a new route because availableShips() requires docking at a port. + * Spawn a lightweight execution to navigate them to their nearest port. + */ + private recoverStrandedShips(): void { + for (const ship of this.cachedShips) { + if (!ship.isActive()) continue; + if (ship.returning()) continue; + if (ship.tradePhase && ship.tradePhase() !== null) continue; + if (ship.targetUnit() !== undefined) continue; + // Ship is idle — check if it's on the ocean and NOT at a port + if (!this.mg.isOcean(ship.tile())) continue; + const isAtPort = this.mg + .unitsAt(ship.tile()) + .some((u) => u.type() === UnitType.Port); + if (isAtPort) continue; + // This ship is stranded. Send it home. + this.mg.addExecution(new StrandedTradeShipReturnExecution(ship)); + } + } } export class AssignedTradeRouteExecution implements Execution { @@ -896,6 +1002,8 @@ export class AssignedTradeRouteExecution implements Execution { // Propagate cleared phase immediately this.ship.touch(); this.active = false; + // If ship is on ocean and not at a port, send it home so it doesn't get stranded + this.sendHomeIfStranded(); this.log( `externalRetargetCancel ship=${this.ship.id()} oldTargetUnit=${this.ship .targetUnit() @@ -963,6 +1071,8 @@ export class AssignedTradeRouteExecution implements Execution { const neighbors = this.mg.neighbors(targetTile); const oceanAdj = neighbors.filter((t) => this.mg.isOcean(t)); this.active = false; + // Send ship home so it doesn't get stranded on the ocean + this.sendHomeIfStranded(); this.log( `abort ship=${this.ship.id()} reason=noNavTarget destPort=${expectedTargetUnit.id()} portTile=(${this.mg.x( targetTile, @@ -1107,6 +1217,8 @@ export class AssignedTradeRouteExecution implements Execution { ); } this.active = false; + // Send ship home so it doesn't get stranded on the ocean + this.sendHomeIfStranded(); this.log( `abort ship=${this.ship.id()} reason=pathNotFound phase=${this.phase} requeuedRoute=(${this.startPort.owner().smallID()}->${this.endPort .owner() @@ -1153,8 +1265,11 @@ export class AssignedTradeRouteExecution implements Execution { const ownerShare = ownerBaseTechShare; a.addGold(aShare); + a.recordTradeShipGold(aShare); b.addGold(bShare); + b.recordTradeShipGold(bShare); owner.addGold(ownerShare); + owner.recordTradeShipGold(ownerShare); // Clear trade phase upon successful completion so the ship is eligible for reassignment this.setPhaseWithLog(null, "complete_clear_phase"); @@ -1279,6 +1394,21 @@ export class AssignedTradeRouteExecution implements Execution { return list[0] ?? null; } + /** + * If the ship is on ocean and not co-located with a port, spawn a + * StrandedTradeShipReturnExecution to navigate it back home. + * Called from abort paths to prevent ships from being stranded. + */ + private sendHomeIfStranded(): void { + if (!this.ship.isActive()) return; + if (!this.mg.isOcean(this.ship.tile())) return; + const isAtPort = this.mg + .unitsAt(this.ship.tile()) + .some((u) => u.type() === UnitType.Port); + if (isAtPort) return; + this.mg.addExecution(new StrandedTradeShipReturnExecution(this.ship)); + } + // --- Logging helpers (human owners only) --- private log(msg: string): void { const owner = this.ship.owner(); @@ -1286,3 +1416,162 @@ export class AssignedTradeRouteExecution implements Execution { } // Per-tile movement logging removed per user request. } + +/** + * Lightweight execution that navigates a stranded idle trade ship back to its + * owner's nearest port. Once docked, the ship becomes available for new routes. + */ +class StrandedTradeShipReturnExecution implements Execution { + private mg!: Game; + private pathfinder!: SteppingPathFinder; + private active = true; + private lastMoveTick = 0; + private destPort: Unit | null = null; + + constructor(private ship: Unit) {} + + init(mg: Game, ticks: number): void { + this.mg = mg; + this.pathfinder = PathFinding.Water(mg); + this.lastMoveTick = ticks; + // Mark a transient trade phase so the recovery manager doesn't re-detect this ship + this.ship.setTradePhase("toStart"); + this.destPort = this.selectNearestPort(this.ship.owner()); + if (this.destPort) { + this.ship.setTargetUnit(this.destPort); + } else { + // No port to return to; clear phase and give up + this.ship.setTradePhase(null); + this.active = false; + } + } + + isActive(): boolean { + return this.active; + } + + activeDuringSpawnPhase(): boolean { + return false; + } + + tick(ticks: number): void { + if (!this.active) return; + if (!this.ship.isActive()) { + this.active = false; + return; + } + if (!this.destPort || !this.destPort.isActive()) { + this.destPort = this.selectNearestPort(this.ship.owner()); + if (!this.destPort) { + this.ship.setTradePhase(null); + this.ship.setTargetUnit(undefined); + this.active = false; + return; + } + this.ship.setTargetUnit(this.destPort); + } + + if (ticks - this.lastMoveTick < 1) return; + this.lastMoveTick = ticks; + + const targetTile = this.destPort.tile(); + + // Adjacent to port -> dock + if (this.mg.manhattanDist(this.ship.tile(), targetTile) === 1) { + this.ship.move(targetTile); + this.ship.setTradePhase(null); + this.ship.setTargetUnit(undefined); + this.ship.touch(); + this.active = false; + return; + } + + // Already on port tile + if (this.ship.tile() === targetTile) { + this.ship.setTradePhase(null); + this.ship.setTargetUnit(undefined); + this.ship.touch(); + this.active = false; + return; + } + + const navTarget = this.navTargetForPort(targetTile); + if (navTarget === null) { + this.ship.setTradePhase(null); + this.ship.setTargetUnit(undefined); + this.active = false; + return; + } + + // If somehow on land without being at a port, step into ocean + if (!this.mg.isOcean(this.ship.tile())) { + const adjOcean = this.mg + .neighbors(this.ship.tile()) + .filter((t) => this.mg.isOcean(t)) + .sort( + (a, b) => + this.mg.manhattanDist(a, navTarget) - + this.mg.manhattanDist(b, navTarget), + ); + if (adjOcean.length > 0) { + this.ship.move(adjOcean[0]); + return; + } + this.ship.setTradePhase(null); + this.ship.setTargetUnit(undefined); + this.active = false; + return; + } + + const res = this.pathfinder.next(this.ship.tile(), navTarget); + switch (res.status) { + case PathStatus.COMPLETE: + this.ship.move(navTarget); + break; + case PathStatus.NEXT: + this.ship.move(res.node); + break; + case PathStatus.PENDING: + this.ship.touch(); + break; + case PathStatus.NOT_FOUND: + // Cannot reach port; give up + this.ship.setTradePhase(null); + this.ship.setTargetUnit(undefined); + this.active = false; + break; + } + } + + private navTargetForPort(portTile: TileRef): TileRef | null { + if (this.mg.isOcean(portTile)) return portTile; + const candidates = this.mg + .neighbors(portTile) + .filter((t) => this.mg.isOcean(t)); + if (candidates.length === 0) return null; + candidates.sort( + (a, b) => + this.mg.manhattanDist(this.ship.tile(), a) - + this.mg.manhattanDist(this.ship.tile(), b), + ); + return candidates[0]; + } + + private selectNearestPort(owner: Player): Unit | null { + const ports = [...this.mg.units(UnitType.Port)].filter( + (p) => p.isActive() && p.owner() === owner, + ); + if (ports.length === 0) return null; + let best: Unit | null = null; + let bestDist = Number.POSITIVE_INFINITY; + const here = this.ship.tile(); + for (const p of ports) { + const d = this.mg.euclideanDistSquared(here, p.tile()); + if (d < bestDist) { + bestDist = d; + best = p; + } + } + return best; + } +} diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index 334f2dca4..b4af64876 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -133,12 +133,7 @@ export class TradeShipExecution implements Execution { private complete() { this.active = false; this.tradeShip!.delete(false); - const gold = this.mg - .config() - .tradeShipGold( - this.tilesTraveled, - this.tradeShip!.owner().unitCount(UnitType.Port), - ); + const gold = this.mg.config().tradeShipGold(this.tilesTraveled); if (this.wasCaptured) { this.tradeShip!.owner().addGold(gold, this._dstPort.tile()); diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index ad3c8ecc7..381a1895c 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -61,10 +61,8 @@ export class TransportShipExecution implements Execution { const defenderType = this.target.type(); if ( - (attackerType === PlayerType.Human || - attackerType === PlayerType.FakeHuman) && - (defenderType === PlayerType.Human || - defenderType === PlayerType.FakeHuman) + (attackerType === PlayerType.Human || attackerType === PlayerType.AI) && + (defenderType === PlayerType.Human || defenderType === PlayerType.AI) ) { mg.displayMessage( `Attack blocked: Peace timer is active.`, @@ -121,13 +119,31 @@ export class TransportShipExecution implements Execution { }); // Track intended target player on the boat for selective cancellation on peace (this.boat as any).setBoatTargetPlayerID?.(this.target.id()); - + // Track the destination tile on the boat so AI warships can intercept + (this.boat as any).setBoatTargetTile?.(this.dst); if (this.dst !== null) { this.boat.setTargetTile(this.dst); } else { this.boat.setTargetTile(undefined); } + // Immediately declare war on the target when launching a boat attack + if (this.target.isPlayer()) { + const targetPlayer = this.target as Player; + // Break alliance first if allied + const alliance = this.attacker.allianceWith(targetPlayer); + if (alliance) { + this.attacker.breakAlliance(alliance); + } + // Declare war if not already at war + if (!this.attacker.isAtWarWith(targetPlayer)) { + this.attacker.setWarWith(targetPlayer); + targetPlayer.setWarWith(this.attacker); + this.attacker.recordAggression(targetPlayer); + targetPlayer.recordAggression(this.attacker); + } + } + // Notify the target player about the incoming naval invasion if (this.target.id() !== mg.terraNullius().id()) { mg.displayIncomingUnit( @@ -162,6 +178,20 @@ export class TransportShipExecution implements Execution { } this.lastMove = ticks; + // Retreat if the destination tile's owner changed, unless we're at war + // with the new owner or the new owner is a bot + if (!this.boat.retreating() && this.dst !== null) { + const dstOwner = this.mg.owner(this.dst); + if ( + dstOwner !== this.target && + dstOwner.isPlayer() && + !this.attacker.isAtWarWith(dstOwner as Player) && + (dstOwner as Player).type() !== PlayerType.Bot + ) { + this.boat.orderBoatRetreat(); + } + } + if (this.boat.retreating()) { // Ensure retreat source is still valid for (new) owner if (this.mg.owner(this.src!) !== this.attacker) { diff --git a/src/core/execution/UnitCreationHelper.ts b/src/core/execution/UnitCreationHelper.ts deleted file mode 100644 index 2900eec5e..000000000 --- a/src/core/execution/UnitCreationHelper.ts +++ /dev/null @@ -1,511 +0,0 @@ -import { Game, Gold, Player, TerrainType, UnitType } from "../game/Game"; -import { TileRef } from "../game/GameMap"; -import { PseudoRandom } from "../PseudoRandom"; -import { ConstructionExecution } from "./ConstructionExecution"; - -export class UnitCreationHelper { - private static readonly CITY_DENSITY_PER_TILE = 1 / 6000; - private static readonly PORT_DENSITY_PER_TILE = 1 / 12000; - private static readonly MIN_BUILDING_DISTANCE_SQUARED = 1600; // 40 tiles squared - private static readonly DEFENSE_POST_DENSITY_PER_BORDER_TILE = 1 / 110; - private static readonly MAX_DISTANCE_FROM_BORDER_SQUARED = 400; // 20 tiles squared - private static readonly MIN_DISTANCE_FROM_BORDER_SQUARED = 100; // 10 tiles squared - private static readonly MIN_DISTANCE_BETWEEN_DEFENSE_POSTS_SQUARED = 900; // 30 tiles squared - private static readonly MAX_PLACEMENT_ATTEMPTS = 100; - // Spatial bucket size for proximity checks around existing buildings. - // Using the minimum allowed distance (~40) ensures we only examine buildings - // that could possibly violate spacing for a candidate tile. - private static readonly BUILDING_BUCKET_SIZE = 40; - - constructor( - private random: PseudoRandom, - private mg: Game, - private player: Player, - ) {} - - // Per-handleUnits invocation caches to avoid repeated heavy work. - // They are reset at the start of handleUnits(). - private spawnCache: Map = new Map(); - private ownedTilesCache: TileRef[] | null = null; - private shoreOwnedTilesCache: TileRef[] | null = null; - - // Bucketed map of existing non-defense buildings for fast radius checks. - private buildingBuckets: Map< - string, - Array<{ tile: TileRef; x: number; y: number }> - > | null = null; - - handleUnits() { - // Reset per-tick caches – this helper may be called multiple times per tick - // and structureSpawnTile can be expensive without caching. - this.spawnCache.clear(); - this.ownedTilesCache = null; - this.shoreOwnedTilesCache = null; - this.buildingBuckets = null; - - const cityInfo = this.getDensityBasedStructureInfo(UnitType.City); - const portInfo = this.getDensityBasedStructureInfo(UnitType.Port); - - let chosenType: UnitType | null = null; - let chosenTile: TileRef | null = null; - - if (cityInfo.canBuild && portInfo.canBuild) { - if (cityInfo.cost < portInfo.cost) { - chosenType = UnitType.City; - chosenTile = cityInfo.tile; - } else if (portInfo.cost < cityInfo.cost) { - chosenType = UnitType.Port; - chosenTile = portInfo.tile; - } else { - // Costs are equal, choose based on density gap - if (cityInfo.densityGap > portInfo.densityGap) { - chosenType = UnitType.City; - chosenTile = cityInfo.tile; - } else { - chosenType = UnitType.Port; - chosenTile = portInfo.tile; - } - } - } else if (cityInfo.canBuild) { - chosenType = UnitType.City; - chosenTile = cityInfo.tile; - } else if (portInfo.canBuild) { - chosenType = UnitType.Port; - chosenTile = portInfo.tile; - } - - if (chosenType !== null && chosenTile !== null) { - this.mg.addExecution( - new ConstructionExecution(this.player, chosenType, chosenTile), - ); - return true; - } - - return ( - this.maybeSpawnStructure(UnitType.Airfield, 1) || - this.maybeSpawnNavalUnit() || - this.maybeSpawnArtillery() || - this.maybeSpawnSAMLauncher() || - this.maybeSpawnStructure(UnitType.MissileSilo, 1) || - this.maybeSpawnDefensePost() - ); - } - - private getDensityBasedStructureInfo(type: UnitType): { - canBuild: boolean; - cost: Gold; - densityGap: number; - tile: TileRef | null; - } { - const tilesOwned = this.player.tiles().size; - if (tilesOwned === 0) { - return { canBuild: false, cost: 0n, densityGap: 0, tile: null }; - } - - const densityThreshold = - type === UnitType.City - ? UnitCreationHelper.CITY_DENSITY_PER_TILE - : UnitCreationHelper.PORT_DENSITY_PER_TILE; - - const currentDensity = this.player.unitsOwned(type) / tilesOwned; - const cost: Gold = this.cost(type); - const densityGap = (densityThreshold - currentDensity) / densityThreshold; - - if (currentDensity < densityThreshold && this.player.gold() >= cost) { - const tile = this.structureSpawnTile(type); - if (tile !== null && this.player.canBuild(type, tile)) { - return { canBuild: true, cost, densityGap, tile }; - } - } - return { canBuild: false, cost, densityGap, tile: null }; - } - - private maybeSpawnStructure(type: UnitType, maxNum: number): boolean { - if (this.player.unitsOwned(type) >= maxNum) { - return false; - } - if (this.player.gold() < this.cost(type)) { - return false; - } - const tile = this.structureSpawnTile(type); - if (tile === null) { - return false; - } - const canBuild = this.player.canBuild(type, tile); - if (canBuild === false) { - return false; - } - this.mg.addExecution(new ConstructionExecution(this.player, type, tile)); - return true; - } - - private structureSpawnTile(type: UnitType): TileRef | null { - // Use memoized result if available within the same handleUnits() pass. - const cached = this.spawnCache.get(type); - if (cached !== undefined) return cached; - - // Get owned tiles (cached) - this.ownedTilesCache ??= Array.from(this.player.tiles()); - - // Restrict to shoreline for ports (cached) - let candidateTiles: TileRef[]; - if (type === UnitType.Port) { - // Filter once per tick; mg.isOceanShore is relatively cheap but can add up. - this.shoreOwnedTilesCache ??= this.ownedTilesCache.filter((t) => - this.mg.isOceanShore(t), - ); - candidateTiles = this.shoreOwnedTilesCache; - } else { - candidateTiles = this.ownedTilesCache; - } - - if (candidateTiles.length === 0) { - this.spawnCache.set(type, null); - return null; - } - - // For most structures we must keep a minimum distance from existing non-defense buildings. - const mustRespectSpacing = - type !== UnitType.DefensePost && - type !== UnitType.SAMLauncher && - type !== UnitType.MissileSilo; - - // Build spatial buckets of existing buildings once per tick for fast neighborhood checks. - if (mustRespectSpacing) { - this.buildingBuckets ??= this.buildBuildingBuckets(); - } - - const isValid = (tile: TileRef): boolean => { - if (!mustRespectSpacing) return true; - if (this.buildingBuckets === null) return true; // defensive - const minDistSq = UnitCreationHelper.MIN_BUILDING_DISTANCE_SQUARED; - const tx = this.mg.x(tile); - const ty = this.mg.y(tile); - const cellSize = UnitCreationHelper.BUILDING_BUCKET_SIZE; - const cx = Math.floor(tx / cellSize); - const cy = Math.floor(ty / cellSize); - - // Only check nearby buckets (3x3 neighborhood) - for (let dx = -1; dx <= 1; dx++) { - for (let dy = -1; dy <= 1; dy++) { - const key = `${cx + dx},${cy + dy}`; - const bucket = this.buildingBuckets.get(key); - if (!bucket) continue; - for (const b of bucket) { - // Early prune on axis if obviously far - const ddx = tx - b.x; - if (ddx > cellSize || ddx < -cellSize) continue; - const ddy = ty - b.y; - if (ddy > cellSize || ddy < -cellSize) continue; - const dist = ddx * ddx + ddy * ddy; - if (dist < minDistSq) return false; - } - } - } - return true; - }; - - // 1) Fast path: random rejection sampling with bounded attempts (uniform over valid tiles in expectation). - for (let i = 0; i < UnitCreationHelper.MAX_PLACEMENT_ATTEMPTS; i++) { - const tile = this.random.randElement(candidateTiles); - if (isValid(tile)) { - this.spawnCache.set(type, tile); - return tile; - } - } - - // 2) Fallback: linear scan starting at a random offset to reduce bias. - const n = candidateTiles.length; - const start = this.random.nextInt(0, Math.max(0, n - 1)); - for (let k = 0; k < n; k++) { - const tile = candidateTiles[(start + k) % n]; - if (isValid(tile)) { - this.spawnCache.set(type, tile); - return tile; - } - } - - this.spawnCache.set(type, null); - return null; - } - - private maybeSpawnNavalUnit(): boolean { - const warshipCount = this.player.units(UnitType.Warship).length; - const submarineCount = this.player.units(UnitType.Submarine).length; - const navalCombatUnitCount = warshipCount + submarineCount; - - const ports = this.player.units(UnitType.Port); - if (ports.length > 0 && navalCombatUnitCount === 0) { - const unitToBuild = this.random.chance(50) - ? UnitType.Submarine - : UnitType.Warship; - - if (this.player.gold() > this.cost(unitToBuild)) { - const port = this.random.randElement(ports); - const targetTile = this.navalUnitSpawnTile(port.tile()); - if (targetTile === null) { - return false; - } - const canBuild = this.player.canBuild(unitToBuild, targetTile); - if (canBuild === false) { - console.warn(`cannot spawn ${unitToBuild}`); - return false; - } - this.mg.addExecution( - new ConstructionExecution(this.player, unitToBuild, targetTile), - ); - return true; - } - } - return false; - } - - private maybeSpawnArtillery(): boolean { - const artilleryCount = this.player.units(UnitType.Artillery).length; - - const factories = this.player.units(UnitType.Factory); - if (factories.length > 0 && artilleryCount === 0) { - if (this.player.gold() > this.cost(UnitType.Artillery)) { - const factory = this.random.randElement(factories); - const targetTile = this.landUnitSpawnTile(factory.tile()); - if (targetTile === null) { - return false; - } - const canBuild = this.player.canBuild(UnitType.Artillery, targetTile); - if (canBuild === false) { - return false; - } - this.mg.addExecution( - new ConstructionExecution( - this.player, - UnitType.Artillery, - targetTile, - ), - ); - return true; - } - } - return false; - } - - private landUnitSpawnTile(factoryTile: TileRef): TileRef | null { - const radius = 100; - for (let attempts = 0; attempts < 50; attempts++) { - const randX = this.random.nextInt( - this.mg.x(factoryTile) - radius, - this.mg.x(factoryTile) + radius, - ); - const randY = this.random.nextInt( - this.mg.y(factoryTile) - radius, - this.mg.y(factoryTile) + radius, - ); - if (!this.mg.isValidCoord(randX, randY)) { - continue; - } - const tile = this.mg.ref(randX, randY); - // Must be land and not barrier - if ( - this.mg.isOcean(tile) || - this.mg.terrainType(tile) === TerrainType.Barrier - ) { - continue; - } - return tile; - } - return null; - } - - private navalUnitSpawnTile(portTile: TileRef): TileRef | null { - const radius = 250; - for (let attempts = 0; attempts < 50; attempts++) { - const randX = this.random.nextInt( - this.mg.x(portTile) - radius, - this.mg.x(portTile) + radius, - ); - const randY = this.random.nextInt( - this.mg.y(portTile) - radius, - this.mg.y(portTile) + radius, - ); - if (!this.mg.isValidCoord(randX, randY)) { - continue; - } - const tile = this.mg.ref(randX, randY); - // Sanity check - if (!this.mg.isOcean(tile)) { - continue; - } - return tile; - } - return null; - } - - private maybeSpawnDefensePost(): boolean { - // keep only those border tiles that touch enemy land - const frontlineBorders = Array.from(this.player.borderTiles()).filter((t) => - this.touchesEnemyLand(t), - ); - if (frontlineBorders.length === 0) return false; // nothing worth guarding - - const currentDensity = - this.player.unitsOwned(UnitType.DefensePost) / frontlineBorders.length; - const cost = this.cost(UnitType.DefensePost); - - if ( - currentDensity < - UnitCreationHelper.DEFENSE_POST_DENSITY_PER_BORDER_TILE && - this.player.gold() >= cost - ) { - const tile = this.findSuitableDefensePostTile(frontlineBorders); - if (tile && this.player.canBuild(UnitType.DefensePost, tile)) { - this.mg.addExecution( - new ConstructionExecution(this.player, UnitType.DefensePost, tile), - ); - return true; - } - } - return false; - } - private maybeSpawnSAMLauncher(): boolean { - // Build 1 SAM for every silo / airfield that has none within 40 tiles. - const sams = this.player.units(UnitType.SAMLauncher); - const silos = this.player.units(UnitType.MissileSilo); - const airfields = this.player.units(UnitType.Airfield); - const samRadiusSq = 40 * 40; // 40-tile protection radius - const cost = this.cost(UnitType.SAMLauncher); - if (this.player.gold() < cost) return false; - - // iterate over all “protected” buildings - for (const b of [...silos, ...airfields]) { - const alreadyCovered = sams.some( - (s) => this.mg.euclideanDistSquared(b.tile(), s.tile()) <= samRadiusSq, - ); - if (alreadyCovered) continue; - - // find an own land tile ≤ 40 away - const candidateTiles = Array.from(this.player.tiles()).filter( - (t) => - this.mg.isLand(t) && - this.mg.euclideanDistSquared(t, b.tile()) <= samRadiusSq, - ); - if (candidateTiles.length === 0) continue; - - const buildTile = this.random.randElement(candidateTiles); - if (!this.player.canBuild(UnitType.SAMLauncher, buildTile)) continue; - - this.mg.addExecution( - new ConstructionExecution(this.player, UnitType.SAMLauncher, buildTile), - ); - return true; // build one SAM per tick at most - } - return false; - } - - private touchesEnemyLand(tile: TileRef): boolean { - for (const n of this.adjacentTiles(tile)) { - if (this.mg.isLand(n) && this.mg.owner(n) !== this.player) { - return true; // enemy LAND neighbour – frontline - } - } - return false; // pure coastline or internal border - } - private findSuitableDefensePostTile( - frontlineBorders: TileRef[], - ): TileRef | null { - const ownedTiles = Array.from(this.player.tiles()); - const existingPosts = this.player.units(UnitType.DefensePost); - - if (ownedTiles.length === 0) return null; - - for (let i = 0; i < UnitCreationHelper.MAX_PLACEMENT_ATTEMPTS; i++) { - const tile = this.random.randElement(ownedTiles); - - // 1- distance to *any* frontline border must be ≤ 20 (squared ≤ 400) - const nearFront = frontlineBorders.some( - (b) => - this.mg.euclideanDistSquared(tile, b) <= - UnitCreationHelper.MAX_DISTANCE_FROM_BORDER_SQUARED, - ); - if (!nearFront) continue; - - // 2- distance to *any* frontline border must be ≥ 10 (squared ≥ 100) - const farEnoughFromBorder = frontlineBorders.every( - (b) => - this.mg.euclideanDistSquared(tile, b) >= - UnitCreationHelper.MIN_DISTANCE_FROM_BORDER_SQUARED, - ); - if (!farEnoughFromBorder) continue; - - // 3- stay ≥ 30 tiles away from every existing defence post - const overlaps = existingPosts.some( - (p) => - this.mg.euclideanDistSquared(tile, p.tile()) <= - UnitCreationHelper.MIN_DISTANCE_BETWEEN_DEFENSE_POSTS_SQUARED, - ); - if (overlaps) continue; - - return tile; // found a good slot - } - return null; - } - - private cost(type: UnitType): Gold { - return this.mg.unitInfo(type).cost(this.player); - } - - /** Returns the 8 adjacent tiles of a tile, skipping out-of-bounds ones. */ - private adjacentTiles(tile: TileRef): TileRef[] { - const cx = this.mg.x(tile); - const cy = this.mg.y(tile); - const result: TileRef[] = []; - - for (let dx = -1; dx <= 1; dx++) { - for (let dy = -1; dy <= 1; dy++) { - if (dx === 0 && dy === 0) continue; - const nx = cx + dx; - const ny = cy + dy; - if (!this.mg.isValidCoord(nx, ny)) continue; - result.push(this.mg.ref(nx, ny)); - } - } - - return result; - } - - // Build buckets of existing buildings (excluding DefensePost and SAMLauncher) - // grouped by coarse grid cells for near-neighbor queries. - private buildBuildingBuckets(): Map< - string, - Array<{ tile: TileRef; x: number; y: number }> - > { - const buckets = new Map< - string, - Array<{ tile: TileRef; x: number; y: number }> - >(); - const cellSize = UnitCreationHelper.BUILDING_BUCKET_SIZE; - - const existingBuildings = this.player - .units() - .filter( - (unit) => - unit.type() !== UnitType.DefensePost && - unit.type() !== UnitType.SAMLauncher, - ); - - for (const b of existingBuildings) { - const tile = b.tile(); - const x = this.mg.x(tile); - const y = this.mg.y(tile); - const cx = Math.floor(x / cellSize); - const cy = Math.floor(y / cellSize); - const key = `${cx},${cy}`; - let arr = buckets.get(key); - if (!arr) { - arr = []; - buckets.set(key, arr); - } - arr.push({ tile, x, y }); - } - - return buckets; - } -} diff --git a/src/core/execution/UpgradeBomberExecution.ts b/src/core/execution/UpgradeBomberExecution.ts deleted file mode 100644 index 2627adfe0..000000000 --- a/src/core/execution/UpgradeBomberExecution.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Execution, Gold, Player, Unit, UnitType } from "../game/Game"; -import { GameImpl } from "../game/GameImpl"; -import { getUnitUpgradeCost } from "../game/UnitUpgrades"; -import { playerMaxUnitLevel } from "../game/Upgradeables"; -import { NoOpExecution } from "./NoOpExecution"; - -/** - * Upgrades all bombers associated with an airfield. - * Uses hardcoded per-level upgrade costs from UnitUpgrades. - */ -export class UpgradeBomberExecution implements Execution { - executionName = "UpgradeBomberExecution"; - private mg!: GameImpl; - private _isActive = true; - - constructor( - private player: Player, - private airfield: Unit, - ) {} - - isActive(): boolean { - return this._isActive; - } - - activeDuringSpawnPhase(): boolean { - return true; - } - - init(mg: GameImpl, _ticks: number): void { - this.mg = mg; - - // Validate airfield - if (!this.airfield.isUnit?.() || !this.airfield.isActive()) { - this._isActive = false; - return; - } - if (this.airfield.owner() !== this.player) { - this._isActive = false; - return; - } - if (this.airfield.type() !== UnitType.Airfield) { - this._isActive = false; - return; - } - - // Get bombers for this airfield - const bombers = this.player - .units(UnitType.Bomber) - .filter((b) => b.sourceAirfield?.()?.id() === this.airfield.id()); - - if (bombers.length === 0) { - this._isActive = false; - return; - } - - // Check if airfield's bomber level can be upgraded (not at player's max level) - const currentBomberLevel = this.airfield.bomberLevel?.() ?? 1; - if ( - currentBomberLevel >= playerMaxUnitLevel(this.player, UnitType.Bomber) - ) { - this._isActive = false; - return; - } - - // Get hardcoded upgrade cost from UnitUpgrades (fromLevel -> nextLevel) - const upgradeCost: Gold = getUnitUpgradeCost( - UnitType.Bomber, - currentBomberLevel, - ); - - if (this.player.gold() < upgradeCost) { - this._isActive = false; - return; - } - - // Deduct cost and upgrade airfield's bomber level - const newLevel = currentBomberLevel + 1; - this.player.removeGold(upgradeCost); - this.airfield.setBomberLevel?.(newLevel); - - // Update existing bombers' max health to match new level - const baseHealth = this.mg.unitInfo(UnitType.Bomber).maxHealth ?? 500; - const newMaxHealth = this.mg.config().bomberMaxHealth(newLevel); - const bonus = newMaxHealth - baseHealth; - for (const bomber of bombers) { - (bomber as any)._bonusMaxHealth = bonus > 0 ? bonus : 0; - // Emit update so client sees new max health - this.mg.addUpdate(bomber.toUpdate()); - } - - this._isActive = false; - } - - tick(_ticks: number): void { - // One-shot handled in init - } - - static fromIntent( - mg: GameImpl, - intent: { - type: "upgrade_bomber"; - airfieldId: number; - clientID: string; - }, - ): Execution { - const player = mg.playerByClientID(intent.clientID); - if (!player) return new NoOpExecution(); - const airfield = player - .units(UnitType.Airfield) - .find((u) => u.id() === intent.airfieldId); - if (!airfield) return new NoOpExecution(); - return new UpgradeBomberExecution(player, airfield); - } -} diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index e63d89ca7..fbac88ab1 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -187,7 +187,7 @@ export class WarshipExecution implements Execution { } } if (unit.type() === UnitType.TradeShip) { - if (!hasPort || unit.isSafeFromPirates()) { + if (!hasPort || unit.isSafeFromPirates() || this.isDockedAtPort(unit)) { continue; } // Keep patrol range constraint for trade ships @@ -587,4 +587,11 @@ export class WarshipExecution implements Execution { return this.mg.owner(targetTile) === this.warship.owner(); } + + /** Returns true when a trade ship is sitting on a port tile (docked). */ + private isDockedAtPort(tradeShip: Unit): boolean { + return this.mg + .unitsAt(tradeShip.tile()) + .some((u) => u.type() === UnitType.Port); + } } diff --git a/src/core/execution/alliance/AllianceExtensionExecution.ts b/src/core/execution/alliance/AllianceExtensionExecution.ts index 9594755b4..64f0a708c 100644 --- a/src/core/execution/alliance/AllianceExtensionExecution.ts +++ b/src/core/execution/alliance/AllianceExtensionExecution.ts @@ -37,12 +37,11 @@ export class AllianceExtensionExecution implements Execution { // Mark this player's intent to extend alliance.requestExtension(from); - // If the other player is a bot or fake human, request extension on their behalf + // If the other player is a bot or AI, request extension on their behalf if ( this.to.type && typeof this.to.type === "function" && - (this.to.type() === PlayerType.Bot || - this.to.type() === PlayerType.FakeHuman) + (this.to.type() === PlayerType.Bot || this.to.type() === PlayerType.AI) ) { alliance.requestExtension(this.to); } diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts deleted file mode 100644 index f7260b3e2..000000000 --- a/src/core/execution/utils/BotBehavior.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { - AllianceRequest, - Game, - Player, - PlayerType, - Relation, - TerraNullius, - Tick, - UnitType, -} from "../../game/Game"; -import { PseudoRandom } from "../../PseudoRandom"; -import { flattenedEmojiTable } from "../../Util"; -import { AttackExecution } from "../AttackExecution"; -import { EmojiExecution } from "../EmojiExecution"; -import { SetAutoBombingExecution } from "../SetAutoBombingExecution"; - -export class BotBehavior { - private enemy: Player | null = null; - private enemyUpdated: Tick; - public enemySearchRadius = 100; - - private assistAcceptEmoji = flattenedEmojiTable.indexOf("👍"); - - private firstAttackSent = false; - - constructor( - private random: PseudoRandom, - private game: Game, - private player: Player, - private triggerRatio: number, - private reserveRatio: number, - ) {} - - handleAllianceRequests() { - for (const req of this.player.incomingAllianceRequests()) { - if (shouldAcceptAllianceRequest(this.player, req)) { - req.accept(); - } else { - req.reject(); - } - } - } - - handleBombers() { - if (this.player.units(UnitType.Airfield).length > 0) { - if (!this.player.isAutoBombingEnabled()) { - this.game.addExecution(new SetAutoBombingExecution(this.player, true)); - } - } - } - - private emoji(player: Player, emoji: number) { - if (player.type() !== PlayerType.Human) return; - this.game.addExecution(new EmojiExecution(this.player, player.id(), emoji)); - } - - private setNewEnemy(newEnemy: Player | null) { - this.enemySearchRadius = 100; - this.enemy = newEnemy; - this.enemyUpdated = this.game.ticks(); - } - - public clearEnemy() { - this.enemy = null; - } - - forgetOldEnemies() { - // Forget old enemies - if (this.game.ticks() - this.enemyUpdated > 200) { - this.clearEnemy(); - } - } - - private hasSufficientTroops(): boolean { - const maxPop = this.game.config().maxPopulation(this.player); - const ratio = this.player.population() / maxPop; - return ratio >= this.triggerRatio; - } - - private checkIncomingAttacks() { - // Switch enemies if we're under attack - const incomingAttacks = this.player.incomingAttacks(); - let largestAttack = 0; - let largestAttacker: Player | undefined; - for (const attack of incomingAttacks) { - if (attack.troops() <= largestAttack) continue; - largestAttack = attack.troops(); - largestAttacker = attack.attacker(); - } - if (largestAttacker !== undefined) { - this.setNewEnemy(largestAttacker); - } - } - - assistAllies() { - outer: for (const ally of this.player.allies()) { - if (ally.targets().length === 0) continue; - if (this.player.relation(ally) < Relation.Friendly) { - // this.emoji(ally, "🤦"); - continue; - } - for (const target of ally.targets()) { - if (target === this.player) { - // this.emoji(ally, "💀"); - continue; - } - if (this.player.isAlliedWith(target)) { - // this.emoji(ally, "👎"); - continue; - } - // All checks passed, assist them - this.player.updateRelation(ally, -20); - this.setNewEnemy(target); - this.emoji(ally, this.assistAcceptEmoji); - break outer; - } - } - } - - selectEnemy(): Player | null { - if (this.enemy !== null) return this.enemySanityCheck(); - if (!this.hasSufficientTroops()) return null; - - /* ---------- 1. lowest-density neighbouring bot (unchanged) ---------- */ - const bots = this.player - .neighbors() - .filter((n): n is Player => n.isPlayer() && n.type() === PlayerType.Bot); - - if (bots.length) { - const density = (p: Player) => p.troops() / p.numTilesOwned(); - let best: Player | null = null; - let bestD = Infinity; - for (const b of bots) { - const d = density(b); - if (d < bestD) { - bestD = d; - best = b; - } - } - if (best) { - this.setNewEnemy(best); - return this.enemySanityCheck(); - } - } - - /* ---------- 2. retaliation if attacked (unchanged) ---------- */ - this.checkIncomingAttacks(); - if (this.enemy) return this.enemySanityCheck(); - - /* ---------- 3. weakest nearby player, using *sampled* border tiles ---------- */ - const ourBordersAll = Array.from(this.player.borderTiles()); - const ourBordersSample = this.random.sampleArray(ourBordersAll, 10); // ≤10 tiles - const radSq = this.enemySearchRadius * this.enemySearchRadius; - - let weakest: Player | null = null; - let weakestTroops = Infinity; - - for (const p of this.game.players()) { - if (!p.isPlayer() || p === this.player || this.player.isFriendly(p)) - continue; - - // Direct neighbour counts immediately - if (this.player.neighbors().includes(p)) { - if (p.troops() < weakestTroops) { - weakest = p; - weakestTroops = p.troops(); - } - continue; - } - - // Sample up to 10 of their border tiles - const theirBorders = this.random.sampleArray( - Array.from(p.borderTiles()), - 10, - ); - if (!theirBorders.length) continue; - - // Cheap nested loop: ≤100 distance checks per player - let closeEnough = false; - outer: for (const tb of theirBorders) { - for (const ob of ourBordersSample) { - const dx = this.game.x(ob) - this.game.x(tb); - const dy = this.game.y(ob) - this.game.y(tb); - if (dx * dx + dy * dy <= radSq) { - closeEnough = true; - break outer; - } - } - } - - if (closeEnough && p.troops() < weakestTroops) { - weakest = p; - weakestTroops = p.troops(); - } - } - - if (weakest) { - this.setNewEnemy(weakest); // resets radius to 100 - } else { - this.enemySearchRadius += 50; // widen search next tick - } - - return this.enemySanityCheck(); - } - - private enemySanityCheck(): Player | null { - if (this.enemy && this.player.isFriendly(this.enemy)) { - this.clearEnemy(); - } - return this.enemy; - } - - sendAttack(target: Player | TerraNullius) { - if (target.isPlayer() && this.player.isOnSameTeam(target)) return; - - if (target.isPlayer()) { - const isPeaceTimerActive = - this.game.peaceTimerEndsAtTick !== null && - this.game.ticks() < this.game.peaceTimerEndsAtTick; - - const attackerType = this.player.type(); - const defenderType = target.type(); - - if ( - isPeaceTimerActive && - (attackerType === PlayerType.Human || - attackerType === PlayerType.FakeHuman) && - (defenderType === PlayerType.Human || - defenderType === PlayerType.FakeHuman) - ) { - // Do not send attack if peace timer is active and both are protected types - return; - } - } - - const maxPop = this.game.config().maxPopulation(this.player); - const maxTroops = maxPop * this.player.targetTroopRatio(); - const targetTroops = maxTroops * this.reserveRatio; - // Don't wait until it has sufficient reserves to send the first attack - // to prevent the bot from waiting too long at the start of the game. - let troops = this.firstAttackSent - ? this.player.troops() - targetTroops - : this.player.troops() / 5; - if (target.isPlayer()) { - troops = Math.min(troops, target.troops() * 3); - } - if (troops < 1) return; - this.firstAttackSent = true; - this.game.addExecution( - new AttackExecution( - troops, - this.player, - target.isPlayer() ? target.id() : null, - ), - ); - } -} - -function shouldAcceptAllianceRequest(player: Player, request: AllianceRequest) { - if (player.relation(request.requestor()) < Relation.Neutral) { - return false; // Reject if hasMalice - } - if (request.requestor().isTraitor()) { - return false; // Reject if isTraitor - } - if (request.requestor().numTilesOwned() > player.numTilesOwned() * 3) { - return true; // Accept if requestorIsMuchLarger - } - if (request.requestor().alliances().length >= 3) { - return false; // Reject if tooManyAlliances - } - return true; // Accept otherwise -} diff --git a/src/core/game/CargoManager.ts b/src/core/game/CargoManager.ts index 5fb1d57f3..84a9449e5 100644 --- a/src/core/game/CargoManager.ts +++ b/src/core/game/CargoManager.ts @@ -184,7 +184,9 @@ export class CargoManager { ), ); truck.owner.addGold(originGold); + truck.owner.recordCargoTruckGold(originGold); truck.destinationOwner.addGold(destinationGold); + truck.destinationOwner.recordCargoTruckGold(destinationGold); this.game.displayMessage( "messages.international_trade_origin", MessageType.RECEIVED_GOLD_FROM_TRADE, @@ -210,6 +212,7 @@ export class CargoManager { // --- REVISED: Domestic Arrival --- const gold = this.game.config().cargoTruckGold(truck.path.length); truck.owner.addGold(gold); + truck.owner.recordCargoTruckGold(gold); const currentGold = this.domesticGoldSinceLastMessage.get(truck.owner.id()) ?? 0n; this.domesticGoldSinceLastMessage.set( diff --git a/src/core/game/Costs.ts b/src/core/game/Costs.ts index 7cf64c040..5c5051b5e 100644 --- a/src/core/game/Costs.ts +++ b/src/core/game/Costs.ts @@ -1,6 +1,5 @@ import { Gold, UnitType } from "./Game"; import { getUnitLevelCost, getUnitUpgradeData } from "./UnitUpgrades"; -import { maxUnitLevel } from "./Upgradeables"; const SCALE = 100n; // two decimal places of precision @@ -64,32 +63,3 @@ export function aggregateStructureBuildCost( } return total; } - -type AirfieldCostProvider = { - unitInfo: (t: UnitType) => { cost: (player: any) => Gold }; -}; - -/** - * Compute bomber upgrade cost for airfields during construction. - * Now uses hardcoded costs from UnitUpgrades instead of calculating. - */ -export function computeBomberUpgradeCost( - provider: AirfieldCostProvider, - player: any, - bomberLevel: number, - _airfieldLevel: number = 1, -): Gold { - const bLevel = Math.min( - maxUnitLevel(UnitType.Bomber), - Math.max(1, bomberLevel), - ); - if (bLevel <= 1) return 0n; - // Check if infinite gold is enabled for human players via base bomber cost - const baseBomberCost = provider.unitInfo(UnitType.Bomber).cost(player); - if (baseBomberCost === 0n) { - // If base cost is 0 (infinite gold enabled), return 0 for upgrades too - return 0n; - } - // Use hardcoded total cost from UnitUpgrades - return getUnitLevelCost(UnitType.Bomber, bLevel); -} diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 76caec2f8..642eaa11d 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -3,6 +3,10 @@ import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph"; import { PathFinder } from "../pathfinding/types"; import { AllPlayersStats, ClientID } from "../Schemas"; import { Category } from "../tech/ResearchTree"; +import { + AttackSpeedModifiers, + DefenseCasualtyModifiers, +} from "../tech/TechEffects"; import { Cell, GameMap, MapPos, TerrainType, TileRef } from "./GameMap"; import { @@ -22,9 +26,6 @@ export type Gold = bigint; export const AllPlayers = "AllPlayers" as const; -// Attack execution subticks per game tick for smoother territory changes -export const ATTACK_SUBTICKS_PER_TICK = 10; - // export type GameUpdates = Record; // Create a type that maps GameUpdateType to its corresponding update type type UpdateTypeMap = Extract; @@ -356,7 +357,7 @@ export class Nation { export enum PlayerType { Bot = "BOT", Human = "HUMAN", - FakeHuman = "FAKEHUMAN", + AI = "AI", } export interface Execution { @@ -396,6 +397,14 @@ export interface AllianceRequest { createdAt(): Tick; } +export interface PeaceRequest { + accept(): void; + reject(): void; + requestor(): Player; + recipient(): Player; + createdAt(): Tick; +} + export interface Alliance { requestor(): Player; recipient(): Player; @@ -568,6 +577,14 @@ export interface Embargo { target: PlayerID; } +export interface TradeDemandMetrics { + shipCount: number; + availableShips: number; + queueLen: number; + queueRatio: number; + availableRatio: number; +} + export interface Player { // Basic Info smallID(): number; @@ -610,15 +627,33 @@ export interface Player { workers(): number; troops(): number; attackingTroops(): number; + militaryStrength(): number; targetTroopRatio(): number; productivity(): number; // Returns the productivity rate based on investment rate updateProductivity(): void; productivityGrowthPerMinute(): number; // Returns the productivity growth per minute removeProductivity(amount: number): void; investmentRate(): number; // Returns the investment rate (0 to 1) + + // Income tracking (per-minute estimates, updated every tick via EMA) + /** Record gold received from a cargo truck delivery */ + recordCargoTruckGold(gold: Gold): void; + /** Record gold received from a trade ship delivery */ + recordTradeShipGold(gold: Gold): void; + /** Update income-per-minute EMA trackers (call every tick) */ + updateIncomeTracking(): void; + /** Gold earned from cargo trucks per minute (EMA) */ + cargoTruckGoldPerMinute(): number; + /** Gold earned from trade ships per minute (EMA) */ + tradeShipGoldPerMinute(): number; + /** Estimated total gold income per minute (net industrial + cargo + trade) */ + estimatedGoldIncomePerMinute(): number; setInvestmentRate(rate: number): void; // Economic: Industrial Production proxy (formerly GDP) - industrialProduction(): number; // Computed as config.industrialProductionFactor() * maxPopulation(this) + /** Raw (unrounded) industrial production: 0.11 * workers^0.65 * productivity * factoryFactor * domesticIncomeMul */ + rawIndustrialProduction(): number; + /** Floored version for display / wire */ + industrialProduction(): number; // Roads: investment ratio (0..1) of per-tick income allocated to roads roadInvestmentRate(): number; setRoadInvestmentRate(rate: number): void; @@ -647,6 +682,9 @@ export interface Player { // Internal mutators used by infrastructure systems addRoadNetworkLength(delta: number): void; + // Trade demand metrics for AI scoring and UI + tradeDemandMetrics(queueLen: number): TradeDemandMetrics; + // Units units(...types: UnitType[]): Unit[]; unitCount(type: UnitType): number; @@ -656,6 +694,11 @@ export interface Player { invalidateEffectiveUnitsCache(type: UnitType): void; buildableUnits(tile: TileRef): BuildableUnit[]; canBuild(type: UnitType, targetTile: TileRef): TileRef | false; + /** + * Check if a structure can be built at a tile, ignoring gold cost. + * Used by AI for tile evaluation. + */ + canBuildAtTile(type: UnitType, targetTile: TileRef): TileRef | false; buildUnit( type: T, spawnTile: TileRef, @@ -673,12 +716,26 @@ export interface Player { hasResearchedTech(techId: string): boolean; addResearchedTech(techId: string): void; removeResearchedTechsByCategory(category: Category): void; + // Cached casualty/speed modifiers (based on researched techs) + getAttackCasualtyModifiers(): DefenseCasualtyModifiers; + getDefenseCasualtyModifiers(): DefenseCasualtyModifiers; + getAttackSpeedModifiers(): AttackSpeedModifiers; captureUnit(unit: Unit): void; // Relations & Diplomacy neighbors(): (Player | TerraNullius)[]; sharesBorderWith(other: Player | TerraNullius): boolean; + /** Returns the number of border tiles shared with another player. */ + sharedBorderLength(other: Player | TerraNullius): number; + /** Returns true if this player has any border tiles on the ocean. Cached. */ + bordersOcean(): boolean; + /** Returns up to 4 extremum ocean shore tiles (min/max X/Y). Cached. */ + oceanShoreExtrema(): readonly TileRef[]; + /** Returns all ocean shore tiles. Cached. */ + oceanShoreTiles(): readonly TileRef[]; + /** Invalidates the cached neighbor set. Called internally when tile ownership changes. */ + invalidateNeighborCache(): void; relation(other: Player): Relation; allRelationsSorted(): { player: Player; relation: Relation }[]; updateRelation(other: Player, delta: number): void; @@ -704,6 +761,10 @@ export interface Player { canSendAllianceRequest(other: Player): boolean; breakAlliance(alliance: Alliance): void; createAllianceRequest(recipient: Player): AllianceRequest | null; + incomingPeaceRequests(): PeaceRequest[]; + outgoingPeaceRequests(): PeaceRequest[]; + canSendPeaceRequest(other: Player): boolean; + createPeaceRequest(recipient: Player): PeaceRequest | null; // Targeting canTarget(other: Player): boolean; @@ -812,6 +873,12 @@ export interface Game extends GameMap { markPlayerNodesForReconnection(player: Player): void; // Road KPIs getRoadNetworkQualityForPlayer(playerId: PlayerID): number; + /** + * Get the road maintenance rate as a fraction of gross gold (0-1). + * Represents what percentage of gross gold is needed to maintain + * current roads at their current quality. + */ + getRoadMaintenanceRateForPlayer(player: Player): number; // Helper for player road KPI calculations getRoadCountsForPlayer(player: Player): { completed: number; @@ -889,6 +956,10 @@ export interface Game extends GameMap { miniWaterGraph(): AbstractGraph | null; getWaterComponent(tile: TileRef): number | null; hasWaterComponent(tile: TileRef, component: number): boolean; + // Border update batching for bulk conquest operations. + // While a batch is open, border recalculations are deferred and deduped. + beginBorderBatch(): void; + endBorderBatch(): void; } export interface PlayerActions { @@ -957,6 +1028,9 @@ export enum MessageType { ALLIANCE_EXPIRED, WAR_DECLARED, PEACE_MADE, + PEACE_REQUEST, + PEACE_ACCEPTED, + PEACE_REJECTED, // Trade ship lifecycle events (new) TRADE_SHIP_CAPTURED, TRADE_SHIP_SUNK, @@ -1005,6 +1079,9 @@ export const MESSAGE_TYPE_CATEGORIES: Record = { [MessageType.ALLIANCE_EXPIRED]: MessageCategory.ALLIANCE, [MessageType.WAR_DECLARED]: MessageCategory.ALLIANCE, [MessageType.PEACE_MADE]: MessageCategory.ALLIANCE, + [MessageType.PEACE_REQUEST]: MessageCategory.ALLIANCE, + [MessageType.PEACE_ACCEPTED]: MessageCategory.ALLIANCE, + [MessageType.PEACE_REJECTED]: MessageCategory.ALLIANCE, [MessageType.WARN]: MessageCategory.ALLIANCE, [MessageType.PEACE_TIMER_BLOCKED]: MessageCategory.ATTACK, [MessageType.TRADE_SHIP_CAPTURED]: MessageCategory.ATTACK, diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 3877f2ca0..30fba99de 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -1,5 +1,4 @@ import { Config } from "../configuration/Config"; -import { AttackExecution } from "../execution/AttackExecution"; import { AbstractGraph, AbstractGraphBuilder, @@ -14,7 +13,6 @@ import { CargoManager } from "./CargoManager"; import { Alliance, AllianceRequest, - ATTACK_SUBTICKS_PER_TICK, Cell, ColoredTeams, Duos, @@ -43,6 +41,7 @@ import { } from "./Game"; import { GameMap, TileRef, TileUpdate } from "./GameMap"; import { GameUpdate, GameUpdateType } from "./GameUpdates"; +import { PeaceRequestImpl } from "./PeaceRequestImpl"; import { PlayerImpl } from "./PlayerImpl"; import { Road, RoadManager } from "./RoadManager"; import { Stats } from "./Stats"; @@ -68,6 +67,9 @@ export class GameImpl implements Game { private _ticks = 0; public peaceTimerEndsAtTick: Tick | null = null; + /** When non-null, border updates are deferred; tiles accumulate here. */ + private _dirtyBorderTiles: Set | null = null; + private unInitExecs: Execution[] = []; _players: Map = new Map(); @@ -79,6 +81,7 @@ export class GameImpl implements Game { _terraNullius: TerraNulliusImpl; allianceRequests: AllianceRequestImpl[] = []; + peaceRequests: PeaceRequestImpl[] = []; alliances_: AllianceImpl[] = []; private nextAllianceID = 0; @@ -433,6 +436,71 @@ export class GameImpl implements Game { }); } + createPeaceRequest( + requestor: Player, + recipient: Player, + ): PeaceRequestImpl | null { + if (!requestor.isAtWarWith(recipient)) { + console.log("cannot request peace, not at war"); + return null; + } + if ( + recipient + .incomingPeaceRequests() + .find((pr) => pr.requestor() === requestor) !== undefined + ) { + console.log(`duplicate peace request from ${requestor.name()}`); + return null; + } + // If both sides sent requests, auto-accept + const correspondingReq = requestor + .incomingPeaceRequests() + .find((pr) => pr.requestor() === recipient); + if (correspondingReq !== undefined) { + console.log(`got corresponding peace requests, accepting`); + correspondingReq.accept(); + return null; + } + const pr = new PeaceRequestImpl(requestor, recipient, this._ticks, this); + this.peaceRequests.push(pr); + this.addUpdate(pr.toUpdate()); + return pr; + } + + acceptPeaceRequest(request: PeaceRequestImpl) { + this.peaceRequests = this.peaceRequests.filter((pr) => pr !== request); + + const requestor = request.requestor(); + const recipient = request.recipient(); + + (request.requestor() as PlayerImpl).pastOutgoingPeaceRequests.push(request); + (request.recipient() as PlayerImpl).pastIncomingPeaceRequests.push(request); + + if (requestor.isAtWarWith(recipient)) { + requestor.setNeutralWith(recipient); + } + if (recipient.isAtWarWith(requestor)) { + recipient.setNeutralWith(requestor); + } + + this.addUpdate({ + type: GameUpdateType.PeaceRequestReply, + request: request.toUpdate(), + accepted: true, + }); + } + + rejectPeaceRequest(request: PeaceRequestImpl) { + this.peaceRequests = this.peaceRequests.filter((pr) => pr !== request); + (request.requestor() as PlayerImpl).pastOutgoingPeaceRequests.push(request); + (request.recipient() as PlayerImpl).pastIncomingPeaceRequests.push(request); + this.addUpdate({ + type: GameUpdateType.PeaceRequestReply, + request: request.toUpdate(), + accepted: false, + }); + } + hasPlayer(id: PlayerID): boolean { return this._players.has(id); } @@ -460,55 +528,23 @@ export class GameImpl implements Game { metrics = (globalThis as any).__PERF_METRICS__; } - // Process attack executions multiple times per tick for smoother territory changes - const attackExecs: Execution[] = []; - const otherExecs: Execution[] = []; - this.execs.forEach((e) => { if ( (!this.inSpawnPhase() || e.activeDuringSpawnPhase()) && e.isActive() ) { - // Separate attack executions from others - use instanceof to survive minification - if (e instanceof AttackExecution) { - attackExecs.push(e); + if (metrics?.enabled) { + const start = performance.now(); + e.tick(this._ticks); + metrics.recordExecutionTime( + e.constructor.name, + performance.now() - start, + ); } else { - otherExecs.push(e); + e.tick(this._ticks); } } }); - - // Process attack executions multiple times per tick - for (let subtick = 0; subtick < ATTACK_SUBTICKS_PER_TICK; subtick++) { - attackExecs.forEach((e) => { - if (e.isActive()) { - if (metrics?.enabled) { - const start = performance.now(); - e.tick(this._ticks); - metrics.recordExecutionTime( - e.executionName ?? e.constructor.name, - performance.now() - start, - ); - } else { - e.tick(this._ticks); - } - } - }); - } - - // Process other executions once per tick - otherExecs.forEach((e) => { - if (metrics?.enabled) { - const start = performance.now(); - e.tick(this._ticks); - metrics.recordExecutionTime( - e.executionName ?? e.constructor.name, - performance.now() - start, - ); - } else { - e.tick(this._ticks); - } - }); const inited: Execution[] = []; const unInited: Execution[] = []; this.unInitExecs.forEach((e) => { @@ -575,6 +611,37 @@ export class GameImpl implements Game { // Removed noisy debug logging of road network length + // Auto-reject pending alliance/peace requests after 150 ticks + const REQUEST_EXPIRY_TICKS = 150; + for (const ar of [...this.allianceRequests]) { + if (this._ticks - ar.createdAt() >= REQUEST_EXPIRY_TICKS) { + ar.reject(); + } + } + for (const pr of [...this.peaceRequests]) { + if (this._ticks - pr.createdAt() >= REQUEST_EXPIRY_TICKS) { + pr.reject(); + } + } + + // Log aggregate income by source every 50 ticks (all players) + if (this._ticks % 50 === 0) { + let totalIndustrial = 0; + let totalCargo = 0; + let totalTrade = 0; + for (const p of this.players()) { + const cargo = p.cargoTruckGoldPerMinute(); + const trade = p.tradeShipGoldPerMinute(); + const estimated = p.estimatedGoldIncomePerMinute(); + totalCargo += cargo; + totalTrade += trade; + totalIndustrial += estimated - cargo - trade; + } + console.log( + `[tick ${this._ticks}] All income/min — Industrial: ${totalIndustrial.toFixed(1)}, Cargo: ${totalCargo.toFixed(1)}, Trade: ${totalTrade.toFixed(1)}, Total: ${(totalIndustrial + totalCargo + totalTrade).toFixed(1)}`, + ); + } + this._ticks++; return this.updates; } @@ -692,6 +759,11 @@ export class GameImpl implements Game { return this.roadManager.getRoadNetworkQualityForPlayer(playerId); } + // Expose road maintenance rate as fraction of gross gold + public getRoadMaintenanceRateForPlayer(player: Player): number { + return this.roadManager.getRoadMaintenanceRateForPlayer(player); + } + // Check if a structure is connected to the road network public isStructureConnectedToRoadNetwork(unit: Unit): boolean { return this.roadManager.isStructureConnectedToRoadNetwork(unit); @@ -806,6 +878,7 @@ export class GameImpl implements Game { (currentOwner as PlayerImpl)._lastTileChange = this._ticks; (currentOwner as PlayerImpl)._tiles.delete(tile); (currentOwner as PlayerImpl)._borderTiles.delete(tile); + (currentOwner as PlayerImpl).invalidateNeighborCache(); } this._map.setOwnerID(tile, newOwner.smallID()); (newOwner as PlayerImpl)._tiles.add(tile); @@ -815,7 +888,11 @@ export class GameImpl implements Game { 1 / numTiles, ); (newOwner as PlayerImpl)._lastTileChange = this._ticks; - this.updateBorders(tile); + if (this._dirtyBorderTiles !== null) { + this._dirtyBorderTiles.add(tile); + } else { + this.updateBorders(tile); + } this._map.setFallout(tile, false); this.addUpdate({ @@ -841,54 +918,97 @@ export class GameImpl implements Game { previousOwner._lastTileChange = this._ticks; previousOwner._tiles.delete(tile); previousOwner._borderTiles.delete(tile); + previousOwner.invalidateNeighborCache(); this._map.setOwnerID(tile, 0); - this.updateBorders(tile); + if (this._dirtyBorderTiles !== null) { + this._dirtyBorderTiles.add(tile); + } else { + this.updateBorders(tile); + } this.addUpdate({ type: GameUpdateType.Tile, update: this.toTileUpdate(tile), }); } - private updateBorders(tile: TileRef) { - const updateBorderStatus = (t: TileRef) => { - if (!this.hasOwner(t)) { - return; + beginBorderBatch(): void { + this._dirtyBorderTiles = new Set(); + } + + endBorderBatch(): void { + const dirty = this._dirtyBorderTiles; + if (dirty === null) return; + this._dirtyBorderTiles = null; + + // Collect all tiles to recheck: dirty tiles + their neighbors, deduped + const toCheck = new Set(); + for (const tile of dirty) { + toCheck.add(tile); + for (const n of this.neighbors(tile)) { + toCheck.add(n); } - const owner = this.owner(t) as PlayerImpl; - if (this.calcIsBorder(t)) { + } + + // Process all unique tiles, tracking players for one-shot cache invalidation + const playersToInvalidate = new Set(); + for (const t of toCheck) { + const oid = this._map.ownerID(t); + if (oid === 0) continue; + const owner = this._playersBySmallID[oid - 1] as PlayerImpl; + playersToInvalidate.add(owner); + if (this.calcIsBorder(t, oid)) { owner._borderTiles.add(t); } else { owner._borderTiles.delete(t); } - }; + } - updateBorderStatus(tile); - this.forEachNeighbor(tile, updateBorderStatus); + // Invalidate caches once per affected player instead of per tile + for (const player of playersToInvalidate) { + player.invalidateNeighborCache(); + } } - private calcIsBorder(tile: TileRef): boolean { - if (!this.hasOwner(tile)) { - return false; + private updateBorders(tile: TileRef) { + this.updateBorderForTile(tile); + for (const t of this.neighbors(tile)) { + this.updateBorderForTile(t); } - const ownerId = this.ownerID(tile); + } + + private updateBorderForTile(t: TileRef): void { + const oid = this._map.ownerID(t); + if (oid === 0) return; + const owner = this._playersBySmallID[oid - 1] as PlayerImpl; + owner.invalidateNeighborCache(); + if (this.calcIsBorder(t, oid)) { + owner._borderTiles.add(t); + } else { + owner._borderTiles.delete(t); + } + } + + private calcIsBorder(tile: TileRef, tileOwnerID?: number): boolean { + const oid = tileOwnerID ?? this._map.ownerID(tile); + if (oid === 0) return false; const x = this.x(tile); const y = this.y(tile); - if (x > 0 && this.ownerID(this._map.ref(x - 1, y)) !== ownerId) { + if (x > 0 && this._map.ownerID(this._map.ref(x - 1, y)) !== oid) { return true; } if ( x + 1 < this._width && - this.ownerID(this._map.ref(x + 1, y)) !== ownerId + this._map.ownerID(this._map.ref(x + 1, y)) !== oid ) { return true; } - if (y > 0 && this.ownerID(this._map.ref(x, y - 1)) !== ownerId) { + if (y > 0 && this._map.ownerID(this._map.ref(x, y - 1)) !== oid) { return true; } if ( y + 1 < this._height && - this.ownerID(this._map.ref(x, y + 1)) !== ownerId + this._map.ownerID(this._map.ref(x, y + 1)) !== oid ) { return true; } @@ -1027,11 +1147,11 @@ export class GameImpl implements Game { .map((p) => p.clientID()!), ]; } else { - const clientId = winner.clientID(); - if (clientId === null) return; + // Use clientID for humans, fall back to player id for AI/bot players + const winnerId = winner.clientID() ?? winner.id(); return [ "player", - clientId, + winnerId, // TODO: Assists (vote for peace) ]; } diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 88c3ea38a..5a47de8a7 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -125,7 +125,7 @@ export class GameMapImpl implements GameMap { private static readonly PLAYER_ID_MASK = 0xfff; private static readonly FALLOUT_BIT = 13; private static readonly DEFENSE_BONUS_BIT = 14; - // Bit 15 still reserved + private static readonly OCEAN_SHORE_BIT = 15; constructor( width: number, @@ -155,6 +155,33 @@ export class GameMapImpl implements GameMap { ref++; } } + // Pre-bake ocean shore bit: land tile adjacent to at least one ocean tile + this.bakeOceanShore(); + } + + private bakeOceanShore(): void { + const w = this.width_; + const h = this.height_; + const landBit = 1 << GameMapImpl.IS_LAND_BIT; + const oceanBit = 1 << GameMapImpl.OCEAN_BIT; + const shoreBit = 1 << GameMapImpl.OCEAN_SHORE_BIT; + const terrain = this.terrain; + const state = this.state; + const total = w * h; + const lastRow = (h - 1) * w; + + for (let ref = 0; ref < total; ref++) { + if (!(terrain[ref] & landBit)) continue; + const x = ref % w; + if ( + (ref >= w && terrain[ref - w] & oceanBit) || + (ref < lastRow && terrain[ref + w] & oceanBit) || + (x !== 0 && terrain[ref - 1] & oceanBit) || + (x !== w - 1 && terrain[ref + 1] & oceanBit) + ) { + state[ref] |= shoreBit; + } + } } numTilesWithFallout(): number { return this._numTilesWithFallout; @@ -203,9 +230,7 @@ export class GameMapImpl implements GameMap { } isOceanShore(ref: TileRef): boolean { - return ( - this.isLand(ref) && this.neighbors(ref).some((tr) => this.isOcean(tr)) - ); + return Boolean(this.state[ref] & (1 << GameMapImpl.OCEAN_SHORE_BIT)); } isOcean(ref: TileRef): boolean { diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 4c446a403..b9e96c158 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -37,6 +37,8 @@ export enum GameUpdateType { DisplayChatEvent, AllianceRequest, AllianceRequestReply, + PeaceRequest, + PeaceRequestReply, BrokeAlliance, AllianceExpired, TargetPlayer, @@ -82,6 +84,8 @@ export type GameUpdate = | PlayerUpdate | AllianceRequestUpdate | AllianceRequestReplyUpdate + | PeaceRequestUpdate + | PeaceRequestReplyUpdate | BrokeAllianceUpdate | AllianceExpiredUpdate | AllianceExtensionAcceptedUpdate @@ -153,7 +157,7 @@ export interface UnitUpdate { level?: number; // Stack count (>=1). Number of stacked instances for stackable structures. stackCount?: number; - // Missile silo specific: remaining launches before cooldown (for stacked silos) + // Silo/SAM specific: number of ready slots (for stacked structures with per-slot cooldowns) launchesRemaining?: number; // Trade-ship specific, for precise UI without heuristics tradeRouteStartOwnerID?: number; // smallID of start port owner @@ -201,6 +205,10 @@ export interface PlayerUpdate { workers: number; productivity: number; productivityGrowthPerMinute: number; + // Income tracking (per-minute EMA estimates) + cargoTruckGoldPerMinute?: number; + tradeShipGoldPerMinute?: number; + estimatedGoldIncomePerMinute?: number; investmentRate: number; // Investment sliders (fractions 0..1) roadInvestmentRate?: number; @@ -216,6 +224,7 @@ export interface PlayerUpdate { roadNetPixelsPerSecond?: number; troops: number; attackingTroops: number; + militaryStrength: number; targetTroopRatio: number; allies: number[]; // Diplomacy: explicit wars (smallIDs), separate from trade embargoes @@ -227,6 +236,7 @@ export interface PlayerUpdate { outgoingAttacks: AttackUpdate[]; incomingAttacks: AttackUpdate[]; outgoingAllianceRequests: PlayerID[]; + outgoingPeaceRequests: PlayerID[]; hasSpawned: boolean; betrayals?: bigint; effectiveUnits: Record; @@ -240,10 +250,6 @@ export interface PlayerUpdate { researchPriorityTech?: string | null; // All selected research priority tech ids (optional; omitted if none) researchPriorities?: string[]; - // Policy directive choices: directiveId -> optionId (optional; omitted if none) - policyChoices?: Record; - // Whether the player has unseen policy directives to review - hasUnseenPolicyDirectives?: boolean; } export interface AllianceRequestUpdate { @@ -259,6 +265,19 @@ export interface AllianceRequestReplyUpdate { accepted: boolean; } +export interface PeaceRequestUpdate { + type: GameUpdateType.PeaceRequest; + requestorID: number; + recipientID: number; + createdAt: Tick; +} + +export interface PeaceRequestReplyUpdate { + type: GameUpdateType.PeaceRequestReply; + request: PeaceRequestUpdate; + accepted: boolean; +} + export interface BrokeAllianceUpdate { type: GameUpdateType.BrokeAlliance; traitorID: number; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index d24c41ecf..87960aa40 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -5,6 +5,7 @@ import { Config } from "../configuration/Config"; import { EventBus } from "../EventBus"; import { ClientID, GameID } from "../Schemas"; import { computeResearchLevel } from "../tech/ResearchTree"; +import { incomeModifiers } from "../tech/TechEffects"; import { createRandomName } from "../Util"; import { WorkerClient } from "../worker/WorkerClient"; import { @@ -25,6 +26,7 @@ import { TerrainType, TerraNullius, Tick, + TradeDemandMetrics, UnitInfo, UnitType, UpgradeType, @@ -198,7 +200,7 @@ export class UnitView { return (this.data as any).stackCount ?? 1; } - // Missile silo specific: remaining launches before cooldown (for stacked silos) + // Silo/SAM specific: number of ready slots (for stacked structures with per-slot cooldowns) launchesRemaining(): number | null { const v = (this.data as any).launchesRemaining as number | undefined; return v ?? null; @@ -393,6 +395,17 @@ export class PlayerView { gold(): Gold { return this.data.gold; } + rawIndustrialProduction(): number { + const base = 0.11 * Math.pow(this.workers(), 0.65); + const productivity = this.productivity(); + const k = this.effectiveUnits(UnitType.Factory); + const factoryFactor = Math.pow(1 + k, 0.35); + const incomeMods = incomeModifiers(this); + const g = + base * productivity * factoryFactor * incomeMods.domesticIncomeMul; + if (!Number.isFinite(g) || g < 0) return 0; + return g; + } industrialProduction(): number { return (this.data as any).industrialProduction; } @@ -411,12 +424,24 @@ export class PlayerView { troops(): number { return this.data.troops; } + militaryStrength(): number { + return this.data.militaryStrength; + } productivity(): number { return this.data.productivity; } productivityGrowthPerMinute(): number { return this.data.productivityGrowthPerMinute; } + cargoTruckGoldPerMinute(): number { + return this.data.cargoTruckGoldPerMinute ?? 0; + } + tradeShipGoldPerMinute(): number { + return this.data.tradeShipGoldPerMinute ?? 0; + } + estimatedGoldIncomePerMinute(): number { + return this.data.estimatedGoldIncomePerMinute ?? 0; + } investmentRate(): number { return this.data.investmentRate; } @@ -460,6 +485,10 @@ export class PlayerView { return this.data.outgoingAllianceRequests.some((id) => other.id() === id); } + isRequestingPeaceWith(other: PlayerView) { + return this.data.outgoingPeaceRequests.some((id) => other.id() === id); + } + hasEmbargoAgainst(other: PlayerView): boolean { return this.data.embargoes.has(other.id()); } @@ -504,6 +533,22 @@ export class PlayerView { tradeDemandQueueLength(): number { return (this.data as any).tradeDemandQueueLength ?? 0; } + + // Trade demand metrics for UI display + tradeDemandMetrics(queueLen: number): TradeDemandMetrics { + const shipCount = this.units(UnitType.TradeShip).length; + // Count idle ships: not returning, no trade phase, no target + const availableShips = this.units(UnitType.TradeShip).filter((ship) => { + const returning = (ship as any).returning?.() ?? false; + const tradePhase = (ship as any).tradePhase?.() ?? null; + const targetUnit = (ship as any).targetUnit?.() ?? undefined; + return !returning && tradePhase === null && targetUnit === undefined; + }).length; + const queueRatio = + shipCount > 0 ? queueLen / shipCount : queueLen > 0 ? 1 : 0; + const availableRatio = shipCount > 0 ? availableShips / shipCount : 0; + return { shipCount, availableShips, queueLen, queueRatio, availableRatio }; + } } export class GameView implements GameMap { diff --git a/src/core/game/PeaceRequestImpl.ts b/src/core/game/PeaceRequestImpl.ts new file mode 100644 index 000000000..0fe476ed5 --- /dev/null +++ b/src/core/game/PeaceRequestImpl.ts @@ -0,0 +1,40 @@ +import { PeaceRequest, Player, Tick } from "./Game"; +import { GameImpl } from "./GameImpl"; +import { GameUpdateType, PeaceRequestUpdate } from "./GameUpdates"; + +export class PeaceRequestImpl implements PeaceRequest { + constructor( + private requestor_: Player, + private recipient_: Player, + private tickCreated: number, + private game: GameImpl, + ) {} + + requestor(): Player { + return this.requestor_; + } + + recipient(): Player { + return this.recipient_; + } + + createdAt(): Tick { + return this.tickCreated; + } + + accept(): void { + this.game.acceptPeaceRequest(this); + } + reject(): void { + this.game.rejectPeaceRequest(this); + } + + toUpdate(): PeaceRequestUpdate { + return { + type: GameUpdateType.PeaceRequest, + requestorID: this.requestor_.smallID(), + recipientID: this.recipient_.smallID(), + createdAt: this.tickCreated, + }; + } +} diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 2cce4db16..bdeceef73 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -4,11 +4,16 @@ import { ClientID } from "../Schemas"; import { Category, findTech } from "../tech/ResearchTree"; import { applyTechCompletionEffects, + attackCasualtyModifiers, + attackSpeedModifiers, + AttackSpeedModifiers, + defenseCasualtyModifiers, + DefenseCasualtyModifiers, + incomeModifiers, roadEffectModifiers, } from "../tech/TechEffects"; import { assertNever, - distSortUnit, maxInt, minInt, simpleHash, @@ -30,8 +35,10 @@ import { GameMode, GameType, Gold, + isStructureType, MessageType, MutableAlliance, + PeaceRequest, Player, PlayerID, PlayerInfo, @@ -42,6 +49,7 @@ import { TerrainType, TerraNullius, Tick, + TradeDemandMetrics, Unit, UnitParams, UnitType, @@ -55,8 +63,22 @@ import { canBuildTransportShip, } from "./TransportShipUtils"; import { UnitImpl } from "./UnitImpl"; +import { getUnitLevelCost } from "./UnitUpgrades"; import { playerMaxUnitLevel } from "./Upgradeables"; +/** Structure types whose unitsOwned count uses stackCount/targetLevel. */ +const STACKABLE_TYPES: ReadonlySet = new Set([ + UnitType.City, + UnitType.Port, + UnitType.Hospital, + UnitType.Academy, + UnitType.ResearchLab, + UnitType.Factory, + UnitType.SAMLauncher, + UnitType.Airfield, + UnitType.MissileSilo, +]); + interface Target { tick: Tick; target: Player; @@ -87,6 +109,13 @@ export class PlayerImpl implements Player { private _roadInvestmentRate: number = 0; // 0..1, fraction of per-tick income allocated to roads private _researchInvestmentRate: number = 0; // 0..1, fraction of per-tick income allocated to research + // Income tracking (per-tick EMA for per-minute estimates) + private _cargoTruckGoldThisTick: bigint = 0n; // per-tick accumulator + private _tradeShipGoldThisTick: bigint = 0n; // per-tick accumulator + private _cargoTruckGoldPerMinute: number = 0; // EMA + private _tradeShipGoldPerMinute: number = 0; // EMA + private _estimatedGoldIncomePerMinute: number = 0; // combined estimate + markedTraitorTick = -1; private embargoes = new Map(); @@ -96,10 +125,31 @@ export class PlayerImpl implements Player { public _units: Unit[] = []; private _effectiveUnitsCache: Map = new Map(); + // Cached total cost of all non-city/non-factory units & structures (updated every 50 ticks) + private _militaryAssetValue: number = 0; + private _militaryAssetValueLastTick: number = -1; + // Phase 1 Optimization: Cache airfield existence private _hasAirfieldCache: boolean = false; private _hasAirfieldCacheDirty: boolean = true; + // Neighbor cache: stores smallIDs of all players (including TerraNullius=0) bordering this player + private _neighborCache: Set | null = null; + // Shared border length cache: stores count of border tiles adjacent to each neighbor (keyed by smallID) + private _sharedBorderLengthCache: Map | null = null; + // Cache for whether this player borders the ocean + private _bordersOceanCache: boolean | null = null; + // Cache for ocean shore extrema tiles (min/max X/Y) + private _oceanShoreExtremaCache: TileRef[] | null = null; + // Cache for all ocean shore tiles + private _oceanShoreTilesCache: TileRef[] | null = null; + + // Phase 2 Optimization: Cache casualty modifiers (invalidated on tech research) + private _attackCasualtyModifiersCache: DefenseCasualtyModifiers | null = null; + private _defenseCasualtyModifiersCache: DefenseCasualtyModifiers | null = + null; + private _attackSpeedModifiersCache: AttackSpeedModifiers | null = null; + public _tiles: Set = new Set(); private _upgrades: Set = new Set(); // Per-match research tree selections (IDs are client-defined strings) @@ -115,6 +165,8 @@ export class PlayerImpl implements Player { private _hospitalReturns: number = 0; public pastOutgoingAllianceRequests: AllianceRequest[] = []; + public pastOutgoingPeaceRequests: PeaceRequest[] = []; + public pastIncomingPeaceRequests: PeaceRequest[] = []; private _expiredAlliances: Alliance[] = []; private targets_: Target[] = []; @@ -168,6 +220,9 @@ export class PlayerImpl implements Player { const outgoingAllianceRequests = this.outgoingAllianceRequests().map((ar) => ar.recipient().id(), ); + const outgoingPeaceRequests = this.outgoingPeaceRequests().map((pr) => + pr.recipient().id(), + ); const stats = this.mg.stats().getPlayerStats(this); return { @@ -202,9 +257,13 @@ export class PlayerImpl implements Player { tradeDemandQueueLength: (this.mg as any).tradeDemandQueueLength?.() ?? 0, troops: this.troops(), attackingTroops: this.attackingTroops(), + militaryStrength: this.militaryStrength(), targetTroopRatio: this.targetTroopRatio(), productivity: this.productivity(), productivityGrowthPerMinute: this.productivityGrowthPerMinute(), + cargoTruckGoldPerMinute: this.cargoTruckGoldPerMinute(), + tradeShipGoldPerMinute: this.tradeShipGoldPerMinute(), + estimatedGoldIncomePerMinute: this.estimatedGoldIncomePerMinute(), investmentRate: this.investmentRate(), roadInvestmentRate: this.roadInvestmentRate(), researchInvestmentRate: this.researchInvestmentRate(), @@ -233,6 +292,7 @@ export class PlayerImpl implements Player { } satisfies AttackUpdate; }), outgoingAllianceRequests: outgoingAllianceRequests, + outgoingPeaceRequests: outgoingPeaceRequests, hasSpawned: this.hasSpawned(), betrayals: stats?.betrayals, effectiveUnits: Object.values(UnitType).reduce( @@ -242,13 +302,7 @@ export class PlayerImpl implements Player { }, {} as Record, ), - unitsOwned: Object.values(UnitType).reduce( - (acc, type) => { - acc[type] = this.unitsOwned(type); - return acc; - }, - {} as Record, - ), + unitsOwned: this.allUnitsOwned(), upgrades: Array.from(this._upgrades), researchTreeTechs: Array.from(this._researchTreeTechs), researchTreeBeakers: @@ -291,20 +345,64 @@ export class PlayerImpl implements Player { return this.playerInfo.playerType; } - // Economic: Industrial Production proxy (formerly GDP) as parameter * max population - industrialProduction(): number { - const factor = this.mg.config().industrialProductionFactor(); - const maxPop = this.mg.config().maxPopulation(this); - const g = factor * maxPop; - // Ensure finite, non-negative number + // Economic: Industrial Production = gross gold rate without gold multiplier + // Formula: 0.11 * workers^0.65 * productivity * factoryFactor * domesticIncomeMul + rawIndustrialProduction(): number { + const base = 0.11 * Math.pow(this.workers(), 0.65); + const productivity = this.productivity(); + const k = this.effectiveUnits(UnitType.Factory); + const factoryFactor = Math.pow(1 + k, 0.35); + const incomeMods = incomeModifiers(this); + const g = + base * productivity * factoryFactor * incomeMods.domesticIncomeMul; if (!Number.isFinite(g) || g < 0) return 0; - return Math.floor(g); + return g; + } + industrialProduction(): number { + return Math.floor(this.rawIndustrialProduction()); } clan(): string | null { return this.playerInfo.clan; } + // Trade demand metrics for AI scoring and UI + tradeDemandMetrics(queueLen: number): TradeDemandMetrics { + const tradeShips = this.units(UnitType.TradeShip).filter((u) => + u.isActive(), + ); + const shipCount = tradeShips.length; + + if (shipCount === 0) { + return { + shipCount: 0, + availableShips: 0, + queueLen, + queueRatio: 0, + availableRatio: 0, + }; + } + + // A ship is idle if not returning, no trade phase, and no target + const availableShips = tradeShips.filter((s) => { + const isReturning = s.returning(); + const phase = s.tradePhase(); + const hasTarget = s.targetUnit() !== undefined; + return !isReturning && phase === null && !hasTarget; + }).length; + + const queueRatio = queueLen / shipCount; + const availableRatio = availableShips / shipCount; + + return { + shipCount, + availableShips, + queueLen, + queueRatio, + availableRatio, + }; + } + units(...types: UnitType[]): Unit[] { const len = types.length; if (len === 0) { @@ -388,25 +486,11 @@ export class PlayerImpl implements Player { // Count of units owned by the player, including construction unitsOwned(type: UnitType): number { let total = 0; - // All stackable structure types - const stackableTypes = new Set([ - UnitType.City, - UnitType.Port, - UnitType.Hospital, - UnitType.Academy, - UnitType.ResearchLab, - UnitType.Factory, - UnitType.SAMLauncher, - UnitType.Airfield, - UnitType.MissileSilo, - ]); - const isStackable = stackableTypes.has(type); + const isStackable = STACKABLE_TYPES.has(type); for (const unit of this._units) { if (unit.type() === type) { if (isStackable) { - // Stacked structures count their stackCount toward totals - // (affects scaling like new build cost and display counts) total += unit.stackCount?.() ?? 1; } else { total++; @@ -415,7 +499,6 @@ export class PlayerImpl implements Player { } if (unit.type() !== UnitType.Construction) continue; if (unit.constructionType() !== type) continue; - // For stackable structures, count the target level instead of just 1 if (isStackable) { total += unit.constructionTargetLevel(); } else { @@ -425,6 +508,37 @@ export class PlayerImpl implements Player { return total; } + /** + * Computes unitsOwned counts for ALL UnitTypes in a single pass over _units. + * Used by toUpdate() to avoid O(UnitTypes × units) iteration. + */ + private allUnitsOwned(): Record { + const counts = {} as Record; + for (const type of Object.values(UnitType)) { + counts[type] = 0; + } + for (const unit of this._units) { + const uType = unit.type(); + if (uType === UnitType.Construction) { + const cType = unit.constructionType(); + if (cType !== null) { + if (STACKABLE_TYPES.has(cType)) { + counts[cType] += unit.constructionTargetLevel(); + } else { + counts[cType]++; + } + } + } else { + if (STACKABLE_TYPES.has(uType)) { + counts[uType] += unit.stackCount?.() ?? 1; + } else { + counts[uType]++; + } + } + } + return counts; + } + hasUpgrade(upgrade: UpgradeType): boolean { return this._upgrades.has(upgrade); } @@ -567,6 +681,11 @@ export class PlayerImpl implements Player { // Add tech to researched set this._researchTreeTechs.add(techId); + // Invalidate casualty/speed modifier caches since they depend on researched techs + this._attackCasualtyModifiersCache = null; + this._defenseCasualtyModifiersCache = null; + this._attackSpeedModifiersCache = null; + // Apply centralized side-effects upon research completion applyTechCompletionEffects(this, this.mg, techId); } @@ -612,6 +731,40 @@ export class PlayerImpl implements Player { hasResearchedTech(techId: string): boolean { return this._researchTreeTechs.has(techId); } + + /** + * Get cached attack casualty modifiers (based on researched techs). + * Cache is invalidated when a new tech is researched. + */ + getAttackCasualtyModifiers(): DefenseCasualtyModifiers { + if (this._attackCasualtyModifiersCache === null) { + this._attackCasualtyModifiersCache = attackCasualtyModifiers(this); + } + return this._attackCasualtyModifiersCache; + } + + /** + * Get cached defense casualty modifiers (based on researched techs). + * Cache is invalidated when a new tech is researched. + */ + getDefenseCasualtyModifiers(): DefenseCasualtyModifiers { + if (this._defenseCasualtyModifiersCache === null) { + this._defenseCasualtyModifiersCache = defenseCasualtyModifiers(this); + } + return this._defenseCasualtyModifiersCache; + } + + /** + * Get cached attack speed modifiers (based on researched techs). + * Cache is invalidated when a new tech is researched. + */ + getAttackSpeedModifiers(): AttackSpeedModifiers { + if (this._attackSpeedModifiersCache === null) { + this._attackSpeedModifiersCache = attackSpeedModifiers(this); + } + return this._attackSpeedModifiersCache; + } + researchBeakers(techId: string): number { return this._researchBeakers.get(techId) ?? 0; } @@ -727,21 +880,153 @@ export class PlayerImpl implements Player { } sharesBorderWith(other: Player | TerraNullius): boolean { - for (const border of this._borderTiles) { - for (const neighbor of this.mg.map().neighbors(border)) { - if (this.mg.map().ownerID(neighbor) === other.smallID()) { - return true; + return this.neighborSmallIDs().has(other.smallID()); + } + + /** + * Returns the cached set of neighbor smallIDs, computing it if needed. + * Includes TerraNullius (smallID=0) if bordering unclaimed land. + * Also populates the shared border length cache. + */ + private neighborSmallIDs(): Set { + if (this._neighborCache === null) { + this._neighborCache = new Set(); + this._sharedBorderLengthCache = new Map(); + for (const border of this._borderTiles) { + // Track which neighbors this border tile touches (to count each tile only once per neighbor) + const touchedNeighbors = new Set(); + for (const neighbor of this.mg.map().neighbors(border)) { + if (this.mg.map().isLand(neighbor)) { + const ownerID = this.mg.map().ownerID(neighbor); + if (ownerID !== this.smallID()) { + this._neighborCache.add(ownerID); + touchedNeighbors.add(ownerID); + } + } + } + // Increment shared border length for each neighbor this tile touches + for (const ownerID of touchedNeighbors) { + const current = this._sharedBorderLengthCache.get(ownerID) ?? 0; + this._sharedBorderLengthCache.set(ownerID, current + 1); } } } - return false; + return this._neighborCache; + } + + /** + * Invalidates the neighbor cache. Called when tile ownership changes. + */ + invalidateNeighborCache(): void { + this._neighborCache = null; + this._sharedBorderLengthCache = null; + this._bordersOceanCache = null; + this._oceanShoreExtremaCache = null; + this._oceanShoreTilesCache = null; + } + + /** + * Returns the number of border tiles shared with another player. + * Uses cached value if available. + */ + sharedBorderLength(other: Player | TerraNullius): number { + // Ensure cache is populated + this.neighborSmallIDs(); + return this._sharedBorderLengthCache?.get(other.smallID()) ?? 0; + } + + /** + * Returns true if this player has any border tiles on the ocean. + * Uses cached value if available. + */ + bordersOcean(): boolean { + if (this._bordersOceanCache === null) { + this._bordersOceanCache = false; + for (const tile of this._borderTiles) { + if (this.mg.isOceanShore(tile)) { + this._bordersOceanCache = true; + break; + } + } + } + return this._bordersOceanCache; + } + + /** + * Returns all ocean shore tiles. Cached. + */ + oceanShoreTiles(): readonly TileRef[] { + if (this._oceanShoreTilesCache === null) { + this._oceanShoreTilesCache = []; + for (const tile of this._borderTiles) { + if (this.mg.isOceanShore(tile)) { + this._oceanShoreTilesCache.push(tile); + } + } + } + return this._oceanShoreTilesCache; + } + + /** + * Returns up to 4 extremum ocean shore tiles (min/max X/Y). + * Uses cached value if available. + */ + oceanShoreExtrema(): readonly TileRef[] { + if (this._oceanShoreExtremaCache === null) { + const oceanShores = this.oceanShoreTiles(); + this._oceanShoreExtremaCache = []; + + if (oceanShores.length === 0) { + return this._oceanShoreExtremaCache; + } + + let minX = oceanShores[0], + maxX = oceanShores[0], + minY = oceanShores[0], + maxY = oceanShores[0]; + let minXVal = this.mg.x(oceanShores[0]), + maxXVal = minXVal, + minYVal = this.mg.y(oceanShores[0]), + maxYVal = minYVal; + + for (const tile of oceanShores) { + const x = this.mg.x(tile); + const y = this.mg.y(tile); + if (x < minXVal) { + minXVal = x; + minX = tile; + } + if (x > maxXVal) { + maxXVal = x; + maxX = tile; + } + if (y < minYVal) { + minYVal = y; + minY = tile; + } + if (y > maxYVal) { + maxYVal = y; + maxY = tile; + } + } + + // Deduplicate + const seen = new Set(); + for (const t of [minX, maxX, minY, maxY]) { + if (!seen.has(t)) { + seen.add(t); + this._oceanShoreExtremaCache.push(t); + } + } + } + return this._oceanShoreExtremaCache; } numTilesOwned(): number { return this._tiles.size; } tiles(): ReadonlySet { - return new Set(this._tiles.values()) as Set; + return this._tiles; } borderTiles(): ReadonlySet { @@ -749,20 +1034,12 @@ export class PlayerImpl implements Player { } neighbors(): (Player | TerraNullius)[] { - const ns: Set = new Set(); - for (const border of this.borderTiles()) { - for (const neighbor of this.mg.map().neighbors(border)) { - if (this.mg.map().isLand(neighbor)) { - const owner = this.mg.map().ownerID(neighbor); - if (owner !== this.smallID()) { - ns.add( - this.mg.playerBySmallID(owner) satisfies Player | TerraNullius, - ); - } - } - } + const smallIDs = this.neighborSmallIDs(); + const result: (Player | TerraNullius)[] = []; + for (const id of smallIDs) { + result.push(this.mg.playerBySmallID(id)); } - return Array.from(ns); + return result; } isPlayer(): this is Player { @@ -906,6 +1183,54 @@ export class PlayerImpl implements Player { return this.mg.createAllianceRequest(this, recipient satisfies Player); } + incomingPeaceRequests(): PeaceRequest[] { + return this.mg.peaceRequests.filter((pr) => pr.recipient() === this); + } + + outgoingPeaceRequests(): PeaceRequest[] { + return this.mg.peaceRequests.filter((pr) => pr.requestor() === this); + } + + canSendPeaceRequest(other: Player): boolean { + if (other === this) { + return false; + } + if (!this.isAtWarWith(other) || !this.isAlive()) { + return false; + } + + const hasPending = + this.incomingPeaceRequests().some((pr) => pr.requestor() === other) || + this.outgoingPeaceRequests().some((pr) => pr.recipient() === other); + + if (hasPending) { + return false; + } + + const recent = this.pastOutgoingPeaceRequests + .filter((pr) => pr.recipient() === other) + .sort((a, b) => b.createdAt() - a.createdAt()); + + if (recent.length === 0) { + return true; + } + + const delta = this.mg.ticks() - recent[0].createdAt(); + + if (delta < this.mg.config().allianceRequestCooldown()) { + return false; + } + + return true; + } + + createPeaceRequest(recipient: Player): PeaceRequest | null { + if (!this.isAtWarWith(recipient)) { + throw new Error(`cannot create peace request, not at war`); + } + return this.mg.createPeaceRequest(this, recipient satisfies Player); + } + relation(other: Player): Relation { if (other === this) { throw new Error(`cannot get relation with self: ${this}`); @@ -1332,12 +1657,142 @@ export class PlayerImpl implements Player { return Number(toRemove); } + militaryStrength(): number { + this.refreshMilitaryAssetValueCache(); + return ( + this.troops() + + 0.9 * this.attackingTroops() + + Number(this._gold) / 10 + + this._militaryAssetValue / 10 + + this.estimatedGoldIncomePerMinute() + ); + } + + /** + * Recompute the cached sum of unit costs for all owned units/structures + * that are not Cities. Uses the unit's actual level cost (falling back + * to the base info cost) and multiplies by stackCount for stacked structures. + * Only recalculates every 50 ticks. + */ + private refreshMilitaryAssetValueCache(): void { + const currentTick = this.mg.ticks(); + if (currentTick - this._militaryAssetValueLastTick < 50) return; + this._militaryAssetValueLastTick = currentTick; + + let total = 0n; + const excludedTypes = new Set([ + // Economic / non-military structures + UnitType.City, + UnitType.Factory, + UnitType.Port, + // In-flight projectiles / transient units + UnitType.Shell, + UnitType.SAMMissile, + UnitType.AtomBomb, + UnitType.HydrogenBomb, + UnitType.MIRV, + UnitType.MIRVWarhead, + UnitType.AABullet, + // Non-combat / transport units + UnitType.TransportShip, + UnitType.TradeShip, + UnitType.CargoPlane, + // Excluded military units + UnitType.Bomber, + UnitType.DoomsdayDevice, + ]); + for (const unit of this._units) { + const t = unit.type(); + if (excludedTypes.has(t)) continue; + + // For in-progress constructions, use the target unit's cost instead of 0. + // Gold was already deducted upfront, so this restores visibility during build. + if (t === UnitType.Construction) { + const targetType = unit.constructionType(); + if (targetType === null || excludedTypes.has(targetType)) continue; + const targetLevel = unit.constructionTargetLevel(); + let constructionCost: Gold; + if (targetLevel > 1) { + const levelCost = getUnitLevelCost(targetType, targetLevel); + constructionCost = + levelCost > 0n + ? levelCost + : this.mg.unitInfo(targetType).cost(this); + } else { + constructionCost = this.mg.unitInfo(targetType).cost(this); + } + total += constructionCost; + continue; + } + + // Use level-specific cost when available, otherwise base cost + const level = unit.level(); + let unitCost: Gold; + if (level > 1) { + const levelCost = getUnitLevelCost(t, level); + unitCost = levelCost > 0n ? levelCost : unit.info().cost(this); + } else { + unitCost = unit.info().cost(this); + } + + // Multiply by stack count for stacked structures + const stacks = unit.stackCount(); + total += stacks > 1 ? unitCost * BigInt(stacks) : unitCost; + } + this._militaryAssetValue = Number(total); + } + productivity(): number { return this._productivity; } productivityGrowthPerMinute(): number { return this._productivityGrowthPerMinute; } + + // --- Income tracking --- + recordCargoTruckGold(gold: Gold): void { + this._cargoTruckGoldThisTick += gold; + } + recordTradeShipGold(gold: Gold): void { + this._tradeShipGoldThisTick += gold; + } + updateIncomeTracking(): void { + // EMA decay: 599/600 ≈ 1-minute time constant (600 ticks = 1 min) + const DECAY = 599 / 600; + this._cargoTruckGoldPerMinute = + this._cargoTruckGoldPerMinute * DECAY + + Number(this._cargoTruckGoldThisTick); + this._tradeShipGoldPerMinute = + this._tradeShipGoldPerMinute * DECAY + + Number(this._tradeShipGoldThisTick); + this._cargoTruckGoldThisTick = 0n; + this._tradeShipGoldThisTick = 0n; + // Net industrial income per tick, extrapolated to per minute (600 ticks) + const grossGoldPerTick = + this.rawIndustrialProduction() * + (this.mg.config().gameConfig().goldMultiplier ?? 1); + const prodInvest = this.investmentRate(); + const roadInvest = this.hasUpgrade(UpgradeType.Roads) + ? this.roadInvestmentRate() + : 0; + const researchInvest = this.researchInvestmentRate(); + const totalInvest = Math.min(prodInvest + roadInvest + researchInvest, 1.1); + const netGoldPerTick = grossGoldPerTick * (1 - totalInvest); + const netIndustrialPerMinute = netGoldPerTick * 600; + this._estimatedGoldIncomePerMinute = + netIndustrialPerMinute + + this._cargoTruckGoldPerMinute + + this._tradeShipGoldPerMinute; + } + cargoTruckGoldPerMinute(): number { + return this._cargoTruckGoldPerMinute; + } + tradeShipGoldPerMinute(): number { + return this._tradeShipGoldPerMinute; + } + estimatedGoldIncomePerMinute(): number { + return this._estimatedGoldIncomePerMinute; + } updateProductivity(): void { const alpha = 0.00035; const beta = 0.5; @@ -1436,7 +1891,6 @@ export class PlayerImpl implements Player { ); } - const cost = this.mg.unitInfo(type).cost(this); const b = new UnitImpl( type, this.mg, @@ -1447,7 +1901,7 @@ export class PlayerImpl implements Player { ); this._units.push(b); this.recordUnitConstructed(type); - this.removeGold(cost); + // Gold is handled by ConstructionExecution upfront; no deduction here. this.removeTroops("troops" in params ? (params.troops ?? 0) : 0); this.mg.addUpdate(b.toUpdate()); this.mg.addUnit(b); @@ -1459,12 +1913,15 @@ export class PlayerImpl implements Player { public buildableUnits(tile: TileRef): BuildableUnit[] { const validTiles = this.validStructureSpawnTiles(tile); return Object.values(UnitType).map((u) => { + const cost = this.mg.config().unitInfo(u).cost(this); return { type: u, canBuild: this.mg.inSpawnPhase() ? false - : this.canBuild(u, tile, validTiles), - cost: this.mg.config().unitInfo(u).cost(this), + : this.gold() < cost + ? false + : this.canBuild(u, tile, validTiles), + cost, } as BuildableUnit; }); } @@ -1541,13 +1998,66 @@ export class PlayerImpl implements Player { return false; } - const cost = this.mg.unitInfo(unitType).cost(this); - if ( - unitType !== UnitType.MIRVWarhead && - (!this.isAlive() || this.gold() < cost) - ) { + if (!this.isAlive() && unitType !== UnitType.MIRVWarhead) { + return false; + } + // Gold affordability is checked by ConstructionExecution and buildableUnits(), + // not here, to avoid false negatives when gold is already reserved. + return this.canBuildAtTileInternal(unitType, targetTile, validTiles); + } + + /** + * Lightweight check if a structure can be built at a specific tile, ignoring gold cost. + * Used by AI for tile evaluation. Unlike canBuild(), this does NOT do a BFS search - + * it only validates the specific tile directly (ownership + structure spacing). + * For ports, it checks if the tile is an ocean shore owned by this player. + * For land structures, it checks if the tile is land owned by this player. + */ + canBuildAtTile(unitType: UnitType, targetTile: TileRef): TileRef | false { + if (!this.isAlive()) { return false; } + if (this.mg.config().isUnitDisabled(unitType)) { + return false; + } + + // Check ownership + if (this.mg.owner(targetTile) !== this) { + return false; + } + + // Type-specific terrain checks + if (unitType === UnitType.Port) { + // Ports must be on ocean shore + if (!this.mg.isOceanShore(targetTile)) { + return false; + } + } else if (isStructureType(unitType)) { + // Land-based structures cannot be on ocean + if (this.mg.isOcean(targetTile)) { + return false; + } + } + + // Check structure spacing - no existing structure within minDist + const minDist = this.mg.config().structureMinDist(); + const types = Object.values(UnitType).filter((t) => { + return this.mg.config().unitInfo(t).territoryBound; + }); + const nearbyUnits = this.mg.nearbyUnits(targetTile, minDist, types); + if (nearbyUnits.length > 0) { + // There's at least one structure too close + return false; + } + + return targetTile; + } + + private canBuildAtTileInternal( + unitType: UnitType, + targetTile: TileRef, + validTiles: TileRef[] | null, + ): TileRef | false { switch (unitType) { case UnitType.MIRV: if (!this.mg.hasOwner(targetTile)) { @@ -1618,7 +2128,16 @@ export class PlayerImpl implements Player { .filter((unit) => { return !unit.isInCooldown(); }) - .sort(distSortUnit(this.mg, tile)); + .sort((a, b) => { + // Prefer the silo with the most levels (highest capacity first), + // break ties by distance to target (closest first). + const levelDiff = (b.stackCount?.() ?? 1) - (a.stackCount?.() ?? 1); + if (levelDiff !== 0) return levelDiff; + return ( + this.mg.euclideanDistSquared(a.tile(), tile) - + this.mg.euclideanDistSquared(b.tile(), tile) + ); + }); if (spawns.length === 0) { return false; } @@ -1862,10 +2381,8 @@ export class PlayerImpl implements Player { const defenderType = other.isPlayer() ? other.type() : null; if ( - (attackerType === PlayerType.Human || - attackerType === PlayerType.FakeHuman) && - (defenderType === PlayerType.Human || - defenderType === PlayerType.FakeHuman) + (attackerType === PlayerType.Human || attackerType === PlayerType.AI) && + (defenderType === PlayerType.Human || defenderType === PlayerType.AI) ) { return false; // Block attack if peace timer is active and both are protected types } diff --git a/src/core/game/RoadManager.ts b/src/core/game/RoadManager.ts index 3d3249189..a43659a1a 100644 --- a/src/core/game/RoadManager.ts +++ b/src/core/game/RoadManager.ts @@ -1787,6 +1787,30 @@ export class RoadManager { return Math.max(minQ, Math.min(maxQ, avg)); } + /** + * Calculate the road maintenance rate as a fraction of gross gold for a player. + * Returns a value between 0 and 1 representing what percentage of gross gold + * is needed to maintain current roads at their current quality. + */ + public getRoadMaintenanceRateForPlayer(player: Player): number { + const creditedLength = this.roadLengthByOwner.get(player.id()) ?? 0; + if (creditedLength <= 0) return 0; + + const config = this.game.config(); + const baseCost = config.roadConstructionBaseCost(); + const maintMult = config.roadMaintenanceMultiplier(); + const productivity = Math.max(0.0001, player.productivity?.() ?? 1); + const quality = this.getRoadNetworkQualityForPlayer(player.id()); + const qualityFactor = quality / 100; + + const maintenancePerTick = + maintMult * baseCost * productivity * creditedLength * qualityFactor; + const grossGoldPerTick = config.grossGoldAdditionRate(player); + + if (grossGoldPerTick <= 0) return 0; + return Math.max(0, Math.min(1, maintenancePerTick / grossGoldPerTick)); + } + // Expose server-computed net road build rate (pixels per second) for client display public getRoadNetPixelsPerSecond(playerId: PlayerID): number { return this.roadNetPxPerSecond.get(playerId) ?? 0; diff --git a/src/core/game/TeamAssignment.ts b/src/core/game/TeamAssignment.ts index 1ec1bb566..ca34b1d5a 100644 --- a/src/core/game/TeamAssignment.ts +++ b/src/core/game/TeamAssignment.ts @@ -59,7 +59,7 @@ export function assignTeams( // Then, assign non-clan players to balance teams let nationPlayers = noClanPlayers.filter( - (player) => player.playerType === PlayerType.FakeHuman, + (player) => player.playerType === PlayerType.AI, ); if (nationPlayers.length > 0) { // Shuffle only nations to randomize their team assignment @@ -67,7 +67,7 @@ export function assignTeams( nationPlayers = random.shuffleArray(nationPlayers); } const otherPlayers = noClanPlayers.filter( - (player) => player.playerType !== PlayerType.FakeHuman, + (player) => player.playerType !== PlayerType.AI, ); for (const player of otherPlayers.concat(nationPlayers)) { diff --git a/src/core/game/TerrainMapLoader.ts b/src/core/game/TerrainMapLoader.ts index 73098359e..d772493ee 100644 --- a/src/core/game/TerrainMapLoader.ts +++ b/src/core/game/TerrainMapLoader.ts @@ -39,6 +39,23 @@ export async function loadTerrainMap( return result; } +/** + * Loads a terrain map without caching. Always creates fresh GameMap objects. + * Use this when the map will be mutated (e.g. headless calibration runs). + */ +export async function loadTerrainMapFresh( + map: GameMapType, +): Promise { + const mapFiles = await terrainMapFileLoader.getMapData(map); + const gameMap = await genTerrainFromBin(mapFiles.mapBin); + const miniGameMap = await genTerrainFromBin(mapFiles.miniMapBin); + return { + nationMap: mapFiles.nationMap, + gameMap, + miniGameMap, + }; +} + export async function genTerrainFromBin(data: string): Promise { const width = (data.charCodeAt(1) << 8) | data.charCodeAt(0); const height = (data.charCodeAt(3) << 8) | data.charCodeAt(2); diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 0d9af3b1f..4b64793a2 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -37,13 +37,15 @@ export class UnitImpl implements Unit { private _patrolTile: TileRef | undefined; private _level: number = 1; private _stackCount: number = 1; // Number of stacked instances (for stackable structures) - private _launchesRemaining: number | null = null; // For stacked silos: remaining launches before cooldown + private _slotCooldowns: ({ start: Tick; duration: Tick } | null)[] = []; // Per-slot cooldown for stacked silos & SAMs private _bonusMaxHealth: number = 0; // Extra max health from upgrades (e.g. city upgrades) private _targetable: boolean = true; private _accumulatedRegen: number = 0; private _insuredBy: Player | null = null; // Transport-ship specific: track intended target player for cancellation on peace private _boatTargetPlayerID: PlayerID | null = null; + // Transport-ship specific: track the destination tile the transport is heading to + private _boatTargetTile: TileRef | null = null; public lastVisibleTick?: number; isDetectedByNavalUnit?: boolean; isAttacking?: boolean; @@ -173,8 +175,10 @@ export class UnitImpl implements Unit { level: this._level > 1 ? this._level : undefined, stackCount: this._stackCount > 1 ? this._stackCount : undefined, launchesRemaining: - this._type === UnitType.MissileSilo && this._launchesRemaining !== null - ? this._launchesRemaining + (this._type === UnitType.MissileSilo || + this._type === UnitType.SAMLauncher) && + this._stackCount > 1 + ? this._readySlotsCount() : undefined, constructionType: this._constructionType, constructionTargetLevel: @@ -187,9 +191,10 @@ export class UnitImpl implements Unit { // Provide both for transition; cooldownEndsAt is the unified field ticksLeftInCooldown: this.ticksLeftInCooldown() ?? undefined, cooldownEndsAt: - this._cooldownStartTick !== null && this._cooldownDuration !== null + this._slotCooldownEndsAt() ?? + (this._cooldownStartTick !== null && this._cooldownDuration !== null ? this._cooldownStartTick + this._cooldownDuration - : undefined, + : undefined), cooldownDuration: this._cooldownDuration ?? undefined, returning: this.returning(), isAttacking: this.isAttacking, @@ -299,6 +304,14 @@ export class UnitImpl implements Unit { setStackCount(count: number): void { const cap = maxStackCount(this._type); this._stackCount = Math.max(1, Math.min(cap, count)); + // Sync per-slot cooldown array for silos/SAMs when it has been initialized + if ( + (this._type === UnitType.MissileSilo || + this._type === UnitType.SAMLauncher) && + this._slotCooldowns.length > 0 + ) { + this._ensureSlots(); + } this.mg.addUpdate(this.toUpdate()); } @@ -349,9 +362,9 @@ export class UnitImpl implements Unit { case UnitType.MissileSilo: { // No cap for silo stacking this._level += 1; - // Reset launches remaining to allow more launches - if (this._launchesRemaining !== null) { - this._launchesRemaining += 1; + // Sync per-slot cooldown array if it was already initialized + if (this._slotCooldowns.length > 0) { + this._ensureSlots(); } this._bonusMaxHealth += 250; const healed = Number(this._health) + 250; @@ -364,6 +377,10 @@ export class UnitImpl implements Unit { } case UnitType.SAMLauncher: { this._level += 1; + // Sync per-slot cooldown array if it was already initialized + if (this._slotCooldowns.length > 0) { + this._ensureSlots(); + } // Small durability boost per upgrade, aligned with MissileSilo behavior this._bonusMaxHealth += 250; const healed = Number(this._health) + 250; @@ -615,35 +632,52 @@ export class UnitImpl implements Unit { return this._boatTargetPlayerID; } + setBoatTargetTile(tile: TileRef | null): void { + this._boatTargetTile = tile; + } + boatTargetTile(): TileRef | null { + return this._boatTargetTile; + } + insure(player: Player | null): void { if (!isStructureType(this._type)) return; this._insuredBy = player; } launch(duration?: Tick): void { - // For stacked missile silos and SAMs: allow multiple launches before cooldown + // Missile silos & SAM launchers: per-slot independent cooldowns if ( - (this.type() === UnitType.MissileSilo || - this.type() === UnitType.SAMLauncher) && - this._stackCount > 1 + this.type() === UnitType.MissileSilo || + this.type() === UnitType.SAMLauncher ) { - // Initialize launches remaining on first launch - if (this._launchesRemaining === null) { - this._launchesRemaining = this._stackCount - 1; // First launch uses one - this.mg.addUpdate(this.toUpdate()); - return; // Don't start cooldown yet - } - // If we have remaining launches, use one - if (this._launchesRemaining > 0) { - this._launchesRemaining--; - this.mg.addUpdate(this.toUpdate()); - if (this._launchesRemaining > 0) { - return; // Still have more launches, don't start cooldown + const defaultCD = + this.type() === UnitType.MissileSilo + ? this.mg.config().SiloCooldown() + : this.mg.config().SAMNukeCooldown(); + const cd = duration ?? defaultCD; + this._cooldownDuration = cd; + + if (this._stackCount > 1) { + // Ensure per-slot array is initialized / sized correctly + this._ensureSlots(); + + // Find first ready slot and put it on cooldown with its own duration + const now = this.mg.ticks(); + const readyIdx = this._slotCooldowns.findIndex((s) => + this._isSlotReady(s, now), + ); + if (readyIdx >= 0) { + this._slotCooldowns[readyIdx] = { start: now, duration: cd }; } - // Fall through to start cooldown when all launches used + + this.mg.addUpdate(this.toUpdate()); + return; } - // Reset launches for next cycle - this._launchesRemaining = null; + + // Stack-1: standard single cooldown + this._cooldownStartTick = this.mg.ticks(); + this.mg.addUpdate(this.toUpdate()); + return; } this._cooldownStartTick = this.mg.ticks(); @@ -651,12 +685,7 @@ export class UnitImpl implements Unit { this._cooldownDuration = duration; } else { // Choose default by unit type - if (this.type() === UnitType.MissileSilo) { - // Use base cooldown - stacking doesn't affect cooldown duration - this._cooldownDuration = this.mg.config().SiloCooldown(); - } else if (this.type() === UnitType.SAMLauncher) { - this._cooldownDuration = this.mg.config().SAMNukeCooldown(); - } else if (this.type() === UnitType.City) { + if (this.type() === UnitType.City) { // City anti-air default will be set by caller via duration; fallback to SAM cooldown this._cooldownDuration = this.mg.config().SAMNukeCooldown(); } else { @@ -667,6 +696,23 @@ export class UnitImpl implements Unit { } ticksLeftInCooldown(): Tick | undefined { + // Per-slot cooldown for stacked silos & SAMs + if ( + (this.type() === UnitType.MissileSilo || + this.type() === UnitType.SAMLauncher) && + this._slotCooldowns.length > 0 + ) { + const now = this.mg.ticks(); + let minLeft: number | null = null; + for (const s of this._slotCooldowns) { + if (this._isSlotReady(s, now)) return undefined; // At least one slot ready + const left = this._slotTicksLeft(s!, now); + if (minLeft === null || left < minLeft) minLeft = left; + } + // All slots on cooldown — return time until next recovery + return minLeft ?? undefined; + } + let cooldownDuration = this._cooldownDuration; if (cooldownDuration === null) { @@ -694,6 +740,19 @@ export class UnitImpl implements Unit { } isInCooldown(duration?: Tick): boolean { + // Per-slot silo/SAM: on cooldown only when ALL slots are busy. + // Each slot tracks its own duration, so the `duration` param is ignored + // for per-slot units — a slot fired at a plane recovers in 40 ticks, + // a slot fired at a nuke recovers in 75 ticks, independently. + if ( + (this.type() === UnitType.MissileSilo || + this.type() === UnitType.SAMLauncher) && + this._slotCooldowns.length > 0 + ) { + const now = this.mg.ticks(); + return !this._slotCooldowns.some((s) => this._isSlotReady(s, now)); + } + const ticksLeft = this.ticksLeftInCooldown(); if (duration !== undefined) { return ( @@ -704,6 +763,98 @@ export class UnitImpl implements Unit { return ticksLeft !== undefined && ticksLeft > 0; } + // --------------------------------------------------------------------------- + // Per-slot cooldown helpers (silos & SAMs) + // --------------------------------------------------------------------------- + + /** Ensure the per-slot cooldown array matches the current stack count. */ + private _ensureSlots(): void { + if (this._slotCooldowns.length === this._stackCount) return; + + // Transition from single-cooldown to per-slot on first init + if (this._slotCooldowns.length === 0 && this._cooldownStartTick !== null) { + const cd = this._cooldownDuration ?? this.mg.config().SiloCooldown(); + this._slotCooldowns = [{ start: this._cooldownStartTick, duration: cd }]; + this._cooldownStartTick = null; + } + + // Grow — new slots start ready (null) + while (this._slotCooldowns.length < this._stackCount) { + this._slotCooldowns.push(null); + } + + // Shrink (shouldn't normally happen but stay safe) + if (this._slotCooldowns.length > this._stackCount) { + this._slotCooldowns.length = this._stackCount; + } + } + + /** Effective cooldown for a single slot, adjusted for health. */ + private _effectiveSlotDuration(baseDuration: Tick): number { + let cd = baseDuration; + if (this.hasHealth()) { + const hp = Number(this.health()) / this.effectiveMaxHealth(); + if (hp > 0) cd /= hp; + } + return cd; + } + + /** Whether a slot is ready to fire. */ + private _isSlotReady( + slot: { start: Tick; duration: Tick } | null, + now: Tick, + ): boolean { + if (slot === null) return true; + return this._slotTicksLeft(slot, now) <= 0; + } + + /** Ticks remaining for a single slot's cooldown. */ + private _slotTicksLeft( + slot: { start: Tick; duration: Tick }, + now: Tick, + ): number { + const effectiveCD = this._effectiveSlotDuration(slot.duration); + return effectiveCD - (now - slot.start); + } + + /** Count how many slots are currently ready (not on cooldown). */ + private _readySlotsCount(): number { + if (this._slotCooldowns.length === 0) return this._stackCount; + const now = this.mg.ticks(); + return this._slotCooldowns.filter((s) => this._isSlotReady(s, now)).length; + } + + /** Whether any slot is still recovering (for update emission). */ + hasRecoveringSlots(): boolean { + if ( + (this._type !== UnitType.MissileSilo && + this._type !== UnitType.SAMLauncher) || + this._slotCooldowns.length === 0 + ) { + return false; + } + return this._readySlotsCount() < this._stackCount; + } + + /** Compute cooldownEndsAt for per-slot units; returns undefined for non-slot or idle. */ + private _slotCooldownEndsAt(): number | undefined { + if ( + (this._type !== UnitType.MissileSilo && + this._type !== UnitType.SAMLauncher) || + this._slotCooldowns.length === 0 + ) { + return undefined; + } + const now = this.mg.ticks(); + let earliest: number | null = null; + for (const s of this._slotCooldowns) { + if (this._isSlotReady(s, now)) return undefined; // At least one slot ready + const end = s!.start + this._effectiveSlotDuration(s!.duration); + if (earliest === null || end < earliest) earliest = end; + } + return earliest ?? undefined; + } + setTargetTile(targetTile: TileRef | undefined) { this._targetTile = targetTile; } diff --git a/src/core/game/Upgradeables.ts b/src/core/game/Upgradeables.ts index b0495066c..fe26e25cc 100644 --- a/src/core/game/Upgradeables.ts +++ b/src/core/game/Upgradeables.ts @@ -53,7 +53,7 @@ export function isUpgradeableUnit(type: UnitType): boolean { return UPGRADEABLE_UNITS.has(type); } -const MAX_STACK_COUNT = 25; +const MAX_STACK_COUNT = 99; // Maximum TECH upgrade level for structures (SAM, Airfield) // This is NOT the stack count - it's the quality tier from research. @@ -68,7 +68,7 @@ export function maxStackCount(type: UnitType): number { return isStackableStructure(type) ? MAX_STACK_COUNT : 1; } -// Legacy function - returns max stack count (25 for all stackable structures) +// Legacy function - returns max stack count (99 for all stackable structures) export function maxStructureLevel(type: UnitType): number { return isStackableStructure(type) ? MAX_STACK_COUNT : 1; } diff --git a/src/core/tech/TechEffects.ts b/src/core/tech/TechEffects.ts index 7f7ece141..352abc6d5 100644 --- a/src/core/tech/TechEffects.ts +++ b/src/core/tech/TechEffects.ts @@ -167,9 +167,9 @@ export const TECHS: Readonly> = Object.freeze({ [RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS]: { meta: { name: "Road Network", - shortDescription: "Roads, Trade Routes", + shortDescription: "Roads", description: - "Develop critical infrastructure to boost your economy and military mobility. Unlocks Roads (increases unit movement speed and generates passive trade income per connected tile) and Trade Routes (enables trade ships to establish international commerce routes, generating continuous gold income).", + "Develop critical infrastructure to boost your economy and military mobility. Unlocks Roads (increases unit movement speed and generates passive trade income per connected tile).", }, effects: { onComplete: (player, game) => { @@ -447,7 +447,7 @@ export function applyTechCompletionEffects( } /** - * Compute casualty multipliers when a player is defending, based on researched techs and policy directives. + * Compute casualty multipliers when a player is defending, based on researched techs. * - attackerLossMul > 1 increases enemy losses * - defenderLossMul < 1 reduces own losses */ @@ -467,7 +467,7 @@ export function defenseCasualtyModifiers(defender: { } /** - * Compute casualty multipliers when a player is attacking, based on researched techs and policy directives. + * Compute casualty multipliers when a player is attacking, based on researched techs. * Returned multipliers stack multiplicatively with defender-side modifiers. * - attackerLossMul < 1 reduces own losses * - defenderLossMul > 1 increases enemy losses @@ -488,7 +488,7 @@ export function attackCasualtyModifiers(attacker: { } /** - * Compute attack speed multiplier based on researched techs and policy directives. + * Compute attack speed multiplier based on researched techs. * speedMul > 1 increases tiles conquered per tick (faster attacks). */ export function attackSpeedModifiers(attacker: { @@ -506,7 +506,7 @@ export function attackSpeedModifiers(attacker: { } /** - * Compute construction speed multiplier based on researched techs and policy directives. + * Compute construction speed multiplier based on researched techs. * speedMul > 1 means construction completes faster (fewer ticks). */ export function constructionSpeedModifiers(player: { @@ -543,7 +543,7 @@ export function researchEffectivenessModifiers( } /** - * Compute income multiplier based on researched techs and policy directives. + * Compute income multiplier based on researched techs. * incomeMul > 1 means higher gross gold income. * domesticIncomeMul > 1 means higher domestic (non-trade) income. */ @@ -563,7 +563,7 @@ export function incomeModifiers(player: { } /** - * Compute infrastructure spending effectiveness multiplier based on researched techs and policy directives. + * Compute infrastructure spending effectiveness multiplier based on researched techs. * effectivenessMul > 1 means more roads per gold spent. */ export function infrastructureEffectivenessModifiers(player: { @@ -581,7 +581,7 @@ export function infrastructureEffectivenessModifiers(player: { } /** - * Compute trade income multiplier based on researched techs and policy directives. + * Compute trade income multiplier based on researched techs. * incomeMul > 1 means higher trade income. * tradeShipIncomeMul > 1 means higher income for trade ship owners. */ @@ -601,7 +601,7 @@ export function tradeIncomeModifiers(player: { } /** - * Compute road effect multiplier based on researched techs and policy directives. + * Compute road effect multiplier based on researched techs. * effectMul > 1 means roads provide stronger bonuses. */ export function roadEffectModifiers(player: { diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 892228925..b7ee1b4fb 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -3,13 +3,17 @@ import { createGameRunner, GameRunner } from "../GameRunner"; import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates"; import { AttackAveragePositionResultMessage, + AttackDebugResultMessage, + ConstructionDebugResultMessage, ExecutionMetricsMessage, InitializedMessage, MainThreadMessage, PlayerActionsResultMessage, PlayerBorderTilesResultMessage, PlayerProfileResultMessage, + TradeDebugResultMessage, TransportShipSpawnResultMessage, + WarScoreDebugResultMessage, WorkerMessage, } from "./WorkerMessages"; @@ -80,6 +84,7 @@ ctx.addEventListener("message", async (e: MessageEvent) => { message.gameStartInfo, message.clientID, gameUpdate, + message.calibration as any, ).then((gr) => { sendMessage({ type: "initialized", @@ -204,6 +209,74 @@ ctx.addEventListener("message", async (e: MessageEvent) => { console.error("Failed to spawn transport ship:", error); } break; + case "war_score_debug": + if (!gameRunner) { + throw new Error("Game runner not initialized"); + } + + try { + const debugData = (await gameRunner).warScoreDebug(); + sendMessage({ + type: "war_score_debug_result", + id: message.id, + result: debugData, + } as WarScoreDebugResultMessage); + } catch (error) { + console.error("Failed to get war score debug:", error); + throw error; + } + break; + case "attack_debug": + if (!gameRunner) { + throw new Error("Game runner not initialized"); + } + + try { + const attackData = (await gameRunner).attackDebug(); + sendMessage({ + type: "attack_debug_result", + id: message.id, + result: attackData, + } as AttackDebugResultMessage); + } catch (error) { + console.error("Failed to get attack debug:", error); + throw error; + } + break; + case "trade_debug": + if (!gameRunner) { + throw new Error("Game runner not initialized"); + } + + try { + const tradeData = (await gameRunner).tradeDebug(); + sendMessage({ + type: "trade_debug_result", + id: message.id, + result: tradeData, + } as TradeDebugResultMessage); + } catch (error) { + console.error("Failed to get trade debug:", error); + throw error; + } + break; + case "construction_debug": + if (!gameRunner) { + throw new Error("Game runner not initialized"); + } + + try { + const constructionData = (await gameRunner).constructionDebug(); + sendMessage({ + type: "construction_debug_result", + id: message.id, + result: constructionData, + } as ConstructionDebugResultMessage); + } catch (error) { + console.error("Failed to get construction debug:", error); + throw error; + } + break; default: console.warn("Unknown message :", message); } diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index 0bf4a139c..1012fdd74 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -1,4 +1,8 @@ import { PerformanceMetrics } from "../../client/utilities/PerformanceMetrics"; +import { AttackDebugData } from "../ai/AIAttackHandler"; +import { WarScoreDebugData } from "../ai/AIDiplomacyHandler"; +import { ConstructionDebugData } from "../ai/ConstructionDebugData"; +import { TradeDebugPayload } from "../execution/TradeDebugData"; import { Cell, PlayerActions, @@ -23,6 +27,7 @@ export class WorkerClient { constructor( private gameStartInfo: GameStartInfo, private clientID: ClientID, + private calibration?: any, ) { this.worker = new Worker(new URL("./Worker.worker.ts", import.meta.url)); this.messageHandlers = new Map(); @@ -89,6 +94,7 @@ export class WorkerClient { id: messageId, gameStartInfo: this.gameStartInfo, clientID: this.clientID, + calibration: this.calibration, }); // Add timeout for initialization @@ -274,6 +280,106 @@ export class WorkerClient { }); } + warScoreDebug(): Promise { + return new Promise((resolve, reject) => { + if (!this.isInitialized) { + reject(new Error("Worker not initialized")); + return; + } + + const messageId = generateID(); + + this.messageHandlers.set(messageId, (message) => { + if ( + message.type === "war_score_debug_result" && + message.result !== undefined + ) { + resolve(message.result); + } + }); + + this.worker.postMessage({ + type: "war_score_debug", + id: messageId, + }); + }); + } + + attackDebug(): Promise { + return new Promise((resolve, reject) => { + if (!this.isInitialized) { + reject(new Error("Worker not initialized")); + return; + } + + const messageId = generateID(); + + this.messageHandlers.set(messageId, (message) => { + if ( + message.type === "attack_debug_result" && + message.result !== undefined + ) { + resolve(message.result); + } + }); + + this.worker.postMessage({ + type: "attack_debug", + id: messageId, + }); + }); + } + + tradeDebug(): Promise { + return new Promise((resolve, reject) => { + if (!this.isInitialized) { + reject(new Error("Worker not initialized")); + return; + } + + const messageId = generateID(); + + this.messageHandlers.set(messageId, (message) => { + if ( + message.type === "trade_debug_result" && + message.result !== undefined + ) { + resolve(message.result); + } + }); + + this.worker.postMessage({ + type: "trade_debug", + id: messageId, + }); + }); + } + + constructionDebug(): Promise { + return new Promise((resolve, reject) => { + if (!this.isInitialized) { + reject(new Error("Worker not initialized")); + return; + } + + const messageId = generateID(); + + this.messageHandlers.set(messageId, (message) => { + if ( + message.type === "construction_debug_result" && + message.result !== undefined + ) { + resolve(message.result); + } + }); + + this.worker.postMessage({ + type: "construction_debug", + id: messageId, + }); + }); + } + cleanup() { this.worker.terminate(); this.messageHandlers.clear(); diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index 0545e1f5a..744894d58 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -1,3 +1,7 @@ +import { AttackDebugData } from "../ai/AIAttackHandler"; +import { WarScoreDebugData } from "../ai/AIDiplomacyHandler"; +import { ConstructionDebugData } from "../ai/ConstructionDebugData"; +import { TradeDebugPayload } from "../execution/TradeDebugData"; import { PlayerActions, PlayerBorderTiles, @@ -25,7 +29,15 @@ export type WorkerMessageType = | "transport_ship_spawn" | "transport_ship_spawn_result" | "set_metrics_enabled" - | "execution_metrics"; + | "execution_metrics" + | "war_score_debug" + | "war_score_debug_result" + | "attack_debug" + | "attack_debug_result" + | "trade_debug" + | "trade_debug_result" + | "construction_debug" + | "construction_debug_result"; // Base interface for all messages interface BaseWorkerMessage { @@ -42,6 +54,12 @@ export interface InitMessage extends BaseWorkerMessage { type: "init"; gameStartInfo: GameStartInfo; clientID: ClientID; + // Calibration data for AI-vs-AI matches + calibration?: { + numPlayers: number; + profileA: { id: string; name: string; params: Record }; + profileB: { id: string; name: string; params: Record }; + }; } export interface TurnMessage extends BaseWorkerMessage { @@ -124,6 +142,42 @@ export interface ExecutionMetricsMessage extends BaseWorkerMessage { metrics: Array<{ type: string; time: number; count: number }>; } +export interface WarScoreDebugMessage extends BaseWorkerMessage { + type: "war_score_debug"; +} + +export interface WarScoreDebugResultMessage extends BaseWorkerMessage { + type: "war_score_debug_result"; + result: WarScoreDebugData[]; +} + +export interface AttackDebugMessage extends BaseWorkerMessage { + type: "attack_debug"; +} + +export interface AttackDebugResultMessage extends BaseWorkerMessage { + type: "attack_debug_result"; + result: AttackDebugData[]; +} + +export interface TradeDebugMessage extends BaseWorkerMessage { + type: "trade_debug"; +} + +export interface TradeDebugResultMessage extends BaseWorkerMessage { + type: "trade_debug_result"; + result: TradeDebugPayload; +} + +export interface ConstructionDebugMessage extends BaseWorkerMessage { + type: "construction_debug"; +} + +export interface ConstructionDebugResultMessage extends BaseWorkerMessage { + type: "construction_debug_result"; + result: ConstructionDebugData[]; +} + // Union types for type safety export type MainThreadMessage = | HeartbeatMessage @@ -134,7 +188,11 @@ export type MainThreadMessage = | PlayerBorderTilesMessage | AttackAveragePositionMessage | TransportShipSpawnMessage - | SetMetricsEnabledMessage; + | SetMetricsEnabledMessage + | WarScoreDebugMessage + | AttackDebugMessage + | TradeDebugMessage + | ConstructionDebugMessage; // Message send from worker export type WorkerMessage = @@ -145,4 +203,8 @@ export type WorkerMessage = | PlayerBorderTilesResultMessage | AttackAveragePositionResultMessage | TransportShipSpawnResultMessage - | ExecutionMetricsMessage; + | ExecutionMetricsMessage + | WarScoreDebugResultMessage + | AttackDebugResultMessage + | TradeDebugResultMessage + | ConstructionDebugResultMessage; diff --git a/tests/BotBehavior.test.ts b/tests/BotBehavior.test.ts deleted file mode 100644 index 99df34764..000000000 --- a/tests/BotBehavior.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { BotBehavior } from "../src/core/execution/utils/BotBehavior"; -import { - AllianceRequest, - Game, - Player, - PlayerInfo, - PlayerType, - Tick, -} from "../src/core/game/Game"; -import { PseudoRandom } from "../src/core/PseudoRandom"; -import { setup } from "./util/Setup"; - -let game: Game; -let player: Player; -let requestor: Player; -let botBehavior: BotBehavior; - -describe("BotBehavior.handleAllianceRequests", () => { - beforeEach(async () => { - game = await setup("BigPlains", { infiniteGold: true, instantBuild: true }); - - const playerInfo = new PlayerInfo( - "us", - "player_id", - PlayerType.Bot, - null, - "player_id", - ); - const requestorInfo = new PlayerInfo( - "fr", - "requestor_id", - PlayerType.Human, - null, - "requestor_id", - ); - - game.addPlayer(playerInfo); - game.addPlayer(requestorInfo); - - player = game.player("player_id"); - requestor = game.player("requestor_id"); - - const random = new PseudoRandom(42); - - botBehavior = new BotBehavior(random, game, player, 0.5, 0.5); - }); - - function setupAllianceRequest({ - isTraitor = false, - relationDelta = 2, - numTilesPlayer = 10, - numTilesRequestor = 10, - alliancesCount = 0, - } = {}) { - if (isTraitor) requestor.markTraitor(); - - player.updateRelation(requestor, relationDelta); - requestor.updateRelation(player, relationDelta); - - game.map().forEachTile((tile) => { - if (game.map().isLand(tile)) { - if (numTilesPlayer > 0) { - player.conquer(tile); - numTilesPlayer--; - } else if (numTilesRequestor > 0) { - requestor.conquer(tile); - numTilesRequestor--; - } - } - }); - - jest - .spyOn(requestor, "alliances") - .mockReturnValue(new Array(alliancesCount)); - - const mockRequest = { - requestor: () => requestor, - recipient: () => player, - createdAt: () => 0 as unknown as Tick, - accept: jest.fn(), - reject: jest.fn(), - } as unknown as AllianceRequest; - - jest - .spyOn(player, "incomingAllianceRequests") - .mockReturnValue([mockRequest]); - - return mockRequest; - } - - test("should accept alliance when all conditions are met", () => { - const request = setupAllianceRequest({}); - - botBehavior.handleAllianceRequests(); - - expect(request.accept).toHaveBeenCalled(); - expect(request.reject).not.toHaveBeenCalled(); - }); - - test("should reject alliance if requestor is a traitor", () => { - const request = setupAllianceRequest({ isTraitor: true }); - - botBehavior.handleAllianceRequests(); - - expect(request.accept).not.toHaveBeenCalled(); - expect(request.reject).toHaveBeenCalled(); - }); - - test("should reject alliance if relation is malicious", () => { - const request = setupAllianceRequest({ relationDelta: -2 }); - - botBehavior.handleAllianceRequests(); - - expect(request.accept).not.toHaveBeenCalled(); - expect(request.reject).toHaveBeenCalled(); - }); - - test("should accept alliance if requestor is much larger (> 3 times size of recipient) and has too many alliances (>= 3)", () => { - const request = setupAllianceRequest({ - numTilesRequestor: 40, - alliancesCount: 4, - }); - - botBehavior.handleAllianceRequests(); - - expect(request.accept).toHaveBeenCalled(); - expect(request.reject).not.toHaveBeenCalled(); - }); - - test("should accept alliance if requestor is much larger (> 3 times size of recipient) and does not have too many alliances (< 3)", () => { - const request = setupAllianceRequest({ - numTilesRequestor: 40, - alliancesCount: 2, - }); - - botBehavior.handleAllianceRequests(); - - expect(request.accept).toHaveBeenCalled(); - expect(request.reject).not.toHaveBeenCalled(); - }); - - test("should reject alliance if requestor is acceptably small (<= 3 times size of recipient) and has too many alliances (>= 3)", () => { - const request = setupAllianceRequest({ alliancesCount: 3 }); - - botBehavior.handleAllianceRequests(); - - expect(request.accept).not.toHaveBeenCalled(); - expect(request.reject).toHaveBeenCalled(); - }); -}); diff --git a/tests/Submarine.test.ts b/tests/Submarine.test.ts index 7256955c5..d4c429106 100644 --- a/tests/Submarine.test.ts +++ b/tests/Submarine.test.ts @@ -163,6 +163,40 @@ describe("Submarine", () => { expect(tradeShip.owner().id()).toBe(player2.id()); }); + test("Submarine does not target trade ship docked at a port", async () => { + // build port so submarine can target trade ships + player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {}); + + const submarine = player1.buildUnit( + UnitType.Submarine, + game.ref(coastX + 1, 10), + { + patrolTile: game.ref(coastX + 1, 10), + }, + ); + game.addExecution(new SubmarineExecution(submarine)); + + // Place a port for player2 and put the trade ship ON the port tile (docked) + const enemyPort = player2.buildUnit( + UnitType.Port, + game.ref(coastX, 11), + {}, + ); + const tradeShip = player2.buildUnit( + UnitType.TradeShip, + enemyPort.tile(), // docked at port + { + targetUnit: enemyPort, + }, + ); + + executeTicks(game, 10); + + // Trade ship should remain alive and owned by player2 + expect(tradeShip.isActive()).toBe(true); + expect(tradeShip.owner().id()).toBe(player2.id()); + }); + test("Submarine moves to new patrol tile", async () => { game.config().warshipTargettingRange = () => 1; diff --git a/tests/Warship.test.ts b/tests/Warship.test.ts index 91a2b1f57..c52c9d18a 100644 --- a/tests/Warship.test.ts +++ b/tests/Warship.test.ts @@ -167,6 +167,39 @@ describe("Warship", () => { expect(tradeShip.owner().id()).toBe(player2.id()); }); + test("Warship does not capture trade ship docked at a port", async () => { + // build port so warship can target trade ships + player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {}); + + const warship = player1.buildUnit( + UnitType.Warship, + game.ref(coastX + 1, 10), + { + patrolTile: game.ref(coastX + 1, 10), + }, + ); + game.addExecution(new WarshipExecution(warship)); + + // Place a port for player2 and put the trade ship ON the port tile (docked) + const enemyPort = player2.buildUnit( + UnitType.Port, + game.ref(coastX, 11), + {}, + ); + const tradeShip = player2.buildUnit( + UnitType.TradeShip, + enemyPort.tile(), // docked at port + { + targetUnit: enemyPort, + }, + ); + + executeTicks(game, 10); + + // Trade ship should remain owned by player2 because it is in port + expect(tradeShip.owner().id()).toBe(player2.id()); + }); + test("Warship moves to new patrol tile", async () => { game.config().warshipTargettingRange = () => 1; diff --git a/tests/client/graphics/UILayer.test.ts b/tests/client/graphics/UILayer.test.ts index db6b13aab..74a16c00e 100644 --- a/tests/client/graphics/UILayer.test.ts +++ b/tests/client/graphics/UILayer.test.ts @@ -129,7 +129,7 @@ describe("UILayer", () => { expect((ui as any)["allProgressBars"].has(2)).toBe(true); }); - it("should remove loading bar for inactive unit", () => { + it("should not add loading bar for Construction unit (handled by StructureLayer)", () => { const ui = new UILayer(game, eventBus, transformHandler); ui.redraw(); @@ -141,37 +141,12 @@ describe("UILayer", () => { tile: () => ({}), isActive: () => true, ticksLeftInCooldown: (): number | undefined => 0, + cooldownEndsAt: () => 6, + cooldownDuration: () => 5, }; + // Construction loading bars are handled in StructureLayer, not UILayer (ui as any).onUnitEvent(unit); - expect((ui as any)["allProgressBars"].has(2)).toBe(true); - - // an inactive unit should not have a loading bar - unit.isActive = () => false; - ui.tick(); - expect((ui as any)["allProgressBars"].has(2)).toBe(false); - }); - - it("should remove loading bar for a finished progress bar", () => { - const ui = new UILayer(game, eventBus, transformHandler); - ui.redraw(); - - const unit: any = { - id: () => 2, - type: () => "Construction", - constructionType: () => "City", - owner: () => ({ id: () => 1 }), - tile: () => ({}), - isActive: () => true, - ticksLeftInCooldown: (): number | undefined => 0, - }; - - (ui as any).onUnitEvent(unit); - expect((ui as any)["allProgressBars"].has(2)).toBe(true); - - // simulate enough ticks for completion - game.ticks = () => 6; - ui.tick(); expect((ui as any)["allProgressBars"].has(2)).toBe(false); }); diff --git a/tests/client/layers/TerritoryLayer.perf.test.ts b/tests/client/layers/TerritoryLayer.perf.test.ts new file mode 100644 index 000000000..2514eb066 --- /dev/null +++ b/tests/client/layers/TerritoryLayer.perf.test.ts @@ -0,0 +1,54 @@ +/** + * @jest-environment jsdom + */ + +/** + * TerritoryLayer (Canvas2D) Performance Benchmark + * ================================================= + * Uses the shared harness to benchmark the current Canvas2D-based + * TerritoryLayer implementation. To benchmark an alternative + * implementation (WebGL, Pixi, OffscreenCanvas, etc.), create a new + * test file that imports the same harness and passes a different factory: + * + * import { runTerritoryBenchSuite } from "./territory-layer-bench-harness"; + * import { MyWebGLTerritoryLayer } from "..."; + * + * runTerritoryBenchSuite("WebGL TerritoryLayer", (game, eventBus, transform) => + * new MyWebGLTerritoryLayer(game, eventBus, transform), + * ); + */ + +// jsdom doesn't provide ImageData — polyfill before any imports that need it +if (typeof globalThis.ImageData === "undefined") { + (globalThis as any).ImageData = class ImageData { + readonly width: number; + readonly height: number; + readonly data: Uint8ClampedArray; + constructor(sw: number, sh: number); + constructor(data: Uint8ClampedArray, sw: number, sh?: number); + constructor( + swOrData: number | Uint8ClampedArray, + shOrSw: number, + maybeH?: number, + ) { + if (swOrData instanceof Uint8ClampedArray) { + this.data = swOrData; + this.width = shOrSw; + this.height = maybeH ?? swOrData.length / 4 / shOrSw; + } else { + this.width = swOrData; + this.height = shOrSw; + this.data = new Uint8ClampedArray(this.width * this.height * 4); + } + } + }; +} + +import { TerritoryLayer } from "../../../src/client/graphics/layers/TerritoryLayer"; +import { runTerritoryBenchSuite } from "./territory-layer-bench-harness"; + +runTerritoryBenchSuite( + "Canvas2D TerritoryLayer", + (game, eventBus, transformHandler) => + new TerritoryLayer(game, eventBus, transformHandler), +); diff --git a/tests/client/layers/territory-layer-bench-harness.ts b/tests/client/layers/territory-layer-bench-harness.ts new file mode 100644 index 000000000..56b9ee118 --- /dev/null +++ b/tests/client/layers/territory-layer-bench-harness.ts @@ -0,0 +1,843 @@ +/** + * TerritoryLayer Performance Benchmark Harness + * ============================================== + * Implementation-agnostic harness for benchmarking any Layer that renders + * territory. Exports mock game state, attack simulation, stats utilities, + * and a `runTerritoryBenchSuite()` function that works with any factory + * producing a `Layer`. + * + * Usage in a test file: + * + * import { runTerritoryBenchSuite } from "./territory-layer-bench-harness"; + * import { MyTerritoryLayer } from "..."; + * + * runTerritoryBenchSuite("MyTerritoryLayer", (game, eventBus, transform) => + * new MyTerritoryLayer(game, eventBus, transform), + * ); + * + * Each implementation gets identical scenarios and the results table is + * printed at the end so you can compare side-by-side. + */ + +import { colord, type Colord } from "colord"; +import type { Layer } from "../../../src/client/graphics/layers/Layer"; +import type { TransformHandler } from "../../../src/client/graphics/TransformHandler"; +import type { EventBus } from "../../../src/core/EventBus"; +import { PlayerType } from "../../../src/core/game/Game"; +import type { TileRef } from "../../../src/core/game/GameMap"; +import { GameUpdateType } from "../../../src/core/game/GameUpdates"; +import type { GameView } from "../../../src/core/game/GameView"; +import { PlayerView } from "../../../src/core/game/GameView"; + +// ═══════════════════════════════════════════════════════════════════════════ +// Map / player constants +// ═══════════════════════════════════════════════════════════════════════════ + +export const MAP_WIDTH = 600; +export const MAP_HEIGHT = 400; +export const TOTAL_TILES = MAP_WIDTH * MAP_HEIGHT; +export const NUM_PLAYERS = 4; + +// ═══════════════════════════════════════════════════════════════════════════ +// Types +// ═══════════════════════════════════════════════════════════════════════════ + +export interface BenchmarkResult { + scenario: string; + samples: number; + /** Mean wall-clock time in ms */ + meanMs: number; + /** Median wall-clock time in ms */ + medianMs: number; + /** 95th percentile in ms */ + p95Ms: number; + /** Standard deviation in ms */ + stdMs: number; + /** Minimum in ms */ + minMs: number; + /** Maximum in ms */ + maxMs: number; + /** Total putImageData calls during measured samples */ + putImageDataCalls: number; + /** Total drawImage calls during measured samples */ + drawImageCalls: number; + /** Sum of dirty-rect pixel areas across all putImageData calls */ + totalDirtyPixels: number; +} + +export interface GpuCounters { + putImageDataCalls: number; + drawImageCalls: number; + totalDirtyPixels: number; +} + +/** Rectangular region of tiles assigned to a player (simple partition). */ +export interface PlayerRegion { + id: string; + smallID: number; + startTile: number; + tileCount: number; +} + +export interface MockGameState { + ownerMap: Int32Array; + borderMap: Uint8Array; + regions: PlayerRegion[]; + players: PlayerView[]; + recentTiles: TileRef[]; + tileOwnerChangedUpdates: { type: number; tile: TileRef }[]; + currentTick: number; +} + +/** + * Factory signature: given the mocked dependencies, return a Layer. + * The factory may also return a cleanup function called after each sample. + */ +export type LayerFactory = ( + game: GameView, + eventBus: EventBus, + transformHandler: TransformHandler, +) => Layer; + +// ═══════════════════════════════════════════════════════════════════════════ +// Stats helpers +// ═══════════════════════════════════════════════════════════════════════════ + +export function computeStats( + label: string, + timings: number[], + gpuMetrics: GpuCounters, +): BenchmarkResult { + const sorted = [...timings].sort((a, b) => a - b); + const n = sorted.length; + const mean = sorted.reduce((s, v) => s + v, 0) / n; + const median = + n % 2 === 0 + ? (sorted[n / 2 - 1] + sorted[n / 2]) / 2 + : sorted[Math.floor(n / 2)]; + const p95 = sorted[Math.min(Math.ceil(n * 0.95) - 1, n - 1)]; + const variance = sorted.reduce((s, v) => s + (v - mean) ** 2, 0) / n; + const std = Math.sqrt(variance); + return { + scenario: label, + samples: n, + meanMs: +mean.toFixed(3), + medianMs: +median.toFixed(3), + p95Ms: +p95.toFixed(3), + stdMs: +std.toFixed(3), + minMs: +sorted[0].toFixed(3), + maxMs: +sorted[n - 1].toFixed(3), + putImageDataCalls: gpuMetrics.putImageDataCalls, + drawImageCalls: gpuMetrics.drawImageCalls, + totalDirtyPixels: gpuMetrics.totalDirtyPixels, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Canvas / context instrumented mock +// ═══════════════════════════════════════════════════════════════════════════ + +export function resetGpuCounters(c: GpuCounters) { + c.putImageDataCalls = 0; + c.drawImageCalls = 0; + c.totalDirtyPixels = 0; +} + +export function createInstrumentedContext( + width: number, + height: number, + counters: GpuCounters, +): CanvasRenderingContext2D { + return { + putImageData: ( + _imageData: ImageData, + _dx: number, + _dy: number, + dirtyX?: number, + dirtyY?: number, + dirtyW?: number, + dirtyH?: number, + ) => { + counters.putImageDataCalls++; + if (dirtyW !== undefined && dirtyH !== undefined) { + counters.totalDirtyPixels += dirtyW * dirtyH; + } else { + counters.totalDirtyPixels += width * height; + } + }, + drawImage: () => { + counters.drawImageCalls++; + }, + clearRect: () => {}, + fillRect: () => {}, + fillStyle: "", + canvas: { width, height }, + } as unknown as CanvasRenderingContext2D; +} + +/** + * Monkey-patch `document.createElement("canvas")` to return instrumented + * canvases that track GPU-proxy calls. + */ +export function installCanvasMock( + width: number, + height: number, + counters: GpuCounters, +) { + const origCreateElement = document.createElement.bind(document); + jest + .spyOn(document, "createElement") + .mockImplementation((tag: string, options?: ElementCreationOptions) => { + if (tag === "canvas") { + const fakeCanvas = { + width, + height, + getContext: (_id: string, _opts?: any) => + createInstrumentedContext(width, height, counters), + toDataURL: () => "", + style: {}, + } as unknown as HTMLCanvasElement; + return fakeCanvas; + } + return origCreateElement(tag, options); + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Game state mock +// ═══════════════════════════════════════════════════════════════════════════ + +const PLAYER_COLORS: Colord[] = [ + colord("#e63946"), + colord("#457b9d"), + colord("#2a9d8f"), + colord("#e9c46a"), +]; + +export function buildPlayerRegions(): PlayerRegion[] { + const tilesPerPlayer = Math.floor(TOTAL_TILES / NUM_PLAYERS); + const regions: PlayerRegion[] = []; + for (let i = 0; i < NUM_PLAYERS; i++) { + regions.push({ + id: `player-${i}`, + smallID: i + 1, + startTile: i * tilesPerPlayer, + tileCount: tilesPerPlayer, + }); + } + return regions; +} + +export function buildOwnerMap(regions: PlayerRegion[]): Int32Array { + const map = new Int32Array(TOTAL_TILES).fill(-1); + for (const r of regions) { + for (let t = r.startTile; t < r.startTile + r.tileCount; t++) { + map[t] = r.smallID; + } + } + return map; +} + +export function computeBorders( + ownerMap: Int32Array, + w: number, + h: number, +): Uint8Array { + const borders = new Uint8Array(w * h); + for (let t = 0; t < w * h; t++) { + if (ownerMap[t] === -1) continue; + const x = t % w; + const y = Math.floor(t / w); + const oid = ownerMap[t]; + let isBorder = false; + if (x > 0 && ownerMap[t - 1] !== oid) isBorder = true; + if (x < w - 1 && ownerMap[t + 1] !== oid) isBorder = true; + if (y > 0 && ownerMap[t - w] !== oid) isBorder = true; + if (y < h - 1 && ownerMap[t + w] !== oid) isBorder = true; + borders[t] = isBorder ? 1 : 0; + } + return borders; +} + +export function neighborsOf(tile: TileRef, w: number, h: number): Uint32Array { + const x = tile % w; + const y = Math.floor(tile / w); + const result: number[] = []; + if (x > 0) result.push(tile - 1); + if (x < w - 1) result.push(tile + 1); + if (y > 0) result.push(tile - w); + if (y < h - 1) result.push(tile + w); + return new Uint32Array(result); +} + +export function createMockPlayerView( + region: PlayerRegion, + color: Colord, +): PlayerView { + return { + id: () => region.id, + smallID: () => region.smallID, + type: () => PlayerType.Human, + isPlayer: () => true, + isFriendly: () => false, + isAtWarWith: () => false, + isAlliedWith: () => false, + nameLocation: () => ({ + x: ((region.startTile % MAP_WIDTH) + MAP_WIDTH / NUM_PLAYERS / 2) | 0, + y: (Math.floor(region.startTile / MAP_WIDTH) + MAP_HEIGHT / 2) | 0, + }), + borderTiles: () => + Promise.resolve({ borderTiles: [], innerBorderTiles: [] }), + numTilesOwned: () => region.tileCount, + _color: color, + } as unknown as PlayerView; +} + +function createMockTheme() { + return { + territoryColor: (pv: any) => (pv as any)._color ?? colord("#888888"), + borderColor: (pv: any) => + ((pv as any)._color ?? colord("#888888")).darken(0.2), + defendedBorderColors: (pv: any) => ({ + light: ((pv as any)._color ?? colord("#888888")).lighten(0.1), + dark: ((pv as any)._color ?? colord("#888888")).darken(0.3), + }), + focusedBorderColor: () => colord("#ffffff"), + falloutColor: () => colord("#333333"), + selfColor: () => colord("#00ff00"), + allyColor: () => colord("#0000ff"), + enemyColor: () => colord("#ff0000"), + spawnHighlightColor: () => colord("#ffff00"), + }; +} + +export function createMockGameView(state: MockGameState): GameView { + const theme = createMockTheme(); + + const playersBySmallID = new Map(); + const playersById = new Map(); + for (const p of state.players) { + playersBySmallID.set(p.smallID(), p); + playersById.set(p.id(), p); + } + + const game: Partial = { + width: () => MAP_WIDTH, + height: () => MAP_HEIGHT, + config: () => + ({ + theme: () => theme, + defensePostRange: () => 3, + }) as any, + ref: (x: number, y: number) => y * MAP_WIDTH + x, + x: (t: TileRef) => t % MAP_WIDTH, + y: (t: TileRef) => Math.floor(t / MAP_WIDTH), + isValidCoord: (x: number, y: number) => + x >= 0 && x < MAP_WIDTH && y >= 0 && y < MAP_HEIGHT, + hasOwner: (t: TileRef) => state.ownerMap[t] !== -1, + ownerID: (t: TileRef) => state.ownerMap[t], + owner: (t: TileRef) => { + const sid = state.ownerMap[t]; + if (sid === -1) return { isPlayer: () => false } as any; + return playersBySmallID.get(sid) ?? ({ isPlayer: () => false } as any); + }, + isBorder: (t: TileRef) => state.borderMap[t] === 1, + hasFallout: (_t: TileRef) => false, + neighbors: (t: TileRef) => neighborsOf(t, MAP_WIDTH, MAP_HEIGHT), + forEachTile: (fn: (t: TileRef) => void) => { + for (let t = 0; t < TOTAL_TILES; t++) fn(t); + }, + ticks: () => state.currentTick, + inSpawnPhase: () => false, + myPlayer: () => null, + focusedPlayer: () => null, + playerViews: () => state.players, + playerBySmallID: (id: number) => + playersBySmallID.get(id) ?? ({ isPlayer: () => false } as any), + hasUnitNearby: () => false, + recentlyUpdatedTiles: () => state.recentTiles, + updatesSinceLastTick: () => { + const updates: any = {}; + for (const key of Object.values(GameUpdateType)) { + if (typeof key === "number") updates[key] = []; + } + updates[GameUpdateType.TileOwnerChanged] = state.tileOwnerChangedUpdates; + return updates; + }, + }; + + return game as GameView; +} + +export function createMockEventBus(): EventBus { + return { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + } as unknown as EventBus; +} + +export function createMockTransformHandler(): TransformHandler { + return { + screenToWorldCoordinates: () => ({ x: 0, y: 0 }), + } as unknown as TransformHandler; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Attack simulation +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Simulate an attack: BFS-flip `count` tiles at the boundary between two + * player regions from `fromSmallID` to `toSmallID`. + * Returns the list of changed tile refs. + */ +export function simulateAttack( + state: MockGameState, + fromSmallID: number, + toSmallID: number, + count: number, +): TileRef[] { + const changed: TileRef[] = []; + const candidates: TileRef[] = []; + for (let t = 0; t < TOTAL_TILES; t++) { + if (state.ownerMap[t] !== fromSmallID) continue; + const ns = neighborsOf(t, MAP_WIDTH, MAP_HEIGHT); + for (let i = 0; i < ns.length; i++) { + if (state.ownerMap[ns[i]] === toSmallID) { + candidates.push(t); + break; + } + } + } + + const visited = new Set(); + const queue = [...candidates]; + for (const c of candidates) visited.add(c); + + while (changed.length < count && queue.length > 0) { + const t = queue.shift()!; + if (state.ownerMap[t] !== fromSmallID) continue; + state.ownerMap[t] = toSmallID; + changed.push(t); + const ns = neighborsOf(t, MAP_WIDTH, MAP_HEIGHT); + for (let i = 0; i < ns.length; i++) { + if (!visited.has(ns[i]) && state.ownerMap[ns[i]] === fromSmallID) { + visited.add(ns[i]); + queue.push(ns[i]); + } + } + } + + // Recompute borders for affected + neighboring tiles + const affectedSet = new Set(changed); + for (const t of changed) { + const ns = neighborsOf(t, MAP_WIDTH, MAP_HEIGHT); + for (let i = 0; i < ns.length; i++) affectedSet.add(ns[i]); + } + for (const t of affectedSet) { + if (state.ownerMap[t] === -1) { + state.borderMap[t] = 0; + continue; + } + const x = t % MAP_WIDTH; + const y = Math.floor(t / MAP_WIDTH); + const oid = state.ownerMap[t]; + let border = false; + if (x > 0 && state.ownerMap[t - 1] !== oid) border = true; + if (x < MAP_WIDTH - 1 && state.ownerMap[t + 1] !== oid) border = true; + if (y > 0 && state.ownerMap[t - MAP_WIDTH] !== oid) border = true; + if (y < MAP_HEIGHT - 1 && state.ownerMap[t + MAP_WIDTH] !== oid) + border = true; + state.borderMap[t] = border ? 1 : 0; + } + + return changed; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Fresh state builder +// ═══════════════════════════════════════════════════════════════════════════ + +export function freshState(): MockGameState { + const regions = buildPlayerRegions(); + const players = regions.map((r, i) => + createMockPlayerView(r, PLAYER_COLORS[i]), + ); + const ownerMap = buildOwnerMap(regions); + const borderMap = computeBorders(ownerMap, MAP_WIDTH, MAP_HEIGHT); + return { + ownerMap, + borderMap, + regions, + players, + recentTiles: [], + tileOwnerChangedUpdates: [], + currentTick: 0, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Generic benchmark runner +// ═══════════════════════════════════════════════════════════════════════════ + +function hrtime(): number { + return performance.now(); +} + +interface BenchCtx { + layer: Layer; + renderCtx: CanvasRenderingContext2D; + gpuCounters: GpuCounters; + state: MockGameState; +} + +function runBenchmark( + label: string, + warmup: number, + iterations: number, + setup: () => BenchCtx, + action: (ctx: BenchCtx) => void, +): BenchmarkResult { + const timings: number[] = []; + const totalGpu: GpuCounters = { + putImageDataCalls: 0, + drawImageCalls: 0, + totalDirtyPixels: 0, + }; + const totalRuns = warmup + iterations; + + for (let i = 0; i < totalRuns; i++) { + const ctx = setup(); + resetGpuCounters(ctx.gpuCounters); + + const t0 = hrtime(); + action(ctx); + const t1 = hrtime(); + + if (i >= warmup) { + timings.push(t1 - t0); + totalGpu.putImageDataCalls += ctx.gpuCounters.putImageDataCalls; + totalGpu.drawImageCalls += ctx.gpuCounters.drawImageCalls; + totalGpu.totalDirtyPixels += ctx.gpuCounters.totalDirtyPixels; + } + } + + return computeStats(label, timings, totalGpu); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Public: run the full benchmark suite for any Layer implementation +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Registers a Jest `describe` block with 6 standard scenarios for the given + * Layer implementation. Call this from a `*.test.ts` file. + * + * @param suiteName Label for the describe block (e.g. "Canvas2D TerritoryLayer") + * @param factory Creates the Layer under test from mock dependencies. + * @param options Optional tuning knobs. + */ +export function runTerritoryBenchSuite( + suiteName: string, + factory: LayerFactory, + options: { warmup?: number; iterations?: number } = {}, +) { + const WARMUP = options.warmup ?? 3; + const ITERATIONS = options.iterations ?? 10; + + describe(suiteName, () => { + const allResults: BenchmarkResult[] = []; + + // Suppress noisy console.log from implementations (e.g. "redrew territory layer") + const origLog = console.log; + beforeAll(() => { + console.log = (...args: any[]) => { + if ( + typeof args[0] === "string" && + args[0].includes("redrew territory layer") + ) + return; + origLog(...args); + }; + }); + + afterAll(() => { + console.log = origLog; + + // Print comparison table + console.log( + `\n╔══════════════════════════════════════════════════════════════╗`, + ); + console.log(`║ ${suiteName.padEnd(56)} ║`); + console.log( + `╚══════════════════════════════════════════════════════════════╝\n`, + ); + console.table( + allResults.map((r) => ({ + Scenario: r.scenario, + Samples: r.samples, + "Mean (ms)": r.meanMs, + "Median (ms)": r.medianMs, + "P95 (ms)": r.p95Ms, + "Std (ms)": r.stdMs, + "Min (ms)": r.minMs, + "Max (ms)": r.maxMs, + putImageData: r.putImageDataCalls, + drawImage: r.drawImageCalls, + "Dirty px (M)": +(r.totalDirtyPixels / 1_000_000).toFixed(2), + })), + ); + }); + + // ---- helpers ---- + + function makeLayer( + state: MockGameState, + gpuCounters: GpuCounters, + ): BenchCtx { + installCanvasMock(MAP_WIDTH, MAP_HEIGHT, gpuCounters); + const gameView = createMockGameView(state); + const eventBus = createMockEventBus(); + const transformHandler = createMockTransformHandler(); + const layer = factory(gameView, eventBus, transformHandler); + const renderCtx = createInstrumentedContext( + MAP_WIDTH, + MAP_HEIGHT, + gpuCounters, + ); + return { layer, renderCtx, gpuCounters, state }; + } + + function newGpuCounters(): GpuCounters { + return { putImageDataCalls: 0, drawImageCalls: 0, totalDirtyPixels: 0 }; + } + + // ---- Scenario 1: Full redraw ---- + + it("Scenario 1 — Full redraw (baseline)", () => { + const result = runBenchmark( + "1. Full redraw (240k tiles)", + WARMUP, + ITERATIONS, + () => makeLayer(freshState(), newGpuCounters()), + ({ layer }) => { + layer.redraw!(); + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + + // ---- Scenario 2: Large single attack (5k tiles) ---- + + it("Scenario 2 — Large single attack (5 000 tiles)", () => { + const result = runBenchmark( + "2. Large attack (5k tiles)", + WARMUP, + ITERATIONS, + () => { + const state = freshState(); + const ctx = makeLayer(state, newGpuCounters()); + ctx.layer.init?.(); + ctx.layer.redraw!(); + resetGpuCounters(ctx.gpuCounters); + + const changed = simulateAttack( + state, + state.regions[0].smallID, + state.regions[1].smallID, + 5_000, + ); + state.recentTiles = changed; + state.tileOwnerChangedUpdates = changed.map((t) => ({ + type: GameUpdateType.TileOwnerChanged, + tile: t, + })); + state.currentTick++; + return ctx; + }, + ({ layer, renderCtx }) => { + layer.tick!(); + layer.renderLayer!(renderCtx); + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + + // ---- Scenario 3: Multiple simultaneous attacks (3 × 3k tiles) ---- + + it("Scenario 3 — Multiple simultaneous attacks (3 × 3k tiles)", () => { + const result = runBenchmark( + "3. Multi-attack (3×3k tiles)", + WARMUP, + ITERATIONS, + () => { + const state = freshState(); + const ctx = makeLayer(state, newGpuCounters()); + ctx.layer.init?.(); + ctx.layer.redraw!(); + resetGpuCounters(ctx.gpuCounters); + + const allChanged: TileRef[] = []; + allChanged.push( + ...simulateAttack( + state, + state.regions[0].smallID, + state.regions[1].smallID, + 3_000, + ), + ); + allChanged.push( + ...simulateAttack( + state, + state.regions[1].smallID, + state.regions[2].smallID, + 3_000, + ), + ); + allChanged.push( + ...simulateAttack( + state, + state.regions[2].smallID, + state.regions[3].smallID, + 3_000, + ), + ); + state.recentTiles = allChanged; + state.tileOwnerChangedUpdates = allChanged.map((t) => ({ + type: GameUpdateType.TileOwnerChanged, + tile: t, + })); + state.currentTick++; + return ctx; + }, + ({ layer, renderCtx }) => { + layer.tick!(); + layer.renderLayer!(renderCtx); + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + + // ---- Scenario 4: Sustained incremental (200 tiles/tick × 50 ticks) ---- + + it("Scenario 4 — Sustained incremental (200 tiles/tick × 50 ticks)", () => { + const NUM_TICKS = 50; + const TILES_PER_TICK = 200; + + const result = runBenchmark( + "4. Sustained (200/tick × 50)", + WARMUP, + ITERATIONS, + () => { + const state = freshState(); + const ctx = makeLayer(state, newGpuCounters()); + ctx.layer.init?.(); + ctx.layer.redraw!(); + resetGpuCounters(ctx.gpuCounters); + return ctx; + }, + ({ layer, renderCtx, state }) => { + for (let tick = 0; tick < NUM_TICKS; tick++) { + const changed = simulateAttack( + state, + state.regions[0].smallID, + state.regions[1].smallID, + TILES_PER_TICK, + ); + state.recentTiles = changed; + state.tileOwnerChangedUpdates = changed.map((t) => ({ + type: GameUpdateType.TileOwnerChanged, + tile: t, + })); + state.currentTick++; + layer.tick!(); + layer.renderLayer!(renderCtx); + } + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + + // ---- Scenario 5: renderLayer only (queue already loaded) ---- + + it("Scenario 5 — renderLayer only (5k tiles queued)", () => { + const result = runBenchmark( + "5. renderLayer only (5k queued)", + WARMUP, + ITERATIONS, + () => { + const state = freshState(); + const ctx = makeLayer(state, newGpuCounters()); + ctx.layer.init?.(); + ctx.layer.redraw!(); + + const changed = simulateAttack( + state, + state.regions[0].smallID, + state.regions[1].smallID, + 5_000, + ); + state.recentTiles = changed; + state.tileOwnerChangedUpdates = changed.map((t) => ({ + type: GameUpdateType.TileOwnerChanged, + tile: t, + })); + state.currentTick++; + ctx.layer.tick!(); + + // Reset — measure only renderLayer + resetGpuCounters(ctx.gpuCounters); + state.recentTiles = []; + state.tileOwnerChangedUpdates = []; + return ctx; + }, + ({ layer, renderCtx }) => { + layer.renderLayer!(renderCtx); + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + + // ---- Scenario 6: tick() only (no render) ---- + + it("Scenario 6 — tick() only (5k ownership changes)", () => { + const result = runBenchmark( + "6. tick() only (5k changes)", + WARMUP, + ITERATIONS, + () => { + const state = freshState(); + const ctx = makeLayer(state, newGpuCounters()); + ctx.layer.init?.(); + ctx.layer.redraw!(); + resetGpuCounters(ctx.gpuCounters); + + const changed = simulateAttack( + state, + state.regions[0].smallID, + state.regions[1].smallID, + 5_000, + ); + state.recentTiles = changed; + state.tileOwnerChangedUpdates = changed.map((t) => ({ + type: GameUpdateType.TileOwnerChanged, + tile: t, + })); + state.currentTick++; + return ctx; + }, + ({ layer }) => { + layer.tick!(); + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + }); +} diff --git a/tests/core/ai/AINukeHandler.test.ts b/tests/core/ai/AINukeHandler.test.ts new file mode 100644 index 000000000..3925ccdfa --- /dev/null +++ b/tests/core/ai/AINukeHandler.test.ts @@ -0,0 +1,495 @@ +import { AIBehaviorParams } from "../../../src/core/ai/AIBehaviorParams"; +import { AINukeHandler } from "../../../src/core/ai/AINukeHandler"; +import { + Game, + Player, + PlayerInfo, + PlayerType, + UnitType, + UpgradeType, +} from "../../../src/core/game/Game"; +import { PseudoRandom } from "../../../src/core/PseudoRandom"; +import { setup } from "../../util/Setup"; +import { TestConfig } from "../../util/TestConfig"; + +// Costs (infiniteGold=false, all players pay full price): +// AtomBomb: 750,000 +// HydrogenBomb: 5,000,000 +// MissileSilo: 1,000,000 +// City: min(1M, 2^numOwned * 125k) — caps at 1M +// SAMLauncher: 1,500,000 + +let game: Game; +let aiPlayer: Player; +let enemy: Player; + +const params: AIBehaviorParams = {}; + +function createHandler(): AINukeHandler { + return new AINukeHandler(game, aiPlayer.id(), new PseudoRandom(42), params); +} + +function conquerBlock( + player: Player, + x: number, + y: number, + size: number, +): void { + for (let dx = 0; dx < size; dx++) { + for (let dy = 0; dy < size; dy++) { + player.conquer(game.ref(x + dx, y + dy)); + } + } +} + +describe("AINukeHandler", () => { + beforeEach(async () => { + // infiniteGold=false keeps costs non-zero for scoring (scoring values + // structures by their cost(owner), which is 0 when infiniteGold+Human). + game = await setup( + "BigPlains", + { infiniteGold: false, instantBuild: true }, + [ + new PlayerInfo( + "us", + "ai_player", + PlayerType.Human, + "client_ai", + "ai_player", + ), + new PlayerInfo( + "us", + "enemy_player", + PlayerType.Human, + "client_enemy", + "enemy_player", + ), + ], + ); + + // Atom inner=5, Hydrogen inner=10 + (game.config() as TestConfig).nukeMagnitudes = jest.fn((type: UnitType) => { + if (type === UnitType.HydrogenBomb) { + return { inner: 10, outer: 15 }; + } + return { inner: 5, outer: 8 }; + }); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + aiPlayer = game.player("ai_player"); + enemy = game.player("enemy_player"); + + aiPlayer.addUpgrade(UpgradeType.NuclearFission); + enemy.addUpgrade(UpgradeType.NuclearFission); + + aiPlayer.setWarWith(enemy); + enemy.setWarWith(aiPlayer); + + // Territory makes players alive (isAlive() checks tiles > 0) + conquerBlock(aiPlayer, 1, 1, 5); + conquerBlock(enemy, 50, 50, 10); + + // Give AI player enough workers so grossGoldPerMinute is non-trivial. + // Without workers the discount-rate denominator (1+r)^T → ∞, zeroing all scores. + aiPlayer.addWorkers(10000); + // Recompute estimated income so the discount formula doesn't divide by zero. + aiPlayer.updateIncomeTracking(); + }); + + // -------------------------------------------------------------------------- + // Atom bomb + // -------------------------------------------------------------------------- + describe("atom bomb scoring", () => { + test("finds high-value target without SAMs", () => { + // 5 cities clustered within atom inner range (5) of each other. + // After 5 cities, cost(enemy) = min(1M, 2^5*125k) = 1M each. + // Total enemy value ≈ 5 * 1M = 5M + // Atom bomb cost = 750k, silo cost = 1M → score ≈ 3.25M + for (let i = 0; i < 5; i++) { + enemy.buildUnit(UnitType.City, game.ref(55 + i, 55), {}); + } + + const handler = new AINukeHandler( + game, + aiPlayer.id(), + new PseudoRandom(42), + params, + ); + for (let i = 0; i < 1000; i++) { + handler.tick(i); + } + + const best = handler.bestAtomTarget(); + expect(best).not.toBeNull(); + expect(best!.score).toBeGreaterThan(0); + }); + + test("SAMs reduce atom bomb score", () => { + for (let i = 0; i < 5; i++) { + enemy.buildUnit(UnitType.City, game.ref(55 + i, 55), {}); + } + + // Without SAMs + const h1 = new AINukeHandler( + game, + aiPlayer.id(), + new PseudoRandom(42), + params, + ); + for (let i = 0; i < 1000; i++) h1.tick(i); + const noSAM = h1.bestAtomTarget(); + + // Add a SAM within SAM range (20) of the city cluster + enemy.buildUnit(UnitType.SAMLauncher, game.ref(55, 57), {}); + + // With SAM + const h2 = new AINukeHandler( + game, + aiPlayer.id(), + new PseudoRandom(42), + params, + ); + for (let i = 0; i < 1000; i++) h2.tick(i); + const withSAM = h2.bestAtomTarget(); + + expect(noSAM).not.toBeNull(); + // SAM penalty = atomBombCost (750k) per SAM level + extra silo levels + // Score must be strictly lower or null (negative → pruned) + if (withSAM !== null) { + expect(withSAM.score).toBeLessThan(noSAM!.score); + } + }); + + test("single low-value target returns lower score than high-value cluster", () => { + // One city: value = 125k (first city). Bomb = 750k, silo = 1M/2 + // Discount-based scoring: low value → low discounted net score + enemy.buildUnit(UnitType.City, game.ref(55, 55), {}); + + const handler = new AINukeHandler( + game, + aiPlayer.id(), + new PseudoRandom(42), + params, + ); + for (let i = 0; i < 500; i++) handler.tick(i); + + const singleCityScore = handler.bestAtomTarget(); + + // Now build a high-value cluster for comparison + for (let i = 1; i < 5; i++) { + enemy.buildUnit(UnitType.City, game.ref(55 + i, 55), {}); + } + + const h2 = new AINukeHandler( + game, + aiPlayer.id(), + new PseudoRandom(42), + params, + ); + for (let i = 0; i < 500; i++) h2.tick(i); + const clusterScore = h2.bestAtomTarget(); + + // Single city score should be strictly less than the cluster score + expect(clusterScore).not.toBeNull(); + if (singleCityScore !== null) { + expect(singleCityScore.score).toBeLessThan(clusterScore!.score); + } + }); + }); + + // -------------------------------------------------------------------------- + // Hydrogen bomb + // -------------------------------------------------------------------------- + describe("hydrogen bomb scoring", () => { + test("finds high-value target without SAMs", () => { + aiPlayer.addUpgrade(UpgradeType.ThermonuclearStaging); + + // 7×7 block of cities within hydrogen inner range (10) + for (let x = 52; x <= 58; x++) { + for (let y = 52; y <= 58; y++) { + enemy.buildUnit(UnitType.City, game.ref(x, y), {}); + } + } + // 49 cities capped at 1M each → value ≈ 49M + // HBomb = 5M, silo = 1M → score ≈ 43M + + const handler = new AINukeHandler( + game, + aiPlayer.id(), + new PseudoRandom(42), + params, + ); + for (let i = 0; i < 1000; i++) handler.tick(i); + + const best = handler.bestHydrogenTarget(); + expect(best).not.toBeNull(); + // Discount-based scoring: high-value cluster should produce positive score + expect(best!.score).toBeGreaterThan(0); + }); + + test("SAMs reduce hydrogen bomb score", () => { + aiPlayer.addUpgrade(UpgradeType.ThermonuclearStaging); + + for (let x = 52; x <= 58; x++) { + for (let y = 52; y <= 58; y++) { + enemy.buildUnit(UnitType.City, game.ref(x, y), {}); + } + } + + const h1 = new AINukeHandler( + game, + aiPlayer.id(), + new PseudoRandom(42), + params, + ); + for (let i = 0; i < 1000; i++) h1.tick(i); + const noSAM = h1.bestHydrogenTarget(); + + // Add 2 SAMs near the cluster + enemy.buildUnit(UnitType.SAMLauncher, game.ref(55, 60), {}); + enemy.buildUnit(UnitType.SAMLauncher, game.ref(58, 55), {}); + + const h2 = new AINukeHandler( + game, + aiPlayer.id(), + new PseudoRandom(42), + params, + ); + for (let i = 0; i < 1000; i++) h2.tick(i); + const withSAM = h2.bestHydrogenTarget(); + + expect(noSAM).not.toBeNull(); + expect(withSAM).not.toBeNull(); + expect(withSAM!.score).toBeLessThan(noSAM!.score); + }); + }); + + // -------------------------------------------------------------------------- + // calculateSAMPenalty + // -------------------------------------------------------------------------- + describe("calculateSAMPenalty", () => { + test("returns 0 when no SAMs exist", () => { + const handler = createHandler(); + expect(handler.calculateSAMPenalty(game.ref(55, 55))).toBe(0); + }); + + test("counts SAM levels within range", () => { + // dist (55,55)→(55,57) = 2 < baseRange 20 + enemy.buildUnit(UnitType.SAMLauncher, game.ref(55, 57), {}); + + const handler = createHandler(); + expect(handler.calculateSAMPenalty(game.ref(55, 55))).toBe(1); + }); + + test("ignores SAMs outside range", () => { + // dist (55,55)→(100,100) ≈ 64 > 20 + conquerBlock(enemy, 100, 100, 3); + enemy.buildUnit(UnitType.SAMLauncher, game.ref(100, 100), {}); + + const handler = createHandler(); + expect(handler.calculateSAMPenalty(game.ref(55, 55))).toBe(0); + }); + + test("sums stack counts from multiple SAMs", () => { + enemy.buildUnit(UnitType.SAMLauncher, game.ref(55, 57), {}); + enemy.buildUnit(UnitType.SAMLauncher, game.ref(53, 55), {}); + + const handler = createHandler(); + expect(handler.calculateSAMPenalty(game.ref(55, 55))).toBe(2); + }); + }); + + // -------------------------------------------------------------------------- + // getSAMsInRange + // -------------------------------------------------------------------------- + describe("getSAMsInRange", () => { + test("returns empty array when no SAMs exist", () => { + expect(createHandler().getSAMsInRange(game.ref(55, 55))).toHaveLength(0); + }); + + test("returns SAMs within range", () => { + const sam = enemy.buildUnit(UnitType.SAMLauncher, game.ref(55, 57), {}); + const sams = createHandler().getSAMsInRange(game.ref(55, 55)); + expect(sams).toHaveLength(1); + expect(sams[0]).toBe(sam); + }); + + test("excludes SAMs outside range", () => { + conquerBlock(enemy, 100, 100, 3); + enemy.buildUnit(UnitType.SAMLauncher, game.ref(100, 100), {}); + expect(createHandler().getSAMsInRange(game.ref(55, 55))).toHaveLength(0); + }); + + test("returns multiple SAMs", () => { + enemy.buildUnit(UnitType.SAMLauncher, game.ref(55, 57), {}); + enemy.buildUnit(UnitType.SAMLauncher, game.ref(53, 55), {}); + expect(createHandler().getSAMsInRange(game.ref(55, 55))).toHaveLength(2); + }); + }); + + // -------------------------------------------------------------------------- + // bombsNeeded + // -------------------------------------------------------------------------- + describe("bombsNeeded", () => { + test("returns 1 when no SAMs in range", () => { + expect(createHandler().bombsNeeded(game.ref(55, 55))).toBe(1); + }); + + test("returns 1 + SAM levels with SAMs present", () => { + enemy.buildUnit(UnitType.SAMLauncher, game.ref(55, 57), {}); + enemy.buildUnit(UnitType.SAMLauncher, game.ref(53, 55), {}); + // 2 SAMs × stackCount 1 → 1 + 2 = 3 + expect(createHandler().bombsNeeded(game.ref(55, 55))).toBe(3); + }); + }); + + // -------------------------------------------------------------------------- + // getPlayerSiloCapacity + // -------------------------------------------------------------------------- + describe("getPlayerSiloCapacity", () => { + test("returns 0 when player has no silos", () => { + expect(createHandler().getPlayerSiloCapacity()).toBe(0); + }); + + test("returns stackCount of largest silo", () => { + const silo = aiPlayer.buildUnit(UnitType.MissileSilo, game.ref(2, 2), {}); + expect(silo.stackCount()).toBe(1); + expect(createHandler().getPlayerSiloCapacity()).toBe(1); + }); + + test("ignores enemy silos", () => { + enemy.buildUnit(UnitType.MissileSilo, game.ref(55, 55), {}); + expect(createHandler().getPlayerSiloCapacity()).toBe(0); + }); + }); + + // -------------------------------------------------------------------------- + // getEffectiveSAMRange + // -------------------------------------------------------------------------- + describe("getEffectiveSAMRange", () => { + test("returns base range at tech level 1", () => { + // TestConfig.defaultSamRange() = 20, no SAM upgrades → level 1 + expect(createHandler().getEffectiveSAMRange(enemy)).toBe(20); + }); + + test("increases with SAMLevel2 upgrade", () => { + enemy.addUpgrade(UpgradeType.SAMLevel2); + const range = createHandler().getEffectiveSAMRange(enemy); + // 20 * (1 + 0.35)^(2-1) = 20 * 1.35 = 27 + expect(range).toBe(27); + }); + }); + + // -------------------------------------------------------------------------- + // resetScores + // -------------------------------------------------------------------------- + describe("resetScores", () => { + test("clears all cached targets", () => { + for (let i = 0; i < 5; i++) { + enemy.buildUnit(UnitType.City, game.ref(55 + i, 55), {}); + } + + const handler = new AINukeHandler( + game, + aiPlayer.id(), + new PseudoRandom(42), + params, + ); + for (let i = 0; i < 1000; i++) handler.tick(i); + + expect(handler.bestAtomTarget()).not.toBeNull(); + + handler.resetScores(); + + expect(handler.bestAtomTarget()).toBeNull(); + expect(handler.bestHydrogenTarget()).toBeNull(); + }); + }); + + // -------------------------------------------------------------------------- + // Silo cost in scoring + // -------------------------------------------------------------------------- + describe("silo cost penalty", () => { + test("silo does not affect fast-path score (silo cost removed from tick scoring)", () => { + // Build a high-value target cluster + for (let i = 0; i < 5; i++) { + enemy.buildUnit(UnitType.City, game.ref(55 + i, 55), {}); + } + + // Without silo + const h1 = new AINukeHandler( + game, + aiPlayer.id(), + new PseudoRandom(42), + params, + ); + for (let i = 0; i < 1000; i++) h1.tick(i); + const withoutSilo = h1.bestAtomTarget(); + + // With silo + aiPlayer.buildUnit(UnitType.MissileSilo, game.ref(2, 2), {}); + + const h2 = new AINukeHandler( + game, + aiPlayer.id(), + new PseudoRandom(42), + params, + ); + for (let i = 0; i < 1000; i++) h2.tick(i); + const withSilo = h2.bestAtomTarget(); + + expect(withoutSilo).not.toBeNull(); + expect(withSilo).not.toBeNull(); + // Silo cost is no longer included in the fast-path scoring, + // so the scores should be equal regardless of silo ownership. + expect(withSilo!.score).toBe(withoutSilo!.score); + }); + }); + + // -------------------------------------------------------------------------- + // Friendly damage + // -------------------------------------------------------------------------- + describe("friendly damage weight", () => { + test("own structures in blast radius reduce score", () => { + // Enemy cities at (55-59, 55) + // Enemy cities span (54-59, 55) minus (57,55) which we give to AI. + // Any tile capturing all 5 enemy cities must also capture (57,55). + const enemyXs = [54, 55, 56, 58, 59]; + for (const x of enemyXs) { + enemy.conquer(game.ref(x, 55)); + enemy.buildUnit(UnitType.City, game.ref(x, 55), {}); + } + + const h1 = new AINukeHandler( + game, + aiPlayer.id(), + new PseudoRandom(42), + params, + ); + for (let i = 0; i < 1000; i++) h1.tick(i); + const clean = h1.bestAtomTarget(); + expect(clean).not.toBeNull(); + + // AI city at exact center — unavoidable in any tile capturing all 5 + aiPlayer.conquer(game.ref(57, 55)); + aiPlayer.buildUnit(UnitType.City, game.ref(57, 55), {}); + + const h2 = new AINukeHandler( + game, + aiPlayer.id(), + new PseudoRandom(42), + params, + ); + for (let i = 0; i < 1000; i++) h2.tick(i); + const withFriendly = h2.bestAtomTarget(); + + // The AI city must reduce the score, or make it null + if (withFriendly !== null) { + expect(withFriendly.score).toBeLessThan(clean!.score); + } + }); + }); +}); diff --git a/tests/core/execution/BotAnnexing.test.ts b/tests/core/execution/BotAnnexing.test.ts index 0943f6beb..39c03fdd6 100644 --- a/tests/core/execution/BotAnnexing.test.ts +++ b/tests/core/execution/BotAnnexing.test.ts @@ -69,34 +69,28 @@ describe("Bot Annexing (Encirclement Mechanic)", () => { expect(human1.numTilesOwned()).toEqual(human1TilesBefore); }); - it("should not annex a fakehuman player", async () => { + it("should not annex an AI player", async () => { game = await setup( "Plains", { infiniteGold: true, instantBuild: true, startingGold: 0 }, [ new PlayerInfo("us", "Human", PlayerType.Human, null, "human_id"), - new PlayerInfo( - "us", - "FakeHuman", - PlayerType.FakeHuman, - null, - "fake_id", - ), + new PlayerInfo("us", "AI", PlayerType.AI, null, "fake_id"), ], ); while (game.inSpawnPhase()) game.executeNextTick(); - const fakeHuman = game.player("fake_id") as Player; - const fakeInitialTiles = fakeHuman.numTilesOwned(); + const aiPlayer = game.player("fake_id") as Player; + const aiInitialTiles = aiPlayer.numTilesOwned(); // Execute many ticks for (let i = 0; i < 250; i++) { game.executeNextTick(); } - // FakeHuman should still own same tiles (not auto-annexed) - expect(fakeHuman.numTilesOwned()).toEqual(fakeInitialTiles); + // AI should still own same tiles (not auto-annexed) + expect(aiPlayer.numTilesOwned()).toEqual(aiInitialTiles); }); }); diff --git a/tests/core/execution/UpgradeStructureExecution.test.ts b/tests/core/execution/UpgradeStructureExecution.test.ts index 9fa25ec47..a15d96a43 100644 --- a/tests/core/execution/UpgradeStructureExecution.test.ts +++ b/tests/core/execution/UpgradeStructureExecution.test.ts @@ -86,13 +86,13 @@ describe("UpgradeStructureExecution", () => { it("does not charge or upgrade a Missile Silo at max stack level", () => { const { mockPlayer, mockGame } = makeMocks(UnitType.MissileSilo); - // Create a unit mock that reports level 25 (max stack count) + // Create a unit mock that reports level 99 (max stack count) const mockUnit = { isUnit: jest.fn().mockReturnValue(true), type: jest.fn().mockReturnValue(UnitType.MissileSilo), owner: jest.fn().mockReturnValue(mockPlayer), - level: jest.fn().mockReturnValue(25), // MAX_STACK_COUNT - stackCount: jest.fn().mockReturnValue(25), + level: jest.fn().mockReturnValue(99), // MAX_STACK_COUNT + stackCount: jest.fn().mockReturnValue(99), setStackCount: jest.fn(), upgradeStructure: jest.fn(), } as unknown as jest.Mocked<