Repository: openfrontio/OpenFrontIO
Pull request #117: feat: cancel attack
Author: login: ilan-schemoul
Created: 2025-03-02T20:13:56Z | Merged: 2025-03-04T13:20:01Z



Diff:
diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts
index fe42612df1..882dfc10c3 100644
--- a/src/client/HostLobbyModal.ts
+++ b/src/client/HostLobbyModal.ts
@@ -10,7 +10,6 @@ import { generateID } from "../core/Util";
 import { getConfig, getServerConfig } from "../core/configuration/Config";
 import randomMap from "../../resources/images/RandomMap.png";
 
-
 @customElement("host-lobby-modal")
 export class HostLobbyModal extends LitElement {
   @state() private isModalOpen = false;
diff --git a/src/client/Transport.ts b/src/client/Transport.ts
index a1dcffd05b..fb7fd93515 100644
--- a/src/client/Transport.ts
+++ b/src/client/Transport.ts
@@ -110,6 +110,13 @@ export class SendEmbargoIntentEvent implements GameEvent {
   ) {}
 }
 
+export class CancelAttackIntentEvent implements GameEvent {
+  constructor(
+    public readonly playerID: PlayerID,
+    public readonly attackID: string,
+  ) {}
+}
+
 export class SendSetTargetTroopRatioEvent implements GameEvent {
   constructor(public readonly ratio: number) {}
 }
@@ -179,6 +186,9 @@ export class Transport {
     this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e));
     this.eventBus.on(SendWinnerEvent, (e) => this.onSendWinnerEvent(e));
     this.eventBus.on(SendHashEvent, (e) => this.onSendHashEvent(e));
+    this.eventBus.on(CancelAttackIntentEvent, (e) =>
+      this.onCancelAttackIntentEvent(e),
+    );
   }
 
   private startPing() {
@@ -501,6 +511,15 @@ export class Transport {
     }
   }
 
