Fix nation relation exploit 🔧 (#2523)

## Description:

Saw this in an Enzo video about beating impossible nations:

You can just donate 1% gold / 1% troops to a nation to get a friendly
relation with them.

This PR adds randomized minimum donation requirements based on the
difficulty.
Randomized in a way that there is a minimum someone has to donate to
surely get the relation improvement, but you can also gamble and send
less.

For troop donations, the minimum is calculated based on what percentage
of their troops the sender donated.

For gold donations, it's fixed values. We cannot use percentages here
because “having nearly no gold” is a usual case. Donating 100% of your
gold wouldn’t hurt if you just spent all your gold on buildings.

I tried to add tests for this but it's really horrible. Because the test
would have to wait until the relation update from the alliance accepting
is gone (we need to have an alliance to send stuff), has returned to
Neutral, and then changes back to Friendly after the donation.

## 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:

FloPinguin

---------

Co-authored-by: Evan <evanpelle@gmail.com>
This commit is contained in:
FloPinguin
2025-12-16 18:01:18 +01:00
committed by GitHub
parent f256f497ce
commit 058bb44273
2 changed files with 105 additions and 7 deletions
+53 -4
View File
@@ -1,8 +1,16 @@
import { Execution, Game, Gold, Player, PlayerID } from "../game/Game";
import { toInt } from "../Util";
import {
Difficulty,
Execution,
Game,
Gold,
Player,
PlayerID,
} from "../game/Game";
import { assertNever, toInt } from "../Util";
export class DonateGoldExecution implements Execution {
private recipient: Player;
private mg: Game;
private active = true;
private gold: Gold;
@@ -16,8 +24,12 @@ export class DonateGoldExecution implements Execution {
}
init(mg: Game, ticks: number): void {
this.mg = mg;
if (!mg.hasPlayer(this.recipientID)) {
console.warn(`DonateExecution recipient ${this.recipientID} not found`);
console.warn(
`DonateGoldExecution recipient ${this.recipientID} not found`,
);
this.active = false;
return;
}
@@ -32,7 +44,11 @@ export class DonateGoldExecution implements Execution {
this.sender.canDonateGold(this.recipient) &&
this.sender.donateGold(this.recipient, this.gold)
) {
this.recipient.updateRelation(this.sender, 50);
// Give relation points based on how much gold was donated
const relationUpdate = this.calculateRelationUpdate(this.gold, ticks);
if (relationUpdate > 0) {
this.recipient.updateRelation(this.sender, relationUpdate);
}
} else {
console.warn(
`cannot send gold from ${this.sender.name()} to ${this.recipient.name()}`,
@@ -41,6 +57,39 @@ export class DonateGoldExecution implements Execution {
this.active = false;
}
private getGoldChunkSize(): number {
const { difficulty } = this.mg.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
return 2_500;
case Difficulty.Medium:
return 5_000;
case Difficulty.Hard:
return 12_500;
case Difficulty.Impossible:
return 25_000;
default:
assertNever(difficulty);
}
}
private calculateRelationUpdate(goldSent: Gold, ticks: number): number {
const chunkSize = this.getGoldChunkSize();
// For every 5 minutes that pass, multiply the chunk size to scale with game progression
const chunkSizeMultiplier =
ticks / (3000 + this.mg.config().numSpawnPhaseTurns());
const adjustedChunkSize = BigInt(
Math.round(chunkSize + chunkSize * chunkSizeMultiplier),
);
// Calculate how many complete chunks were donated
const chunks = Number(goldSent / adjustedChunkSize);
// Each chunk gives 5 relation points
const relationUpdate = chunks * 5;
// Cap at 100 relation points
if (relationUpdate > 100) return 100;
return relationUpdate;
}
isActive(): boolean {
return this.active;
}
+52 -3
View File
@@ -1,7 +1,11 @@
import { Execution, Game, Player, PlayerID } from "../game/Game";
import { Difficulty, Execution, Game, Player, PlayerID } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { assertNever } from "../Util";
export class DonateTroopsExecution implements Execution {
private recipient: Player;
private random: PseudoRandom;
private mg: Game;
private active = true;
@@ -12,8 +16,13 @@ export class DonateTroopsExecution implements Execution {
) {}
init(mg: Game, ticks: number): void {
this.mg = mg;
this.random = new PseudoRandom(mg.ticks());
if (!mg.hasPlayer(this.recipientID)) {
console.warn(`DonateExecution recipient ${this.recipientID} not found`);
console.warn(
`DonateTroopExecution recipient ${this.recipientID} not found`,
);
this.active = false;
return;
}
@@ -27,11 +36,17 @@ export class DonateTroopsExecution implements Execution {
tick(ticks: number): void {
if (this.troops === null) throw new Error("not initialized");
const minTroops = this.getMinTroopsForRelationUpdate();
if (
this.sender.canDonateTroops(this.recipient) &&
this.sender.donateTroops(this.recipient, this.troops)
) {
this.recipient.updateRelation(this.sender, 50);
// Prevent players from just buying a good relation by sending 1% troops. Instead, a minimum is needed, and it's random.
if (this.troops >= minTroops) {
this.recipient.updateRelation(this.sender, 50);
}
} else {
console.warn(
`cannot send troops from ${this.sender} to ${this.recipient}`,
@@ -40,6 +55,40 @@ export class DonateTroopsExecution implements Execution {
this.active = false;
}
private getMinTroopsForRelationUpdate(): number {
const { difficulty } = this.mg.config().gameConfig();
const recipientMaxTroops = this.mg.config().maxTroops(this.recipient);
switch (difficulty) {
// ~7.7k - ~9.1k troops (for 100k troops)
case Difficulty.Easy:
return this.random.nextInt(
recipientMaxTroops / 13,
recipientMaxTroops / 11,
);
// ~9.1k - ~11.1k troops (for 100k troops)
case Difficulty.Medium:
return this.random.nextInt(
recipientMaxTroops / 11,
recipientMaxTroops / 9,
);
// ~11.1k - ~14.3k troops (for 100k troops)
case Difficulty.Hard:
return this.random.nextInt(
recipientMaxTroops / 9,
recipientMaxTroops / 7,
);
// ~14.3k - ~20k troops (for 100k troops)
case Difficulty.Impossible:
return this.random.nextInt(
recipientMaxTroops / 7,
recipientMaxTroops / 5,
);
default:
assertNever(difficulty);
}
}
isActive(): boolean {
return this.active;
}