Repository: openfrontio/OpenFrontIO
Pull request #270: sam do not target twice same nuke
Author: login: ilan-schemoul
Created: 2025-03-16T19:53:52Z | Merged: 2025-03-21T17:17:33Z



Diff:
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index 0116b69924..e0cfc2d3ac 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -46,6 +46,8 @@ export interface ServerConfig {
 }
 
 export interface Config {
+  samHittingChance(): number;
+  samCooldown(): Tick;
   spawnImmunityDuration(): Tick;
   serverConfig(): ServerConfig;
   gameConfig(): GameConfig;
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 2a9bece6f4..f3e1612f3c 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -89,6 +89,14 @@ export class DefaultConfig implements Config {
     private _userSettings: UserSettings,
   ) {}
 
+  samHittingChance(): number {
+    return 0.8;
+  }
+
+  samCooldown(): Tick {
+    return 100;
+  }
+
   traitorDefenseDebuff(): number {
     return 0.8;
   }
diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts
index efe4160334..0f5914f1da 100644
--- a/src/core/execution/ConstructionExecution.ts
+++ b/src/core/execution/ConstructionExecution.ts
@@ -55,7 +55,7 @@ export class ConstructionExecution implements Execution {
       }
       const spawnTile = this.player.canBuild(this.constructionType, this.tile);
       if (spawnTile == false) {
-        consolex.warn(`cannot build ${UnitType.Construction}`);
+        consolex.warn(`cannot build ${this.constructionType}`);
         this.active = false;
         return;
       }
diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts
index f27c47b9d3..3efc435252 100644
--- a/src/core/execution/NukeExecution.ts
+++ b/src/core/execution/NukeExecution.ts
@@ -87,7 +87,7 @@ export class NukeExecution implements Execution {
 
     // make the nuke unactive if it was intercepted
     if (!this.nuke.isActive()) {
-      consolex.warn(`Nuke destroyed before reaching target`);
+      consolex.log(`Nuke destroyed before reaching target`);
       this.active = false;
       return;
     }
diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts
index 5f6eb89ea2..810e9a5e6e 100644
--- a/src/core/execution/SAMLauncherExecution.ts
+++ b/src/core/execution/SAMLauncherExecution.ts
@@ -7,6 +7,7 @@ import {
   Unit,
   PlayerID,
   UnitType,
+  MessageType,
 } from "../game/Game";
 import { manhattanDistFN, TileRef } from "../game/GameMap";
 import { SAMMissileExecution } from "./SAMMissileExecution";
@@ -22,7 +23,6 @@ export class SAMLauncherExecution implements Execution {
 
   private searchRangeRadius = 75;
 
-  private missileAttackRate = 75; // 7.5 seconds
   private lastMissileAttack = 0;
 
   private pseudoRandom: PseudoRandom;
@@ -100,22 +100,38 @@ export class SAMLauncherExecution implements Execution {
 
     const cooldown =
       this.lastMissileAttack != 0 &&
-      this.mg.ticks() - this.lastMissileAttack <= this.missileAttackRate;
-    if (this.post.isSamCooldown() != cooldown) {
-      this.post.setSamCooldown(cooldown);
+      this.mg.ticks() - this.lastMissileAttack <=
+        this.mg.config().samCooldown();
+
+    if (this.post.isSamCooldown() && !cooldown) {
+      this.post.setSamCooldown(false);
     }
 
-    if (this.target != null) {
-      if (!this.post.isSamCooldown()) {
-        this.lastMissileAttack = this.mg.ticks();
+    if (
+      this.target &&
+      !this.post.isSamCooldown() &&
+      !this.target.targetedBySAM()
+    ) {
+      this.lastMissileAttack = this.mg.ticks();
+      this.post.setSamCooldown(true);
+      const random = this.pseudoRandom.next();
+      const hit = random < this.mg.config().samHittingChance();
+
+      this.lastMissileAttack = this.mg.ticks();
+      if (!hit) {
+        this.mg.displayMessage(
+          `Missile failed to intercept ${this.target.type()}`,
+          MessageType.ERROR,
+          this.post.owner().id(),
+        );
+      } else {
+        this.target.setTargetedBySAM(true);
         this.mg.addExecution(
           new SAMMissileExecution(
             this.post.tile(),
             this.post.owner(),
             this.post,
             this.target,
-            this.mg,
-            this.pseudoRandom.next(),
           ),
         );
       }
diff --git a/src/core/execution/SAMMissileExecution.ts b/src/core/execution/SAMMissileExecution.ts
index 9a110c3bee..1ea88acd44 100644
--- a/src/core/execution/SAMMissileExecution.ts
+++ b/src/core/execution/SAMMissileExecution.ts
@@ -15,20 +15,19 @@ export class SAMMissileExecution implements Execution {
   private active = true;
   private pathFinder: PathFinder;
   private SAMMissile: Unit;
+  private mg: Game;
 
   constructor(
     private spawn: TileRef,
     private _owner: Player,
     private ownerUnit: Unit,
     private target: Unit,
-    private mg: Game,
-    private pseudoRandom: number,
     private speed: number = 12,
-    private hittingChance: number = 0.75,
   ) {}
 
   init(mg: Game, ticks: number): void {
     this.pathFinder = PathFinder.Mini(mg, 2000, true, 10);
+    this.mg = mg;
   }
 
   tick(ticks: number): void {
@@ -63,22 +62,13 @@ export class SAMMissileExecution implements Execution {
       );
       switch (result.type) {
         case PathFindResultType.Completed:
+          this.mg.displayMessage(
+            `Missile intercepted ${this.target.type()}`,
+            MessageType.SUCCESS,
+            this._owner.id(),
+          );
           this.active = false;
-          if (this.pseudoRandom < this.hittingChance) {
-            this.target.delete();
-
-            this.mg.displayMessage(
-              `Missile succesfully intercepted ${this.target.type()}`,
-              MessageType.SUCCESS,
-              this._owner.id(),
-            );
-          } else {
-            this.mg.displayMessage(
-              `Missile failed to target ${this.target.type()}`,
-              MessageType.ERROR,
-              this._owner.id(),
-            );
-          }
+          this.target.delete();
           this.SAMMissile.delete(false);
           return;
         case PathFindResultType.NextTile:
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index 77c9a7d94c..d61143fa8e 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -244,6 +244,9 @@ export interface Unit {
   setMoveTarget(cell: TileRef): void;
   moveTarget(): TileRef | null;
 
+  setTargetedBySAM(targeted: boolean): void;
+  targetedBySAM(): boolean;
+
   // Mutations
   setTroops(troops: number): void;
   delete(displayerMessage?: boolean): void;
diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts
index aaa7835082..d31e6b111f 100644
--- a/src/core/game/UnitImpl.ts
+++ b/src/core/game/UnitImpl.ts
@@ -15,10 +15,11 @@ export class UnitImpl implements Unit {
   // Currently only warship use it
   private _target: Unit = null;
   private _moveTarget: TileRef = null;
+  private _targetedBySAM = false;
 
   private _constructionType: UnitType = undefined;
 
-  private _isSamCooldown: boolean;
+  private _isSamCooldown: boolean = false;
   private _dstPort: Unit | null = null; // Only for trade ships
   private _detonationDst: TileRef | null = null; // Only for nukes
   private _warshipTarget: Unit | null = null;
@@ -204,4 +205,12 @@ export class UnitImpl implements Unit {
   moveTarget(): TileRef | null {
     return this._moveTarget;
   }
+
+  setTargetedBySAM(targeted: boolean): void {
+    this._targetedBySAM = targeted;
+  }
+
+  targetedBySAM(): boolean {
+    return this._targetedBySAM;
+  }
 }
diff --git a/tests/SAM.test.ts b/tests/SAM.test.ts
new file mode 100644
index 0000000000..3e033c61cd
--- /dev/null
+++ b/tests/SAM.test.ts
@@ -0,0 +1,132 @@
+import {
+  Game,
+  Player,
+  PlayerInfo,
+  PlayerType,
+  UnitType,
+} from "../src/core/game/Game";
+import { SpawnExecution } from "../src/core/execution/SpawnExecution";
+import { setup } from "./util/Setup";
+import { constructionExecution } from "./util/utils";
+import { NukeExecution } from "../src/core/execution/NukeExecution";
+import { TileRef } from "../src/core/game/GameMap";
+
+let game: Game;
+let attacker: Player;
+let defender: Player;
+
+function attackerBuildsNuke(
+  source: TileRef,
+  target: TileRef,
+  initialize = true,
+) {
+  game.addExecution(
+    new NukeExecution(UnitType.AtomBomb, attacker.id(), target, source),
+  );
+  if (initialize) {
+    game.executeNextTick();
+    game.executeNextTick();
+  }
+}
+
+function defenderBuildsSam(x: number, y: number) {
+  constructionExecution(game, defender.id(), x, y, UnitType.SAMLauncher);
+}
+
+describe("SAM", () => {
+  beforeEach(async () => {
+    game = await setup("Plains", { infiniteGold: true, instantBuild: true });
+    const defender_info = new PlayerInfo(
+      "us",
+      "defender_id",
+      PlayerType.Human,
+      null,
+      "defender_id",
+    );
+    const attacker_info = new PlayerInfo(
+      "fr",
+      "attacker_id",
+      PlayerType.Human,
+      null,
+      "attacker_id",
+    );
+    game.addPlayer(defender_info, 1000);
+    game.addPlayer(attacker_info, 1000);
+
+    game.addExecution(
+      new SpawnExecution(game.player(defender_info.id).info(), game.ref(1, 1)),
+      new SpawnExecution(game.player(attacker_info.id).info(), game.ref(7, 7)),
+    );
+
+    while (game.inSpawnPhase()) {
+      game.executeNextTick();
+    }
+
+    defender = game.player("defender_id");
+    attacker = game.player("attacker_id");
+
+    constructionExecution(game, attacker.id(), 7, 7, UnitType.MissileSilo);
+  });
+
+  test("one sam should take down one nuke", async () => {
+    defenderBuildsSam(1, 1);
+    attackerBuildsNuke(game.ref(7, 7), game.ref(1, 1));
+    expect(attacker.units(UnitType.AtomBomb)).toHaveLength(1);
+
+    game.executeNextTick();
+    game.executeNextTick();
+    expect(attacker.units(UnitType.AtomBomb)).toHaveLength(0);
+  });
+
+  test("sam should only get one nuke at a time", async () => {
+    defenderBuildsSam(1, 1);
+    attackerBuildsNuke(game.ref(7, 7), game.ref(1, 1), false);
+    attackerBuildsNuke(game.ref(7, 7), game.ref(1, 1));
+    expect(attacker.units(UnitType.AtomBomb)).toHaveLength(2);
+
+    game.executeNextTick();
+    game.executeNextTick();
+    expect(attacker.units(UnitType.AtomBomb)).toHaveLength(1);
+  });
+
+  test("sam should cooldown as long as configured", async () => {
+    defenderBuildsSam(1, 1);
+    expect(defender.units(UnitType.SAMLauncher)[0].isSamCooldown()).toBe(false);
+    attackerBuildsNuke(game.ref(7, 7), game.ref(1, 1));
+    expect(attacker.units(UnitType.AtomBomb)).toHaveLength(1);
+
+    game.executeNextTick();
+    game.executeNextTick();
+    expect(attacker.units(UnitType.AtomBomb)).toHaveLength(0);
+
+    for (let i = 0; i < game.config().samCooldown() - 1; i++) {
+      game.executeNextTick();
+      expect(defender.units(UnitType.SAMLauncher)[0].isSamCooldown()).toBe(
+        true,
+      );
+    }
+
+    game.executeNextTick();
+    expect(defender.units(UnitType.SAMLauncher)[0].isSamCooldown()).toBe(false);
+  });
+
+  test("two sams should not target twice same nuke", async () => {
+    defenderBuildsSam(1, 1);
+    defenderBuildsSam(1, 2);
+    attackerBuildsNuke(game.ref(7, 7), game.ref(1, 1));
+
+    expect(defender.units(UnitType.SAMLauncher)).toHaveLength(2);
+    expect(attacker.units(UnitType.AtomBomb)).toHaveLength(1);
+
+    game.executeNextTick();
+    game.executeNextTick();
+
+    expect(attacker.units(UnitType.AtomBomb)).toHaveLength(0);
+    const sams = defender.units(UnitType.SAMLauncher);
+    // Only one sam must have shot
+    expect(
+      (sams[0].isSamCooldown() && !sams[1].isSamCooldown()) ||
+        (sams[1].isSamCooldown() && !sams[0].isSamCooldown()),
+    ).toBe(true);
+  });
+});
diff --git a/tests/util/TestConfig.ts b/tests/util/TestConfig.ts
index e48a63162e..1e52658c02 100644
--- a/tests/util/TestConfig.ts
+++ b/tests/util/TestConfig.ts
@@ -1,3 +1,7 @@
 import { DefaultConfig } from "../../src/core/configuration/DefaultConfig";
 
-export class TestConfig extends DefaultConfig {}
+export class TestConfig extends DefaultConfig {
+  samHittingChance(): number {
+    return 1;
+  }
+}
diff --git a/tests/util/utils.ts b/tests/util/utils.ts
index 17ca1a4478..e3175423e6 100644
--- a/tests/util/utils.ts
+++ b/tests/util/utils.ts
@@ -15,9 +15,12 @@ export function constructionExecution(
   unit: UnitType,
 ) {
   game.addExecution(new ConstructionExecution(playerID, game.ref(x, y), unit));
-  // Init
+  // Init exec
   game.executeNextTick();
-  // Exec
+  // Exec construction execution
   game.executeNextTick();
+  // Add the execution related to the building
+  game.executeNextTick();
+  // First tick of the execution of the constructed structure/unit
   game.executeNextTick();
 }


Reviews:
user: login: evanpelle
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-18T19:24:37Z
user: login: ilan-schemoul
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T20:55:42Z
user: login: evanpelle
body: Thanks for writing tests!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-21T17:17:28Z

Review comments:
diff_hunk: @@ -92,17 +93,46 @@ export class SAMLauncherExecution implements Execution {
         );
       })[0] ?? null;
 
-    if (this.target != null) {
-      if (this.mg.ticks() - this.lastMissileAttack > this.missileAttackRate) {
-        this.lastMissileAttack = this.mg.ticks();
+    const hittingChance = 0.75;
+    const hittingChanceHydrogen = 0.5;
+
+    if (
+      this.target &&
+      this.mg.ticks() - this.lastMissileAttack > this.missileAttackRate &&
+      !this.target.targetedBySAM()
+    ) {
+      const random = this.pseudoRandom.next();
+      let hit = false;
+      if (
+        this.target.type() == UnitType.HydrogenBomb &&
+        random < hittingChanceHydrogen
+      ) {
+        hit = true;
+      } else if (
+        [UnitType.MIRVWarhead, UnitType.AtomBomb].includes(
+          this.target.type(),
+        ) &&
+        random < hittingChance
+      ) {
+        hit = true;
+      }
+
+      this.lastMissileAttack = this.mg.ticks();
+      if (!hit) {
+        this.mg.displayMessage(
path: src/core/execution/SAMLauncherExecution.ts
user: login: evanpelle
body: so if hit is false we don't send a SAM? maybe in the future we could have it launch a missile and it just misses or something
created_at: 2025-03-18T19:22:39Z
author_association: COLLABORATOR
diff_hunk: @@ -92,17 +93,46 @@ export class SAMLauncherExecution implements Execution {
         );
       })[0] ?? null;
 
-    if (this.target != null) {
-      if (this.mg.ticks() - this.lastMissileAttack > this.missileAttackRate) {
-        this.lastMissileAttack = this.mg.ticks();
+    const hittingChance = 0.75;
+    const hittingChanceHydrogen = 0.5;
+
+    if (
+      this.target &&
+      this.mg.ticks() - this.lastMissileAttack > this.missileAttackRate &&
+      !this.target.targetedBySAM()
+    ) {
+      const random = this.pseudoRandom.next();
+      let hit = false;
+      if (
+        this.target.type() == UnitType.HydrogenBomb &&
+        random < hittingChanceHydrogen
+      ) {
+        hit = true;
+      } else if (
+        [UnitType.MIRVWarhead, UnitType.AtomBomb].includes(
+          this.target.type(),
+        ) &&
+        random < hittingChance
+      ) {
+        hit = true;
+      }
+
+      this.lastMissileAttack = this.mg.ticks();
+      if (!hit) {
+        this.mg.displayMessage(
+          `Missile failed to intercept ${this.target.type()}`,
path: src/core/execution/SAMLauncherExecution.ts
user: login: evanpelle
body: Since the missile doesn't launch let's say something like

`Missile failed to target ...`
created_at: 2025-03-18T19:23:00Z
author_association: COLLABORATOR
diff_hunk: @@ -92,17 +93,46 @@ export class SAMLauncherExecution implements Execution {
         );
       })[0] ?? null;
 
-    if (this.target != null) {
-      if (this.mg.ticks() - this.lastMissileAttack > this.missileAttackRate) {
-        this.lastMissileAttack = this.mg.ticks();
+    const hittingChance = 0.75;
+    const hittingChanceHydrogen = 0.5;
+
+    if (
+      this.target &&
+      this.mg.ticks() - this.lastMissileAttack > this.missileAttackRate &&
+      !this.target.targetedBySAM()
+    ) {
+      const random = this.pseudoRandom.next();
+      let hit = false;
+      if (
+        this.target.type() == UnitType.HydrogenBomb &&
+        random < hittingChanceHydrogen
+      ) {
+        hit = true;
+      } else if (
+        [UnitType.MIRVWarhead, UnitType.AtomBomb].includes(
+          this.target.type(),
+        ) &&
+        random < hittingChance
+      ) {
+        hit = true;
+      }
+
+      this.lastMissileAttack = this.mg.ticks();
+      if (!hit) {
+        this.mg.displayMessage(
path: src/core/execution/SAMLauncherExecution.ts
user: login: ilan-schemoul
body: Well my other PR at least it turns red but yeah kinda agree you. I guess you should make it in a way that's not confusing, which might not be trivial with a 2D pixel art game lol
created_at: 2025-03-18T20:55:42Z
author_association: CONTRIBUTOR

PR comments:
user: login: ilan-schemoul
created_at: 2025-03-18T20:57:54Z
body: don't merge yet, I'll add tests
author_association: CONTRIBUTOR
user: login: ilan-schemoul
created_at: 2025-03-19T00:12:48Z
body: tests are not testing anything
author_association: CONTRIBUTOR