mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
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:
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user