mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 21:14:14 +00:00
713 lines
18 KiB
TypeScript
713 lines
18 KiB
TypeScript
import {
|
|
Difficulty,
|
|
Game,
|
|
GameMapType,
|
|
GameMode,
|
|
GameType,
|
|
Gold,
|
|
Player,
|
|
PlayerInfo,
|
|
PlayerType,
|
|
TerrainType,
|
|
TerraNullius,
|
|
Tick,
|
|
UnitInfo,
|
|
UnitType,
|
|
} from "../game/Game";
|
|
import { TileRef } from "../game/GameMap";
|
|
import { PlayerView } from "../game/GameView";
|
|
import { UserSettings } from "../game/UserSettings";
|
|
import { GameConfig, GameID } from "../Schemas";
|
|
import { assertNever, simpleHash, within } from "../Util";
|
|
import { Config, GameEnv, NukeMagnitude, ServerConfig, Theme } from "./Config";
|
|
import { pastelTheme } from "./PastelTheme";
|
|
import { pastelThemeDark } from "./PastelThemeDark";
|
|
|
|
export abstract class DefaultServerConfig implements ServerConfig {
|
|
region(): string {
|
|
if (this.env() == GameEnv.Dev) {
|
|
return "dev";
|
|
}
|
|
return process.env.REGION;
|
|
}
|
|
gitCommit(): string {
|
|
return process.env.GIT_COMMIT;
|
|
}
|
|
r2Endpoint(): string {
|
|
return `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`;
|
|
}
|
|
r2AccessKey(): string {
|
|
return process.env.R2_ACCESS_KEY;
|
|
}
|
|
r2SecretKey(): string {
|
|
return process.env.R2_SECRET_KEY;
|
|
}
|
|
abstract r2Bucket(): string;
|
|
adminHeader(): string {
|
|
return "x-admin-key";
|
|
}
|
|
adminToken(): string {
|
|
return process.env.ADMIN_TOKEN;
|
|
}
|
|
abstract numWorkers(): number;
|
|
abstract env(): GameEnv;
|
|
abstract discordRedirectURI(): string;
|
|
turnIntervalMs(): number {
|
|
return 100;
|
|
}
|
|
gameCreationRate(): number {
|
|
return 60 * 1000;
|
|
}
|
|
lobbyMaxPlayers(map: GameMapType): number {
|
|
// Maps with ~4 mil pixels
|
|
if (
|
|
[
|
|
GameMapType.GatewayToTheAtlantic,
|
|
GameMapType.SouthAmerica,
|
|
GameMapType.NorthAmerica,
|
|
GameMapType.Africa,
|
|
GameMapType.Europe,
|
|
].includes(map)
|
|
) {
|
|
return Math.random() < 0.2 ? 100 : 50;
|
|
}
|
|
// Maps with ~2.5 - ~3.5 mil pixels
|
|
if (
|
|
[
|
|
GameMapType.Australia,
|
|
GameMapType.Iceland,
|
|
GameMapType.Britannia,
|
|
GameMapType.Asia,
|
|
].includes(map)
|
|
) {
|
|
return Math.random() < 0.3 ? 50 : 25;
|
|
}
|
|
// Maps with ~2 mil pixels
|
|
if (
|
|
[
|
|
GameMapType.Mena,
|
|
GameMapType.Mars,
|
|
GameMapType.Oceania,
|
|
GameMapType.Japan, // Japan at this level because its 2/3 water
|
|
GameMapType.FaroeIslands,
|
|
].includes(map)
|
|
) {
|
|
return Math.random() < 0.3 ? 50 : 25;
|
|
}
|
|
// Maps smaller than ~2 mil pixels
|
|
if (
|
|
[
|
|
GameMapType.BetweenTwoSeas,
|
|
GameMapType.BlackSea,
|
|
GameMapType.Pangaea,
|
|
].includes(map)
|
|
) {
|
|
return Math.random() < 0.5 ? 30 : 15;
|
|
}
|
|
// world belongs with the ~2 mils, but these amounts never made sense so I assume the insanity is intended.
|
|
if (map == GameMapType.World) {
|
|
return Math.random() < 0.2 ? 150 : 50;
|
|
}
|
|
// default return for non specified map
|
|
return Math.random() < 0.2 ? 50 : 20;
|
|
}
|
|
workerIndex(gameID: GameID): number {
|
|
return simpleHash(gameID) % this.numWorkers();
|
|
}
|
|
workerPath(gameID: GameID): string {
|
|
return `w${this.workerIndex(gameID)}`;
|
|
}
|
|
workerPort(gameID: GameID): number {
|
|
return this.workerPortByIndex(this.workerIndex(gameID));
|
|
}
|
|
workerPortByIndex(index: number): number {
|
|
return 3001 + index;
|
|
}
|
|
}
|
|
|
|
export class DefaultConfig implements Config {
|
|
constructor(
|
|
private _serverConfig: ServerConfig,
|
|
private _gameConfig: GameConfig,
|
|
private _userSettings: UserSettings,
|
|
) {}
|
|
|
|
numPlayerTeams(): number {
|
|
return this.gameConfig().numPlayerTeams;
|
|
}
|
|
|
|
samHittingChance(): number {
|
|
return 0.8;
|
|
}
|
|
|
|
samWarheadHittingChance(): number {
|
|
return 0.5;
|
|
}
|
|
|
|
traitorDefenseDebuff(): number {
|
|
return 0.5;
|
|
}
|
|
traitorDuration(): number {
|
|
return 15 * 10; // 15 seconds
|
|
}
|
|
spawnImmunityDuration(): Tick {
|
|
return 5 * 10;
|
|
}
|
|
|
|
gameConfig(): GameConfig {
|
|
return this._gameConfig;
|
|
}
|
|
|
|
serverConfig(): ServerConfig {
|
|
return this._serverConfig;
|
|
}
|
|
|
|
userSettings(): UserSettings | null {
|
|
return this._userSettings;
|
|
}
|
|
|
|
difficultyModifier(difficulty: Difficulty): number {
|
|
switch (difficulty) {
|
|
case Difficulty.Easy:
|
|
return 1;
|
|
case Difficulty.Medium:
|
|
return 3;
|
|
case Difficulty.Hard:
|
|
return 9;
|
|
case Difficulty.Impossible:
|
|
return 18;
|
|
}
|
|
}
|
|
|
|
cityPopulationIncrease(): number {
|
|
return 500_000;
|
|
}
|
|
|
|
falloutDefenseModifier(falloutRatio: number): number {
|
|
// falloutRatio is between 0 and 1
|
|
// So defense modifier is between [5, 2.5]
|
|
return 5 - falloutRatio * 2;
|
|
}
|
|
SAMCooldown(): number {
|
|
return 75;
|
|
}
|
|
SiloCooldown(): number {
|
|
return 75;
|
|
}
|
|
|
|
defensePostRange(): number {
|
|
return 40;
|
|
}
|
|
defensePostDefenseBonus(): number {
|
|
return 5;
|
|
}
|
|
spawnNPCs(): boolean {
|
|
return !this._gameConfig.disableNPCs;
|
|
}
|
|
disableNukes(): boolean {
|
|
return this._gameConfig.disableNukes;
|
|
}
|
|
bots(): number {
|
|
return this._gameConfig.bots;
|
|
}
|
|
instantBuild(): boolean {
|
|
return this._gameConfig.instantBuild;
|
|
}
|
|
infiniteGold(): boolean {
|
|
return this._gameConfig.infiniteGold;
|
|
}
|
|
infiniteTroops(): boolean {
|
|
return this._gameConfig.infiniteTroops;
|
|
}
|
|
tradeShipGold(dist: number): Gold {
|
|
return 10000 + 150 * Math.pow(dist, 1.1);
|
|
}
|
|
tradeShipSpawnRate(numberOfPorts: number): number {
|
|
return Math.round(10 * Math.pow(numberOfPorts, 0.5));
|
|
}
|
|
|
|
unitInfo(type: UnitType): UnitInfo {
|
|
switch (type) {
|
|
case UnitType.TransportShip:
|
|
return {
|
|
cost: () => 0,
|
|
territoryBound: false,
|
|
};
|
|
case UnitType.Warship:
|
|
return {
|
|
cost: (p: Player) =>
|
|
p.type() == PlayerType.Human && this.infiniteGold()
|
|
? 0
|
|
: Math.min(
|
|
1_000_000,
|
|
(p.unitsIncludingConstruction(UnitType.Warship).length + 1) *
|
|
250_000,
|
|
),
|
|
territoryBound: false,
|
|
maxHealth: 1000,
|
|
};
|
|
case UnitType.Shell:
|
|
return {
|
|
cost: () => 0,
|
|
territoryBound: false,
|
|
damage: 200,
|
|
};
|
|
case UnitType.SAMMissile:
|
|
return {
|
|
cost: () => 0,
|
|
territoryBound: false,
|
|
};
|
|
case UnitType.Port:
|
|
return {
|
|
cost: (p: Player) =>
|
|
p.type() == PlayerType.Human && this.infiniteGold()
|
|
? 0
|
|
: Math.min(
|
|
1_000_000,
|
|
Math.pow(
|
|
2,
|
|
p.unitsIncludingConstruction(UnitType.Port).length,
|
|
) * 125_000,
|
|
),
|
|
territoryBound: true,
|
|
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
|
|
};
|
|
case UnitType.AtomBomb:
|
|
return {
|
|
cost: (p: Player) =>
|
|
p.type() == PlayerType.Human && this.infiniteGold() ? 0 : 750_000,
|
|
territoryBound: false,
|
|
};
|
|
case UnitType.HydrogenBomb:
|
|
return {
|
|
cost: (p: Player) =>
|
|
p.type() == PlayerType.Human && this.infiniteGold() ? 0 : 5_000_000,
|
|
territoryBound: false,
|
|
};
|
|
case UnitType.MIRV:
|
|
return {
|
|
cost: (p: Player) =>
|
|
p.type() == PlayerType.Human && this.infiniteGold()
|
|
? 0
|
|
: 25_000_000,
|
|
territoryBound: false,
|
|
};
|
|
case UnitType.MIRVWarhead:
|
|
return {
|
|
cost: () => 0,
|
|
territoryBound: false,
|
|
};
|
|
case UnitType.TradeShip:
|
|
return {
|
|
cost: () => 0,
|
|
territoryBound: false,
|
|
};
|
|
case UnitType.MissileSilo:
|
|
return {
|
|
cost: (p: Player) =>
|
|
p.type() == PlayerType.Human && this.infiniteGold() ? 0 : 1_000_000,
|
|
territoryBound: true,
|
|
constructionDuration: this.instantBuild() ? 0 : 10 * 10,
|
|
};
|
|
case UnitType.DefensePost:
|
|
return {
|
|
cost: (p: Player) =>
|
|
p.type() == PlayerType.Human && this.infiniteGold()
|
|
? 0
|
|
: Math.min(
|
|
250_000,
|
|
(p.unitsIncludingConstruction(UnitType.DefensePost).length +
|
|
1) *
|
|
50_000,
|
|
),
|
|
territoryBound: true,
|
|
constructionDuration: this.instantBuild() ? 0 : 5 * 10,
|
|
};
|
|
case UnitType.SAMLauncher:
|
|
return {
|
|
cost: (p: Player) =>
|
|
p.type() == PlayerType.Human && this.infiniteGold()
|
|
? 0
|
|
: Math.min(
|
|
3_000_000,
|
|
(p.unitsIncludingConstruction(UnitType.SAMLauncher).length +
|
|
1) *
|
|
1_500_000,
|
|
),
|
|
territoryBound: true,
|
|
constructionDuration: this.instantBuild() ? 0 : 30 * 10,
|
|
};
|
|
case UnitType.City:
|
|
return {
|
|
cost: (p: Player) =>
|
|
p.type() == PlayerType.Human && this.infiniteGold()
|
|
? 0
|
|
: Math.min(
|
|
1_000_000,
|
|
Math.pow(
|
|
2,
|
|
p.unitsIncludingConstruction(UnitType.City).length,
|
|
) * 125_000,
|
|
),
|
|
territoryBound: true,
|
|
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
|
|
};
|
|
case UnitType.Construction:
|
|
return {
|
|
cost: () => 0,
|
|
territoryBound: true,
|
|
};
|
|
default:
|
|
assertNever(type);
|
|
}
|
|
}
|
|
defaultDonationAmount(sender: Player): number {
|
|
return Math.floor(sender.troops() / 3);
|
|
}
|
|
donateCooldown(): Tick {
|
|
return 10 * 10;
|
|
}
|
|
emojiMessageDuration(): Tick {
|
|
return 5 * 10;
|
|
}
|
|
emojiMessageCooldown(): Tick {
|
|
return 5 * 10;
|
|
}
|
|
targetDuration(): Tick {
|
|
return 10 * 10;
|
|
}
|
|
targetCooldown(): Tick {
|
|
return 15 * 10;
|
|
}
|
|
allianceRequestCooldown(): Tick {
|
|
return 30 * 10;
|
|
}
|
|
allianceDuration(): Tick {
|
|
return 600 * 10; // 10 minutes.
|
|
}
|
|
percentageTilesOwnedToWin(): number {
|
|
if (this._gameConfig.gameMode == GameMode.Team) {
|
|
return 95;
|
|
}
|
|
return 80;
|
|
}
|
|
boatMaxNumber(): number {
|
|
return 9;
|
|
}
|
|
numSpawnPhaseTurns(): number {
|
|
return this._gameConfig.gameType == GameType.Singleplayer ? 100 : 300;
|
|
}
|
|
numBots(): number {
|
|
return this.bots();
|
|
}
|
|
theme(): Theme {
|
|
return this.userSettings().darkMode() ? pastelThemeDark : pastelTheme;
|
|
}
|
|
|
|
attackLogic(
|
|
gm: Game,
|
|
attackTroops: number,
|
|
attacker: Player,
|
|
defender: Player | TerraNullius,
|
|
tileToConquer: TileRef,
|
|
): {
|
|
attackerTroopLoss: number;
|
|
defenderTroopLoss: number;
|
|
tilesPerTickUsed: number;
|
|
} {
|
|
const terrainModifiers = {
|
|
[TerrainType.Plains]: { mag: 0.85, speed: 0.75 },
|
|
[TerrainType.Highland]: { mag: 1, speed: 1 },
|
|
[TerrainType.Mountain]: { mag: 1.2, speed: 1.5 },
|
|
} as const;
|
|
|
|
const type = gm.terrainType(tileToConquer);
|
|
const mod = terrainModifiers[type];
|
|
if (!mod) {
|
|
throw new Error(`terrain type ${type} not supported`);
|
|
}
|
|
let mag = mod.mag;
|
|
let speed = mod.speed;
|
|
|
|
const attackerType = attacker.type();
|
|
const defenderIsPlayer = defender.isPlayer();
|
|
const defenderType = defenderIsPlayer ? defender.type() : null;
|
|
|
|
if (defenderIsPlayer) {
|
|
for (const dp of gm.nearbyUnits(
|
|
tileToConquer,
|
|
gm.config().defensePostRange(),
|
|
UnitType.DefensePost,
|
|
)) {
|
|
if (dp.unit.owner() == defender) {
|
|
mag *= this.defensePostDefenseBonus();
|
|
speed *= this.defensePostDefenseBonus();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (gm.hasFallout(tileToConquer)) {
|
|
const falloutRatio = gm.numTilesWithFallout() / gm.numLandTiles();
|
|
//mag *= this.falloutDefenseModifier(falloutRatio);
|
|
//speed *= this.falloutDefenseModifier(falloutRatio);
|
|
}
|
|
|
|
if (attacker.isPlayer() && defenderIsPlayer) {
|
|
if (attackerType == PlayerType.Human && defenderType == PlayerType.Bot) {
|
|
mag *= 0.8;
|
|
}
|
|
if (
|
|
attackerType == PlayerType.FakeHuman &&
|
|
defenderType == PlayerType.Bot
|
|
) {
|
|
mag *= 0.8;
|
|
}
|
|
}
|
|
if (attackerType == PlayerType.Bot) {
|
|
speed *= 3; // slow bot attacks
|
|
}
|
|
if (defenderIsPlayer) {
|
|
const defenderTroops = defender.troops();
|
|
const defenderTiles = defender.numTilesOwned();
|
|
const defenderdensity = defenderTroops / defenderTiles;
|
|
const adjustedRatio = within(defenderTroops / attackTroops, 0.3, 20);
|
|
return {
|
|
attackerTroopLoss:
|
|
mag * 10 +
|
|
defenderdensity *
|
|
mag *
|
|
(defender.isTraitor() ? this.traitorDefenseDebuff() : 1),
|
|
defenderTroopLoss: defenderdensity,
|
|
tilesPerTickUsed: within(
|
|
2.1 * defenderdensity ** 0.6 * adjustedRatio ** 0.8 * speed,
|
|
8,
|
|
1000,
|
|
),
|
|
};
|
|
} else {
|
|
return {
|
|
attackerTroopLoss: attackerType == PlayerType.Bot ? mag * 10 : mag * 10,
|
|
defenderTroopLoss: 0,
|
|
tilesPerTickUsed: 30 * speed,
|
|
};
|
|
}
|
|
}
|
|
|
|
attackTilesPerTick(
|
|
attackTroops: number,
|
|
attacker: Player,
|
|
defender: Player | TerraNullius,
|
|
numAdjacentTilesWithEnemy: number,
|
|
): number {
|
|
if (defender.isPlayer()) {
|
|
return 10 * numAdjacentTilesWithEnemy;
|
|
} else {
|
|
return 12 * numAdjacentTilesWithEnemy;
|
|
}
|
|
}
|
|
|
|
boatAttackAmount(attacker: Player, defender: Player | TerraNullius): number {
|
|
return Math.floor(attacker.troops() / 5);
|
|
}
|
|
|
|
warshipShellLifetime(): number {
|
|
return 20; // in ticks (one tick is 100ms)
|
|
}
|
|
|
|
radiusPortSpawn() {
|
|
return 20;
|
|
}
|
|
|
|
proximityBonusPortsNb(totalPorts: number) {
|
|
return within(totalPorts / 3, 4, totalPorts);
|
|
}
|
|
|
|
attackAmount(attacker: Player, defender: Player | TerraNullius) {
|
|
if (attacker.type() == PlayerType.Bot) {
|
|
return attacker.troops() / 20;
|
|
} else {
|
|
return attacker.troops() / 5;
|
|
}
|
|
}
|
|
|
|
startManpower(playerInfo: PlayerInfo): number {
|
|
if (playerInfo.playerType == PlayerType.Bot) {
|
|
return 8_000;
|
|
}
|
|
if (playerInfo.playerType == PlayerType.FakeHuman) {
|
|
switch (this._gameConfig.difficulty) {
|
|
case Difficulty.Easy:
|
|
return 2_500 * (playerInfo?.nation?.strength ?? 1);
|
|
case Difficulty.Medium:
|
|
return 12_000 + 2000 * (playerInfo?.nation?.strength ?? 1);
|
|
case Difficulty.Hard:
|
|
return 20_000 * (playerInfo?.nation?.strength ?? 1);
|
|
case Difficulty.Impossible:
|
|
return 50_000 * (playerInfo?.nation?.strength ?? 1);
|
|
}
|
|
}
|
|
return this.infiniteTroops() ? 1_000_000 : 25_000;
|
|
}
|
|
|
|
maxPopulation(player: Player | PlayerView): number {
|
|
const maxPop =
|
|
player.type() == PlayerType.Human && this.infiniteTroops()
|
|
? 1_000_000_000
|
|
: 1 * (player.numTilesOwned() * 30 + 100000) +
|
|
player.units(UnitType.City).length * this.cityPopulationIncrease();
|
|
|
|
if (player.type() == PlayerType.Bot) {
|
|
return maxPop / 3;
|
|
}
|
|
|
|
if (player.type() == PlayerType.Human) {
|
|
return maxPop;
|
|
}
|
|
|
|
switch (this._gameConfig.difficulty) {
|
|
case Difficulty.Easy:
|
|
return maxPop * 0.5;
|
|
case Difficulty.Medium:
|
|
return maxPop * 1;
|
|
case Difficulty.Hard:
|
|
return maxPop * 1.5;
|
|
case Difficulty.Impossible:
|
|
return maxPop * 2;
|
|
}
|
|
}
|
|
|
|
populationIncreaseRate(player: Player): number {
|
|
const max = this.maxPopulation(player);
|
|
|
|
let toAdd =
|
|
10 +
|
|
(800 / max + 1 / 160) * (0.7 * player.troops() + 1.3 * player.workers());
|
|
const adjustedPop =
|
|
typeof player.adjustedPopulation === "function"
|
|
? player.adjustedPopulation()
|
|
: player.population();
|
|
|
|
const ratio = 1 - adjustedPop / max;
|
|
toAdd *= ratio;
|
|
|
|
if (player.type() == PlayerType.Bot) {
|
|
toAdd *= 0.7;
|
|
}
|
|
|
|
if (player.type() == PlayerType.FakeHuman) {
|
|
switch (this._gameConfig.difficulty) {
|
|
case Difficulty.Easy:
|
|
toAdd *= 0.9;
|
|
break;
|
|
case Difficulty.Medium:
|
|
toAdd *= 1;
|
|
break;
|
|
case Difficulty.Hard:
|
|
toAdd *= 1.1;
|
|
break;
|
|
case Difficulty.Impossible:
|
|
toAdd *= 1.2;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return Math.min(player.population() + toAdd, max) - player.population();
|
|
}
|
|
|
|
goldAdditionRate(player: Player): number {
|
|
const numCities = player.units(UnitType.City).length;
|
|
const baseCityPopulation = numCities * this.cityPopulationIncrease();
|
|
|
|
const totalWorkers = player.workers() ?? 0;
|
|
const totalPopulation = player.population() ?? 0;
|
|
const maxPopulation = this.maxPopulation(player) ?? 0;
|
|
const numTiles = player.numTilesOwned() ?? 0;
|
|
|
|
if (totalWorkers <= 0 || totalPopulation <= 0 || maxPopulation <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
const populationRatio = totalPopulation / maxPopulation;
|
|
const adjustedCityPopulation = baseCityPopulation * populationRatio;
|
|
|
|
const cityWorkers =
|
|
(adjustedCityPopulation * totalWorkers) / totalPopulation;
|
|
const ruralWorkers = totalWorkers - cityWorkers;
|
|
|
|
const cityGold = cityWorkers / 2500;
|
|
const tileGold = (Math.sqrt(ruralWorkers) * Math.sqrt(numTiles)) / 750;
|
|
|
|
const totalGold = cityGold + tileGold;
|
|
|
|
return Number.isFinite(totalGold) ? totalGold : 0;
|
|
}
|
|
|
|
troopAdjustmentRate(player: Player): number {
|
|
const maxDiff = this.maxPopulation(player) / 500;
|
|
const target = player.population() * player.targetTroopRatio();
|
|
const diff = target - player.troops();
|
|
if (Math.abs(diff) < maxDiff) {
|
|
return diff;
|
|
}
|
|
const adjustment = maxDiff * Math.sign(diff);
|
|
// Can ramp down troops much faster
|
|
if (adjustment < 0) {
|
|
return adjustment * 5;
|
|
}
|
|
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 (2 * humans) / Math.max(1, tilesOwned);
|
|
}
|
|
|
|
structureMinDist(): number {
|
|
return 18;
|
|
}
|
|
|
|
shellLifetime(): number {
|
|
return 50;
|
|
}
|
|
|
|
warshipPatrolRange(): number {
|
|
return 100;
|
|
}
|
|
|
|
warshipTargettingRange(): number {
|
|
return 130;
|
|
}
|
|
|
|
warshipShellAttackRate(): number {
|
|
return 20;
|
|
}
|
|
|
|
defensePostShellAttackRate(): number {
|
|
return 120;
|
|
}
|
|
|
|
safeFromPiratesCooldownMax(): number {
|
|
return 20;
|
|
}
|
|
|
|
defensePostTargettingRange(): number {
|
|
return 75;
|
|
}
|
|
}
|