From 3e880a34ef550f5afaf306bfe4c44c4ab3caf577 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 19 Aug 2025 18:53:42 -0700 Subject: [PATCH] v25 meta update (#1872) ## Description: v25 meta update: - Trade ship spawn rate is determined by number of ports players has and total number of tradeships on the map - Train spawn rate scales hyperbolically with number of factories owned by player - Factory & Port share the same early unit discount (eg building a port makes the factory more expensive), this is to encourage more specialization: become a naval economy or land based economy. - Trains spawn from factories and arrive on cities - Trains only give gold on cities - Trains give 50k gold for allies, 25k for nonallies, and 10k for self - Large players are given a 30% speed/attack debuff in sigmoid curve - Reduced attack bonus for large players - Nerf bot gold production from 1k/s => 500/s - Nerf bot max troops ## 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 - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/core/Util.ts | 8 + src/core/configuration/Config.ts | 6 +- src/core/configuration/DefaultConfig.ts | 166 +++++++++++++------- src/core/execution/PortExecution.ts | 5 +- src/core/execution/TrainStationExecution.ts | 10 +- src/core/game/TrainStation.ts | 43 ++--- tests/core/game/TrainStation.test.ts | 4 +- 7 files changed, 153 insertions(+), 89 deletions(-) diff --git a/src/core/Util.ts b/src/core/Util.ts index 6133b9a18..0ded7f5de 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -287,3 +287,11 @@ export const flattenedEmojiTable: string[] = emojiTable.flat(); export function replacer(_key: string, value: any): any { return typeof value === "bigint" ? value.toString() : value; } + +export function sigmoid( + value: number, + decayRate: number, + midpoint: number, +): number { + return 1 / (1 + Math.exp(-decayRate * (value - midpoint))); +} diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index f06724999..7d83eb86f 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -130,9 +130,9 @@ export interface Config { defaultDonationAmount(sender: Player): number; unitInfo(type: UnitType): UnitInfo; tradeShipGold(dist: number, numPorts: number): Gold; - tradeShipSpawnRate(numberOfPorts: number): number; - trainGold(isFriendly: boolean): Gold; - trainSpawnRate(numberOfStations: number): number; + tradeShipSpawnRate(numTradeShips: number, numPlayerPorts: number): number; + trainGold(rel: "self" | "friendly" | "other"): Gold; + trainSpawnRate(numPlayerFactories: number): number; trainStationMinRange(): number; trainStationMaxRange(): number; railroadMaxSize(): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 7a7fee7f1..13c361cf0 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -24,11 +24,14 @@ import { PlayerView } from "../game/GameView"; import { UserSettings } from "../game/UserSettings"; import { GameConfig, GameID, TeamCountConfig } from "../Schemas"; import { NukeType } from "../StatsSchemas"; -import { assertNever, simpleHash, within } from "../Util"; +import { assertNever, sigmoid, simpleHash, within } from "../Util"; import { Config, GameEnv, NukeMagnitude, ServerConfig, Theme } from "./Config"; import { PastelTheme } from "./PastelTheme"; import { PastelThemeDark } from "./PastelThemeDark"; +const DEFENSE_DEBUFF_MIDPOINT = 150_000; +const DEFENSE_DEBUFF_DECAY_RATE = Math.LN2 / 50000; + const JwksSchema = z.object({ keys: z .object({ @@ -325,46 +328,62 @@ export class DefaultConfig implements Config { infiniteTroops(): boolean { return this._gameConfig.infiniteTroops; } - trainSpawnRate(numberOfStations: number): number { - return Math.min(1400, Math.round(40 * Math.pow(numberOfStations, 0.5))); + trainSpawnRate(numPlayerFactories: number): number { + // hyperbolic decay, midpoint at 10 factories + // expected number of trains = numPlayerFactories / trainSpawnRate(numPlayerFactories) + return (numPlayerFactories + 10) * 20; } - trainGold(isFriendly: boolean): Gold { - return isFriendly ? 100_000n : 25_000n; + trainGold(rel: "self" | "friendly" | "other"): Gold { + switch (rel) { + case "friendly": + return 50_000n; + case "other": + return 25_000n; + case "self": + return 10_000n; + } } trainStationMinRange(): number { return 15; } trainStationMaxRange(): number { - return 80; + return 100; } railroadMaxSize(): number { - return 100; + return 120; } tradeShipGold(dist: number, numPorts: number): Gold { - const baseGold = Math.floor(50000 + 100 * dist); - const basePortBonus = 0.25; - const diminishingFactor = 0.9; - - let totalMultiplier = 1; - for (let i = 0; i < numPorts; i++) { - totalMultiplier += basePortBonus * Math.pow(diminishingFactor, i); - } - - return BigInt(Math.floor(baseGold * totalMultiplier)); + const baseGold = Math.floor(50_000 + 50 * dist); + const numPortBonus = numPorts - 1; + // Hyperbolic decay, midpoint at 5 ports, 3x bonus max. + const bonus = 1 + 2 * (numPortBonus / (numPortBonus + 5)); + return BigInt(Math.floor(baseGold * bonus)); } - // Chance to spawn a trade ship in one second, - tradeShipSpawnRate(numTradeShips: number): number { - if (numTradeShips < 20) { - return 5; - } - if (numTradeShips <= 150) { - const additional = numTradeShips - 20; - return Math.floor(Math.pow(additional, 0.85) + 5); - } - return 1_000_000; + // Probability of trade ship spawn = 1 / tradeShipSpawnRate + tradeShipSpawnRate(numTradeShips: number, numPlayerPorts: number): number { + // Geometric mean of base spawn rate and port multiplier + const combined = Math.sqrt( + this.tradeShipBaseSpawn(numTradeShips) * + this.tradeShipPortMultiplier(numPlayerPorts), + ); + + return Math.floor(12 / combined); + } + + private tradeShipBaseSpawn(numTradeShips: number): number { + const decayRate = Math.LN2 / 30; + return 1 - sigmoid(numTradeShips, decayRate, 100); + } + + private tradeShipPortMultiplier(numPlayerPorts: number): number { + // Hyperbolic decay function with midpoint at 10 ports + // Expected trade ship spawn rate is proportional to numPlayerPorts * multiplier + // Gradual decay prevents scenario where more ports => fewer ships + const decayRate = 1 / 10; + return 1 / (1 + decayRate * numPlayerPorts); } unitInfo(type: UnitType): UnitInfo { @@ -376,8 +395,9 @@ export class DefaultConfig implements Config { }; case UnitType.Warship: return { - cost: this.costWrapper(UnitType.Warship, (numUnits: number) => - Math.min(1_000_000, (numUnits + 1) * 250_000), + cost: this.costWrapper( + (numUnits: number) => Math.min(1_000_000, (numUnits + 1) * 250_000), + UnitType.Warship, ), territoryBound: false, maxHealth: 1000, @@ -395,8 +415,11 @@ export class DefaultConfig implements Config { }; case UnitType.Port: return { - cost: this.costWrapper(UnitType.Port, (numUnits: number) => - Math.min(1_000_000, Math.pow(2, numUnits) * 125_000), + cost: this.costWrapper( + (numUnits: number) => + Math.min(1_000_000, Math.pow(2, numUnits) * 125_000), + UnitType.Port, + UnitType.Factory, ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 2 * 10, @@ -405,17 +428,17 @@ export class DefaultConfig implements Config { }; case UnitType.AtomBomb: return { - cost: this.costWrapper(UnitType.AtomBomb, () => 750_000), + cost: this.costWrapper(() => 750_000, UnitType.AtomBomb), territoryBound: false, }; case UnitType.HydrogenBomb: return { - cost: this.costWrapper(UnitType.HydrogenBomb, () => 5_000_000), + cost: this.costWrapper(() => 5_000_000, UnitType.HydrogenBomb), territoryBound: false, }; case UnitType.MIRV: return { - cost: this.costWrapper(UnitType.MIRV, () => 35_000_000), + cost: this.costWrapper(() => 35_000_000, UnitType.MIRV), territoryBound: false, }; case UnitType.MIRVWarhead: @@ -430,23 +453,26 @@ export class DefaultConfig implements Config { }; case UnitType.MissileSilo: return { - cost: this.costWrapper(UnitType.MissileSilo, () => 1_000_000), + cost: this.costWrapper(() => 1_000_000, UnitType.MissileSilo), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 10 * 10, upgradable: true, }; case UnitType.DefensePost: return { - cost: this.costWrapper(UnitType.DefensePost, (numUnits: number) => - Math.min(250_000, (numUnits + 1) * 50_000), + cost: this.costWrapper( + (numUnits: number) => Math.min(250_000, (numUnits + 1) * 50_000), + UnitType.DefensePost, ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 5 * 10, }; case UnitType.SAMLauncher: return { - cost: this.costWrapper(UnitType.SAMLauncher, (numUnits: number) => - Math.min(3_000_000, (numUnits + 1) * 1_500_000), + cost: this.costWrapper( + (numUnits: number) => + Math.min(3_000_000, (numUnits + 1) * 1_500_000), + UnitType.SAMLauncher, ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 30 * 10, @@ -454,8 +480,10 @@ export class DefaultConfig implements Config { }; case UnitType.City: return { - cost: this.costWrapper(UnitType.City, (numUnits: number) => - Math.min(1_000_000, Math.pow(2, numUnits) * 125_000), + cost: this.costWrapper( + (numUnits: number) => + Math.min(1_000_000, Math.pow(2, numUnits) * 125_000), + UnitType.City, ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 2 * 10, @@ -464,8 +492,11 @@ export class DefaultConfig implements Config { }; case UnitType.Factory: return { - cost: this.costWrapper(UnitType.Factory, (numUnits: number) => - Math.min(1_000_000, Math.pow(2, numUnits) * 125_000), + cost: this.costWrapper( + (numUnits: number) => + Math.min(1_000_000, Math.pow(2, numUnits) * 125_000), + UnitType.Factory, + UnitType.Port, ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 2 * 10, @@ -490,14 +521,18 @@ export class DefaultConfig implements Config { } private costWrapper( - type: UnitType, costFn: (units: number) => number, + ...types: UnitType[] ): (p: Player) => bigint { return (p: Player) => { if (p.type() === PlayerType.Human && this.infiniteGold()) { return 0n; } - const numUnits = Math.min(p.unitsOwned(type), p.unitsConstructed(type)); + const numUnits = types.reduce( + (acc, type) => + acc + Math.min(p.unitsOwned(type), p.unitsConstructed(type)), + 0, + ); return BigInt(costFn(numUnits)); }; } @@ -619,29 +654,41 @@ export class DefaultConfig implements Config { } } - let largeLossModifier = 1; - if (attacker.numTilesOwned() > 100_000) { - largeLossModifier = Math.sqrt(100_000 / attacker.numTilesOwned()); - } - let largeSpeedMalus = 1; - if (attacker.numTilesOwned() > 75_000) { - // sqrt is only exponent 1/2 which doesn't slow enough huge players - largeSpeedMalus = (75_000 / attacker.numTilesOwned()) ** 0.6; - } - 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; + } + return { attackerTroopLoss: within(defender.troops() / attackTroops, 0.6, 2) * mag * 0.8 * - largeLossModifier * + largeDefenderAttackDebuff * + largeAttackBonus * (defender.isTraitor() ? this.traitorDefenseDebuff() : 1), defenderTroopLoss: defender.troops() / defender.numTilesOwned(), tilesPerTickUsed: within(defender.troops() / (5 * attackTroops), 0.2, 1.5) * speed * - largeSpeedMalus * + largeDefenderSpeedDebuff * + largeAttackerSpeedBonus * (defender.isTraitor() ? this.traitorSpeedDebuff() : 1), }; } else { @@ -730,7 +777,7 @@ export class DefaultConfig implements Config { this.cityTroopIncrease(); if (player.type() === PlayerType.Bot) { - return maxTroops / 2; + return maxTroops / 3; } if (player.type() === PlayerType.Human) { @@ -782,6 +829,9 @@ export class DefaultConfig implements Config { } goldAdditionRate(player: Player): Gold { + if (player.type() === PlayerType.Bot) { + return 50n; + } return 100n; } diff --git a/src/core/execution/PortExecution.ts b/src/core/execution/PortExecution.ts index 6d3f45351..4e6bb6e7e 100644 --- a/src/core/execution/PortExecution.ts +++ b/src/core/execution/PortExecution.ts @@ -78,7 +78,10 @@ export class PortExecution implements Execution { shouldSpawnTradeShip(): boolean { const numTradeShips = this.mg.unitCount(UnitType.TradeShip); - const spawnRate = this.mg.config().tradeShipSpawnRate(numTradeShips); + const numPlayerPorts = this.player.unitCount(UnitType.Port); + const spawnRate = this.mg + .config() + .tradeShipSpawnRate(numTradeShips, numPlayerPorts); for (let i = 0; i < this.port!.level(); i++) { if (this.random.chance(spawnRate)) { return true; diff --git a/src/core/execution/TrainStationExecution.ts b/src/core/execution/TrainStationExecution.ts index ee0174bcc..a257a909b 100644 --- a/src/core/execution/TrainStationExecution.ts +++ b/src/core/execution/TrainStationExecution.ts @@ -1,4 +1,4 @@ -import { Execution, Game, Unit } from "../game/Game"; +import { Execution, Game, Unit, UnitType } from "../game/Game"; import { TrainStation } from "../game/TrainStation"; import { PseudoRandom } from "../PseudoRandom"; import { TrainExecution } from "./TrainExecution"; @@ -48,8 +48,10 @@ export class TrainStationExecution implements Execution { this.spawnTrain(this.station, ticks); } - private shouldSpawnTrain(clusterSize: number): boolean { - const spawnRate = this.mg.config().trainSpawnRate(clusterSize); + private shouldSpawnTrain(): boolean { + const spawnRate = this.mg + .config() + .trainSpawnRate(this.unit.owner().unitCount(UnitType.Factory)); for (let i = 0; i < this.unit!.level(); i++) { if (this.random.chance(spawnRate)) { return true; @@ -73,7 +75,7 @@ export class TrainStationExecution implements Execution { if (availableForTrade.size === 0) { return; } - if (!this.shouldSpawnTrain(availableForTrade.size)) { + if (!this.shouldSpawnTrain()) { return; } diff --git a/src/core/game/TrainStation.ts b/src/core/game/TrainStation.ts index f12125abe..b804755c2 100644 --- a/src/core/game/TrainStation.ts +++ b/src/core/game/TrainStation.ts @@ -23,13 +23,11 @@ class CityStopHandler implements TrainStopHandler { station: TrainStation, trainExecution: TrainExecution, ): void { - const level = BigInt(station.unit.level() + 1); const stationOwner = station.unit.owner(); const trainOwner = trainExecution.owner(); - const isFriendly = stationOwner.isFriendly(trainOwner); - const goldBonus = mg.config().trainGold(isFriendly) * level; + const goldBonus = mg.config().trainGold(rel(trainOwner, stationOwner)); // Share revenue with the station owner if it's not the current player - if (isFriendly) { + if (trainOwner !== stationOwner) { stationOwner.addGold(goldBonus, station.tile()); } trainOwner.addGold(goldBonus, station.tile()); @@ -43,16 +41,15 @@ class PortStopHandler implements TrainStopHandler { station: TrainStation, trainExecution: TrainExecution, ): void { - const level = BigInt(station.unit.level() + 1); const stationOwner = station.unit.owner(); const trainOwner = trainExecution.owner(); - const isFriendly = stationOwner.isFriendly(trainOwner); - const goldBonus = mg.config().trainGold(isFriendly) * level; + const goldBonus = mg.config().trainGold(rel(trainOwner, stationOwner)); - if (isFriendly) { + trainOwner.addGold(goldBonus, station.tile()); + // Share revenue with the station owner if it's not the current player + if (trainOwner !== stationOwner) { stationOwner.addGold(goldBonus, station.tile()); } - trainOwner.addGold(goldBonus, station.tile()); } } @@ -61,17 +58,7 @@ class FactoryStopHandler implements TrainStopHandler { mg: Game, station: TrainStation, trainExecution: TrainExecution, - ): void { - const stationOwner = station.unit.owner(); - const trainOwner = trainExecution.owner(); - const isFriendly = stationOwner.isFriendly(trainOwner); - const goldBonus = mg.config().trainGold(isFriendly); - // Share revenue with the station owner if it's not the current player - if (isFriendly) { - stationOwner.addGold(goldBonus, station.tile()); - } - trainOwner.addGold(goldBonus, station.tile()); - } + ): void {} } export function createTrainStopHandlers( @@ -226,7 +213,11 @@ export class Cluster { availableForTrade(player: Player): Set { const tradingStations = new Set(); for (const station of this.stations) { - if (station.tradeAvailable(player)) { + if ( + (station.unit.type() === UnitType.City || + station.unit.type() === UnitType.Port) && + station.tradeAvailable(player) + ) { tradingStations.add(station); } } @@ -241,3 +232,13 @@ export class Cluster { this.stations.clear(); } } + +function rel(player: Player, other: Player): "self" | "friendly" | "other" { + if (player === other) { + return "self"; + } + if (player.isFriendly(other)) { + return "friendly"; + } + return "other"; +} diff --git a/tests/core/game/TrainStation.test.ts b/tests/core/game/TrainStation.test.ts index b0ed06165..f99847ee2 100644 --- a/tests/core/game/TrainStation.test.ts +++ b/tests/core/game/TrainStation.test.ts @@ -61,9 +61,9 @@ describe("TrainStation", () => { station.onTrainStop(trainExecution); - expect(unit.owner().addGold).toHaveBeenCalledWith(2000n, unit.tile()); + expect(unit.owner().addGold).toHaveBeenCalledWith(1000n, unit.tile()); expect(trainExecution.owner().addGold).toHaveBeenCalledWith( - 2000n, + 1000n, unit.tile(), ); });