diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 95b5d7554..9dc185805 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -8,6 +8,7 @@ import { PlayerInfo, TerraNullius, Tick, + Unit, UnitInfo, UnitType, } from "../game/Game"; @@ -45,6 +46,11 @@ export interface ServerConfig { r2SecretKey(): string; } +export interface NukeMagnitude { + inner: number; + outer: number; +} + export interface Config { samHittingChance(): number; spawnImmunityDuration(): Tick; @@ -112,6 +118,9 @@ export interface Config { difficultyModifier(difficulty: Difficulty): number; // 0-1 traitorDefenseDebuff(): number; + nukeMagnitudes(unitType: UnitType): NukeMagnitude; + defaultNukeSpeed(): number; + nukeDeathFactor(humans: number, tilesOwned: number): number; } export interface Theme { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 03517c79b..b2a117192 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -10,6 +10,7 @@ import { TerrainType, TerraNullius, Tick, + Unit, UnitInfo, UnitType, } from "../game/Game"; @@ -18,7 +19,7 @@ import { PlayerView } from "../game/GameView"; import { UserSettings } from "../game/UserSettings"; import { GameConfig, GameID } from "../Schemas"; import { assertNever, simpleHash, within } from "../Util"; -import { Config, GameEnv, ServerConfig, Theme } from "./Config"; +import { Config, GameEnv, NukeMagnitude, ServerConfig, Theme } from "./Config"; import { pastelTheme } from "./PastelTheme"; import { pastelThemeDark } from "./PastelThemeDark"; @@ -592,4 +593,24 @@ export class DefaultConfig implements Config { } return adjustment; } + + nukeMagnitudes(unitType: UnitType): NukeMagnitude { + switch (unitType) { + case UnitType.MIRVWarhead: + return { inner: 25, outer: 30 }; + case UnitType.AtomBomb: + return { inner: 12, outer: 30 }; + case UnitType.HydrogenBomb: + return { inner: 80, outer: 100 }; + } + } + + defaultNukeSpeed(): number { + return 4; + } + + // Humans can be population, soldiers attacking, soldiers in boat etc. + nukeDeathFactor(humans: number, tilesOwned: number): number { + return (5 * humans) / Math.max(1, tilesOwned); + } } diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 2935377ef..a41d156b3 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -27,7 +27,7 @@ export class NukeExecution implements Execution { private senderID: PlayerID, private dst: TileRef, private src?: TileRef, - private speed: number = 4, + private speed: number = -1, private waitTicks = 0, ) {} @@ -41,12 +41,55 @@ export class NukeExecution implements Execution { this.mg = mg; this.player = mg.player(this.senderID); this.random = new PseudoRandom(ticks); + if (this.speed == -1) { + this.speed = this.mg.config().defaultNukeSpeed(); + } } public target(): Player | TerraNullius { return this.mg.owner(this.dst); } + private tilesToDestroy(): Set { + const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type()); + const rand = new PseudoRandom(this.mg.ticks()); + return this.mg.bfs(this.dst, (_, n: TileRef) => { + const d = this.mg.euclideanDist(this.dst, n); + return (d <= magnitude.inner || rand.chance(2)) && d <= magnitude.outer; + }); + } + + private getAttackedTiles() { + const toDestroy = this.tilesToDestroy(); + + const attacked = new Map(); + for (const tile of toDestroy) { + const owner = this.mg.owner(tile); + if (owner.isPlayer()) { + const mp = this.mg.player(owner.id()); + const prev = attacked.get(mp) ?? 0; + attacked.set(mp, prev + 1); + } + } + + return attacked; + } + + private breakAlliances() { + for (const [other, tilesDestroyed] of this.getAttackedTiles()) { + if (tilesDestroyed > 100 && this.nuke.type() != UnitType.MIRVWarhead) { + // Mirv warheads shouldn't break alliances + const alliance = this.player.allianceWith(other); + if (alliance != null) { + this.player.breakAlliance(alliance); + } + if (other != this.player) { + other.updateRelation(this.player, -100); + } + } + } + } + tick(ticks: number): void { if (this.nuke == null) { const spawn = this.src ?? this.player.canBuild(this.type, this.dst); @@ -91,6 +134,7 @@ export class NukeExecution implements Execution { if (silo) { silo.setCooldown(true); } + return; } // make the nuke unactive if it was intercepted @@ -100,6 +144,8 @@ export class NukeExecution implements Execution { return; } + this.breakAlliances(); + if (this.waitTicks > 0) { this.waitTicks--; return; @@ -149,55 +195,38 @@ export class NukeExecution implements Execution { } private detonate() { - let magnitude; - switch (this.type) { - case UnitType.MIRVWarhead: - magnitude = { inner: 25, outer: 30 }; - break; - case UnitType.AtomBomb: - magnitude = { inner: 12, outer: 30 }; - break; - case UnitType.HydrogenBomb: - magnitude = { inner: 80, outer: 100 }; - break; - } + const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type()); + const toDestroy = this.tilesToDestroy(); - const rand = new PseudoRandom(this.mg.ticks()); - const toDestroy = this.mg.bfs(this.dst, (_, n: TileRef) => { - const d = this.mg.euclideanDist(this.dst, n); - return (d <= magnitude.inner || rand.chance(2)) && d <= magnitude.outer; - }); - - const attacked = new Map(); for (const tile of toDestroy) { const owner = this.mg.owner(tile); if (owner.isPlayer()) { const mp = this.mg.player(owner.id()); mp.relinquish(tile); - mp.removeTroops((5 * mp.population()) / mp.numTilesOwned()); - if (!attacked.has(mp)) { - attacked.set(mp, 0); - } - const prev = attacked.get(mp); - attacked.set(mp, prev + 1); + mp.removeTroops( + this.mg.config().nukeDeathFactor(mp.troops(), mp.numTilesOwned()), + ); + mp.removeWorkers( + this.mg.config().nukeDeathFactor(mp.workers(), mp.numTilesOwned()), + ); + mp.outgoingAttacks().forEach((attack) => { + const deaths = this.mg + .config() + .nukeDeathFactor(attack.troops(), mp.numTilesOwned()); + attack.setTroops(attack.troops() - deaths); + }); + mp.units(UnitType.TransportShip).forEach((attack) => { + const deaths = this.mg + .config() + .nukeDeathFactor(attack.troops(), mp.numTilesOwned()); + attack.setTroops(attack.troops() - deaths); + }); } if (this.mg.isLand(tile)) { this.mg.setFallout(tile, true); } } - for (const [other, tilesDestroyed] of attacked) { - if (tilesDestroyed > 100 && this.nuke.type() != UnitType.MIRVWarhead) { - // Mirv warheads shouldn't break alliances - const alliance = this.player.allianceWith(other); - if (alliance != null) { - this.player.breakAlliance(alliance); - } - if (other != this.player) { - other.updateRelation(this.player, -100); - } - } - } for (const unit of this.mg.units()) { if ( diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts new file mode 100644 index 000000000..f964b8b9d --- /dev/null +++ b/tests/Attack.test.ts @@ -0,0 +1,114 @@ +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 { TransportShipExecution } from "../src/core/execution/TransportShipExecution"; +import { TileRef } from "../src/core/game/GameMap"; +import { AttackExecution } from "../src/core/execution/AttackExecution"; +import { TestConfig } from "./util/TestConfig"; + +let game: Game; +let attacker: Player; +let defender: Player; +let defenderSpawn: TileRef; +let attackerSpawn: TileRef; + +function sendBoat(target: TileRef, troops: number) { + game.addExecution( + new TransportShipExecution(defender.id(), null, target, troops), + ); +} + +describe("Attack", () => { + beforeEach(async () => { + game = await setup("ocean_and_land", { + infiniteGold: true, + instantBuild: true, + infiniteTroops: true, + }); + const attackerInfo = new PlayerInfo( + "us", + "attacker dude", + PlayerType.Human, + null, + "attacker_id", + ); + game.addPlayer(attackerInfo, 1000); + const defenderInfo = new PlayerInfo( + "us", + "defender dude", + PlayerType.Human, + null, + "defender_id", + ); + game.addPlayer(defenderInfo, 1000); + + defenderSpawn = game.ref(0, 15); + attackerSpawn = game.ref(0, 10); + + game.addExecution( + new SpawnExecution(game.player(attackerInfo.id).info(), attackerSpawn), + new SpawnExecution(game.player(defenderInfo.id).info(), defenderSpawn), + ); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + attacker = game.player(attackerInfo.id); + defender = game.player(defenderInfo.id); + + game.addExecution(new AttackExecution(100, defender.id(), null)); + game.executeNextTick(); + while (defender.outgoingAttacks().length > 0) { + game.executeNextTick(); + } + + (game.config() as TestConfig).setDefaultNukeSpeed(50); + }); + + test("Nuke reduce attacking troop counts", async () => { + // Not building exactly spawn to it's better protected from attacks (but still + // on defender territory) + constructionExecution(game, defender.id(), 1, 1, UnitType.MissileSilo); + expect(defender.units(UnitType.MissileSilo)).toHaveLength(1); + game.addExecution(new AttackExecution(100, attacker.id(), defender.id())); + constructionExecution(game, defender.id(), 0, 15, UnitType.AtomBomb, 3); + const nuke = defender.units(UnitType.AtomBomb)[0]; + expect(nuke.isActive()).toBe(true); + + expect(attacker.outgoingAttacks()).toHaveLength(1); + expect(attacker.outgoingAttacks()[0].troops()).toBe(98); + + // Make the nuke go kaboom + game.executeNextTick(); + expect(nuke.isActive()).toBe(false); + expect(attacker.outgoingAttacks()[0].troops()).not.toBe(97); + expect(attacker.outgoingAttacks()[0].troops()).toBeLessThan(90); + }); + + test("Nuke reduce attacking boat troop count", async () => { + constructionExecution(game, defender.id(), 1, 1, UnitType.MissileSilo); + expect(defender.units(UnitType.MissileSilo)).toHaveLength(1); + + sendBoat(game.ref(15, 8), 100); + + constructionExecution(game, defender.id(), 0, 15, UnitType.AtomBomb, 3); + const nuke = defender.units(UnitType.AtomBomb)[0]; + expect(nuke.isActive()).toBe(true); + + const ship = defender.units(UnitType.TransportShip)[0]; + expect(ship.troops()).toBe(100); + + game.executeNextTick(); + + expect(nuke.isActive()).toBe(false); + expect(defender.units(UnitType.TransportShip)[0].troops()).toBeLessThan(90); + }); +}); diff --git a/tests/MissileSilo.test.ts b/tests/MissileSilo.test.ts index 707f295a1..1a6c00795 100644 --- a/tests/MissileSilo.test.ts +++ b/tests/MissileSilo.test.ts @@ -56,12 +56,11 @@ describe("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), + expect(attacker.units(UnitType.AtomBomb)[0].tile()).not.toBe( + game.map().ref(7, 7), ); - for (let i = 0; i < 3; i++) { + for (let i = 0; i < 5; i++) { game.executeNextTick(); } expect(attacker.units(UnitType.AtomBomb)).toHaveLength(0); @@ -79,12 +78,7 @@ describe("MissileSilo", () => { 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++) { + for (let i = 0; i < game.config().SiloCooldown() - 1; i++) { game.executeNextTick(); expect(attacker.units(UnitType.MissileSilo)[0].isCooldown()).toBeTruthy(); } diff --git a/tests/testdata/ocean_and_land.png b/tests/testdata/ocean_and_land.png new file mode 100755 index 000000000..43d7ef32d Binary files /dev/null and b/tests/testdata/ocean_and_land.png differ diff --git a/tests/util/TestConfig.ts b/tests/util/TestConfig.ts index 5a3493440..40427a90e 100644 --- a/tests/util/TestConfig.ts +++ b/tests/util/TestConfig.ts @@ -1,7 +1,17 @@ +import { NukeMagnitude } from "../../src/core/configuration/Config"; import { DefaultConfig } from "../../src/core/configuration/DefaultConfig"; +import { + Game, + Player, + TerraNullius, + Tick, + UnitType, +} from "../../src/core/game/Game"; +import { TileRef } from "../../src/core/game/GameMap"; export class TestConfig extends DefaultConfig { - _proximityBonusPortsNb: number = 0; + private _proximityBonusPortsNb: number = 0; + private _defaultNukeSpeed: number = 4; samHittingChance(): number { return 1; @@ -19,4 +29,43 @@ export class TestConfig extends DefaultConfig { setProximityBonusPortsNb(nb: number): void { this._proximityBonusPortsNb = nb; } + + nukeMagnitudes(_: UnitType): NukeMagnitude { + return { inner: 1, outer: 1 }; + } + + setDefaultNukeSpeed(speed: number): void { + this._defaultNukeSpeed = speed; + } + + defaultNukeSpeed(): number { + return this._defaultNukeSpeed; + } + + spawnImmunityDuration(): Tick { + return 0; + } + + attackLogic( + gm: Game, + attackTroops: number, + attacker: Player, + defender: Player | TerraNullius, + tileToConquer: TileRef, + ): { + attackerTroopLoss: number; + defenderTroopLoss: number; + tilesPerTickUsed: number; + } { + return { attackerTroopLoss: 1, defenderTroopLoss: 1, tilesPerTickUsed: 1 }; + } + + attackTilesPerTick( + attackTroops: number, + attacker: Player, + defender: Player | TerraNullius, + numAdjacentTilesWithEnemy: number, + ): number { + return 1; + } } diff --git a/tests/util/utils.ts b/tests/util/utils.ts index e3175423e..34210b9d8 100644 --- a/tests/util/utils.ts +++ b/tests/util/utils.ts @@ -13,14 +13,18 @@ export function constructionExecution( x: number, y: number, unit: UnitType, + ticks = 4, ) { game.addExecution(new ConstructionExecution(playerID, game.ref(x, y), unit)); - // Init exec - game.executeNextTick(); + + // 4 ticks by default as it usually goes like this + // Init of construction execution // 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(); + // Tick of construction execution which adds the execution related to the building/unit + // First tick of the execution of the constructed building/unit + // (sometimes step 3 and 4 are merged in one) + + for (let i = 0; i < ticks; i++) { + game.executeNextTick(); + } }