Files
OpenFrontIO/src/core/configuration/Config.ts
T
Zixer1 78ef7b56fd feat(doomsday-clock): battle-royale style zone gamemode (#4469)
Resolves Issue #4463

## Description:

An optional game mode that (almost) guarantees a finish instead of
letting late-game
stalemates drag on.
Originally called sudden death, renamed to Doomsday clock

Once enabled, every side (each player in FFA, each whole team in team
modes)
must hold a rising share of the map. A side below the bar is skulled;
after a
short warn its troops bleed to zero, forcing consolidation to a winner.

### How it works
- **Rising zone:** a grace period, then the required share ramps up
linearly to
each level with 30s pauses between (a battle-royale "zone"). Levels
track the
  ofstats FFA territory median (3/5/10/20/30%).
- **Four speed presets** (slow / normal / fast / very fast) change only
the pace:
  normal ends ~30 min, very fast ~15.
- **Troop decay:** a linear ramp as a % of max capacity, ~50s from
caught to zero
  (10s warn + ~50s ≈ 1 min total).
- **UI:** a HUD panel (live share vs target, wave/decay countdowns,
red/orange
cues) and an on-map skull above flagged players (blinks in danger,
steady while
  draining).

### Notes for review
- Off by default; no effect on existing games. However, as discussed we
can add it to the modifier pool for public games to see how popular the
gamemode is vs normal play.
- Sim is deterministic (integer-only, in `src/core`), covered by unit +
  integration tests.
- One-line addition to `GameServer.updateGameConfig` so the setting
survives the
  host → server → client round-trip.
- Status is packed into the existing name-pass data slot (`pd4.w`: 0/1/2
=
none/danger/draining); the skull is composited into the icon atlas at
load.

### Testing
`npm test`, `npm run lint`, `npx prettier --check .`, `npm run
build-prod` all pass.

### UI:
<img width="243" height="100" alt="Image"
src="https://github.com/user-attachments/assets/c4c9eeb0-4feb-437d-9aac-b2786a841b74"
/>

Dropdown between slow, normal, fast, very fast

Before zone:
<img width="302" height="175" alt="Image"
src="https://github.com/user-attachments/assets/7359a1ea-4951-446d-a23c-0711fe06cc5d"
/>

Zone started, player not affected the pannel also blinks orange for 10s:
<img width="297" height="175" alt="Image"
src="https://github.com/user-attachments/assets/fcc565a5-d5d0-47a7-97ea-d0ba9d9ad899"
/>

Player affected, grace period (Danger):
<img width="314" height="170" alt="Image"
src="https://github.com/user-attachments/assets/ff96d21e-96f3-4ef9-8190-48eecc7aac0f"
/>

Skull icon blinking over player (everyone sees it) - older screenshot,
the clipping has been fixed
<img width="462" height="145" alt="Image"
src="https://github.com/user-attachments/assets/53899211-33b1-40e1-83f2-77f2096f0cad"
/>

Player affected, grace period ended (Draining):
<img width="360" height="159" alt="Image"
src="https://github.com/user-attachments/assets/4b226d57-da4d-4866-ab5f-db48e4ed1ea2"
/>

Skull icon no longer blinking, everyone can see you are in a state of
decay, and troops are draining:
<img width="732" height="146" alt="image"
src="https://github.com/user-attachments/assets/cd10fedb-6e87-4dfc-9fbf-55d3945a7901"
/>


Skull is visible like alliances icon also on player tab
<img width="558" height="81" alt="Image"
src="https://github.com/user-attachments/assets/6acdbe91-bdd0-40c7-942b-3990d4dae87f"
/>

(just UI example, best way to see it is to hop on a solo game and play
against AI)

## 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:

zixer._
2026-07-02 18:42:03 -07:00

1016 lines
26 KiB
TypeScript

import { z } from "zod";
import { PlayerView } from "../../client/view";
import { AssetManifest } from "../AssetUrls";
import { DoomsdayClockSpeed } from "../game/DoomsdayClock";
import {
Difficulty,
Game,
GameMode,
GameType,
Gold,
Player,
PlayerInfo,
PlayerType,
TerrainType,
TerraNullius,
Tick,
UnitInfo,
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
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;
// Doomsday Clock tunables (anti-stall). Off unless enabled in GameConfig.
// Times in seconds. The required map share rises in waves (levels + times in
// DoomsdayClock.ts, chosen by `speed`). A side caught below the bar gets a
// warnSeconds cooldown ("Danger, decay in Xs"), then troops bleed to zero: the
// warn (10s) + the linear drain (~55s from full troops, sooner with fewer troops
// or a shrinking territory) make ~1 minute from caught to wiped out.
const DOOMSDAY_CLOCK_DEFAULTS = {
enabled: false,
speed: "normal" as DoomsdayClockSpeed,
warnSeconds: 10, // cooldown before decay after the bar catches you
drainStartPercent: 2, // starts bleeding at once (already beats troop income)
drainMaxPercent: 6,
drainRampSeconds: 50, // ramps LINEARLY to the max over this long
};
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
}
// Doomsday Clock config, resolved against defaults. One read per tick.
doomsdayClockConfig(): typeof DOOMSDAY_CLOCK_DEFAULTS {
const c = this._gameConfig.doomsdayClock;
const d = DOOMSDAY_CLOCK_DEFAULTS;
return {
enabled: c?.enabled ?? d.enabled,
speed: c?.speed ?? d.speed,
// Drain/warn tuning is internal (not wire-configurable): always defaults.
warnSeconds: d.warnSeconds,
drainStartPercent: d.drainStartPercent,
drainMaxPercent: d.drainMaxPercent,
drainRampSeconds: d.drainRampSeconds,
};
}
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() * 1.4142;
}
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;
case TerrainType.Impassable:
throw new Error(`impassable terrain cannot be attacked`);
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 10;
}
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;
}
/** Health at or below which a warship retreats to repair, as a percent of its
* (veterancy-adjusted) max health, so the threshold scales with max health. */
warshipRetreatHealthPercent(): number {
return 75;
}
warshipPassiveHealing(): number {
return 1;
}
warshipPassiveHealingRange(): number {
return 150;
}
warshipPortSwitchThreshold(): number {
return 0.75;
}
// --- Warship veterancy ---
/** Maximum veterancy level a warship can reach. */
warshipMaxVeterancy(): number {
return 3;
}
/** Max-health boost per veterancy level, as an integer percent of base max
* health. Integer-only to keep src/core deterministic (no float constants). */
warshipVeterancyHealthBonus(): number {
return 20;
}
/** Shell-damage boost per veterancy level, as an integer percent of the
* rolled damage. Integer-only to keep src/core deterministic. */
warshipVeterancyShellDamageBonus(): number {
return 20;
}
/** Transport ships a warship must destroy to gain one veterancy level. */
warshipVeterancyTransportKills(): number {
return 10;
}
/** Trade ships a warship must capture to gain one veterancy level. */
warshipVeterancyTradeCaptures(): number {
return 25;
}
defensePostShellAttackRate(): number {
return 100;
}
safeFromPiratesCooldownMax(): number {
return 20;
}
defensePostTargettingRange(): number {
return 75;
}
allianceExtensionPromptOffset(): number {
return 300; // 30 seconds before expiration
}
}