diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 28c734d12..45959fbc8 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -157,6 +157,12 @@ export class PlayerExecution implements Execution { clusterBox: { min: Cell; max: Cell }, ): false | Player { const enemies = new Set(); + + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const tile of cluster) { let hasUnownedNeighbor = false; if (this.mg.isOceanShore(tile) || this.mg.isOnEdgeOfMap(tile)) { @@ -170,6 +176,12 @@ export class PlayerExecution implements Execution { const ownerId = this.mg.ownerID(n); if (ownerId !== this.player.smallID()) { enemies.add(ownerId); + const px = this.mg.x(n); + const py = this.mg.y(n); + minX = Math.min(minX, px); + minY = Math.min(minY, py); + maxX = Math.max(maxX, px); + maxY = Math.max(maxY, py); } }); if (hasUnownedNeighbor) { @@ -182,9 +194,13 @@ export class PlayerExecution implements Execution { if (enemies.size !== 1) { return false; } + const enemy = this.mg.playerBySmallID(Array.from(enemies)[0]) as Player; - const enemyBox = calculateBoundingBox(this.mg, enemy.borderTiles()); - if (inscribed(enemyBox, clusterBox)) { + const localEnemyBox = { + min: new Cell(minX, minY), + max: new Cell(maxX, maxY), + }; + if (inscribed(localEnemyBox, clusterBox)) { return enemy; } return false; diff --git a/tests/core/executions/NoInverseAnnexation.test.ts b/tests/core/executions/NoInverseAnnexation.test.ts new file mode 100644 index 000000000..e72bc9938 --- /dev/null +++ b/tests/core/executions/NoInverseAnnexation.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { PlayerExecution } from "../../../src/core/execution/PlayerExecution"; +import { + Game, + Player, + PlayerInfo, + PlayerType, +} from "../../../src/core/game/Game"; +import { setup } from "../../util/Setup"; +import { executeTicks } from "../../util/utils"; + +let game: Game; +let largePlayer: Player; +let smallPlayer: Player; + +describe("PlayerExecution Annexation Bug", () => { + beforeEach(async () => { + game = await setup( + "big_plains", + { + infiniteGold: true, + instantBuild: true, + }, + [ + new PlayerInfo("large", PlayerType.Human, "client1", "large_id"), + new PlayerInfo("small", PlayerType.Human, "client2", "small_id"), + ], + ); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + largePlayer = game.player("large_id"); + smallPlayer = game.player("small_id"); + + game.addExecution(new PlayerExecution(largePlayer)); + game.addExecution(new PlayerExecution(smallPlayer)); + }); + + test("A large player is not reverse-annexed by surrounded smaller player", () => { + // Cluster A + smallPlayer.conquer(game.ref(50, 50)); + smallPlayer.conquer(game.ref(50, 51)); + smallPlayer.conquer(game.ref(51, 50)); + smallPlayer.conquer(game.ref(51, 51)); + // Cluster B + smallPlayer.conquer(game.ref(10, 10)); + smallPlayer.conquer(game.ref(90, 90)); + + // Larger player gets the rest + game.map().forEachTile((tile) => { + if (game.ownerID(tile) !== smallPlayer.smallID()) { + largePlayer.conquer(tile); + } + }); + + const initialLargeTiles = largePlayer.numTilesOwned(); + expect(largePlayer.numTilesOwned()).toBe(initialLargeTiles); + expect(smallPlayer.numTilesOwned()).toBeGreaterThan(0); + + // Keep ticksPerClusterCalc and lastTileChange in mind + executeTicks(game, 20); + largePlayer.conquer(game.ref(49, 49)); + smallPlayer.conquer(game.ref(50, 50)); + + // Annexation happens here + executeTicks(game, 50); + expect(largePlayer.numTilesOwned()).toBeGreaterThan(initialLargeTiles); + expect(smallPlayer.numTilesOwned()).toBe(0); + }); +});