diff --git a/resources/images/buildings/silo1-reloading.png b/resources/images/buildings/silo1-reloading.png new file mode 100644 index 000000000..28d1aef99 Binary files /dev/null and b/resources/images/buildings/silo1-reloading.png differ diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index 87190a303..2f646c1ee 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -7,6 +7,7 @@ import anchorIcon from "../../../../resources/images/buildings/port1.png"; import missileSiloIcon from "../../../../resources/images/buildings/silo1.png"; import SAMMissileIcon from "../../../../resources/images/buildings/silo4.png"; import SAMMissileReloadingIcon from "../../../../resources/images/buildings/silo4-reloading.png"; +import MissileSiloReloadingIcon from "../../../../resources/images/buildings/silo1-reloading.png"; import shieldIcon from "../../../../resources/images/buildings/fortAlt2.png"; import cityIcon from "../../../../resources/images/buildings/cityAlt1.png"; import { GameView, UnitView } from "../../../core/game/GameView"; @@ -90,6 +91,12 @@ export class StructureLayer implements Layer { territoryRadius: 6.525, borderType: UnitBorderType.Square, }); + this.loadIcon("reloadingSilo", { + icon: MissileSiloReloadingIcon, + borderRadius: 8.525, + territoryRadius: 6.525, + borderType: UnitBorderType.Square, + }); } private loadIcon(unitType: string, config: UnitRenderConfig) { @@ -215,12 +222,18 @@ export class StructureLayer implements Layer { const config = this.unitConfigs[unitType]; let icon: ImageData; - if (unitType == UnitType.SAMLauncher && unit.isSamCooldown()) { + if (unitType == UnitType.SAMLauncher && unit.isCooldown()) { icon = this.unitIcons.get("reloadingSam"); } else { icon = this.unitIcons.get(iconType); } + if (unitType == UnitType.MissileSilo && unit.isCooldown()) { + icon = this.unitIcons.get("reloadingSilo"); + } else { + icon = this.unitIcons.get(iconType); + } + if (!config || !icon) return; const drawFunction = this.getDrawFN(config.borderType); @@ -235,7 +248,13 @@ export class StructureLayer implements Layer { if (!unit.isActive()) return; let borderColor = this.theme.borderColor(unit.owner()); - if (unitType == UnitType.SAMLauncher && unit.isSamCooldown()) { + if (unitType == UnitType.SAMLauncher && unit.isCooldown()) { + borderColor = reloadingColor; + } else if (unit.type() == UnitType.Construction) { + borderColor = underConstructionColor; + } + + if (unitType == UnitType.MissileSilo && unit.isCooldown()) { borderColor = reloadingColor; } else if (unit.type() == UnitType.Construction) { borderColor = underConstructionColor; diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 71756b3aa..95b5d7554 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -47,7 +47,6 @@ export interface ServerConfig { export interface Config { samHittingChance(): number; - samCooldown(): Tick; spawnImmunityDuration(): Tick; serverConfig(): ServerConfig; gameConfig(): GameConfig; @@ -106,6 +105,8 @@ export interface Config { tradeShipGold(dist: number): Gold; tradeShipSpawnRate(numberOfPorts: number): number; defensePostRange(): number; + SAMCooldown(): number; + SiloCooldown(): number; defensePostDefenseBonus(): number; falloutDefenseModifier(percentOfFallout: number): number; difficultyModifier(difficulty: Difficulty): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 14b64c44e..03517c79b 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -93,10 +93,6 @@ export class DefaultConfig implements Config { return 0.8; } - samCooldown(): Tick { - return 100; - } - traitorDefenseDebuff(): number { return 0.8; } @@ -138,6 +134,12 @@ export class DefaultConfig implements Config { // So defense modifier is between [5, 2.5] return 5 - falloutRatio * 2; } + SAMCooldown(): number { + return 75; + } + SiloCooldown(): number { + return 75; + } defensePostRange(): number { return 30; diff --git a/src/core/execution/MissileSiloExecution.ts b/src/core/execution/MissileSiloExecution.ts index f9ae6f3d5..b88ea38bd 100644 --- a/src/core/execution/MissileSiloExecution.ts +++ b/src/core/execution/MissileSiloExecution.ts @@ -41,12 +41,21 @@ export class MissileSiloExecution implements Execution { this.active = false; return; } - this.silo = this.player.buildUnit(UnitType.MissileSilo, 0, this.tile); + this.silo = this.player.buildUnit(UnitType.MissileSilo, 0, this.tile, { + cooldownDuration: this.mg.config().SiloCooldown(), + }); if (this.player != this.silo.owner()) { this.player = this.silo.owner(); } } + + if ( + this.silo.isCooldown() && + this.silo.ticksLeftInCooldown(this.mg.config().SiloCooldown()) == 0 + ) { + this.silo.setCooldown(false); + } } isActive(): boolean { diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 3efc43525..bda5eca39 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -83,6 +83,12 @@ export class NukeExecution implements Execution { this.nuke.type() as NukeType, ); } + + // after sending an nuke set the missilesilo on cooldown + const silo = this.player + .units(UnitType.MissileSilo) + .find((silo) => silo.tile() === spawn); + silo.setCooldown(true); } // make the nuke unactive if it was intercepted diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index 16b241bbd..a733e224b 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -16,15 +16,13 @@ import { PseudoRandom } from "../PseudoRandom"; export class SAMLauncherExecution implements Execution { private player: Player; private mg: Game; - private post: Unit; + private sam: Unit; private active: boolean = true; private target: Unit = null; private searchRangeRadius = 75; - private lastMissileAttack = 0; - private pseudoRandom: PseudoRandom; constructor( @@ -43,30 +41,32 @@ export class SAMLauncherExecution implements Execution { } tick(ticks: number): void { - if (this.post == null) { + if (this.sam == null) { const spawnTile = this.player.canBuild(UnitType.SAMLauncher, this.tile); if (spawnTile == false) { consolex.warn("cannot build SAM Launcher"); this.active = false; return; } - this.post = this.player.buildUnit(UnitType.SAMLauncher, 0, spawnTile); + this.sam = this.player.buildUnit(UnitType.SAMLauncher, 0, spawnTile, { + cooldownDuration: this.mg.config().SAMCooldown(), + }); } - if (!this.post.isActive()) { + if (!this.sam.isActive()) { this.active = false; return; } - if (this.player != this.post.owner()) { - this.player = this.post.owner(); + if (this.player != this.sam.owner()) { + this.player = this.sam.owner(); } if (!this.pseudoRandom) { - this.pseudoRandom = new PseudoRandom(this.post.id()); + this.pseudoRandom = new PseudoRandom(this.sam.id()); } const nukes = this.mg - .nearbyUnits(this.post.tile(), this.searchRangeRadius, [ + .nearbyUnits(this.sam.tile(), this.searchRangeRadius, [ UnitType.AtomBomb, UnitType.HydrogenBomb, ]) @@ -96,39 +96,30 @@ export class SAMLauncherExecution implements Execution { return distA - distB; })[0]?.unit ?? null; - const cooldown = - this.lastMissileAttack != 0 && - this.mg.ticks() - this.lastMissileAttack <= - this.mg.config().samCooldown(); - - if (this.post.isSamCooldown() && !cooldown) { - this.post.setSamCooldown(false); + if ( + this.sam.isCooldown() && + this.sam.ticksLeftInCooldown(this.mg.config().SAMCooldown()) == 0 + ) { + this.sam.setCooldown(false); } - if ( - this.target && - !this.post.isSamCooldown() && - !this.target.targetedBySAM() - ) { - this.lastMissileAttack = this.mg.ticks(); - this.post.setSamCooldown(true); + if (this.target && !this.sam.isCooldown() && !this.target.targetedBySAM()) { + this.sam.setCooldown(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(), + this.sam.owner().id(), ); } else { this.target.setTargetedBySAM(true); this.mg.addExecution( new SAMMissileExecution( - this.post.tile(), - this.post.owner(), - this.post, + this.sam.tile(), + this.sam.owner(), + this.sam, this.target, ), ); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 03c8a2c6c..38a827aef 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -228,6 +228,7 @@ export interface UnitSpecificInfos { dstPort?: Unit; // Only for trade ships detonationDst?: TileRef; // Only for nukes warshipTarget?: Unit; + cooldownDuration?: number; } export interface Unit { @@ -253,8 +254,9 @@ export interface Unit { setWarshipTarget(target: Unit): void; // warship only warshipTarget(): Unit; - setSamCooldown(isCoolingDown: boolean): void; // Only for sam - isSamCooldown(): boolean; + setCooldown(triggerCooldown: boolean): void; + ticksLeftInCooldown(cooldownDuration: number): Tick; + isCooldown(): boolean; setDstPort(dstPort: Unit): void; dstPort(): Unit; // Only for trade ships detonationDst(): TileRef; // Only for nukes diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index cea31f9f9..0dd2b111f 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -75,7 +75,7 @@ export interface UnitUpdate { warshipTargetId?: number; health?: number; constructionType?: UnitType; - isSamCooldown?: boolean; + ticksLeftInCooldown?: Tick; } export interface AttackUpdate { diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 573653a1b..b9a3556c9 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -114,8 +114,11 @@ export class UnitView { } return this.data.warshipTargetId; } - isSamCooldown(): boolean { - return this.data.isSamCooldown; + ticksLeftInCooldown(): Tick { + return this.data.ticksLeftInCooldown; + } + isCooldown(): boolean { + return this.data.ticksLeftInCooldown > 0; } } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 6bcb93076..03dab715b 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -741,8 +741,12 @@ export class PlayerImpl implements Player { } nukeSpawn(tile: TileRef): TileRef | false { + // only get missilesilos that are not on cooldown const spawns = this.units(UnitType.MissileSilo) .map((u) => u as Unit) + .filter((silo) => { + return !silo.isCooldown(); + }) .sort(distSortUnit(this.mg, tile)); if (spawns.length == 0) { return false; diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index cc7502623..9b5492ac9 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -1,4 +1,4 @@ -import { MessageType, UnitSpecificInfos } from "./Game"; +import { MessageType, Tick, UnitSpecificInfos } from "./Game"; import { UnitUpdate } from "./GameUpdates"; import { GameUpdateType } from "./GameUpdates"; import { simpleHash, toInt, within, withinInt } from "../Util"; @@ -19,10 +19,11 @@ export class UnitImpl implements Unit { private _constructionType: UnitType = undefined; - private _isSamCooldown: boolean = false; + private _cooldownTick: Tick | null = null; private _dstPort: Unit | null = null; // Only for trade ships private _detonationDst: TileRef | null = null; // Only for nukes private _warshipTarget: Unit | null = null; + private _cooldownDuration: number | null = null; constructor( private _type: UnitType, @@ -38,6 +39,7 @@ export class UnitImpl implements Unit { this._dstPort = unitsSpecificInfos.dstPort; this._detonationDst = unitsSpecificInfos.detonationDst; this._warshipTarget = unitsSpecificInfos.warshipTarget; + this._cooldownDuration = unitsSpecificInfos.cooldownDuration; } id() { @@ -61,7 +63,7 @@ export class UnitImpl implements Unit { dstPortId: dstPort ? dstPort.id() : null, warshipTargetId: warshipTarget ? warshipTarget.id() : null, detonationDst: this.detonationDst(), - isSamCooldown: this.isSamCooldown() ? this.isSamCooldown() : null, + ticksLeftInCooldown: this.ticksLeftInCooldown(this._cooldownDuration), }; } @@ -183,13 +185,26 @@ export class UnitImpl implements Unit { return this._dstPort; } - setSamCooldown(cooldown: boolean): void { - this._isSamCooldown = cooldown; - this.mg.addUpdate(this.toUpdate()); + // set the cooldown to the current tick or remove it + setCooldown(triggerCooldown: boolean): void { + if (triggerCooldown) { + this._cooldownTick = this.mg.ticks(); + this.mg.addUpdate(this.toUpdate()); + } else { + this._cooldownTick = null; + this.mg.addUpdate(this.toUpdate()); + } } - isSamCooldown(): boolean { - return this._isSamCooldown; + ticksLeftInCooldown(cooldownDuration: number): Tick { + return Math.max( + 0, + cooldownDuration - (this.mg.ticks() - this._cooldownTick), + ); + } + + isCooldown(): boolean { + return this._cooldownTick ? true : false; } setDstPort(dstPort: Unit): void { diff --git a/tests/MissileSilo.test.ts b/tests/MissileSilo.test.ts new file mode 100644 index 000000000..707f295a1 --- /dev/null +++ b/tests/MissileSilo.test.ts @@ -0,0 +1,95 @@ +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; + +function attackerBuildsNuke( + source: TileRef, + target: TileRef, + initialize = true, +) { + game.addExecution( + new NukeExecution(UnitType.AtomBomb, attacker.id(), target, source), + ); + if (initialize) { + game.executeNextTick(); + game.executeNextTick(); + } +} + +describe("MissileSilo", () => { + beforeEach(async () => { + game = await setup("Plains", { infiniteGold: true, instantBuild: true }); + const attacker_info = new PlayerInfo( + "fr", + "attacker_id", + PlayerType.Human, + null, + "attacker_id", + ); + game.addPlayer(attacker_info, 1000); + + game.addExecution( + new SpawnExecution(game.player(attacker_info.id).info(), game.ref(1, 1)), + ); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + attacker = game.player("attacker_id"); + + constructionExecution(game, attacker.id(), 1, 1, UnitType.MissileSilo); + }); + + test("missilesilo should launch nuke", async () => { + attackerBuildsNuke(null, game.ref(7, 7)); + expect(attacker.units(UnitType.AtomBomb)).toHaveLength(1); + //because of initilization the nuke already moved so it should be at 204 when starting from 101 + expect(attacker.units(UnitType.AtomBomb)[0].tile()).toBe( + game.map().ref(5, 1), + ); + + for (let i = 0; i < 3; i++) { + game.executeNextTick(); + } + expect(attacker.units(UnitType.AtomBomb)).toHaveLength(0); + }); + + test("missilesilo should only launch one nuke at a time", async () => { + attackerBuildsNuke(null, game.ref(7, 7)); + attackerBuildsNuke(null, game.ref(7, 7)); + expect(attacker.units(UnitType.AtomBomb)).toHaveLength(1); + }); + + test("missilesilo should cooldown as long as configured", async () => { + expect(attacker.units(UnitType.MissileSilo)[0].isCooldown()).toBeFalsy(); + // send the nuke far enough away so it doesnt destroy the silo + attackerBuildsNuke(null, game.ref(50, 50)); + expect(attacker.units(UnitType.AtomBomb)).toHaveLength(1); + + for (let i = 0; i < 24; i++) { + game.executeNextTick(); + } + expect(attacker.units(UnitType.AtomBomb)).toHaveLength(0); + + for (let i = 0; i < game.config().SiloCooldown() - 25; i++) { + game.executeNextTick(); + expect(attacker.units(UnitType.MissileSilo)[0].isCooldown()).toBeTruthy(); + } + + game.executeNextTick(); + expect(attacker.units(UnitType.MissileSilo)[0].isCooldown()).toBeFalsy(); + }); +}); diff --git a/tests/SAM.test.ts b/tests/SAM.test.ts index 3e033c61c..5bbc058e6 100644 --- a/tests/SAM.test.ts +++ b/tests/SAM.test.ts @@ -91,7 +91,7 @@ describe("SAM", () => { test("sam should cooldown as long as configured", async () => { defenderBuildsSam(1, 1); - expect(defender.units(UnitType.SAMLauncher)[0].isSamCooldown()).toBe(false); + expect(defender.units(UnitType.SAMLauncher)[0].isCooldown()).toBeFalsy(); attackerBuildsNuke(game.ref(7, 7), game.ref(1, 1)); expect(attacker.units(UnitType.AtomBomb)).toHaveLength(1); @@ -99,15 +99,13 @@ describe("SAM", () => { game.executeNextTick(); expect(attacker.units(UnitType.AtomBomb)).toHaveLength(0); - for (let i = 0; i < game.config().samCooldown() - 1; i++) { + for (let i = 0; i < game.config().SAMCooldown() - 2; i++) { game.executeNextTick(); - expect(defender.units(UnitType.SAMLauncher)[0].isSamCooldown()).toBe( - true, - ); + expect(defender.units(UnitType.SAMLauncher)[0].isCooldown()).toBeTruthy(); } game.executeNextTick(); - expect(defender.units(UnitType.SAMLauncher)[0].isSamCooldown()).toBe(false); + expect(defender.units(UnitType.SAMLauncher)[0].isCooldown()).toBeFalsy(); }); test("two sams should not target twice same nuke", async () => { @@ -125,8 +123,8 @@ describe("SAM", () => { 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()), + (sams[0].isCooldown() && !sams[1].isCooldown()) || + (sams[1].isCooldown() && !sams[0].isCooldown()), ).toBe(true); }); });