+  private onCancelAttackIntentEvent(event: CancelAttackIntentEvent) {
+    this.sendIntent({
+      type: "cancel_attack",
+      clientID: this.lobbyConfig.clientID,
+      playerID: event.playerID,
+      attackID: event.attackID,
+    });
+  }
+
   private sendIntent(intent: Intent) {
     if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
       const msg = ClientIntentMessageSchema.parse({
diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts
index a479fc5e1c..d80f96120d 100644
--- a/src/client/graphics/layers/EventsDisplay.ts
+++ b/src/client/graphics/layers/EventsDisplay.ts
@@ -20,7 +20,10 @@ import { AllianceRequestUpdate } from "../../../core/game/GameUpdates";
 import { GameUpdateType } from "../../../core/game/GameUpdates";
 import { ClientID } from "../../../core/Schemas";
 import { Layer } from "./Layer";
-import { SendAllianceReplyIntentEvent } from "../../Transport";
+import {
+  CancelAttackIntentEvent,
+  SendAllianceReplyIntentEvent,
+} from "../../Transport";
 import { unsafeHTML } from "lit/directives/unsafe-html.js";
 import { onlyImages, sanitize } from "../../../core/Util";
 import { GameView, PlayerView } from "../../../core/game/GameView";
@@ -298,6 +301,12 @@ export class EventsDisplay extends LitElement implements Layer {
     });
   }
 
+  emitCancelAttackIntent(id: string) {
+    const myPlayer = this.game.playerByClientID(this.clientID);
+    if (!myPlayer) return;
+    this.eventBus.emit(new CancelAttackIntentEvent(myPlayer.id(), id));
+  }
+
   onEmojiMessageEvent(update: EmojiUpdate) {
     const myPlayer = this.game.playerByClientID(this.clientID);
     if (!myPlayer) return;
@@ -386,6 +395,16 @@ export class EventsDisplay extends LitElement implements Layer {
                       ${(
                         this.game.playerBySmallID(attack.targetID) as PlayerView
                       )?.name()}
+                      ${!attack.retreating
+                        ? html`<button
+                            ${attack.retreating ? "disabled" : ""}
+                            @click=${() => {
+                              this.emitCancelAttackIntent(attack.id);
+                            }}
+                          >
+                            ❌
+                          </button>`
+                        : "(retreating...)"}
                     </div>
                   `,
                 )}
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index bf6aff0ef6..2d20dd7c55 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -14,6 +14,7 @@ export type ClientID = string;
 export type Intent =
   | SpawnIntent
   | AttackIntent
+  | CancelAttackIntent
   | BoatAttackIntent
   | AllianceRequestIntent
   | AllianceRequestReplyIntent
@@ -26,6 +27,7 @@ export type Intent =
   | EmbargoIntent;
 
 export type AttackIntent = z.infer<typeof AttackIntentSchema>;
+export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
 export type SpawnIntent = z.infer<typeof SpawnIntentSchema>;
 export type BoatAttackIntent = z.infer<typeof BoatAttackIntentSchema>;
 export type AllianceRequestIntent = z.infer<typeof AllianceRequestIntentSchema>;
@@ -134,6 +136,7 @@ const ID = z
 const BaseIntentSchema = z.object({
   type: z.enum([
     "attack",
+    "cancel_attack",
     "spawn",
     "boat",
     "name",
@@ -233,8 +236,15 @@ export const BuildUnitIntentSchema = BaseIntentSchema.extend({
   y: z.number(),
 });
 
+export const CancelAttackIntentSchema = BaseIntentSchema.extend({
+  type: z.literal("cancel_attack"),
+  playerID: ID,
+  attackID: z.string(),
+});
+
 const IntentSchema = z.union([
   AttackIntentSchema,
+  CancelAttackIntentSchema,
   SpawnIntentSchema,
   BoatAttackIntentSchema,
   AllianceRequestIntentSchema,
diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts
index 0bd6a365f5..469b0eb7cf 100644
--- a/src/core/execution/AttackExecution.ts
+++ b/src/core/execution/AttackExecution.ts
@@ -15,6 +15,8 @@ import { MessageType } from "../game/Game";
 import { renderNumber } from "../../client/Utils";
 import { TileRef } from "../game/GameMap";
 
+const malusForRetreat = 25;
+
 export class AttackExecution implements Execution {
   private breakAlliance = false;
   private active: boolean = true;
@@ -162,7 +164,19 @@ export class AttackExecution implements Execution {
     }
   }
 
+  private retreat(malusPercent = 0) {
+    this._owner.addTroops(this.attack.troops() * (1 - malusPercent / 100));
+    this.attack.delete();
+    this.active = false;
+  }
+
   tick(ticks: number) {
+    if (this.attack.retreated()) {
+      this.retreat(malusForRetreat);
+      this.active = false;
+      return;
+    }
+
     if (!this.attack.isActive()) {
       this.active = false;
       return;
@@ -175,9 +189,7 @@ export class AttackExecution implements Execution {
     }
     if (this.target.isPlayer() && this._owner.isAlliedWith(this.target)) {
       // In this case a new alliance was created AFTER the attack started.
-      this._owner.addTroops(this.attack.troops());
-      this.attack.delete();
-      this.active = false;
+      this.retreat();
       return;
     }
 
@@ -201,9 +213,7 @@ export class AttackExecution implements Execution {
 
       if (this.toConquer.size() == 0) {
         this.refreshToConquer();
-        this.active = false;
-        this._owner.addTroops(this.attack.troops());
-        this.attack.delete();
+        this.retreat();
         return;
       }
 
diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts
index c6b84e850c..224418df89 100644
--- a/src/core/execution/ExecutionManager.ts
+++ b/src/core/execution/ExecutionManager.ts
@@ -35,6 +35,7 @@ import { ConstructionExecution } from "./ConstructionExecution";
 import { fixProfaneUsername, isProfaneUsername } from "../validations/username";
 import { NoOpExecution } from "./NoOpExecution";
 import { EmbargoExecution } from "./EmbargoExecution";
+import { RetreatExecution } from "./RetreatExecution";
 
 export class Executor {
   // private random = new PseudoRandom(999)
@@ -80,6 +81,8 @@ export class Executor {
           null,
         );
       }
+      case "cancel_attack":
+        return new RetreatExecution(intent.playerID, intent.attackID);
       case "spawn":
         return new SpawnExecution(
           new PlayerInfo(
diff --git a/src/core/execution/RetreatExecution.ts b/src/core/execution/RetreatExecution.ts
new file mode 100644
index 0000000000..fd341765df
--- /dev/null
+++ b/src/core/execution/RetreatExecution.ts
@@ -0,0 +1,50 @@
+import { Execution, Game, Player, PlayerID } from "../game/Game";
+
+const cancelDelay = 2;
+
+export class RetreatExecution implements Execution {
+  private active = true;
+  private retreatOrdered = false;
+  private player: Player;
+  private executionDateInSecs = new Date().getTime() / 1000 + cancelDelay;
+
+  constructor(
+    private playerID: PlayerID,
+    private attackID: string,
+  ) {}
+
+  init(mg: Game, ticks: number): void {
+    if (!mg.hasPlayer(this.playerID)) {
+      console.warn(`RetreatExecution: player ${this.player.id()} not found`);
+      return;
+    }
+
+    this.player = mg.player(this.playerID);
+  }
+
+  tick(ticks: number): void {
+    const nowInSecs = new Date().getTime() / 1000;
+
+    if (!this.retreatOrdered) {
+      this.player.orderRetreat(this.attackID);
+      this.retreatOrdered = true;
+    }
+
+    if (nowInSecs >= this.executionDateInSecs) {
+      this.player.executeRetreat(this.attackID);
+      this.active = false;
+    }
+  }
+
+  owner(): Player {
+    return this.player;
+  }
+
+  isActive(): boolean {
+    return this.active;
+  }
+
+  activeDuringSpawnPhase(): boolean {
+    return false;
+  }
+}
diff --git a/src/core/game/AttackImpl.ts b/src/core/game/AttackImpl.ts
index 7444f6b244..d2f91bff21 100644
--- a/src/core/game/AttackImpl.ts
+++ b/src/core/game/AttackImpl.ts
@@ -4,8 +4,11 @@ import { PlayerImpl } from "./PlayerImpl";
 
 export class AttackImpl implements Attack {
   private _isActive = true;
+  public _retreating = false;
+  public _retreated = false;
 
   constructor(
+    private _id: string,
     private _target: Player | TerraNullius,
     private _attacker: Player,
     private _troops: number,
@@ -33,6 +36,10 @@ export class AttackImpl implements Attack {
     return this._isActive;
   }
 
+  id() {
+    return this._id;
+  }
+
   delete() {
     if (this._target.isPlayer()) {
       (this._target as PlayerImpl)._incomingAttacks = (
@@ -46,4 +53,20 @@ export class AttackImpl implements Attack {
 
     this._isActive = false;
   }
+
+  orderRetreat() {
+    this._retreating = true;
+  }
+
+  executeRetreat() {
+    this._retreated = true;
+  }
+
+  retreating(): boolean {
+    return this._retreating;
+  }
+
+  retreated(): boolean {
+    return this._retreated;
+  }
 }
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index ab2286f000..0d32ca2591 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -149,6 +149,11 @@ export interface Execution {
 }
 
 export interface Attack {
+  id(): string;
+  retreating(): boolean;
+  retreated(): boolean;
+  orderRetreat(): void;
+  executeRetreat(): void;
   target(): Player | TerraNullius;
   attacker(): Player;
   troops(): number;
@@ -334,6 +339,8 @@ export interface Player {
   ): Attack;
   outgoingAttacks(): Attack[];
   incomingAttacks(): Attack[];
+  orderRetreat(attackID: string): void;
+  executeRetreat(attackID: string): void;
 
   // Misc
   executions(): Execution[];
diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts
index 4ed5f9acef..88b73aa6b1 100644
--- a/src/core/game/GameUpdates.ts
+++ b/src/core/game/GameUpdates.ts
@@ -77,6 +77,8 @@ export interface AttackUpdate {
   attackerID: number;
   targetID: number;
   troops: number;
+  id: string;
+  retreating: boolean;
 }
 
 export interface PlayerUpdate {
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index 88fb686b9e..d566e290a3 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -41,6 +41,8 @@ import { renderTroops } from "../../client/Utils";
 import { TerraNulliusImpl } from "./TerraNulliusImpl";
 import { andFN, manhattanDistFN, TileRef } from "./GameMap";
 import { AttackImpl } from "./AttackImpl";
+import { PseudoRandom } from "../PseudoRandom";
+import { consolex } from "../Consolex";
 
 interface Target {
   tick: Tick;
@@ -56,6 +58,7 @@ class Donation {
 
 export class PlayerImpl implements Player {
   public _lastTileChange: number = 0;
+  public _pseudo_random: PseudoRandom;
 
   private _gold: bigint;
   private _troops: bigint;
@@ -103,6 +106,7 @@ export class PlayerImpl implements Player {
     this._workers = 0n;
     this._gold = 0n;
     this._displayName = this._name; // processName(this._name)
+    this._pseudo_random = new PseudoRandom(simpleHash(this.playerInfo.id));
   }
 
   largestClusterBoundingBox: { min: Cell; max: Cell } | null;
@@ -139,6 +143,8 @@ export class PlayerImpl implements Player {
             attackerID: a.attacker().smallID(),
             targetID: a.target().smallID(),
             troops: a.troops(),
+            id: a.id(),
+            retreating: a.retreating(),
           }) as AttackUpdate,
       ),
       incomingAttacks: this._incomingAttacks.map(
@@ -147,6 +153,8 @@ export class PlayerImpl implements Player {
             attackerID: a.attacker().smallID(),
             targetID: a.target().smallID(),
             troops: a.troops(),
+            id: a.id(),
+            retreating: a.retreating(),
           }) as AttackUpdate,
       ),
       outgoingAllianceRequests: outgoingAllianceRequests,
@@ -246,6 +254,22 @@ export class PlayerImpl implements Player {
   conquer(tile: TileRef) {
     this.mg.conquer(this, tile);
   }
+  orderRetreat(id: string) {
+    const attack = this._outgoingAttacks.filter((attack) => attack.id() == id);
+    if (!attack || !attack[0]) {
+      consolex.warn(`Didn't find outgoing attack with id ${id}`);
+      return;
+    }
+    attack[0].orderRetreat();
+  }
+  executeRetreat(id: string): void {
+    const attack = this._outgoingAttacks.filter((attack) => attack.id() == id);
+    // Execution is delayed so it's not an error that the attack does not exist.
+    if (!attack || !attack[0]) {
+      return;
+    }
+    attack[0].executeRetreat();
+  }
   relinquish(tile: TileRef) {
     if (this.mg.owner(tile) != this) {
       throw new Error(`Cannot relinquish tile not owned by this player`);
@@ -845,7 +869,13 @@ export class PlayerImpl implements Player {
     troops: number,
     sourceTile: TileRef,
   ): Attack {
-    const attack = new AttackImpl(target, this, troops, sourceTile);
+    const attack = new AttackImpl(
+      this._pseudo_random.nextID(),
+      target,
+      this,
+      troops,
+      sourceTile,
+    );
     this._outgoingAttacks.push(attack);
     if (target.isPlayer()) {
       (target as PlayerImpl)._incomingAttacks.push(attack);


Reviews:
user: login: evanpelle
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-02T21:15:58Z
user: login: ilan-schemoul
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-02T22:12:37Z
user: login: evanpelle
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-03T04:10:00Z
user: login: ilan-schemoul
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T21:00:34Z

Review comments:
diff_hunk: @@ -1,9 +1,12 @@
+import { generateID } from "../Util";
 import { Attack, Player, TerraNullius } from "./Game";
 import { TileRef } from "./GameMap";
 import { PlayerImpl } from "./PlayerImpl";
 
 export class AttackImpl implements Attack {
   private _isActive = true;
+  public retreating = false;
+  private readonly _id = generateID();
path: src/core/game/AttackImpl.ts
user: login: evanpelle
body: I think generateID() is random, use pseudorandom for id.
created_at: 2025-03-02T21:15:14Z
author_association: COLLABORATOR
diff_hunk: @@ -1,9 +1,12 @@
+import { generateID } from "../Util";
 import { Attack, Player, TerraNullius } from "./Game";
 import { TileRef } from "./GameMap";
 import { PlayerImpl } from "./PlayerImpl";
 
 export class AttackImpl implements Attack {
   private _isActive = true;
+  public retreating = false;
+  private readonly _id = generateID();
path: src/core/game/AttackImpl.ts
user: login: ilan-schemoul
body: I don't know why it matters random or pseudo random but done
created_at: 2025-03-02T22:12:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,9 +1,13 @@
+import { PseudoRandom } from "../PseudoRandom";
 import { Attack, Player, TerraNullius } from "./Game";
 import { TileRef } from "./GameMap";
 import { PlayerImpl } from "./PlayerImpl";
 
 export class AttackImpl implements Attack {
   private _isActive = true;
+  public retreating = false;
+  private rand = new PseudoRandom(97);
+  private _id = this.rand.nextID();
path: src/core/game/AttackImpl.ts
user: login: evanpelle
body: This can cause problems because all attacks will have the same ids because it's psuedorandom. 

I would probably have the random be in PlayerImpl and the seed can be player id. and then each time player creates an attack it can call rand.nextID() and pass it into the attackimpl.
created_at: 2025-03-03T04:07:37Z
author_association: COLLABORATOR
diff_hunk: @@ -1,9 +1,13 @@
+import { PseudoRandom } from "../PseudoRandom";
 import { Attack, Player, TerraNullius } from "./Game";
 import { TileRef } from "./GameMap";
 import { PlayerImpl } from "./PlayerImpl";
 
 export class AttackImpl implements Attack {
   private _isActive = true;
+  public retreating = false;
+  private rand = new PseudoRandom(97);
+  private _id = this.rand.nextID();
path: src/core/game/AttackImpl.ts
user: login: ilan-schemoul
body: done
created_at: 2025-03-03T21:00:34Z
author_association: CONTRIBUTOR

PR comments:
user: login: evanpelle
created_at: 2025-03-03T04:09:42Z
body: i've been thinking about this feature a bit and i realized it can break the meta. When an attack has large troops it has a bonus. so if you can cancel an attack the best thing to do is to send very large attack and then cancel it after a few seconds and repeat..

We can merge it and try it out on .dev, but not sure if we can release it or not.
author_association: COLLABORATOR
user: login: ilan-schemoul
created_at: 2025-03-03T08:51:51Z
body: > i've been thinking about this feature a bit and i realized it can break the meta. When an attack has large troops it has a bonus. so if you can cancel an attack the best thing to do is to send very large attack and then cancel it after a few seconds and repeat..
> 
> We can merge it and try it out on .dev, but not sure if we can release it or not.

Interesting, I can make retreat an option that's available every let's say 30 seconds for each player u invade.
So I just clicked cancel on troops attacking XYZ I cannot cancel again for 30 seconds.
Prolly not too hard to implement a good old object with player IDs as key and a timestamp as value
author_association: CONTRIBUTOR
user: login: BrunoJurkovic
created_at: 2025-03-03T18:50:53Z
body: @ilan-schemoul Also maybe punish canceling an attack instead of a delay? 20% of the troop count deleted? 
author_association: CONTRIBUTOR
user: login: ilan-schemoul
created_at: 2025-03-03T20:43:13Z
body: > @ilan-schemoul Also maybe punish canceling an attack instead of a delay? 20% of the troop count deleted?

I like that a lot ! thanks
author_association: CONTRIBUTOR