diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 38d449c24..d2a5eaaba 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -654,17 +654,52 @@ export class ClientGameRunner { } } - if (upgradeUnits.length > 0) { - const bestUpgrade = findClosestBy(upgradeUnits, (u) => u.distance); - if (bestUpgrade) { - this.eventBus.emit( - new SendUpgradeStructureIntentEvent( - bestUpgrade.unitId, - bestUpgrade.unitType, - ), - ); + if (upgradeUnits.length === 0) { + return; + } + + // Upgrade the closest affordable building. But if there's an unaffordable + // building (any type) that's closer to clickedTile than the best candidate, + // do nothing — the player clicked on that unaffordable building intending + // to upgrade it, and we must not spend their gold on a different building. + const bestUpgrade = findClosestBy(upgradeUnits, (u) => u.distance); + if (!bestUpgrade) { + return; + } + + // Check if any unaffordable building is closer than bestUpgrade + for (const bu of actions.buildableUnits) { + if (bu.canUpgrade === false && bu.type !== bestUpgrade.unitType) { + const myPlayerID = this.myPlayer!.id(); + const closestOfType = this.gameView + .nearbyUnits( + clickedTile, + this.gameView.config().structureMinDist(), + bu.type, + ) + .filter(({ unit }) => unit.owner().id() === myPlayerID) + .sort((a, b) => a.distSquared - b.distSquared)[0]; + + if (closestOfType) { + const dist = this.gameView.manhattanDist( + clickedTile, + closestOfType.unit.tile(), + ); + if (dist <= bestUpgrade.distance) { + // An unaffordable building of type bu.type is at least as close + // as bestUpgrade — player clicked on it, not on bestUpgrade. + return; + } + } } } + + this.eventBus.emit( + new SendUpgradeStructureIntentEvent( + bestUpgrade.unitId, + bestUpgrade.unitType, + ), + ); }); } diff --git a/tests/FindAndUpgradeNearestBuilding.test.ts b/tests/FindAndUpgradeNearestBuilding.test.ts new file mode 100644 index 000000000..8bf3f1969 --- /dev/null +++ b/tests/FindAndUpgradeNearestBuilding.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, test, vi } from "vitest"; +import { SendUpgradeStructureIntentEvent } from "../src/client/Transport"; +import { EventBus } from "../src/core/EventBus"; +import { UnitType } from "../src/core/game/Game"; +import { TileRef } from "../src/core/game/GameMap"; + +/** + * NOTE: The `findAndUpgradeNearestBuilding` function below is a test-local + * mirror of `ClientGameRunner.findAndUpgradeNearestBuilding` (src/client/ClientGameRunner.ts). + * If you change the production logic, update this stub accordingly so the + * tests remain meaningful. + */ + +const TILE = 42 as TileRef; +const PLAYER_ID = "player-1"; +const SEARCH_RADIUS = 15; + +/** Creates a minimal unit view stub for testing. */ +function makeUnit(id: number, type: UnitType, ownerID: string, tile = TILE) { + return { + id: () => id, + type: () => type, + tile: () => tile, + owner: () => ({ id: () => ownerID }), + }; +} + +/** + * Builds a minimal ClientGameRunner stub with mocked dependencies. + * @param buildableUnits - list returned by myPlayer.actions(); canUpgrade is a + * unit id when upgradeable, or false when the unit exists but is blocked + * (e.g. insufficient gold). + * @param allUnits - units returned by gameView.units() + * @param nearbySamUnits - own SAM units returned by gameView.nearbyUnits() + * @param distanceMap - optional map of unit tile → manhattanDist from clickedTile + */ +function makeRunner( + buildableUnits: any[], + allUnits: any[], + nearbySamUnits: { unit: ReturnType; distSquared: number }[], + distanceMap: Map = new Map(), +) { + const eventBus = new EventBus(); + const emitSpy = vi.spyOn(eventBus, "emit"); + + const myPlayer = { + id: () => PLAYER_ID, + actions: vi.fn().mockResolvedValue({ buildableUnits }), + }; + + const gameView = { + units: () => allUnits, + manhattanDist: (_a: TileRef, b: TileRef) => distanceMap.get(b) ?? 5, + nearbyUnits: vi.fn().mockReturnValue(nearbySamUnits), + config: () => ({ structureMinDist: () => SEARCH_RADIUS }), + }; + + // Mirrors ClientGameRunner.findAndUpgradeNearestBuilding + const runner = { + myPlayer, + gameView, + eventBus, + findAndUpgradeNearestBuilding: async function (tile: TileRef) { + const actions = await this.myPlayer!.actions(tile, []); + const upgradeUnits: { + unitId: number; + unitType: UnitType; + distance: number; + }[] = []; + + for (const bu of actions.buildableUnits) { + if (bu.canUpgrade !== false) { + const existingUnit = this.gameView + .units() + .find((unit: any) => unit.id() === bu.canUpgrade); + if (existingUnit) { + const distance = this.gameView.manhattanDist( + tile, + existingUnit.tile(), + ); + upgradeUnits.push({ + unitId: bu.canUpgrade, + unitType: bu.type, + distance, + }); + } + } + } + + if (upgradeUnits.length === 0) { + return; + } + + const bestUpgrade = upgradeUnits.reduce((a, b) => + a.distance <= b.distance ? a : b, + ); + + // Check if any unaffordable building is closer than bestUpgrade + for (const bu of actions.buildableUnits) { + if (bu.canUpgrade === false && bu.type !== bestUpgrade.unitType) { + const myPlayerID = this.myPlayer!.id(); + const closestOfType = this.gameView + .nearbyUnits( + tile, + this.gameView.config().structureMinDist(), + bu.type, + ) + .filter(({ unit }: any) => unit.owner().id() === myPlayerID) + .sort((a: any, b: any) => a.distSquared - b.distSquared)[0]; + + if (closestOfType) { + const dist = this.gameView.manhattanDist( + tile, + closestOfType.unit.tile(), + ); + if (dist <= bestUpgrade.distance) { + return; + } + } + } + } + + this.eventBus.emit( + new SendUpgradeStructureIntentEvent( + bestUpgrade.unitId, + bestUpgrade.unitType, + ), + ); + }, + }; + + return { runner, emitSpy }; +} + +describe("findAndUpgradeNearestBuilding", () => { + describe("no SAM nearby", () => { + test("upgrades DefensePost when it is the only upgradeable building", async () => { + const defensePost = makeUnit(1, UnitType.DefensePost, PLAYER_ID); + const buildableUnits = [{ type: UnitType.DefensePost, canUpgrade: 1 }]; + const { runner, emitSpy } = makeRunner(buildableUnits, [defensePost], []); + + await runner.findAndUpgradeNearestBuilding(TILE); + + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ unitId: 1, unitType: UnitType.DefensePost }), + ); + }); + + test("does nothing when no buildings are upgradeable", async () => { + const buildableUnits = [ + { type: UnitType.DefensePost, canUpgrade: false }, + ]; + const { runner, emitSpy } = makeRunner(buildableUnits, [], []); + + await runner.findAndUpgradeNearestBuilding(TILE); + + expect(emitSpy).not.toHaveBeenCalled(); + }); + }); + + describe("SAM nearby — the bug scenario", () => { + test("does NOT upgrade DefensePost when unaffordable SAM is closer to click", async () => { + // SAM is at tile 5 (dist=2), DefensePost at tile 20 (dist=10) + // Player clicked near the SAM — should do nothing + const samTile = 5 as TileRef; + const dpTile = 20 as TileRef; + const samUnit = makeUnit(10, UnitType.SAMLauncher, PLAYER_ID, samTile); + const defensePost = makeUnit(1, UnitType.DefensePost, PLAYER_ID, dpTile); + + const buildableUnits = [ + { type: UnitType.SAMLauncher, canUpgrade: false }, + { type: UnitType.DefensePost, canUpgrade: 1 }, + ]; + const distMap = new Map([ + [samTile, 2], + [dpTile, 10], + ]); + const { runner, emitSpy } = makeRunner( + buildableUnits, + [defensePost], + [{ unit: samUnit, distSquared: 4 }], + distMap, + ); + + await runner.findAndUpgradeNearestBuilding(TILE); + + expect(emitSpy).not.toHaveBeenCalled(); + }); + + test("DOES upgrade DefensePost when unaffordable SAM is farther than DefensePost", async () => { + // DefensePost at tile 5 (dist=2), SAM at tile 20 (dist=10) + // Player clicked near the DefensePost — should upgrade it + const samTile = 20 as TileRef; + const dpTile = 5 as TileRef; + const samUnit = makeUnit(10, UnitType.SAMLauncher, PLAYER_ID, samTile); + const defensePost = makeUnit(1, UnitType.DefensePost, PLAYER_ID, dpTile); + + const buildableUnits = [ + { type: UnitType.SAMLauncher, canUpgrade: false }, + { type: UnitType.DefensePost, canUpgrade: 1 }, + ]; + const distMap = new Map([ + [samTile, 10], + [dpTile, 2], + ]); + const { runner, emitSpy } = makeRunner( + buildableUnits, + [defensePost], + [{ unit: samUnit, distSquared: 100 }], + distMap, + ); + + await runner.findAndUpgradeNearestBuilding(TILE); + + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ unitId: 1, unitType: UnitType.DefensePost }), + ); + }); + + test("does NOT upgrade Factory when unaffordable City is closer (Evan's scenario)", async () => { + // City at tile 5 (dist=2, costs 1M), Factory at tile 20 (dist=10, costs 500K) + // Player clicked near the City — should do nothing + const cityTile = 5 as TileRef; + const factoryTile = 20 as TileRef; + const cityUnit = makeUnit(10, UnitType.City, PLAYER_ID, cityTile); + const factoryUnit = makeUnit(1, UnitType.Factory, PLAYER_ID, factoryTile); + + const buildableUnits = [ + { type: UnitType.City, canUpgrade: false }, + { type: UnitType.Factory, canUpgrade: 1 }, + ]; + const distMap = new Map([ + [cityTile, 2], + [factoryTile, 10], + ]); + const { runner, emitSpy } = makeRunner( + buildableUnits, + [factoryUnit], + [], + distMap, + ); + // Mock nearbyUnits to return city when queried for City type + runner.gameView.nearbyUnits = vi.fn((tile, radius, type) => { + if (type === UnitType.City) { + return [{ unit: cityUnit, distSquared: 4 }]; + } + return []; + }); + + await runner.findAndUpgradeNearestBuilding(TILE); + + expect(emitSpy).not.toHaveBeenCalled(); + }); + + test("upgrades SAM when it IS affordable", async () => { + const samUnit = makeUnit(10, UnitType.SAMLauncher, PLAYER_ID); + const buildableUnits = [{ type: UnitType.SAMLauncher, canUpgrade: 10 }]; + const { runner, emitSpy } = makeRunner(buildableUnits, [samUnit], []); + + await runner.findAndUpgradeNearestBuilding(TILE); + + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ unitId: 10, unitType: UnitType.SAMLauncher }), + ); + }); + }); + + describe("multiple upgradeable buildings", () => { + test("picks the closest upgradeable building when no SAM nearby", async () => { + const dpTile = 10 as TileRef; + const factoryTile = 20 as TileRef; + const defensePost = makeUnit(1, UnitType.DefensePost, PLAYER_ID, dpTile); + const factory = makeUnit(2, UnitType.Factory, PLAYER_ID, factoryTile); + const buildableUnits = [ + { type: UnitType.DefensePost, canUpgrade: 1 }, + { type: UnitType.Factory, canUpgrade: 2 }, + ]; + const distMap = new Map([ + [dpTile, 3], + [factoryTile, 8], + ]); + const { runner, emitSpy } = makeRunner( + buildableUnits, + [defensePost, factory], + [], + distMap, + ); + + await runner.findAndUpgradeNearestBuilding(TILE); + + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ unitId: 1, unitType: UnitType.DefensePost }), + ); + }); + + test("upgrades SAM when both SAM and DefensePost are upgradeable and SAM is closer", async () => { + const samTile = 5 as TileRef; + const dpTile = 20 as TileRef; + const samUnit = makeUnit(10, UnitType.SAMLauncher, PLAYER_ID, samTile); + const defensePost = makeUnit(1, UnitType.DefensePost, PLAYER_ID, dpTile); + const buildableUnits = [ + { type: UnitType.SAMLauncher, canUpgrade: 10 }, + { type: UnitType.DefensePost, canUpgrade: 1 }, + ]; + const distMap = new Map([ + [samTile, 2], + [dpTile, 10], + ]); + const { runner, emitSpy } = makeRunner( + buildableUnits, + [samUnit, defensePost], + [], + distMap, + ); + + await runner.findAndUpgradeNearestBuilding(TILE); + + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ unitId: 10, unitType: UnitType.SAMLauncher }), + ); + }); + }); +});