From 8925f48ba73e08d6a11771e8cb51da71b92b164b Mon Sep 17 00:00:00 2001 From: Ilan Schemoul Date: Fri, 18 Apr 2025 04:02:50 +0200 Subject: [PATCH 01/12] sam protects againt mirv warhead (#376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #483 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ![Capture d'Γ©cran 2025-03-30 174759](https://github.com/user-attachments/assets/8245723d-68de-4b96-ab19-5d85be18e4d9) ## Please put your Discord username so you can be contacted if a bug or regression is found: respectful pinguin --- src/core/configuration/Config.ts | 1 + src/core/configuration/DefaultConfig.ts | 4 + src/core/configuration/DevConfig.ts | 8 ++ src/core/execution/MIRVExecution.ts | 1 - src/core/execution/SAMLauncherExecution.ts | 140 +++++++++++++++------ src/core/game/UnitImpl.ts | 2 +- 6 files changed, 113 insertions(+), 43 deletions(-) diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index f53d90aa2..35fa3368c 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -52,6 +52,7 @@ export interface NukeMagnitude { export interface Config { samHittingChance(): number; + samWarheadHittingChance(): number; spawnImmunityDuration(): Tick; serverConfig(): ServerConfig; gameConfig(): GameConfig; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 254bfc99e..6c20883b9 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -136,6 +136,10 @@ export class DefaultConfig implements Config { return 0.8; } + samWarheadHittingChance(): number { + return 0.5; + } + traitorDefenseDebuff(): number { return 0.8; } diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts index 853467814..99e616cc1 100644 --- a/src/core/configuration/DevConfig.ts +++ b/src/core/configuration/DevConfig.ts @@ -24,6 +24,14 @@ export class DevServerConfig extends DefaultServerConfig { return Math.random() < 0.5 ? 2 : 3; } + samWarheadHittingChance(): number { + return 1; + } + + samHittingChance(): number { + return 1; + } + discordRedirectURI(): string { return "http://localhost:3000/auth/callback"; } diff --git a/src/core/execution/MIRVExecution.ts b/src/core/execution/MIRVExecution.ts index bd0120c22..d2b118c0a 100644 --- a/src/core/execution/MIRVExecution.ts +++ b/src/core/execution/MIRVExecution.ts @@ -170,7 +170,6 @@ export class MirvExecution implements Execution { if (!this.mg.isValidCoord(x, y)) { continue; } - console.log(`got coord ${x}, ${y}`); const tile = this.mg.ref(x, y); if (!this.mg.isLand(tile)) { continue; diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index e3337f8fd..07f55b3c1 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -19,8 +19,13 @@ export class SAMLauncherExecution implements Execution { private active: boolean = true; private target: Unit = null; + private warheadTargets: Unit[] = []; private searchRangeRadius = 75; + // As MIRV go very fast we have to detect them very early but we only + // shoot the one targeting very close (MIRVWarheadProtectionRadius) + private MIRVWarheadSearchRadius = 400; + private MIRVWarheadProtectionRadius = 50; private pseudoRandom: PseudoRandom; @@ -39,6 +44,52 @@ export class SAMLauncherExecution implements Execution { this.player = mg.player(this.ownerId); } + private getSingleTarget(): Unit | null { + const nukes = this.mg + .nearbyUnits(this.sam.tile(), this.searchRangeRadius, [ + UnitType.AtomBomb, + UnitType.HydrogenBomb, + ]) + .filter( + ({ unit }) => + unit.owner() !== this.player && !this.player.isFriendly(unit.owner()), + ); + + return ( + nukes.sort((a, b) => { + const { unit: unitA, distSquared: distA } = a; + const { unit: unitB, distSquared: distB } = b; + + // Prioritize Hydrogen Bombs + if ( + unitA.type() === UnitType.HydrogenBomb && + unitB.type() !== UnitType.HydrogenBomb + ) + return -1; + if ( + unitA.type() !== UnitType.HydrogenBomb && + unitB.type() === UnitType.HydrogenBomb + ) + return 1; + + // If both are the same type, sort by distance (lower `distSquared` means closer) + return distA - distB; + })[0]?.unit ?? null + ); + } + + private isHit(type: UnitType, random: number): boolean { + if (type == UnitType.AtomBomb) { + return true; + } + + if (type == UnitType.MIRVWarhead) { + return random < this.mg.config().samWarheadHittingChance(); + } + + return random < this.mg.config().samHittingChance(); + } + tick(ticks: number): void { if (this.sam == null) { const spawnTile = this.player.canBuild(UnitType.SAMLauncher, this.tile); @@ -64,36 +115,26 @@ export class SAMLauncherExecution implements Execution { this.pseudoRandom = new PseudoRandom(this.sam.id()); } - const nukes = this.mg - .nearbyUnits(this.sam.tile(), this.searchRangeRadius, [ - UnitType.AtomBomb, - UnitType.HydrogenBomb, - ]) + this.warheadTargets = this.mg + .nearbyUnits( + this.sam.tile(), + this.MIRVWarheadSearchRadius, + UnitType.MIRVWarhead, + ) + .map(({ unit }) => unit) .filter( - ({ unit }) => + (unit) => unit.owner() !== this.player && !this.player.isFriendly(unit.owner()), + ) + .filter( + (unit) => + this.mg.manhattanDist(unit.detonationDst(), this.sam.tile()) < + this.MIRVWarheadProtectionRadius, ); - this.target = - nukes.sort((a, b) => { - const { unit: unitA, distSquared: distA } = a; - const { unit: unitB, distSquared: distB } = b; - - // Prioritize Hydrogen Bombs - if ( - unitA.type() === UnitType.HydrogenBomb && - unitB.type() !== UnitType.HydrogenBomb - ) - return -1; - if ( - unitA.type() !== UnitType.HydrogenBomb && - unitB.type() === UnitType.HydrogenBomb - ) - return 1; - - // If both are the same type, sort by distance (lower `distSquared` means closer) - return distA - distB; - })[0]?.unit ?? null; + if (this.warheadTargets.length == 0) { + this.target = this.getSingleTarget(); + } if ( this.sam.isCooldown() && @@ -102,29 +143,46 @@ export class SAMLauncherExecution implements Execution { this.sam.setCooldown(false); } - if (this.target && !this.sam.isCooldown() && !this.target.targetedBySAM()) { + const isSingleTarget = this.target && !this.target.targetedBySAM(); + if ( + (isSingleTarget || this.warheadTargets.length > 0) && + !this.sam.isCooldown() + ) { this.sam.setCooldown(true); + const type = + this.warheadTargets.length > 0 + ? UnitType.MIRVWarhead + : this.target.type(); const random = this.pseudoRandom.next(); - let hit = true; - if (this.target.type() != UnitType.AtomBomb) { - hit = random < this.mg.config().samHittingChance(); - } + const hit = this.isHit(type, random); if (!hit) { this.mg.displayMessage( - `Missile failed to intercept ${this.target.type()}`, + `Missile failed to intercept ${type}`, MessageType.ERROR, this.sam.owner().id(), ); } else { - this.target.setTargetedBySAM(true); - this.mg.addExecution( - new SAMMissileExecution( - this.sam.tile(), - this.sam.owner(), - this.sam, - this.target, - ), - ); + if (this.warheadTargets.length > 0) { + // Message + this.mg.displayMessage( + `${this.warheadTargets.length} MIRV warheads intercepted`, + MessageType.SUCCESS, + this.sam.owner().id(), + ); + // Delete warheads + this.warheadTargets.forEach((u) => u.delete()); + } else { + this.target.setTargetedBySAM(true); + this.mg.addExecution( + new SAMMissileExecution( + this.sam.tile(), + this.sam.owner(), + this.sam, + this.target, + ), + ); + this.warheadTargets = []; + } } } } diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index ac5b80fc6..f0dd2796d 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -141,7 +141,7 @@ export class UnitImpl implements Unit { this._active = false; this.mg.addUpdate(this.toUpdate()); this.mg.removeUnit(this); - if (displayMessage) { + if (displayMessage && this.type() != UnitType.MIRVWarhead) { this.mg.displayMessage( `Your ${this.type()} was destroyed`, MessageType.ERROR, From ccadd7f6a8c5c1b7338286871c76ab99feea2550 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Thu, 17 Apr 2025 22:22:35 -0400 Subject: [PATCH 02/12] Nations target structures with nukes (#393) ## Description: Nations will prefer to target their nukes at structures. This updates the existing logic, which attempts ten times to randomly select a coordinate 15 tiles inside the border, to instead generate a list of ten random tiles in addition to a list of all of the tiles of relevant structures. The two lists are concatenated to create a set of tile candidates. These candidates are scored, and the tile with the highest score, if one is found, is nuked. The scoring function considers three factors: 1. Damage potential: values of structures with 25 tiles of the target tile. 2. Distance from silo to target (hang time). 3. Recently nuked locations. ![image](https://github.com/user-attachments/assets/10c7bd96-ad51-404c-84c9-c0840dc8250c) Fixes #471 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: fake.neo --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/core/execution/FakeHumanExecution.ts | 103 ++++++++++++++++++++--- 1 file changed, 91 insertions(+), 12 deletions(-) diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index d0063aa6c..8d460bc4e 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -13,9 +13,10 @@ import { TerrainType, TerraNullius, Tick, + Unit, UnitType, } from "../game/Game"; -import { manhattanDistFN, TileRef } from "../game/GameMap"; +import { euclDistFN, manhattanDistFN, TileRef } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; import { GameID } from "../Schemas"; import { calculateBoundingBox, simpleHash } from "../Util"; @@ -40,6 +41,7 @@ export class FakeHumanExecution implements Execution { private lastEnemyUpdateTick: number = 0; private lastEmojiSent = new Map(); + private lastNukeSent: [Tick, TileRef][] = []; private embargoMalusApplied = new Set(); constructor( @@ -288,32 +290,109 @@ export class FakeHumanExecution implements Execution { } private maybeSendNuke(other: Player) { + const silos = this.player.units(UnitType.MissileSilo); if ( - this.player.units(UnitType.MissileSilo).length == 0 || + silos.length == 0 || this.player.gold() < this.mg.config().unitInfo(UnitType.AtomBomb).cost(this.player) || this.player.isOnSameTeam(other) ) { return; } - outer: for (let i = 0; i < 10; i++) { - const tile = this.randTerritoryTile(other); - if (tile == null) { - return; - } + + const structures = other.units( + UnitType.City, + UnitType.DefensePost, + UnitType.MissileSilo, + UnitType.Port, + UnitType.SAMLauncher, + ); + const structureTiles = structures.map((u) => u.tile()); + const randomTiles: TileRef[] = new Array(10); + for (let i = 0; i < randomTiles.length; i++) { + randomTiles[i] = this.randTerritoryTile(other); + } + const allTiles = randomTiles.concat(structureTiles); + + let bestTile = null; + let bestValue = 0; + this.removeOldNukeEvents(); + outer: for (const tile of new Set(allTiles)) { + if (tile == null) continue; for (const t of this.mg.bfs(tile, manhattanDistFN(tile, 15))) { // Make sure we nuke at least 15 tiles in border if (this.mg.owner(t) != other) { continue outer; } } - if (this.player.canBuild(UnitType.AtomBomb, tile)) { - this.mg.addExecution( - new NukeExecution(UnitType.AtomBomb, this.player.id(), tile), - ); - return; + if (!this.player.canBuild(UnitType.AtomBomb, tile)) continue; + const value = this.nukeTileScore(tile, silos, structures); + if (value > bestTile) { + bestTile = tile; + bestValue = value; } } + if (bestTile != null) { + this.sendNuke(bestTile); + } + } + + private removeOldNukeEvents() { + const maxAge = 500; + const tick = this.mg.ticks(); + while ( + this.lastNukeSent.length > 0 && + this.lastNukeSent[0][0] + maxAge < tick + ) { + this.lastNukeSent.shift(); + } + } + + private sendNuke(tile: TileRef) { + const tick = this.mg.ticks(); + this.lastNukeSent.push([tick, tile]); + this.mg.addExecution( + new NukeExecution(UnitType.AtomBomb, this.player.id(), tile), + ); + } + + private nukeTileScore(tile: TileRef, silos: Unit[], targets: Unit[]): number { + // Potential damage in a 25-tile radius + const dist = euclDistFN(tile, 25, false); + let tileValue = targets + .filter((unit) => dist(this.mg, unit.tile())) + .map((unit) => { + switch (unit.type()) { + case UnitType.City: + return 25_000; + case UnitType.DefensePost: + return 5_000; + case UnitType.MissileSilo: + return 50_000; + case UnitType.Port: + return 10_000; + case UnitType.SAMLauncher: + return 5_000; + default: + return 0; + } + }) + .reduce((prev, cur) => prev + cur, 0); + + // Prefer tiles that are closer to a silo + const siloTiles = silos.map((u) => u.tile()); + const { x: closestSilo } = closestTwoTiles(this.mg, siloTiles, [tile]); + const distanceSquared = this.mg.euclideanDistSquared(tile, closestSilo); + const distanceToClosestSilo = Math.sqrt(distanceSquared); + tileValue -= distanceToClosestSilo * 30; + + // Don't target near recent targets + tileValue -= this.lastNukeSent + .filter(([_tick, tile]) => dist(this.mg, tile)) + .map((_) => 1_000_000) + .reduce((prev, cur) => prev + cur, 0); + + return tileValue; } private maybeSendBoatAttack(other: Player) { From 1b672420b36b7e1a639300445efac18356573163 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Thu, 17 Apr 2025 22:37:16 -0400 Subject: [PATCH 03/12] Combine Bot and Nation behaviors in to a shared class (#434) This is the first move in the effort to combine the redundant logic that exists between BotExecution and FakeHumonExecution. This commit: 1. Combines the alliance request handler, moving bots to use the same logic as nations for acceptance. 2. Combines the sendAttack() functions, which may later be reworked. 3. Introduces selectEnemy() function to wrap enemy selection logic that nations use. 4. Blocks nations from nuking bots. 5. Alters enemy selection to prefer neighboring bots if there are any. ## Description: Fixes #467 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: fake.neo --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/core/execution/BotExecution.ts | 79 ++++--------- src/core/execution/FakeHumanExecution.ts | 142 ++++++----------------- src/core/execution/utils/BotBehavior.ts | 140 ++++++++++++++++++++++ src/core/game/PlayerImpl.ts | 2 +- 4 files changed, 195 insertions(+), 168 deletions(-) create mode 100644 src/core/execution/utils/BotBehavior.ts diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts index f7321ef93..f71bd6407 100644 --- a/src/core/execution/BotExecution.ts +++ b/src/core/execution/BotExecution.ts @@ -1,13 +1,7 @@ -import { - Execution, - Game, - Player, - PlayerType, - TerraNullius, -} from "../game/Game"; +import { Execution, Game, Player } from "../game/Game"; import { PseudoRandom } from "../PseudoRandom"; import { simpleHash } from "../Util"; -import { AttackExecution } from "./AttackExecution"; +import { BotBehavior } from "./utils/BotBehavior"; export class BotExecution implements Execution { private active = true; @@ -16,18 +10,20 @@ export class BotExecution implements Execution { private mg: Game; private neighborsTerraNullius = true; + private behavior: BotBehavior | null = null; + constructor(private bot: Player) { this.random = new PseudoRandom(simpleHash(bot.id())); this.attackRate = this.random.nextInt(10, 50); } + activeDuringSpawnPhase(): boolean { return false; } - init(mg: Game, ticks: number) { + init(mg: Game) { this.mg = mg; this.bot.setTargetTroopRatio(0.7); - // this.neighborsTerra = this.bot.neighbors().filter(n => n == this.gs.terraNullius()).length > 0 } tick(ticks: number) { @@ -40,14 +36,15 @@ export class BotExecution implements Execution { return; } - this.bot.incomingAllianceRequests().forEach((ar) => { - if (ar.requestor().isTraitor()) { - ar.reject(); - } else { - ar.accept(); - } - }); + if (this.behavior === null) { + this.behavior = new BotBehavior(this.random, this.mg, this.bot, 1 / 20); + } + this.behavior.handleAllianceRequests(); + this.maybeAttack(); + } + + private maybeAttack() { const traitors = this.bot .neighbors() .filter((n) => n.isPlayer() && n.isTraitor()) as Player[]; @@ -55,56 +52,22 @@ export class BotExecution implements Execution { const toAttack = this.random.randElement(traitors); const odds = this.bot.isFriendly(toAttack) ? 6 : 3; if (this.random.chance(odds)) { - this.sendAttack(toAttack); + this.behavior.sendAttack(toAttack); return; } } if (this.neighborsTerraNullius) { - for (const b of this.bot.borderTiles()) { - for (const n of this.mg.neighbors(b)) { - if (!this.mg.hasOwner(n) && this.mg.isLand(n)) { - this.sendAttack(this.mg.terraNullius()); - return; - } - } + if (this.bot.sharesBorderWith(this.mg.terraNullius())) { + this.behavior.sendAttack(this.mg.terraNullius()); + return; } this.neighborsTerraNullius = false; } - const border = Array.from(this.bot.borderTiles()) - .flatMap((t) => this.mg.neighbors(t)) - .filter((t) => this.mg.hasOwner(t) && this.mg.owner(t) != this.bot); - - if (border.length == 0) { - return; - } - - const toAttack = border[this.random.nextInt(0, border.length)]; - const owner = this.mg.owner(toAttack); - - if (owner.isPlayer()) { - if (this.bot.isFriendly(owner)) { - return; - } - if (owner.type() == PlayerType.FakeHuman) { - if (!this.random.chance(2)) { - return; - } - } - } - this.sendAttack(owner); - } - - sendAttack(toAttack: Player | TerraNullius) { - if (toAttack.isPlayer() && this.bot.isOnSameTeam(toAttack)) return; - this.mg.addExecution( - new AttackExecution( - this.bot.troops() / 20, - this.bot.id(), - toAttack.isPlayer() ? toAttack.id() : null, - ), - ); + const enemy = this.behavior.selectRandomEnemy(); + if (!enemy) return; + this.behavior.sendAttack(enemy); } owner(): Player { diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index 8d460bc4e..3b336fee7 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -1,6 +1,5 @@ import { consolex } from "../Consolex"; import { - AllianceRequest, Cell, Difficulty, Execution, @@ -11,7 +10,6 @@ import { PlayerType, Relation, TerrainType, - TerraNullius, Tick, Unit, UnitType, @@ -20,26 +18,23 @@ import { euclDistFN, manhattanDistFN, TileRef } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; import { GameID } from "../Schemas"; import { calculateBoundingBox, simpleHash } from "../Util"; -import { AllianceRequestReplyExecution } from "./alliance/AllianceRequestReplyExecution"; -import { AttackExecution } from "./AttackExecution"; import { ConstructionExecution } from "./ConstructionExecution"; import { EmojiExecution } from "./EmojiExecution"; import { NukeExecution } from "./NukeExecution"; import { SpawnExecution } from "./SpawnExecution"; import { TransportShipExecution } from "./TransportShipExecution"; import { closestTwoTiles } from "./Util"; +import { BotBehavior } from "./utils/BotBehavior"; export class FakeHumanExecution implements Execution { private firstMove = true; private active = true; private random: PseudoRandom; + private behavior: BotBehavior | null = null; private mg: Game; private player: Player = null; - private enemy: Player | null = null; - - private lastEnemyUpdateTick: number = 0; private lastEmojiSent = new Map(); private lastNukeSent: [Tick, TileRef][] = []; private embargoMalusApplied = new Set(); @@ -53,7 +48,7 @@ export class FakeHumanExecution implements Execution { ); } - init(mg: Game, ticks: number) { + init(mg: Game) { this.mg = mg; if (this.random.chance(10)) { // this.isTraitor = true @@ -118,16 +113,23 @@ export class FakeHumanExecution implements Execution { return; } } - if (this.firstMove) { - this.firstMove = false; - this.sendAttack(this.mg.terraNullius()); - return; - } + if (!this.player.isAlive()) { this.active = false; return; } + if (this.behavior === null) { + // Player is unavailable during init() + this.behavior = new BotBehavior(this.random, this.mg, this.player, 1 / 5); + } + + if (this.firstMove) { + this.firstMove = false; + this.behavior.sendAttack(this.mg.terraNullius()); + return; + } + if (ticks % this.random.nextInt(40, 80) != 0) { return; } @@ -140,7 +142,7 @@ export class FakeHumanExecution implements Execution { } this.updateRelationsFromEmbargos(); - this.handleAllianceRequests(); + this.behavior.handleAllianceRequests(); this.handleEnemies(); this.handleUnits(); this.handleEmbargoesToHostileNations(); @@ -166,7 +168,7 @@ export class FakeHumanExecution implements Execution { this.mg.playerBySmallID(this.mg.ownerID(t)), ); if (enemiesWithTN.filter((o) => !o.isPlayer()).length > 0) { - this.sendAttack(this.mg.terraNullius()); + this.behavior.sendAttack(this.mg.terraNullius()); return; } @@ -188,7 +190,7 @@ export class FakeHumanExecution implements Execution { ? enemies[0] : this.random.randElement(enemies); if (this.shouldAttack(toAttack)) { - this.sendAttack(toAttack); + this.behavior.sendAttack(toAttack); } } @@ -225,65 +227,27 @@ export class FakeHumanExecution implements Execution { } handleEnemies() { - if (this.mg.ticks() - this.lastEnemyUpdateTick > 100) { - this.enemy = null; - } - - const target = - this.player - .allies() - .filter((ally) => this.player.relation(ally) == Relation.Friendly) - .filter((ally) => ally.targets().length > 0) - .map((ally) => ({ ally: ally, t: ally.targets()[0] }))[0] ?? null; - - if ( - target != null && - target.t != this.player && - !this.player.isAlliedWith(target.t) - ) { - this.player.updateRelation(target.ally, -20); - this.enemy = target.t; - this.lastEnemyUpdateTick = this.mg.ticks(); - if (target.ally.type() == PlayerType.Human) { - this.mg.addExecution( - new EmojiExecution(this.player.id(), target.ally.id(), "πŸ‘"), - ); - } - } - - if (this.enemy == null) { - const mostHated = this.player.allRelationsSorted()[0] ?? null; - if (mostHated != null && mostHated.relation == Relation.Hostile) { - this.enemy = mostHated.player; - this.lastEnemyUpdateTick = this.mg.ticks(); - } - } - - if (this.enemy) { - if (this.player.isFriendly(this.enemy)) { - this.enemy = null; - return; - } - this.maybeSendEmoji(); - this.maybeSendNuke(this.enemy); - if (this.player.sharesBorderWith(this.enemy)) { - this.sendAttack(this.enemy); - } else { - this.maybeSendBoatAttack(this.enemy); - } - return; + this.behavior.assistAllies(); + const enemy = this.behavior.selectEnemy(); + if (!enemy) return; + this.maybeSendEmoji(enemy); + this.maybeSendNuke(enemy); + if (this.player.sharesBorderWith(enemy)) { + this.behavior.sendAttack(enemy); + } else { + this.maybeSendBoatAttack(enemy); } } - private maybeSendEmoji() { - if (this.enemy.type() != PlayerType.Human) return; - const lastSent = this.lastEmojiSent.get(this.enemy) ?? -300; + private maybeSendEmoji(enemy: Player) { + if (enemy.type() != PlayerType.Human) return; + const lastSent = this.lastEmojiSent.get(enemy) ?? -300; if (this.mg.ticks() - lastSent <= 300) return; - this.lastEmojiSent.set(this.enemy, this.mg.ticks()); + this.lastEmojiSent.set(enemy, this.mg.ticks()); this.mg.addExecution( new EmojiExecution( this.player.id(), - this.enemy.id(), + enemy.id(), this.random.randElement(["🀑", "😑"]), ), ); @@ -295,6 +259,7 @@ export class FakeHumanExecution implements Execution { silos.length == 0 || this.player.gold() < this.mg.config().unitInfo(UnitType.AtomBomb).cost(this.player) || + other.type() == PlayerType.Bot || this.player.isOnSameTeam(other) ) { return; @@ -552,36 +517,6 @@ export class FakeHumanExecution implements Execution { return this.mg.unitInfo(type).cost(this.player); } - handleAllianceRequests() { - for (const req of this.player.incomingAllianceRequests()) { - if (req.requestor().isTraitor()) { - this.replyToAllianceRequest(req, false); - continue; - } - if (this.player.relation(req.requestor()) < Relation.Neutral) { - this.replyToAllianceRequest(req, false); - continue; - } - const requestorIsMuchLarger = - req.requestor().numTilesOwned() > this.player.numTilesOwned() * 3; - if (!requestorIsMuchLarger && req.requestor().alliances().length >= 3) { - this.replyToAllianceRequest(req, false); - continue; - } - this.replyToAllianceRequest(req, true); - } - } - - private replyToAllianceRequest(req: AllianceRequest, accept: boolean): void { - this.mg.addExecution( - new AllianceRequestReplyExecution( - req.requestor().id(), - this.player.id(), - accept, - ), - ); - } - sendBoatRandomly() { const oceanShore = Array.from(this.player.borderTiles()).filter((t) => this.mg.isOceanShore(t), @@ -633,17 +568,6 @@ export class FakeHumanExecution implements Execution { return null; } - sendAttack(toAttack: Player | TerraNullius) { - if (toAttack.isPlayer() && this.player.isOnSameTeam(toAttack)) return; - this.mg.addExecution( - new AttackExecution( - this.player.troops() / 5, - this.player.id(), - toAttack.isPlayer() ? toAttack.id() : null, - ), - ); - } - private randOceanShoreTile(tile: TileRef, dist: number): TileRef | null { const x = this.mg.x(tile); const y = this.mg.y(tile); diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts new file mode 100644 index 000000000..3c6b6108f --- /dev/null +++ b/src/core/execution/utils/BotBehavior.ts @@ -0,0 +1,140 @@ +import { + AllianceRequest, + Game, + Player, + PlayerType, + Relation, + TerraNullius, + Tick, +} from "../../game/Game"; +import { PseudoRandom } from "../../PseudoRandom"; +import { AttackExecution } from "../AttackExecution"; +import { EmojiExecution } from "../EmojiExecution"; + +export class BotBehavior { + private enemy: Player | null = null; + private enemyUpdated: Tick; + + constructor( + private random: PseudoRandom, + private game: Game, + private player: Player, + private attackRatio: number, + ) {} + + handleAllianceRequests() { + for (const req of this.player.incomingAllianceRequests()) { + if (shouldAcceptAllianceRequest(this.player, req)) { + req.accept(); + } else { + req.reject(); + } + } + } + + private emoji(player: Player, emoji: string) { + if (player.type() !== PlayerType.Human) return; + this.game.addExecution( + new EmojiExecution(this.player.id(), player.id(), emoji), + ); + } + + assistAllies() { + outer: for (const ally of this.player.allies()) { + if (ally.targets().length === 0) continue; + if (this.player.relation(ally) < Relation.Friendly) { + // this.emoji(ally, "🀦"); + continue; + } + for (const target of ally.targets()) { + if (target === this.player) { + // this.emoji(ally, "πŸ’€"); + continue; + } + if (this.player.isAlliedWith(target)) { + // this.emoji(ally, "πŸ‘Ž"); + continue; + } + // All checks passed, assist them + this.player.updateRelation(ally, -20); + this.enemy = target; + this.enemyUpdated = this.game.ticks(); + this.emoji(ally, "πŸ‘"); + break outer; + } + } + } + + selectEnemy(): Player | null { + // Forget old enemies + if (this.game.ticks() - this.enemyUpdated > 100) { + this.enemy = null; + } + + // Prefer neighboring bots + if (this.enemy === null) { + const bots = this.player + .neighbors() + .filter((n) => n.isPlayer() && n.type() === PlayerType.Bot) as Player[]; + if (bots.length > 0) { + const density = (p: Player) => p.troops() / p.numTilesOwned(); + this.enemy = bots.sort((a, b) => density(a) - density(b))[0]; + this.enemyUpdated = this.game.ticks(); + } + } + + // Select the most hated player + if (this.enemy === null) { + const mostHated = this.player.allRelationsSorted()[0] ?? null; + if (mostHated != null && mostHated.relation === Relation.Hostile) { + this.enemy = mostHated.player; + this.enemyUpdated = this.game.ticks(); + } + } + + // Sanity check, don't attack our allies or teammates + if (this.enemy && this.player.isFriendly(this.enemy)) { + this.enemy = null; + } + return this.enemy; + } + + selectRandomEnemy(): Player | TerraNullius | null { + const neighbors = this.player.neighbors(); + for (const neighbor of this.random.shuffleArray(neighbors)) { + if (neighbor.isPlayer()) { + if (this.player.isFriendly(neighbor)) continue; + if (neighbor.type() == PlayerType.FakeHuman) { + if (this.random.chance(2)) { + continue; + } + } + } + return neighbor; + } + return null; + } + + sendAttack(target: Player | TerraNullius) { + if (target.isPlayer() && this.player.isOnSameTeam(target)) return; + const troops = this.player.troops() * this.attackRatio; + if (troops < 1) return; + this.game.addExecution( + new AttackExecution( + troops, + this.player.id(), + target.isPlayer() ? target.id() : null, + ), + ); + } +} + +function shouldAcceptAllianceRequest(player: Player, request: AllianceRequest) { + const notTraitor = !request.requestor().isTraitor(); + const noMalice = player.relation(request.requestor()) >= Relation.Neutral; + const requestorIsMuchLarger = + request.requestor().numTilesOwned() > player.numTilesOwned() * 3; + const notTooManyAlliances = + requestorIsMuchLarger || request.requestor().alliances().length < 3; + return notTraitor && noMalice && notTooManyAlliances; +} diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 566b5ee05..bec1b255a 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -244,7 +244,7 @@ export class PlayerImpl implements Player { const ns: Set = new Set(); for (const border of this.borderTiles()) { for (const neighbor of this.mg.map().neighbors(border)) { - if (this.mg.map().isLake(neighbor)) { + if (this.mg.map().isLand(neighbor)) { const owner = this.mg.map().ownerID(neighbor); if (owner != this.smallID()) { ns.add( From 0995b0a5e3afea728a2155ce021bad59cf2466dc Mon Sep 17 00:00:00 2001 From: Brandon Yi Date: Thu, 17 Apr 2025 19:38:37 -0700 Subject: [PATCH 04/12] Increase SAM search radius and cap cost at 3mil (#529) Fixes https://github.com/openfrontio/OpenFrontIO/issues/515 ## Description: Bumping SAM launcher search range from 75 to 100. Also, decreasing the max cost of the SAM to 3 mil (down from 4.5mil) ## Please complete the following: - [x] I have added screenshots for all UI updates - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [X] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: bypie5 --------- Co-authored-by: APuddle210 --- src/core/configuration/DefaultConfig.ts | 2 +- src/core/execution/SAMLauncherExecution.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 6c20883b9..4f36e1183 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -330,7 +330,7 @@ export class DefaultConfig implements Config { p.type() == PlayerType.Human && this.infiniteGold() ? 0 : Math.min( - 1_500_000 * 3, + 3_000_000, (p.unitsIncludingConstruction(UnitType.SAMLauncher).length + 1) * 1_500_000, diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index 07f55b3c1..af1317319 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -21,7 +21,7 @@ export class SAMLauncherExecution implements Execution { private target: Unit = null; private warheadTargets: Unit[] = []; - private searchRangeRadius = 75; + private searchRangeRadius = 80; // As MIRV go very fast we have to detect them very early but we only // shoot the one targeting very close (MIRVWarheadProtectionRadius) private MIRVWarheadSearchRadius = 400; From 7342caaafd0db9ff1bdb6662d879be5620f66171 Mon Sep 17 00:00:00 2001 From: Duwibi <86431918+Duwibi@users.noreply.github.com> Date: Fri, 18 Apr 2025 05:39:28 +0300 Subject: [PATCH 05/12] Fix Australia and Iceland chance (#550) ## Description: This PR fixes the chances of the Iceland and Australia maps being picked. fixes #551 ## Please complete the following: - [ ] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: Nikola123 --- src/server/MapPlaylist.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 3e9258e03..d26852460 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -86,8 +86,8 @@ export class MapPlaylist { Africa: 2, Britannia: 1, GatewayToTheAtlantic: 2, - Australia: 1, - Iceland: 1, + Australia: 2, + Iceland: 2, SouthAmerica: 3, KnownWorld: 2, }; From ae4f4d3ed62d2956a2a82f86a689211450eefd7b Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 17 Apr 2025 20:30:52 -0700 Subject: [PATCH 06/12] traitor only lasts 30 seconds, 50% defense debuff --- resources/images/TraitorIcon.png | Bin 14635 -> 0 bytes resources/images/TraitorIcon.svg | 92 ++++++++++++++++++++++-- src/core/configuration/Config.ts | 1 + src/core/configuration/DefaultConfig.ts | 5 +- src/core/game/Game.ts | 1 + src/core/game/GameImpl.ts | 2 +- src/core/game/PlayerImpl.ts | 11 ++- 7 files changed, 104 insertions(+), 8 deletions(-) delete mode 100644 resources/images/TraitorIcon.png diff --git a/resources/images/TraitorIcon.png b/resources/images/TraitorIcon.png deleted file mode 100644 index 23f5d252a36fc69778271715b5035fea069da85b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14635 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 - - - + + + + + + + + + + + + + + + + diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 35fa3368c..2fdf9a813 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -119,6 +119,7 @@ export interface Config { difficultyModifier(difficulty: Difficulty): number; // 0-1 traitorDefenseDebuff(): number; + traitorDuration(): number; nukeMagnitudes(unitType: UnitType): NukeMagnitude; defaultNukeSpeed(): number; nukeDeathFactor(humans: number, tilesOwned: number): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 4f36e1183..e0c4d240c 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -141,7 +141,10 @@ export class DefaultConfig implements Config { } traitorDefenseDebuff(): number { - return 0.8; + return 0.5; + } + traitorDuration(): number { + return 30 * 10; // 30 seconds } spawnImmunityDuration(): Tick { return 5 * 10; diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 25a7c9328..b82016ed9 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -315,6 +315,7 @@ export interface Player { // State & Properties isAlive(): boolean; isTraitor(): boolean; + markTraitor(): void; largestClusterBoundingBox: { min: Cell; max: Cell } | null; lastTileChange(): Tick; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 8a1051fb3..06bcc57ba 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -518,7 +518,7 @@ export class GameImpl implements Game { ); } if (!other.isTraitor()) { - (breaker as PlayerImpl).isTraitor_ = true; + breaker.markTraitor(); } const breakerSet = new Set(breaker.alliances()); diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index bec1b255a..7f2786183 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -68,7 +68,7 @@ export class PlayerImpl implements Player { // 0 to 100 private _targetTroopRatio: bigint; - isTraitor_ = false; + markedTraitorTick = -1; private embargoes: Set = new Set(); @@ -373,7 +373,14 @@ export class PlayerImpl implements Player { } isTraitor(): boolean { - return this.isTraitor_; + return ( + this.markedTraitorTick >= 0 && + this.mg.ticks() - this.markedTraitorTick < + this.mg.config().traitorDuration() + ); + } + markTraitor(): void { + this.markedTraitorTick = this.mg.ticks(); } createAllianceRequest(recipient: Player): AllianceRequest { From 38b1845ed16ce7713b169d86d7ff0c2aab946e0a Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 18 Apr 2025 11:51:54 -0700 Subject: [PATCH 07/12] don't allow structures to spawn too close to each other. When choosing a spawn, canBuild() finds a suitable nearby tile if chosen tile is too close to an existing structure. --- src/core/GameRunner.ts | 10 +-- src/core/configuration/Config.ts | 1 + src/core/configuration/DefaultConfig.ts | 4 ++ src/core/execution/MissileSiloExecution.ts | 5 +- src/core/game/Game.ts | 1 + src/core/game/PlayerImpl.ts | 84 +++++++++++++++++++--- 6 files changed, 84 insertions(+), 21 deletions(-) diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 13d8fcc10..c4b3ab3f7 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -4,7 +4,6 @@ import { Executor } from "./execution/ExecutionManager"; import { WinCheckExecution } from "./execution/WinCheckExecution"; import { AllPlayers, - BuildableUnit, Game, GameUpdates, NameViewData, @@ -15,7 +14,6 @@ import { PlayerInfo, PlayerProfile, PlayerType, - UnitType, } from "./game/Game"; import { createGame } from "./game/GameImpl"; import { @@ -161,13 +159,7 @@ export class GameRunner { const actions = { canBoat: player.canBoat(tile), canAttack: player.canAttack(tile), - buildableUnits: Object.values(UnitType).map((u) => { - return { - type: u, - canBuild: player.canBuild(u, tile) != false, - cost: this.game.config().unitInfo(u).cost(player), - } as BuildableUnit; - }), + buildableUnits: player.buildableUnits(tile), canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers), } as PlayerActions; diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 2fdf9a813..aaed33112 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -123,6 +123,7 @@ export interface Config { nukeMagnitudes(unitType: UnitType): NukeMagnitude; defaultNukeSpeed(): number; nukeDeathFactor(humans: number, tilesOwned: number): number; + structureMinDist(): number; } export interface Theme { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index e0c4d240c..9b51ae7ca 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -676,4 +676,8 @@ export class DefaultConfig implements Config { nukeDeathFactor(humans: number, tilesOwned: number): number { return (5 * humans) / Math.max(1, tilesOwned); } + + structureMinDist(): number { + return 18; + } } diff --git a/src/core/execution/MissileSiloExecution.ts b/src/core/execution/MissileSiloExecution.ts index 928b8afdf..fd9bf2111 100644 --- a/src/core/execution/MissileSiloExecution.ts +++ b/src/core/execution/MissileSiloExecution.ts @@ -33,14 +33,15 @@ export class MissileSiloExecution implements Execution { tick(ticks: number): void { if (this.silo == null) { - if (!this.player.canBuild(UnitType.MissileSilo, this.tile)) { + const spawn = this.player.canBuild(UnitType.MissileSilo, this.tile); + if (spawn === false) { consolex.warn( `player ${this.player} cannot build missile silo at ${this.tile}`, ); this.active = false; return; } - this.silo = this.player.buildUnit(UnitType.MissileSilo, 0, this.tile, { + this.silo = this.player.buildUnit(UnitType.MissileSilo, 0, spawn, { cooldownDuration: this.mg.config().SiloCooldown(), }); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index b82016ed9..10a10af3d 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -347,6 +347,7 @@ export interface Player { // Units units(...types: UnitType[]): Unit[]; unitsIncludingConstruction(type: UnitType): Unit[]; + buildableUnits(tile: TileRef): BuildableUnit[]; canBuild(type: UnitType, targetTile: TileRef): TileRef | false; buildUnit( type: UnitType, diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 7f2786183..3c1de6733 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -20,6 +20,7 @@ import { AllianceRequest, AllPlayers, Attack, + BuildableUnit, Cell, EmojiMessage, GameMode, @@ -729,7 +730,22 @@ export class PlayerImpl implements Player { return b; } - canBuild(unitType: UnitType, targetTile: TileRef): TileRef | false { + public buildableUnits(tile: TileRef): BuildableUnit[] { + const validTiles = this.validStructureSpawnTiles(tile); + return Object.values(UnitType).map((u) => { + return { + type: u, + canBuild: this.canBuild(u, tile, validTiles) != false, + cost: this.mg.config().unitInfo(u).cost(this), + } as BuildableUnit; + }); + } + + canBuild( + unitType: UnitType, + targetTile: TileRef, + validTiles: TileRef[] | null = null, + ): TileRef | false { // prevent the building of nukes and nuke related buildings if (this.mg.config().disableNukes()) { if ( @@ -761,7 +777,7 @@ export class PlayerImpl implements Player { case UnitType.MIRVWarhead: return targetTile; case UnitType.Port: - return this.portSpawn(targetTile); + return this.portSpawn(targetTile, validTiles); case UnitType.Warship: return this.warshipSpawn(targetTile); case UnitType.Shell: @@ -776,7 +792,7 @@ export class PlayerImpl implements Player { case UnitType.SAMLauncher: case UnitType.City: case UnitType.Construction: - return this.landBasedStructureSpawn(targetTile); + return this.landBasedStructureSpawn(targetTile, validTiles); default: assertNever(unitType); } @@ -802,7 +818,7 @@ export class PlayerImpl implements Player { return spawns[0].tile(); } - portSpawn(tile: TileRef): TileRef | false { + portSpawn(tile: TileRef, validTiles: TileRef[]): TileRef | false { const spawns = Array.from( this.mg.bfs( tile, @@ -814,10 +830,15 @@ export class PlayerImpl implements Player { (a, b) => this.mg.manhattanDist(a, tile) - this.mg.manhattanDist(b, tile), ); - if (spawns.length == 0) { - return false; + const validTileSet = new Set( + validTiles ?? this.validStructureSpawnTiles(tile), + ); + for (const t of spawns) { + if (validTileSet.has(t)) { + return t; + } } - return spawns[0]; + return false; } warshipSpawn(tile: TileRef): TileRef | false { @@ -835,11 +856,54 @@ export class PlayerImpl implements Player { return spawns[0].tile(); } - landBasedStructureSpawn(tile: TileRef): TileRef | false { - if (this.mg.owner(tile) != this) { + landBasedStructureSpawn( + tile: TileRef, + validTiles: TileRef[] | null = null, + ): TileRef | false { + const tiles = validTiles ?? this.validStructureSpawnTiles(tile); + if (tiles.length == 0) { return false; } - return tile; + return tiles[0]; + } + + private validStructureSpawnTiles(tile: TileRef): TileRef[] { + if (this.mg.owner(tile) != this) { + return []; + } + const searchRadius = 15; + const searchRadiusSquared = searchRadius ** 2; + const types = Object.values(UnitType).filter((unitTypeValue) => { + return this.mg.config().unitInfo(unitTypeValue).territoryBound; + }); + + const nearbyUnits = this.mg + .nearbyUnits(tile, searchRadius * 2, types) + .map((u) => u.unit); + const nearbyTiles = this.mg.bfs(tile, (gm, t) => { + return ( + this.mg.euclideanDistSquared(tile, t) < searchRadiusSquared && + gm.ownerID(t) == this.smallID() + ); + }); + const validSet: Set = new Set(nearbyTiles); + + const minDistSquared = this.mg.config().structureMinDist() ** 2; + for (const t of nearbyTiles) { + for (const unit of nearbyUnits) { + if (this.mg.euclideanDistSquared(unit.tile(), t) < minDistSquared) { + validSet.delete(t); + break; + } + } + } + const valid = Array.from(validSet); + valid.sort( + (a, b) => + this.mg.euclideanDistSquared(a, tile) - + this.mg.euclideanDistSquared(b, tile), + ); + return valid; } transportShipSpawn(targetTile: TileRef): TileRef | false { From 582ebf3151accded972ec5ddfe85cca0484acce3 Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 18 Apr 2025 13:31:50 -0700 Subject: [PATCH 08/12] rebalance multiplayer maps: 1. reduce number of players per map 2. Modify map frequencies --- src/core/configuration/DefaultConfig.ts | 12 ++++++------ src/server/MapPlaylist.ts | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 9b51ae7ca..03726a83b 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -69,7 +69,7 @@ export abstract class DefaultServerConfig implements ServerConfig { GameMapType.Europe, ].includes(map) ) { - return Math.random() < 0.2 ? 150 : 70; + return Math.random() < 0.2 ? 100 : 50; } // Maps with ~2.5 - ~3.5 mil pixels if ( @@ -80,7 +80,7 @@ export abstract class DefaultServerConfig implements ServerConfig { GameMapType.Asia, ].includes(map) ) { - return Math.random() < 0.2 ? 100 : 50; + return Math.random() < 0.3 ? 50 : 25; } // Maps with ~2 mil pixels if ( @@ -92,7 +92,7 @@ export abstract class DefaultServerConfig implements ServerConfig { GameMapType.FaroeIslands, ].includes(map) ) { - return Math.random() < 0.2 ? 70 : 40; + return Math.random() < 0.3 ? 50 : 25; } // Maps smaller than ~2 mil pixels if ( @@ -102,14 +102,14 @@ export abstract class DefaultServerConfig implements ServerConfig { GameMapType.Pangaea, ].includes(map) ) { - return Math.random() < 0.2 ? 60 : 35; + return Math.random() < 0.5 ? 30 : 15; } // world belongs with the ~2 mils, but these amounts never made sense so I assume the insanity is intended. if (map == GameMapType.World) { - return Math.random() < 0.2 ? 150 : 60; + return Math.random() < 0.2 ? 150 : 50; } // default return for non specified map - return Math.random() < 0.2 ? 85 : 45; + return Math.random() < 0.2 ? 50 : 20; } workerIndex(gameID: GameID): number { return simpleHash(gameID) % this.numWorkers(); diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index d26852460..91beb160a 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -81,25 +81,25 @@ export class MapPlaylist { // Big Maps are those larger than ~2.5 mil pixels case PlaylistType.BigMaps: return { - Europe: 3, - NorthAmerica: 2, + Europe: 2, + NorthAmerica: 1, Africa: 2, Britannia: 1, GatewayToTheAtlantic: 2, Australia: 2, Iceland: 2, - SouthAmerica: 3, + SouthAmerica: 1, KnownWorld: 2, }; case PlaylistType.SmallMaps: return { - World: 1, + World: 4, Mena: 2, Pangaea: 1, Asia: 1, Mars: 1, - BetweenTwoSeas: 3, - Japan: 3, + BetweenTwoSeas: 2, + Japan: 2, BlackSea: 1, FaroeIslands: 2, }; From 11a9b9e5b0ac9777e712548184c8a2c249fcaff2 Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 18 Apr 2025 13:46:50 -0700 Subject: [PATCH 09/12] move "join discord button" to footer, this makes space for the login button --- src/client/index.html | 18 ++++-------------- src/client/styles/layout/footer.css | 2 +- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/client/index.html b/src/client/index.html index f0b8810f3..0de10d117 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -215,20 +215,7 @@ - +
@@ -331,6 +318,9 @@ > Wiki + + Join the Discord! +