mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-26 22:54:36 +00:00
b0e7d04f6e
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
952 lines
23 KiB
TypeScript
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
|
|
}
|
|
}
|