fix: allies cannot annex your clusters (#2158)

## Description:

Fixes #1685
Continuation from #1924, which was auto-closed after the upstream repo
force-pushed main and I synced my fork.

This change ensures that allies are excluded from the `getMode()` call
made by `getCapturingPlayer()` inside `removeCluster()`.

- Previously, friendly neighbors were treated as candidates for
capturing, leading to incorrect annexations in certain edge cases.
- Added a small efficiency improvement by filtering out non-player and
friendly neighbors up front to reduce total computations down-the-line.
- Important: we can’t simply check if the `getMode(neighborsIDs)` result
is a friendly. Doing so would cause the territory to go to nobody until
the user is attacked. I believe the expected behavior is the largest
neighboring enemy should take it automatically.

Here's an example of the new behavior in an extreme edge case:
<img width="622" height="422" alt="Screenshot 2025-08-24 at 4 56 46 PM"
src="https://github.com/user-attachments/assets/c5dd9c0d-0a3c-4657-8154-e114fa920689"
/>

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

nottirb
This commit is contained in:
Britton Fischer
2025-10-08 18:28:26 -07:00
committed by GitHub
parent 187ef1f2dd
commit 3680d9cc16
2 changed files with 26 additions and 42 deletions
-21
View File
@@ -114,27 +114,6 @@ export function inscribed(
);
}
export function getMode(list: Set<number>): number {
// Count occurrences
const counts = new Map<number, number>();
for (const item of list) {
counts.set(item, (counts.get(item) ?? 0) + 1);
}
// Find the item with the highest count
let mode = 0;
let maxCount = 0;
for (const [item, count] of counts) {
if (count > maxCount) {
maxCount = count;
mode = item;
}
}
return mode;
}
export function sanitize(name: string): string {
return Array.from(name)
.join("")
+26 -21
View File
@@ -2,7 +2,7 @@ import { Config } from "../configuration/Config";
import { Execution, Game, Player, UnitType } from "../game/Game";
import { GameImpl } from "../game/GameImpl";
import { GameMap, TileRef } from "../game/GameMap";
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
import { calculateBoundingBox, inscribed, simpleHash } from "../Util";
export class PlayerExecution implements Execution {
private readonly ticksPerClusterCalc = 20;
@@ -214,22 +214,37 @@ export class PlayerExecution implements Execution {
}
private getCapturingPlayer(cluster: Set<TileRef>): Player | null {
const neighborsIDs = new Set<number>();
// Collect unique neighbor IDs (excluding self) as candidates
const candidatesIDs = new Set<number>();
const selfID = this.player.smallID();
for (const t of cluster) {
for (const neighbor of this.mg.neighbors(t)) {
if (this.mg.ownerID(neighbor) !== this.player.smallID()) {
neighborsIDs.add(this.mg.ownerID(neighbor));
if (this.mg.ownerID(neighbor) !== selfID) {
candidatesIDs.add(this.mg.ownerID(neighbor));
}
}
}
let largestNeighborAttack: Player | null = null;
let largestTroopCount: number = 0;
for (const id of neighborsIDs) {
// Filter out friendly and non-player candidates
const neighbors = new Set<Player>();
for (const id of candidatesIDs) {
const neighbor = this.mg.playerBySmallID(id);
if (!neighbor.isPlayer() || this.player.isFriendly(neighbor)) {
if (!neighbor.isPlayer() || neighbor.isFriendly(this.player)) {
continue;
}
neighbors.add(neighbor);
}
// If there are no enemies, return null
if (neighbors.size === 0) {
return null;
}
// Get the largest attack from the neighbors
let largestNeighborAttack: Player | null = null;
let largestTroopCount = 0;
for (const neighbor of neighbors) {
for (const attack of neighbor.outgoingAttacks()) {
if (attack.target() === this.player) {
if (attack.troops() > largestTroopCount) {
@@ -239,20 +254,10 @@ export class PlayerExecution implements Execution {
}
}
}
if (largestNeighborAttack !== null) {
return largestNeighborAttack;
}
// fall back to getting mode if no attacks
const mode = getMode(neighborsIDs);
if (!this.mg.playerBySmallID(mode).isPlayer()) {
return null;
}
const capturing = this.mg.playerBySmallID(mode);
if (!capturing.isPlayer()) {
return null;
}
return capturing;
// Return the largest neighbor attack
// If there is no largest neighbor attack, this will return null
return largestNeighborAttack;
}
private calculateClusters(): Set<TileRef>[] {