fix(2388): troop penalty applied to boat retreat (#2389)

Description:
There is a boating exploit where players could repeatedly send and
retreat boats to effectively increase troop regeneration and maintain
almost double the max troop cap. This PR fixes #2388.

Summary
This pr adds a troop penalty to the boat retreats in
src/core/execution/TransportShipExecution.ts of 25 percent (currently
the same as land attacks, but may require fine tuning). this prevents,
to some degree, the ability to stockpile large amounts of troops above
your max cap.

Testing
I tested the change locally and confirmed that the troop penalty is
applied at the correct times and under the correct conditions, i.e. it
is only applied on successful retreat (applying on reteat initiation may
be confusing if you are told you have lost troops if the transport ship
never arrives anyway). The boating exploit is far less effective with
this fix in place.

Notes
- This is a exploit and does not introduce any new features.
- Existing game behavior outside the retreat penalty remains unchanged.
- The attack message is the same as the land attack, so the translation
should already be present.

Checklist
- [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

---------

Co-authored-by: Evan <evanpelle@gmail.com>
This commit is contained in:
unique-coder-124
2025-11-20 07:37:48 +11:00
committed by GitHub
parent ce2b353dbe
commit 599342995c
2 changed files with 33 additions and 2 deletions
+13 -2
View File
@@ -1,3 +1,4 @@
import { renderTroops } from "../../client/Utils";
import {
Execution,
Game,
@@ -14,6 +15,7 @@ import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import { AttackExecution } from "./AttackExecution";
const malusForRetreat = 25;
export class TransportShipExecution implements Execution {
private lastMove: number;
@@ -222,14 +224,23 @@ export class TransportShipExecution implements Execution {
switch (result.type) {
case PathFindResultType.Completed:
if (this.mg.owner(this.dst) === this.attacker) {
this.attacker.addTroops(this.boat.troops());
const deaths = this.boat.troops() * (malusForRetreat / 100);
const survivors = this.boat.troops() - deaths;
this.attacker.addTroops(survivors);
this.boat.delete(false);
this.active = false;
// Record stats
this.mg
.stats()
.boatArriveTroops(this.attacker, this.target, this.boat.troops());
.boatArriveTroops(this.attacker, this.target, survivors);
if (deaths) {
this.mg.displayMessage(
`Attack cancelled, ${renderTroops(deaths)} soldiers killed during retreat.`,
MessageType.ATTACK_CANCELLED,
this.attacker.id(),
);
}
return;
}
this.attacker.conquer(this.dst);
+20
View File
@@ -111,6 +111,26 @@ describe("Attack", () => {
expect(nuke.isActive()).toBe(false);
expect(defender.units(UnitType.TransportShip)[0].troops()).toBeLessThan(90);
});
test("Boat penalty on retreat Transport Ship arrival", async () => {
const player_start_troops = defender.troops();
const boat_troops = player_start_troops * 0.5;
sendBoat(game.ref(15, 8), game.ref(10, 5), boat_troops);
game.executeNextTick();
const ship = defender.units(UnitType.TransportShip)[0];
expect(ship.troops()).toBe(boat_troops);
expect(ship.isActive()).toBe(true);
ship.orderBoatRetreat();
game.executeNextTick();
expect(ship.isActive()).toBe(false);
expect(boat_troops).toBeLessThan(defender.troops());
expect(defender.troops()).toBeLessThan(player_start_troops);
});
});
let playerA: Player;