Files
OpenFrontIO/src/core/configuration/Config.ts
T
FloPinguin b0e7d04f6e Add help notification system to control panel ℹ️ (#4212)
Resolves https://github.com/openfrontio/OpenFrontIO/issues/3445

## Description:

I copied the PR #3743 from @luctrate (Add army limit warning indicator
for team games) to this PR because he didn't respond to requested
changes but I thought it's important.

I expanded on it, now its a full help message system:

**Warnings (orange):**
- Army limit: shown in team games with donations when troops exceed 80%
of max
- Low troops: shown when troops drop below 1k (=> new noob player who
clicks too much)

<img width="764" height="251"
alt="582494157-cf19b13e-a0a9-44e4-8de8-86c007fe9c79"
src="https://github.com/user-attachments/assets/6b4996d9-1993-4d2c-98ba-afba17a5ca4d"
/>

**Info messages (blue):**
- Borders a traitor ally: "You can betray traitors without becoming a
traitor yourself" (Because its not obvious for new players)
- Borders an allied AFK player: "You can attack disconnected players
even if you are allied with them" (Because its not obvious for new
players)
- Borders an AFK teammate: "You can attack disconnected teammates"
(Because its not obvious for new players)

Info messages only appear when the player has not attacked the relevant
neighbor for at least 15 seconds, so they do not show up without reason.

<img width="524" height="141" alt="image"
src="https://github.com/user-attachments/assets/88d74661-d47e-45a7-9f91-d4f5361114b7"
/>

New "Help Messages" toggle in settings (default: on)

<img width="409" height="105" alt="image"
src="https://github.com/user-attachments/assets/24bc8bed-777b-4f72-9451-02116ac39db0"
/>

Implementation details:
- Border detection uses async borderTiles() refreshed every 1s, cached
in a Set of nearby player smallIDs
- Outgoing attacks are tracked per-target to compute the 15-second idle
threshold
- New armyLimitWarningThreshold() on Config (returns 0.8)
- All user-facing strings go through translateText() with en.json
entries

AI Model used: MiMo 2.5 Pro

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

FloPinguin
2026-06-10 17:00:24 -07:00

952 lines
23 KiB
TypeScript

