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
This commit is contained in:
evanpelle
2025-08-19 18:53:42 -07:00
committed by GitHub
parent 53f4a102c7
commit 3e880a34ef
7 changed files with 153 additions and 89 deletions
+8
View File
@@ -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)));
}
+3 -3
View File
@@ -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;
+108 -58
View File
@@ -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;
}
+4 -1
View File
@@ -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;
+6 -4
View File
@@ -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;
}
+22 -21
View File
@@ -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<TrainStation> {
const tradingStations = new Set<TrainStation>();
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";
}
+2 -2
View File
@@ -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(),
);
});