Fix Race conditions on alliances (#1605)

## Description:

Players received "traitor" debuff when alliances were formed after
attacks started, creating an unfair race condition.

the problem was mentioned here
https://discord.com/channels/1284581928254701718/1399115120486912100

## Please complete the following:

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

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

Kipstzz

---------

Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com>
This commit is contained in:
Kipstz Avenger
2025-08-03 00:12:23 +02:00
committed by GitHub
parent 86a329f7cb
commit 0943b1544c
2 changed files with 154 additions and 3 deletions
+10 -3
View File
@@ -17,6 +17,7 @@ import { FlatBinaryHeap } from "./utils/FlatBinaryHeap"; // adjust path if neede
const malusForRetreat = 25;
export class AttackExecution implements Execution {
private breakAlliance = false;
private wasAlliedAtInit = false; // Store alliance state at initialization
private active: boolean = true;
private toConquer = new FlatBinaryHeap();
@@ -147,8 +148,9 @@ export class AttackExecution implements Execution {
}
if (this.target.isPlayer()) {
if (this._owner.isAlliedWith(this.target)) {
// No updates should happen in init.
// Store the alliance state at initialization time to prevent race conditions
this.wasAlliedAtInit = this._owner.isAlliedWith(this.target);
if (this.wasAlliedAtInit) {
this.breakAlliance = true;
}
this.target.updateRelation(this._owner, -80);
@@ -226,8 +228,13 @@ export class AttackExecution implements Execution {
this.breakAlliance = false;
this._owner.breakAlliance(alliance);
}
if (targetPlayer && this._owner.isAlliedWith(targetPlayer)) {
if (
targetPlayer &&
this._owner.isAlliedWith(targetPlayer) &&
!this.wasAlliedAtInit
) {
// In this case a new alliance was created AFTER the attack started.
// We should retreat to avoid the attacker becoming a traitor.
this.retreat();
return;
}
+144
View File
@@ -112,3 +112,147 @@ describe("Attack", () => {
expect(defender.units(UnitType.TransportShip)[0].troops()).toBeLessThan(90);
});
});
describe("Attack race condition with alliance requests", () => {
it("should not mark attacker as traitor when alliance is formed after attack starts", async () => {
const game = await setup("ocean_and_land", {
infiniteGold: true,
instantBuild: true,
infiniteTroops: true,
});
const playerAInfo = new PlayerInfo(
"playerA",
PlayerType.Human,
null,
"playerA_id",
);
const playerBInfo = new PlayerInfo(
"playerB",
PlayerType.Human,
null,
"playerB_id",
);
game.addPlayer(playerAInfo);
game.addPlayer(playerBInfo);
const playerA = game.player(playerAInfo.id);
const playerB = game.player(playerBInfo.id);
// Spawn both players
const spawnA = game.ref(0, 10);
const spawnB = game.ref(0, 15);
game.addExecution(
new SpawnExecution(playerAInfo, spawnA),
new SpawnExecution(playerBInfo, spawnB),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
// Player A sends alliance request to Player B
const allianceRequest = playerA.createAllianceRequest(playerB);
expect(allianceRequest).not.toBeNull();
// Player A attacks Player B
const attackExecution = new AttackExecution(
null,
playerA,
playerB.id(),
null,
);
game.addExecution(attackExecution);
// Player B counter-attacks Player A
const counterAttackExecution = new AttackExecution(
null,
playerB,
playerA.id(),
null,
);
game.addExecution(counterAttackExecution);
// Player B accepts the alliance request
if (allianceRequest) {
allianceRequest.accept();
}
// Execute a few ticks to process the attacks
for (let i = 0; i < 5; i++) {
game.executeNextTick();
}
// Player A should not be marked as traitor because the alliance was formed after the attack started
expect(playerA.isTraitor()).toBe(false);
// The attacks should have retreated due to the alliance being formed
expect(playerA.outgoingAttacks()).toHaveLength(0);
expect(playerB.outgoingAttacks()).toHaveLength(0);
});
it("should mark attacker as traitor when alliance existed before attack", async () => {
const game = await setup("ocean_and_land", {
infiniteGold: true,
instantBuild: true,
infiniteTroops: true,
});
const playerAInfo = new PlayerInfo(
"playerA",
PlayerType.Human,
null,
"playerA_id",
);
const playerBInfo = new PlayerInfo(
"playerB",
PlayerType.Human,
null,
"playerB_id",
);
game.addPlayer(playerAInfo);
game.addPlayer(playerBInfo);
const playerA = game.player(playerAInfo.id);
const playerB = game.player(playerBInfo.id);
// Spawn both players
const spawnA = game.ref(0, 10);
const spawnB = game.ref(0, 15);
game.addExecution(
new SpawnExecution(playerAInfo, spawnA),
new SpawnExecution(playerBInfo, spawnB),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
// Create an alliance between Player A and Player B
const allianceRequest = playerA.createAllianceRequest(playerB);
if (allianceRequest) {
allianceRequest.accept();
}
// Player A attacks Player B (should break the alliance)
const attackExecution = new AttackExecution(
null,
playerA,
playerB.id(),
null,
);
game.addExecution(attackExecution);
// Execute a few ticks to process the attack
for (let i = 0; i < 10; i++) {
game.executeNextTick();
}
// Player A should be marked as traitor because they attacked an ally
expect(playerA.isTraitor()).toBe(true);
});
});