import { z } from "zod";
import { AssetManifest } from "../AssetUrls";
import {
Difficulty,
Game,
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, TeamCountConfig } from "../Schemas";
import { NukeType } from "../StatsSchemas";
import { assertNever, sigmoid, toInt, within } from "../Util";
declare global {
interface Window {
BOOTSTRAP_CONFIG?: {
gitCommit?: string;
assetManifest?: AssetManifest;
cdnBase?: string;
gameEnv?: string;
numWorkers?: number;
turnstileSiteKey?: string;
jwtAudience?: string;
instanceId?: string;
};
}
}
export enum GameEnv {
Dev,
Preprod,
Prod,
}
export function parseGameEnv(value: string | undefined): GameEnv {
switch (value) {
case "dev":
return GameEnv.Dev;
case "staging":
return GameEnv.Preprod;
case "prod":
return GameEnv.Prod;
default:
throw new Error(`unsupported game env: ${value}`);
}
}
export interface NukeMagnitude {
inner: number;
outer: number;
}
const DEFENSE_DEBUFF_MIDPOINT = 150_000;
const DEFENSE_DEBUFF_DECAY_RATE = Math.LN2 / 50000;
const DEFAULT_SPAWN_IMMUNITY_TICKS = 5 * 10;
export const JwksSchema = z.object({
keys: z
.object({
alg: z.literal("EdDSA"),
crv: z.literal("Ed25519"),
kty: z.literal("OKP"),
x: z.string(),
})
.array()
.min(1),
});
/** SAM launcher construction duration in ticks (non-instant-build). */
export const SAM_CONSTRUCTION_TICKS = 30 * 10;
export class Config {
private unitInfoCache = new Map<UnitType, UnitInfo>();
constructor(
private _gameConfig: GameConfig,
private _userSettings: UserSettings | null,
private _isReplay: boolean,
) {}
isReplay(): boolean {
return this._isReplay;
}
traitorDefenseDebuff(): number {
return 0.5;
}
traitorSpeedDebuff(): number {
return 0.8;
}
traitorDuration(): number {
return 30 * 10; // 30 seconds
}
spawnImmunityDuration(): Tick {
return (
this._gameConfig.spawnImmunityDuration ?? DEFAULT_SPAWN_IMMUNITY_TICKS
);
}
nationSpawnImmunityDuration(): Tick {
return DEFAULT_SPAWN_IMMUNITY_TICKS;
}
hasExtendedSpawnImmunity(): boolean {
return this.spawnImmunityDuration() > DEFAULT_SPAWN_IMMUNITY_TICKS;
}
gameConfig(): GameConfig {
return this._gameConfig;
}
userSettings(): UserSettings {
if (this._userSettings === null) {
throw new Error("userSettings is null");
}
return this._userSettings;
}
cityTroopIncrease(): number {
return 250_000;
}
falloutDefenseModifier(falloutRatio: number): number {
// falloutRatio is between 0 and 1
// So defense modifier is between [5, 2.5]
return 5 - falloutRatio * 2;
}
msPerTick(): number {
return 100;
}
SAMCooldown(): number {
return 90;
}
SiloCooldown(): number {
return 90;
}
defensePostRange(): number {
return 30;
}
defensePostDefenseBonus(): number {
return 5;
}
defensePostSpeedBonus(): number {
return 3;
}
playerTeams(): TeamCountConfig {
return this._gameConfig.playerTeams ?? 0;
}
spawnNations(): boolean {
return this._gameConfig.nations !== "disabled";
}
isUnitDisabled(unitType: UnitType): boolean {
return this._gameConfig.disabledUnits?.includes(unitType) ?? false;
}
bots(): number {
return this._gameConfig.bots;
}
instantBuild(): boolean {
return this._gameConfig.instantBuild;
}
disableNavMesh(): boolean {
return this._gameConfig.disableNavMesh ?? false;
}
disableAlliances(): boolean {
return this._gameConfig.disableAlliances ?? false;
}
waterNukes(): boolean {
return this._gameConfig.waterNukes ?? false;
}
isRandomSpawn(): boolean {
return this._gameConfig.randomSpawn;
}
infiniteGold(): boolean {
return this._gameConfig.infiniteGold;
}
donateGold(): boolean {
return this._gameConfig.donateGold;
}
infiniteTroops(): boolean {
return this._gameConfig.infiniteTroops;
}
donateTroops(): boolean {
return this._gameConfig.donateTroops;
}
goldMultiplier(): number {
return this._gameConfig.goldMultiplier ?? 1;
}
startingGold(playerInfo: PlayerInfo): Gold {
if (playerInfo.playerType === PlayerType.Bot) {
return 0n;
}
return this.startingGoldFor(playerInfo);
}
trainSpawnRate(numPlayerFactories: number): number {
// hyperbolic decay, midpoint at 10 factories
// expected number of trains = numPlayerFactories / trainSpawnRate(numPlayerFactories)
return (numPlayerFactories + 10) * 15;
}
trainGold(
rel: "self" | "team" | "ally" | "other",
citiesVisited: number,
player: Player | PlayerView,
): Gold {
// No penalty for the first 10 cities.
citiesVisited = Math.max(0, citiesVisited - 9);
let baseGold: number;
switch (rel) {
case "ally":
baseGold = 35_000;
break;
case "team":
case "other":
baseGold = 25_000;
break;
case "self":
baseGold = 10_000;
break;
}
const distPenalty = citiesVisited * 5_000;
const gold = Math.max(5000, baseGold - distPenalty);
return toInt(gold * this.goldMultiplierFor(player));
}
trainStationMinRange(): number {
return 15;
}
trainStationMaxRange(): number {
return 110;
}
railroadMaxSize(): number {
return this.trainStationMaxRange();
}
tradeShipGold(dist: number, player: Player | PlayerView): Gold {
// Sigmoid: concave start, sharp S-curve middle, linear end - heavily punishes trades under range debuff.
const debuff = this.tradeShipShortRangeDebuff();
const baseGold =
75_000 / (1 + Math.exp(-0.03 * (dist - debuff))) + 50 * dist;
return BigInt(Math.floor(baseGold * this.goldMultiplierFor(player)));
}
// Probability of trade ship spawn = 1 / tradeShipSpawnRate
tradeShipSpawnRate(
tradeShipSpawnRejections: number,
numTradeShips: number,
): number {
const decayRate = Math.LN2 / 50;
// Approaches 0 as numTradeShips increase
const baseSpawnRate = 1 - sigmoid(numTradeShips, decayRate, 400);
// Pity timer: increases spawn chance after consecutive rejections
const rejectionModifier = 1 / (tradeShipSpawnRejections + 1);
return Math.floor((100 * rejectionModifier) / baseSpawnRate);
}
unitInfo(type: UnitType): UnitInfo {
const cached = this.unitInfoCache.get(type);
if (cached !== undefined) {
return cached;
}
let info: UnitInfo;
switch (type) {
case UnitType.TransportShip:
info = {
cost: () => 0n,
};
break;
case UnitType.Warship:
info = {
cost: this.costWrapper(
(numUnits: number) => Math.min(1_000_000, (numUnits + 1) * 250_000),
UnitType.Warship,
),
maxHealth: 1000,
};
break;
case UnitType.Shell:
info = {
cost: () => 0n,
damage: 250,
};
break;
case UnitType.SAMMissile:
info = {
cost: () => 0n,
};
break;
case UnitType.Port:
info = {
cost: this.costWrapper(
(numUnits: number) =>
Math.min(1_000_000, Math.pow(2, numUnits) * 125_000),
UnitType.Port,
UnitType.Factory,
),
constructionDuration: this.instantBuild() ? 0 : 5 * 10,
upgradable: true,
};
break;
case UnitType.AtomBomb:
info = {
cost: this.costWrapper(() => 750_000, UnitType.AtomBomb),
};
break;
case UnitType.HydrogenBomb:
info = {
cost: this.costWrapper(() => 5_000_000, UnitType.HydrogenBomb),
};
break;
case UnitType.MIRV:
info = {
cost: (game: Game, player: Player) => {
if (
player.type() === PlayerType.Human &&
this.hasInfiniteGoldFor(player)
) {
return 0n;
}
return 25_000_000n + game.stats().numMirvsLaunched() * 15_000_000n;
},
};
break;
case UnitType.MIRVWarhead:
info = {
cost: () => 0n,
};
break;
case UnitType.TradeShip:
info = {
cost: () => 0n,
};
break;
case UnitType.MissileSilo:
info = {
cost: this.costWrapper(() => 1_000_000, UnitType.MissileSilo),
constructionDuration: this.instantBuild() ? 0 : 10 * 10,
upgradable: true,
};
break;
case UnitType.DefensePost:
info = {
cost: this.costWrapper(
(numUnits: number) => Math.min(250_000, (numUnits + 1) * 50_000),
UnitType.DefensePost,
),
constructionDuration: this.instantBuild() ? 0 : 5 * 10,
};
break;
case UnitType.SAMLauncher:
info = {
cost: this.costWrapper(
(numUnits: number) =>
Math.min(3_000_000, (numUnits + 1) * 1_500_000),
UnitType.SAMLauncher,
),
constructionDuration: this.instantBuild()
? 0
: SAM_CONSTRUCTION_TICKS,
upgradable: true,
};
break;
case UnitType.City:
info = {
cost: this.costWrapper(
(numUnits: number) =>
Math.min(1_000_000, Math.pow(2, numUnits) * 125_000),
UnitType.City,
),
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
upgradable: true,
};
break;
case UnitType.Factory:
info = {
cost: this.costWrapper(
(numUnits: number) =>
Math.min(1_000_000, Math.pow(2, numUnits) * 125_000),
UnitType.Factory,
UnitType.Port,
),
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
upgradable: true,
};
break;
case UnitType.Train:
info = {
cost: () => 0n,
};
break;
default:
assertNever(type);
}
this.unitInfoCache.set(type, info);
return info;
}
private hasInfiniteGoldFor(player: Player | PlayerView): boolean {
if (this.infiniteGold()) return true;
const hc = this._gameConfig.hostCheats;
return (hc?.infiniteGold ?? false) && player.isLobbyCreator();
}
private hasInfiniteTroopsFor(player: Player | PlayerView): boolean {
if (this.infiniteTroops()) return true;
return (
(this._gameConfig.hostCheats?.infiniteTroops ?? false) &&
player.isLobbyCreator()
);
}
private hasInfiniteTroopsForInfo(playerInfo: PlayerInfo): boolean {
if (this.infiniteTroops()) return true;
return (
(this._gameConfig.hostCheats?.infiniteTroops ?? false) &&
playerInfo.isLobbyCreator
);
}
private goldMultiplierFor(player: Player | PlayerView): number {
const base = this.goldMultiplier();
const hc = this._gameConfig.hostCheats;
if (hc?.goldMultiplier && player.isLobbyCreator()) {
return hc.goldMultiplier;
}
return base;
}
public conquerGoldAmount(captured: Player): Gold {
if (
captured.type() === PlayerType.Bot ||
captured.type() === PlayerType.Nation
) {
return captured.gold();
} else {
return captured.gold() / 2n;
}
}
private startingGoldFor(playerInfo: PlayerInfo): Gold {
const base = BigInt(this._gameConfig.startingGold ?? 0);
const hc = this._gameConfig.hostCheats;
if (hc?.startingGold && playerInfo.isLobbyCreator) {
return base + BigInt(hc.startingGold);
}
return base;
}
private costWrapper(
costFn: (units: number) => number,
...types: UnitType[]
): (g: Game, p: Player) => bigint {
return (game: Game, player: Player) => {
if (
player.type() === PlayerType.Human &&
this.hasInfiniteGoldFor(player)
) {
return 0n;
}
const numUnits = types.reduce(
(acc, type) =>
acc +
Math.min(player.unitsOwned(type), player.unitsConstructed(type)),
0,
);
return BigInt(costFn(numUnits));
};
}
defaultDonationAmount(sender: Player): number {
return Math.floor(sender.troops() / 3);
}
donateCooldown(): Tick {
return 10 * 10;
}
embargoAllCooldown(): Tick {
return 10 * 10;
}
deletionMarkDuration(): Tick {
return 30 * 10;
}
deleteUnitCooldown(): Tick {
return 30 * 10;
}
emojiMessageDuration(): Tick {
return 5 * 10;
}
emojiMessageCooldown(): Tick {
return 5 * 10;
}
quickChatCooldown(): Tick {
return 3 * 10;
}
targetDuration(): Tick {
return 10 * 10;
}
targetCooldown(): Tick {
return 15 * 10;
}
allianceRequestDuration(): Tick {
return 20 * 10;
}
allianceRequestCooldown(): Tick {
return 30 * 10;
}
allianceDuration(): Tick {
return 300 * 10; // 5 minutes.
}
temporaryEmbargoDuration(): Tick {
return 300 * 10; // 5 minutes.
}
minDistanceBetweenPlayers(): number {
return 30;
}
percentageTilesOwnedToWin(): number {
if (this._gameConfig.gameMode === GameMode.Team) {
return 95;
}
return 80;
}
armyLimitWarningThreshold(): number {
return 0.8;
}
boatMaxNumber(): number {
if (this.isUnitDisabled(UnitType.TransportShip)) {
return 0;
}
return 3;
}
numSpawnPhaseTurns(): number {
if (this._gameConfig.gameType === GameType.Singleplayer) {
return 100;
}
if (this.isRandomSpawn()) {
return 150;
}
return 300;
}
numBots(): number {
return this.bots();
}
attackLogic(
gm: Game,
attackTroops: number,
attacker: Player,
defender: Player | TerraNullius,
tileToConquer: TileRef,
): {
attackerTroopLoss: number;
defenderTroopLoss: number;
tilesPerTickUsed: number;
} {
let mag;
let speed;
const type = gm.terrainType(tileToConquer);
switch (type) {
case TerrainType.Plains:
mag = 80;
speed = 16.5;
break;
case TerrainType.Highland:
mag = 100;
speed = 20;
break;
case TerrainType.Mountain:
mag = 120;
speed = 25;
break;
default:
throw new Error(`terrain type ${type} not supported`);
}
if (defender.isPlayer()) {
for (const dp of gm.nearbyUnits(
tileToConquer,
gm.config().defensePostRange(),
UnitType.DefensePost,
)) {
if (dp.unit.owner() === defender) {
mag *= this.defensePostDefenseBonus();
speed *= this.defensePostSpeedBonus();
break;
}
}
}
if (gm.hasFallout(tileToConquer)) {
const falloutRatio = gm.numTilesWithFallout() / gm.numLandTiles();
mag *= this.falloutDefenseModifier(falloutRatio);
speed *= this.falloutDefenseModifier(falloutRatio);
}
if (attacker.isPlayer() && defender.isPlayer()) {
if (defender.isDisconnected() && attacker.isOnSameTeam(defender)) {
// No troop loss if defender is disconnected and on same team
mag = 0;
}
if (
(attacker.type() === PlayerType.Human ||
attacker.type() === PlayerType.Nation) &&
defender.type() === PlayerType.Bot
) {
mag *= 0.7;
}
}
if (defender.isPlayer()) {
const defenseSig =
1 -
sigmoid(
defender.numTilesOwned(),
DEFENSE_DEBUFF_DECAY_RATE,
DEFENSE_DEBUFF_MIDPOINT,
);
const largeDefenderSpeedDebuff = 0.7 + 0.3 * defenseSig;
const largeDefenderAttackDebuff = 0.7 + 0.3 * defenseSig;
let largeAttackBonus = 1;
if (attacker.numTilesOwned() > 100_000) {
largeAttackBonus = Math.sqrt(100_000 / attacker.numTilesOwned()) ** 0.7;
}
let largeAttackerSpeedBonus = 1;
if (attacker.numTilesOwned() > 100_000) {
largeAttackerSpeedBonus = (100_000 / attacker.numTilesOwned()) ** 0.6;
}
const defenderTroopLoss = defender.troops() / defender.numTilesOwned();
const traitorMod = defender.isTraitor() ? this.traitorDefenseDebuff() : 1;
const currentAttackerLoss =
within(defender.troops() / attackTroops, 0.6, 2) *
mag *
0.8 *
largeDefenderAttackDebuff *
largeAttackBonus *
traitorMod;
const altAttackerLoss =
1.3 * defenderTroopLoss * (mag / 100) * traitorMod;
const attackerTroopLoss =
0.6 * currentAttackerLoss + 0.4 * altAttackerLoss;
return {
attackerTroopLoss,
defenderTroopLoss,
tilesPerTickUsed:
within(defender.troops() / (5 * attackTroops), 0.2, 1.5) *
speed *
largeDefenderSpeedDebuff *
largeAttackerSpeedBonus *
(defender.isTraitor() ? this.traitorSpeedDebuff() : 1),
};
} else {
return {
attackerTroopLoss:
attacker.type() === PlayerType.Bot ? mag / 10 : mag / 5,
defenderTroopLoss: 0,
tilesPerTickUsed: within(
(2000 * Math.max(10, speed)) / attackTroops,
5,
100,
),
};
}
}
attackTilesPerTick(
attackTroops: number,
attacker: Player,
defender: Player | TerraNullius,
numAdjacentTilesWithEnemy: number,
): number {
if (defender.isPlayer()) {
return (
within(((5 * attackTroops) / defender.troops()) * 2, 0.01, 0.5) *
numAdjacentTilesWithEnemy *
3
);
} else {
return numAdjacentTilesWithEnemy * 2;
}
}
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;
}
tradeShipShortRangeDebuff(): number {
return 300;
}
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 10_000;
}
if (playerInfo.playerType === PlayerType.Nation) {
switch (this._gameConfig.difficulty) {
case Difficulty.Easy:
return 12_500;
case Difficulty.Medium:
return 18_750;
case Difficulty.Hard:
return 25_000; // Like humans
case Difficulty.Impossible:
return 31_250;
default:
assertNever(this._gameConfig.difficulty);
}
}
return this.hasInfiniteTroopsForInfo(playerInfo) ? 1_000_000 : 25_000;
}
maxTroops(player: Player | PlayerView): number {
const maxTroops =
player.type() === PlayerType.Human && this.hasInfiniteTroopsFor(player)
? 1_000_000_000
: 2 * (Math.pow(player.numTilesOwned(), 0.6) * 1000 + 50000) +
player
.units(UnitType.City)
.filter((u) => !u.isUnderConstruction())
.map((city) => city.level())
.reduce((a, b) => a + b, 0) *
this.cityTroopIncrease();
if (player.type() === PlayerType.Bot) {
return maxTroops / 3;
}
if (player.type() === PlayerType.Human) {
return maxTroops;
}
switch (this._gameConfig.difficulty) {
case Difficulty.Easy:
return maxTroops * 0.5;
case Difficulty.Medium:
return maxTroops * 0.75;
case Difficulty.Hard:
return maxTroops * 1; // Like humans
case Difficulty.Impossible:
return maxTroops * 1.25;
default:
assertNever(this._gameConfig.difficulty);
}
}
troopIncreaseRate(player: Player | PlayerView): number {
const max = this.maxTroops(player);
let toAdd = 10 + Math.pow(player.troops(), 0.73) / 4;
const ratio = 1 - player.troops() / max;
toAdd *= ratio;
if (player.type() === PlayerType.Bot) {
toAdd *= 0.5;
}
if (player.type() === PlayerType.Nation) {
switch (this._gameConfig.difficulty) {
case Difficulty.Easy:
toAdd *= 0.9;
break;
case Difficulty.Medium:
toAdd *= 0.95;
break;
case Difficulty.Hard:
toAdd *= 1; // Like humans
break;
case Difficulty.Impossible:
toAdd *= 1.05;
break;
default:
assertNever(this._gameConfig.difficulty);
}
}
return Math.min(player.troops() + toAdd, max) - player.troops();
}
goldAdditionRate(player: Player | PlayerView): Gold {
const multiplier = this.goldMultiplierFor(player);
let baseRate: bigint;
if (player.type() === PlayerType.Bot) {
baseRate = 50n;
} else {
baseRate = 100n;
}
return BigInt(Math.floor(Number(baseRate) * multiplier));
}
nukeMagnitudes(unitType: UnitType): NukeMagnitude {
switch (unitType) {
case UnitType.MIRVWarhead:
return { inner: 12, outer: 18 };
case UnitType.AtomBomb:
return { inner: 12, outer: 30 };
case UnitType.HydrogenBomb:
return { inner: 80, outer: 100 };
}
throw new Error(`Unknown nuke type: ${unitType}`);
}
nukeAllianceBreakThreshold(): number {
return 100;
}
defaultNukeSpeed(): number {
return 8;
}
defaultNukeTargetableRange(): number {
return 150;
}
defaultSamRange(): number {
return 70;
}
samRange(level: number): number {
// rational growth function (level 1 = 70, level 5 just above hydro range, asymptotically approaches 150)
return this.maxSamRange() - 480 / (level + 5);
}
maxSamRange(): number {
return 150;
}
defaultSamMissileSpeed(): number {
return 12;
}
// Humans can be soldiers, soldiers attacking, soldiers in boat etc.
nukeDeathFactor(
nukeType: NukeType,
humans: number,
tilesOwned: number,
maxTroops: number,
): number {
if (nukeType !== UnitType.MIRVWarhead) {
return (5 * humans) / Math.max(1, tilesOwned);
}
const targetTroops = 0.03 * maxTroops;
const excessTroops = Math.max(0, humans - targetTroops);
const scalingFactor = 500;
const steepness = 2;
const normalizedExcess = excessTroops / maxTroops;
return scalingFactor * (1 - Math.exp(-steepness * normalizedExcess));
}
structureMinDist(): number {
return 15;
}
shellLifetime(): number {
return 50;
}
warshipPatrolRange(): number {
return 100;
}
warshipTargettingRange(): number {
return 130;
}
warshipShellAttackRate(): number {
return 20;
}
warshipDockingRange(): number {
return 5;
}
warshipPortHealingBonusPerLevel(): number {
return 5;
}
warshipRetreatHealthThreshold(): number {
return 750;
}
warshipPassiveHealing(): number {
return 1;
}
warshipPassiveHealingRange(): number {
return 150;
}
warshipPortSwitchThreshold(): number {
return 0.75;
}
defensePostShellAttackRate(): number {
return 100;
}
safeFromPiratesCooldownMax(): number {
return 20;
}
defensePostTargettingRange(): number {
return 75;
}
allianceExtensionPromptOffset(): number {
return 300; // 30 seconds before expiration
}
}