From c7f7fb0ee46711a1dc24adc0aebc6e97a2a44b9c Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Mon, 25 Aug 2025 03:34:52 -0400 Subject: [PATCH 1/9] Refactor `structureSpawnTileValue()` (#1927) ## Description: Move `structureSpawnTileValue()` into its own file, as `FakeHumanExecution.ts` was getting quite large. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced --- src/core/execution/FakeHumanExecution.ts | 69 ++----------------- .../nation/structureSpawnTileValue.ts | 62 +++++++++++++++++ 2 files changed, 66 insertions(+), 65 deletions(-) create mode 100644 src/core/execution/nation/structureSpawnTileValue.ts diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index 7326375f1..e498d63c2 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -20,6 +20,7 @@ import { GameID } from "../Schemas"; import { calculateBoundingBox, flattenedEmojiTable, simpleHash } from "../Util"; import { ConstructionExecution } from "./ConstructionExecution"; import { EmojiExecution } from "./EmojiExecution"; +import { structureSpawnTileValue } from "./nation/structureSpawnTileValue"; import { NukeExecution } from "./NukeExecution"; import { SpawnExecution } from "./SpawnExecution"; import { TransportShipExecution } from "./TransportShipExecution"; @@ -486,7 +487,8 @@ export class FakeHumanExecution implements Execution { } private structureSpawnTile(type: UnitType): TileRef | null { - if (this.player === null) throw new Error("not initialized"); + if (this.mg === undefined) throw new Error("Not initialized"); + if (this.player === null) throw new Error("Not initialized"); const tiles = type === UnitType.Port ? Array.from(this.player.borderTiles()).filter((t) => @@ -494,7 +496,7 @@ export class FakeHumanExecution implements Execution { ) : Array.from(this.player.tiles()); if (tiles.length === 0) return null; - const valueFunction = this.structureSpawnTileValue(type); + const valueFunction = structureSpawnTileValue(this.mg, this.player, type); let bestTile: TileRef | null = null; let bestValue = 0; const sampledTiles = this.arraySampler(tiles); @@ -524,69 +526,6 @@ export class FakeHumanExecution implements Execution { } } - private structureSpawnTileValue(type: UnitType): (tile: TileRef) => number { - if (this.player === null) throw new Error("not initialized"); - const borderTiles = this.player.borderTiles(); - const mg = this.mg; - const otherUnits = this.player.units(type); - // Prefer spacing structures out of atom bomb range - const borderSpacing = this.mg - .config() - .nukeMagnitudes(UnitType.AtomBomb).outer; - const structureSpacing = borderSpacing * 2; - switch (type) { - case UnitType.Port: - return (tile) => { - let w = 0; - - // Prefer to be far away from other structures of the same type - const otherTiles: Set = new Set( - otherUnits.map((u) => u.tile()), - ); - otherTiles.delete(tile); - const closestOther = closestTwoTiles(mg, otherTiles, [tile]); - if (closestOther !== null) { - const d = mg.manhattanDist(closestOther.x, tile); - w += Math.min(d, structureSpacing); - } - - return w; - }; - case UnitType.City: - case UnitType.Factory: - case UnitType.MissileSilo: - return (tile) => { - let w = 0; - - // Prefer higher elevations - w += mg.magnitude(tile); - - // Prefer to be away from the border - const closestBorder = closestTwoTiles(mg, borderTiles, [tile]); - if (closestBorder !== null) { - const d = mg.manhattanDist(closestBorder.x, tile); - w += Math.min(d, borderSpacing); - } - - // Prefer to be away from other structures of the same type - const otherTiles: Set = new Set( - otherUnits.map((u) => u.tile()), - ); - otherTiles.delete(tile); - const closestOther = closestTwoTiles(mg, otherTiles, [tile]); - if (closestOther !== null) { - const d = mg.manhattanDist(closestOther.x, tile); - w += Math.min(d, structureSpacing); - } - - // TODO: Cities and factories should consider train range limits - return w; - }; - default: - throw new Error(`Value function not implemented for ${type}`); - } - } - private maybeSpawnWarship(): boolean { if (this.player === null) throw new Error("not initialized"); if (!this.random.chance(50)) { diff --git a/src/core/execution/nation/structureSpawnTileValue.ts b/src/core/execution/nation/structureSpawnTileValue.ts new file mode 100644 index 000000000..563f42f80 --- /dev/null +++ b/src/core/execution/nation/structureSpawnTileValue.ts @@ -0,0 +1,62 @@ +import { Game, Player, UnitType } from "../../game/Game"; +import { TileRef } from "../../game/GameMap"; +import { closestTwoTiles } from "../Util"; + +export function structureSpawnTileValue( + mg: Game, + player: Player, + type: UnitType, +): (tile: TileRef) => number { + const borderTiles = player.borderTiles(); + const otherUnits = player.units(type); + // Prefer spacing structures out of atom bomb range + const borderSpacing = mg.config().nukeMagnitudes(UnitType.AtomBomb).outer; + const structureSpacing = borderSpacing * 2; + switch (type) { + case UnitType.Port: + return (tile) => { + let w = 0; + + // Prefer to be far away from other structures of the same type + const otherTiles: Set = new Set(otherUnits.map((u) => u.tile())); + otherTiles.delete(tile); + const closestOther = closestTwoTiles(mg, otherTiles, [tile]); + if (closestOther !== null) { + const d = mg.manhattanDist(closestOther.x, tile); + w += Math.min(d, structureSpacing); + } + + return w; + }; + case UnitType.City: + case UnitType.Factory: + case UnitType.MissileSilo: + return (tile) => { + let w = 0; + + // Prefer higher elevations + w += mg.magnitude(tile); + + // Prefer to be away from the border + const closestBorder = closestTwoTiles(mg, borderTiles, [tile]); + if (closestBorder !== null) { + const d = mg.manhattanDist(closestBorder.x, tile); + w += Math.min(d, borderSpacing); + } + + // Prefer to be away from other structures of the same type + const otherTiles: Set = new Set(otherUnits.map((u) => u.tile())); + otherTiles.delete(tile); + const closestOther = closestTwoTiles(mg, otherTiles, [tile]); + if (closestOther !== null) { + const d = mg.manhattanDist(closestOther.x, tile); + w += Math.min(d, structureSpacing); + } + + // TODO: Cities and factories should consider train range limits + return w; + }; + default: + throw new Error(`Value function not implemented for ${type}`); + } +} From 47edfe3e4046ea6b4a62d1664925ffd57ad081b8 Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Mon, 25 Aug 2025 18:07:57 -0400 Subject: [PATCH 2/9] Nations build SAM launchers (#1931) ## Description: Fixes #201 by adding the ability for nations to build SAM launchers. image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced --- src/core/execution/FakeHumanExecution.ts | 1 + src/core/execution/SAMLauncherExecution.ts | 3 +- .../nation/structureSpawnTileValue.ts | 79 +++++++++++++++---- 3 files changed, 66 insertions(+), 17 deletions(-) diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index e498d63c2..10eac66ea 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -461,6 +461,7 @@ export class FakeHumanExecution implements Execution { this.maybeSpawnStructure(UnitType.Port) || this.maybeSpawnWarship() || this.maybeSpawnStructure(UnitType.Factory) || + this.maybeSpawnStructure(UnitType.SAMLauncher) || this.maybeSpawnStructure(UnitType.MissileSilo) ); } diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index d44522cea..baabf0fb2 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -50,7 +50,8 @@ class SAMTargetingSystem { private isInRange(tile: TileRef) { const samTile = this.sam.tile(); - const rangeSquared = this.mg.config().defaultSamRange() ** 2; + const range = this.mg.config().defaultSamRange(); + const rangeSquared = range * range; return this.mg.euclideanDistSquared(samTile, tile) <= rangeSquared; } diff --git a/src/core/execution/nation/structureSpawnTileValue.ts b/src/core/execution/nation/structureSpawnTileValue.ts index 563f42f80..55e1cfa90 100644 --- a/src/core/execution/nation/structureSpawnTileValue.ts +++ b/src/core/execution/nation/structureSpawnTileValue.ts @@ -13,24 +13,9 @@ export function structureSpawnTileValue( const borderSpacing = mg.config().nukeMagnitudes(UnitType.AtomBomb).outer; const structureSpacing = borderSpacing * 2; switch (type) { - case UnitType.Port: - return (tile) => { - let w = 0; - - // Prefer to be far away from other structures of the same type - const otherTiles: Set = new Set(otherUnits.map((u) => u.tile())); - otherTiles.delete(tile); - const closestOther = closestTwoTiles(mg, otherTiles, [tile]); - if (closestOther !== null) { - const d = mg.manhattanDist(closestOther.x, tile); - w += Math.min(d, structureSpacing); - } - - return w; - }; case UnitType.City: case UnitType.Factory: - case UnitType.MissileSilo: + case UnitType.MissileSilo: { return (tile) => { let w = 0; @@ -56,6 +41,68 @@ export function structureSpawnTileValue( // TODO: Cities and factories should consider train range limits return w; }; + } + case UnitType.Port: { + return (tile) => { + let w = 0; + + // Prefer to be far away from other structures of the same type + const otherTiles: Set = new Set(otherUnits.map((u) => u.tile())); + otherTiles.delete(tile); + const closestOther = closestTwoTiles(mg, otherTiles, [tile]); + if (closestOther !== null) { + const d = mg.manhattanDist(closestOther.x, tile); + w += Math.min(d, structureSpacing); + } + + return w; + }; + } + case UnitType.SAMLauncher: { + const protectTiles: Set = new Set(); + for (const unit of player.units()) { + switch(unit.type()) { + case UnitType.City: + case UnitType.Factory: + case UnitType.MissileSilo: + case UnitType.Port: + protectTiles.add(unit.tile()); + } + } + const range = mg.config().defaultSamRange(); + const rangeSquared = range * range; + return (tile) => { + let w = 0; + + // Prefer higher elevations + w += mg.magnitude(tile); + + // Prefer to be away from the border + const closestBorder = closestTwoTiles(mg, borderTiles, [tile]); + if (closestBorder !== null) { + const d = mg.manhattanDist(closestBorder.x, tile); + w += Math.min(d, borderSpacing); + } + + // Prefer to be far away from other structures of the same type + const otherTiles: Set = new Set(otherUnits.map((u) => u.tile())); + otherTiles.delete(tile); + const closestOther = closestTwoTiles(mg, otherTiles, [tile]); + if (closestOther !== null) { + const d = mg.manhattanDist(closestOther.x, tile); + w += Math.min(d, structureSpacing); + } + + // Prefer to be in range of other structures + for (const maybeProtected of protectTiles) { + const distanceSquared = mg.euclideanDistSquared(tile, maybeProtected); + if (distanceSquared > rangeSquared) continue; + w += structureSpacing; + } + + return w; + }; + } default: throw new Error(`Value function not implemented for ${type}`); } From f0f9318852dd901890c8a3d4de1c9947f7c6d6a8 Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Mon, 25 Aug 2025 19:26:02 -0400 Subject: [PATCH 3/9] Nations build defense posts (#1935) ## Description: Nations build defense posts. Fixes #1854. image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced --- src/core/execution/FakeHumanExecution.ts | 1 + .../nation/structureSpawnTileValue.ts | 48 +++++++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index 10eac66ea..bae5b3690 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -461,6 +461,7 @@ export class FakeHumanExecution implements Execution { this.maybeSpawnStructure(UnitType.Port) || this.maybeSpawnWarship() || this.maybeSpawnStructure(UnitType.Factory) || + this.maybeSpawnStructure(UnitType.DefensePost) || this.maybeSpawnStructure(UnitType.SAMLauncher) || this.maybeSpawnStructure(UnitType.MissileSilo) ); diff --git a/src/core/execution/nation/structureSpawnTileValue.ts b/src/core/execution/nation/structureSpawnTileValue.ts index 55e1cfa90..e1a716e7c 100644 --- a/src/core/execution/nation/structureSpawnTileValue.ts +++ b/src/core/execution/nation/structureSpawnTileValue.ts @@ -1,4 +1,4 @@ -import { Game, Player, UnitType } from "../../game/Game"; +import { Game, Player, Relation, UnitType } from "../../game/Game"; import { TileRef } from "../../game/GameMap"; import { closestTwoTiles } from "../Util"; @@ -46,7 +46,47 @@ export function structureSpawnTileValue( return (tile) => { let w = 0; - // Prefer to be far away from other structures of the same type + // Prefer to be away from other structures of the same type + const otherTiles: Set = new Set(otherUnits.map((u) => u.tile())); + otherTiles.delete(tile); + const closestOther = closestTwoTiles(mg, otherTiles, [tile]); + if (closestOther !== null) { + const d = mg.manhattanDist(closestOther.x, tile); + w += Math.min(d, structureSpacing); + } + + return w; + }; + } + case UnitType.DefensePost: { + return (tile) => { + let w = 0; + + // Prefer higher elevations + w += mg.magnitude(tile); + + const closestBorder = closestTwoTiles(mg, borderTiles, [tile]); + if (closestBorder !== null) { + // Prefer to be borderSpacing tiles from the border + const d = mg.manhattanDist(closestBorder.x, tile); + w += Math.max(0, borderSpacing - Math.abs(borderSpacing - d)); + + // Prefer adjacent players who are hostile + const neighbors: Set = new Set(); + for (const tile of mg.neighbors(closestBorder.x)) { + if (!mg.isLand(tile)) continue; + const id = mg.ownerID(tile); + if (id === player.smallID()) continue; + const neighbor = mg.playerBySmallID(id); + if (!neighbor.isPlayer()) continue; + neighbors.add(neighbor); + } + for (const neighbor of neighbors) { + w += borderSpacing * (Relation.Friendly - player.relation(neighbor)); + } + } + + // Prefer to be away from other structures of the same type const otherTiles: Set = new Set(otherUnits.map((u) => u.tile())); otherTiles.delete(tile); const closestOther = closestTwoTiles(mg, otherTiles, [tile]); @@ -61,7 +101,7 @@ export function structureSpawnTileValue( case UnitType.SAMLauncher: { const protectTiles: Set = new Set(); for (const unit of player.units()) { - switch(unit.type()) { + switch (unit.type()) { case UnitType.City: case UnitType.Factory: case UnitType.MissileSilo: @@ -84,7 +124,7 @@ export function structureSpawnTileValue( w += Math.min(d, borderSpacing); } - // Prefer to be far away from other structures of the same type + // Prefer to be away from other structures of the same type const otherTiles: Set = new Set(otherUnits.map((u) => u.tile())); otherTiles.delete(tile); const closestOther = closestTwoTiles(mg, otherTiles, [tile]); From 81bd98c8d68db817a7d6d7319d86f46d5108e993 Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Tue, 26 Aug 2025 17:57:40 -0400 Subject: [PATCH 4/9] Nations send emoji when declining assistance requests (#1911) Nations will now send emoji when declining assistance requests. - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced Cancel alliance requests if the recipient attacks (#1733) Problem: attacking a player right before accepting an alliance request is very effective since the requester can't fight back or reclaim his territory without canceling the alliance and being penalized with the traitor debuff. Change: - Attacking a player after he requested an alliance automatically rejects the request - No changes to existing attacks in both directions, only new attacks affect the request - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I have read and accepted the CLA agreement (only required once). regression is found: IngloriousTom --- src/client/graphics/layers/EmojiTable.ts | 4 +- src/client/graphics/layers/PlayerPanel.ts | 9 +++-- .../graphics/layers/RadialMenuElements.ts | 4 +- src/core/Util.ts | 9 +++-- src/core/execution/FakeHumanExecution.ts | 16 ++++---- src/core/execution/utils/BotBehavior.ts | 38 +++++++++++++++---- 6 files changed, 53 insertions(+), 27 deletions(-) diff --git a/src/client/graphics/layers/EmojiTable.ts b/src/client/graphics/layers/EmojiTable.ts index aefec7ade..ca87f009b 100644 --- a/src/client/graphics/layers/EmojiTable.ts +++ b/src/client/graphics/layers/EmojiTable.ts @@ -4,7 +4,7 @@ import { EventBus } from "../../../core/EventBus"; import { AllPlayers } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { TerraNulliusImpl } from "../../../core/game/TerraNulliusImpl"; -import { emojiTable, flattenedEmojiTable } from "../../../core/Util"; +import { Emoji, emojiTable, flattenedEmojiTable } from "../../../core/Util"; import { CloseViewEvent, ShowEmojiMenuEvent } from "../../InputHandler"; import { SendEmojiIntentEvent } from "../../Transport"; import { TransformHandler } from "../TransformHandler"; @@ -42,7 +42,7 @@ export class EmojiTable extends LitElement { eventBus.emit( new SendEmojiIntentEvent( recipient, - flattenedEmojiTable.indexOf(emoji), + flattenedEmojiTable.indexOf(emoji as Emoji), ), ); this.hideTable(); diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 65d4bebf1..d3c03bc48 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -20,7 +20,7 @@ import { } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; -import { flattenedEmojiTable } from "../../../core/Util"; +import { Emoji, flattenedEmojiTable } from "../../../core/Util"; import { actionButton } from "../../components/ui/ActionButton"; import "../../components/ui/Divider"; import Countries from "../../data/countries.json"; @@ -218,12 +218,15 @@ export class PlayerPanel extends LitElement implements Layer { this.eventBus.emit( new SendEmojiIntentEvent( AllPlayers, - flattenedEmojiTable.indexOf(emoji), + flattenedEmojiTable.indexOf(emoji as Emoji), ), ); } else { this.eventBus.emit( - new SendEmojiIntentEvent(other, flattenedEmojiTable.indexOf(emoji)), + new SendEmojiIntentEvent( + other, + flattenedEmojiTable.indexOf(emoji as Emoji), + ), ); } this.emojiTable.hideTable(); diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index df95334d8..9e1fe21ef 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -2,7 +2,7 @@ import { Config } from "../../../core/configuration/Config"; import { AllPlayers, PlayerActions, UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; -import { flattenedEmojiTable } from "../../../core/Util"; +import { Emoji, flattenedEmojiTable } from "../../../core/Util"; import { renderNumber, translateText } from "../../Utils"; import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu"; import { ChatIntegration } from "./ChatIntegration"; @@ -271,7 +271,7 @@ const infoEmojiElement: MenuElement = { : params.selected; params.playerActionHandler.handleEmoji( targetPlayer!, - flattenedEmojiTable.indexOf(emoji), + flattenedEmojiTable.indexOf(emoji as Emoji), ); params.emojiTable.hideTable(); }); diff --git a/src/core/Util.ts b/src/core/Util.ts index 8d30c1a23..c16ecd465 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -257,7 +257,7 @@ export function createRandomName( return randomName; } -export const emojiTable: string[][] = [ +export const emojiTable = [ ["😀", "😊", "🥰", "😇", "😎"], ["😞", "🥺", "😭", "😱", "😡"], ["😈", "🤡", "🖕", "🥱", "🤦‍♂️"], @@ -269,9 +269,12 @@ export const emojiTable: string[][] = [ ["⬅️", "🎯", "➡️", "🥈", "🥉"], ["↙️", "⬇️", "↘️", "❤️", "💔"], ["💰", "⚓", "⛵", "🏡", "🛡️"], -]; +] as const; + +export type Emoji = (typeof emojiTable)[number][number]; + // 2d to 1d array -export const flattenedEmojiTable: string[] = emojiTable.flat(); +export const flattenedEmojiTable = emojiTable.flat(); /** * JSON.stringify replacer function that converts bigint values to strings. diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index bae5b3690..344ec0866 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -14,10 +14,10 @@ import { Unit, UnitType, } from "../game/Game"; -import { euclDistFN, manhattanDistFN, TileRef } from "../game/GameMap"; +import { TileRef, euclDistFN, manhattanDistFN } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; import { GameID } from "../Schemas"; -import { calculateBoundingBox, flattenedEmojiTable, simpleHash } from "../Util"; +import { calculateBoundingBox, simpleHash } from "../Util"; import { ConstructionExecution } from "./ConstructionExecution"; import { EmojiExecution } from "./EmojiExecution"; import { structureSpawnTileValue } from "./nation/structureSpawnTileValue"; @@ -25,7 +25,7 @@ import { NukeExecution } from "./NukeExecution"; import { SpawnExecution } from "./SpawnExecution"; import { TransportShipExecution } from "./TransportShipExecution"; import { closestTwoTiles } from "./Util"; -import { BotBehavior } from "./utils/BotBehavior"; +import { BotBehavior, EMOJI_HECKLE } from "./utils/BotBehavior"; export class FakeHumanExecution implements Execution { private active = true; @@ -40,10 +40,9 @@ export class FakeHumanExecution implements Execution { private reserveRatio: number; private expandRatio: number; - private lastEmojiSent = new Map(); - private lastNukeSent: [Tick, TileRef][] = []; - private embargoMalusApplied = new Set(); - private heckleEmoji: number[]; + private readonly lastEmojiSent = new Map(); + private readonly lastNukeSent: [Tick, TileRef][] = []; + private readonly embargoMalusApplied = new Set(); constructor( gameID: GameID, @@ -57,7 +56,6 @@ export class FakeHumanExecution implements Execution { this.triggerRatio = this.random.nextInt(60, 90) / 100; this.reserveRatio = this.random.nextInt(30, 60) / 100; this.expandRatio = this.random.nextInt(15, 25) / 100; - this.heckleEmoji = ["🤡", "😡"].map((e) => flattenedEmojiTable.indexOf(e)); } init(mg: Game) { @@ -309,7 +307,7 @@ export class FakeHumanExecution implements Execution { new EmojiExecution( this.player, enemy.id(), - this.random.randElement(this.heckleEmoji), + this.random.randElement(EMOJI_HECKLE), ), ); } diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts index 91c7e03a9..0774ee102 100644 --- a/src/core/execution/utils/BotBehavior.ts +++ b/src/core/execution/utils/BotBehavior.ts @@ -13,12 +13,34 @@ import { AllianceExtensionExecution } from "../alliance/AllianceExtensionExecuti import { AttackExecution } from "../AttackExecution"; import { EmojiExecution } from "../EmojiExecution"; +const emojiId = (e: typeof flattenedEmojiTable[number]) => flattenedEmojiTable.indexOf(e); +const EMOJI_ASSIST_ACCEPT = ([ + "👍", + "⛵", + "🤝", + "🎯", +] as const).map(emojiId); +const EMOJI_RELATION_TOO_LOW = ([ + "🥱", + "🤦‍♂️", +] as const).map(emojiId); +const EMOJI_TARGET_ME = ([ + "🥺", + "💀", +] as const).map(emojiId); +const EMOJI_TARGET_ALLY = ([ + "🕊️", + "👎", +] as const).map(emojiId); +export const EMOJI_HECKLE = ([ + "🤡", + "😡", +] as const).map(emojiId); + export class BotBehavior { private enemy: Player | null = null; private enemyUpdated: Tick; - private assistAcceptEmoji = flattenedEmojiTable.indexOf("👍"); - constructor( private random: PseudoRandom, private game: Game, @@ -110,26 +132,26 @@ export class BotBehavior { } assistAllies() { - outer: for (const ally of this.player.allies()) { + for (const ally of this.player.allies()) { if (ally.targets().length === 0) continue; if (this.player.relation(ally) < Relation.Friendly) { - // this.emoji(ally, "🤦"); + this.emoji(ally, this.random.randElement(EMOJI_RELATION_TOO_LOW)); continue; } for (const target of ally.targets()) { if (target === this.player) { - // this.emoji(ally, "💀"); + this.emoji(ally, this.random.randElement(EMOJI_TARGET_ME)); continue; } if (this.player.isAlliedWith(target)) { - // this.emoji(ally, "👎"); + this.emoji(ally, this.random.randElement(EMOJI_TARGET_ALLY)); continue; } // All checks passed, assist them this.player.updateRelation(ally, -20); this.setNewEnemy(target); - this.emoji(ally, this.assistAcceptEmoji); - break outer; + this.emoji(ally, this.random.randElement(EMOJI_ASSIST_ACCEPT)); + return; } } } From 8308d7f1e7fa755a1f4bd3bedcf93e0c6aa61b79 Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Tue, 26 Aug 2025 17:57:40 -0400 Subject: [PATCH 5/9] Nations send emoji when declining assistance requests (#1911) ## Description: Nations will now send emoji when declining assistance requests. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced --- src/core/Util.ts | 5 ++- .../nation/structureSpawnTileValue.ts | 19 ++++++++--- src/core/execution/utils/BotBehavior.ts | 34 +++++-------------- 3 files changed, 25 insertions(+), 33 deletions(-) diff --git a/src/core/Util.ts b/src/core/Util.ts index c16ecd465..d74d27b11 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -270,12 +270,11 @@ export const emojiTable = [ ["↙️", "⬇️", "↘️", "❤️", "💔"], ["💰", "⚓", "⛵", "🏡", "🛡️"], ] as const; - -export type Emoji = (typeof emojiTable)[number][number]; - // 2d to 1d array export const flattenedEmojiTable = emojiTable.flat(); +export type Emoji = (typeof flattenedEmojiTable)[number]; + /** * JSON.stringify replacer function that converts bigint values to strings. */ diff --git a/src/core/execution/nation/structureSpawnTileValue.ts b/src/core/execution/nation/structureSpawnTileValue.ts index e1a716e7c..5c8edae45 100644 --- a/src/core/execution/nation/structureSpawnTileValue.ts +++ b/src/core/execution/nation/structureSpawnTileValue.ts @@ -30,7 +30,9 @@ export function structureSpawnTileValue( } // Prefer to be away from other structures of the same type - const otherTiles: Set = new Set(otherUnits.map((u) => u.tile())); + const otherTiles: Set = new Set( + otherUnits.map((u) => u.tile()), + ); otherTiles.delete(tile); const closestOther = closestTwoTiles(mg, otherTiles, [tile]); if (closestOther !== null) { @@ -47,7 +49,9 @@ export function structureSpawnTileValue( let w = 0; // Prefer to be away from other structures of the same type - const otherTiles: Set = new Set(otherUnits.map((u) => u.tile())); + const otherTiles: Set = new Set( + otherUnits.map((u) => u.tile()), + ); otherTiles.delete(tile); const closestOther = closestTwoTiles(mg, otherTiles, [tile]); if (closestOther !== null) { @@ -82,12 +86,15 @@ export function structureSpawnTileValue( neighbors.add(neighbor); } for (const neighbor of neighbors) { - w += borderSpacing * (Relation.Friendly - player.relation(neighbor)); + w += + borderSpacing * (Relation.Friendly - player.relation(neighbor)); } } // Prefer to be away from other structures of the same type - const otherTiles: Set = new Set(otherUnits.map((u) => u.tile())); + const otherTiles: Set = new Set( + otherUnits.map((u) => u.tile()), + ); otherTiles.delete(tile); const closestOther = closestTwoTiles(mg, otherTiles, [tile]); if (closestOther !== null) { @@ -125,7 +132,9 @@ export function structureSpawnTileValue( } // Prefer to be away from other structures of the same type - const otherTiles: Set = new Set(otherUnits.map((u) => u.tile())); + const otherTiles: Set = new Set( + otherUnits.map((u) => u.tile()), + ); otherTiles.delete(tile); const closestOther = closestTwoTiles(mg, otherTiles, [tile]); if (closestOther !== null) { diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts index 0774ee102..b3b9378f7 100644 --- a/src/core/execution/utils/BotBehavior.ts +++ b/src/core/execution/utils/BotBehavior.ts @@ -13,33 +13,17 @@ import { AllianceExtensionExecution } from "../alliance/AllianceExtensionExecuti import { AttackExecution } from "../AttackExecution"; import { EmojiExecution } from "../EmojiExecution"; -const emojiId = (e: typeof flattenedEmojiTable[number]) => flattenedEmojiTable.indexOf(e); -const EMOJI_ASSIST_ACCEPT = ([ - "👍", - "⛵", - "🤝", - "🎯", -] as const).map(emojiId); -const EMOJI_RELATION_TOO_LOW = ([ - "🥱", - "🤦‍♂️", -] as const).map(emojiId); -const EMOJI_TARGET_ME = ([ - "🥺", - "💀", -] as const).map(emojiId); -const EMOJI_TARGET_ALLY = ([ - "🕊️", - "👎", -] as const).map(emojiId); -export const EMOJI_HECKLE = ([ - "🤡", - "😡", -] as const).map(emojiId); +const emojiId = (e: (typeof flattenedEmojiTable)[number]) => + flattenedEmojiTable.indexOf(e); +const EMOJI_ASSIST_ACCEPT = (["👍", "⛵", "🤝", "🎯"] as const).map(emojiId); +const EMOJI_RELATION_TOO_LOW = (["🥱", "🤦‍♂️"] as const).map(emojiId); +const EMOJI_TARGET_ME = (["🥺", "💀"] as const).map(emojiId); +const EMOJI_TARGET_ALLY = (["🕊️", "👎"] as const).map(emojiId); +export const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId); export class BotBehavior { private enemy: Player | null = null; - private enemyUpdated: Tick; + private enemyUpdated: Tick | undefined; constructor( private random: PseudoRandom, @@ -98,7 +82,7 @@ export class BotBehavior { forgetOldEnemies() { // Forget old enemies - if (this.game.ticks() - this.enemyUpdated > 100) { + if (this.game.ticks() - (this.enemyUpdated ?? 0) > 100) { this.clearEnemy(); } } From d83a66196a2cf8de019a7219c23c1e289d994f7f Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Thu, 28 Aug 2025 02:16:22 -0400 Subject: [PATCH 6/9] bugfix: Nations rarely launch nukes (#1860) ## Description: Simplify nation enemy selection to make nations more likely to launch nukes. Partially fixes #1855 by addressing a v24 regression in nation behavior. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced --- src/core/execution/BotExecution.ts | 4 +- src/core/execution/FakeHumanExecution.ts | 19 ++---- src/core/execution/utils/BotBehavior.ts | 75 +++++++++++++++++++++--- 3 files changed, 73 insertions(+), 25 deletions(-) diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts index 8535a9b81..69f1f4f29 100644 --- a/src/core/execution/BotExecution.ts +++ b/src/core/execution/BotExecution.ts @@ -20,8 +20,8 @@ export class BotExecution implements Execution { this.random = new PseudoRandom(simpleHash(bot.id())); this.attackRate = this.random.nextInt(40, 80); this.attackTick = this.random.nextInt(0, this.attackRate); - this.triggerRatio = this.random.nextInt(60, 90) / 100; - this.reserveRatio = this.random.nextInt(20, 30) / 100; + this.triggerRatio = this.random.nextInt(50, 60) / 100; + this.reserveRatio = this.random.nextInt(30, 40) / 100; this.expandRatio = this.random.nextInt(10, 20) / 100; } diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index 344ec0866..73c56c1ec 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -53,9 +53,9 @@ export class FakeHumanExecution implements Execution { ); this.attackRate = this.random.nextInt(40, 80); this.attackTick = this.random.nextInt(0, this.attackRate); - this.triggerRatio = this.random.nextInt(60, 90) / 100; - this.reserveRatio = this.random.nextInt(30, 60) / 100; - this.expandRatio = this.random.nextInt(15, 25) / 100; + this.triggerRatio = this.random.nextInt(50, 60) / 100; + this.reserveRatio = this.random.nextInt(30, 40) / 100; + this.expandRatio = this.random.nextInt(10, 20) / 100; } init(mg: Game) { @@ -223,23 +223,12 @@ export class FakeHumanExecution implements Execution { const toAlly = this.random.randElement(enemies); if (this.player.canSendAllianceRequest(toAlly)) { this.player.createAllianceRequest(toAlly); - return; } } - // 50-50 attack weakest player vs random player - const toAttack = this.random.chance(2) - ? enemies[0] - : this.random.randElement(enemies); - - if (this.shouldAttack(toAttack)) { - this.behavior.sendAttack(toAttack); - return; - } - this.behavior.forgetOldEnemies(); this.behavior.assistAllies(); - const enemy = this.behavior.selectEnemy(); + const enemy = this.behavior.selectEnemy(enemies); if (!enemy) return; this.maybeSendEmoji(enemy); this.maybeSendNuke(enemy); diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts index b3b9378f7..3cff57227 100644 --- a/src/core/execution/utils/BotBehavior.ts +++ b/src/core/execution/utils/BotBehavior.ts @@ -1,5 +1,6 @@ import { AllianceRequest, + Difficulty, Game, Player, PlayerType, @@ -71,11 +72,48 @@ export class BotBehavior { this.game.addExecution(new EmojiExecution(this.player, player.id(), emoji)); } - private setNewEnemy(newEnemy: Player | null) { + private setNewEnemy(newEnemy: Player | null, force = false) { + if (newEnemy !== null && !force && !this.shouldAttack(newEnemy)) return; this.enemy = newEnemy; this.enemyUpdated = this.game.ticks(); } + private shouldAttack(other: Player): boolean { + if (this.player === null) throw new Error("not initialized"); + if (this.player.isOnSameTeam(other)) { + return false; + } + if (this.player.isFriendly(other)) { + if (this.shouldDiscourageAttack(other)) { + return this.random.chance(200); + } + return this.random.chance(50); + } else { + if (this.shouldDiscourageAttack(other)) { + return this.random.chance(4); + } + return true; + } + } + + private shouldDiscourageAttack(other: Player) { + if (other.isTraitor()) { + return false; + } + const { difficulty } = this.game.config().gameConfig(); + if ( + difficulty === Difficulty.Hard || + difficulty === Difficulty.Impossible + ) { + return false; + } + if (other.type() !== PlayerType.Human) { + return false; + } + // Only discourage attacks on Humans who are not traitors on easy or medium difficulty. + return true; + } + private clearEnemy() { this.enemy = null; } @@ -87,7 +125,13 @@ export class BotBehavior { } } - private hasSufficientTroops(): boolean { + private hasReserveRatioTroops(): boolean { + const maxTroops = this.game.config().maxTroops(this.player); + const ratio = this.player.troops() / maxTroops; + return ratio >= this.reserveRatio; + } + + private hasTriggerRatioTroops(): boolean { const maxTroops = this.game.config().maxTroops(this.player); const ratio = this.player.troops() / maxTroops; return ratio >= this.triggerRatio; @@ -104,7 +148,7 @@ export class BotBehavior { largestAttacker = attack.attacker(); } if (largestAttacker !== undefined) { - this.setNewEnemy(largestAttacker); + this.setNewEnemy(largestAttacker, true); } } @@ -140,10 +184,13 @@ export class BotBehavior { } } - selectEnemy(): Player | null { + selectEnemy(enemies: Player[]): Player | null { if (this.enemy === null) { - // Save up troops until we reach the trigger ratio - if (!this.hasSufficientTroops()) return null; + // Save up troops until we reach the reserve ratio + if (!this.hasReserveRatioTroops()) return null; + + // Maybe save up troops until we reach the trigger ratio + if (!this.hasTriggerRatioTroops() && !this.random.chance(10)) return null; // Prefer neighboring bots const bots = this.player @@ -171,11 +218,13 @@ export class BotBehavior { // Retaliate against incoming attacks if (this.enemy === null) { + // Only after clearing bots this.checkIncomingAttacks(); } // Select the most hated player - if (this.enemy === null) { + if (this.enemy === null && this.random.chance(2)) { + // 50% chance const mostHated = this.player.allRelationsSorted()[0]; if ( mostHated !== undefined && @@ -184,6 +233,16 @@ export class BotBehavior { this.setNewEnemy(mostHated.player); } } + + // Select the weakest player + if (this.enemy === null && enemies.length > 0) { + this.setNewEnemy(enemies[0]); + } + + // Select a random player + if (this.enemy === null && enemies.length > 0) { + this.setNewEnemy(this.random.randElement(enemies)); + } } // Sanity check, don't attack our allies or teammates @@ -193,7 +252,7 @@ export class BotBehavior { selectRandomEnemy(): Player | TerraNullius | null { if (this.enemy === null) { // Save up troops until we reach the trigger ratio - if (!this.hasSufficientTroops()) return null; + if (!this.hasTriggerRatioTroops()) return null; // Choose a new enemy randomly const neighbors = this.player.neighbors(); From e216b8a22bd1eef2c8ffc945db56e2003be6d6a1 Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Thu, 28 Aug 2025 03:22:46 -0400 Subject: [PATCH 7/9] cleanup: Nations rarely launch nukes (#1948) ## Description: In #1860, some unused code was left behind. Remove the unused code. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced --- src/core/execution/FakeHumanExecution.ts | 48 ------------------------ 1 file changed, 48 deletions(-) diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index 73c56c1ec..aaefa84de 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -1,6 +1,5 @@ import { Cell, - Difficulty, Execution, Game, Gold, @@ -239,53 +238,6 @@ export class FakeHumanExecution implements Execution { } } - private shouldAttack(other: Player): boolean { - if (this.player === null) throw new Error("not initialized"); - - if (this.player.isOnSameTeam(other)) { - return false; - } - - const shouldAttack = this.attackChance(other); - - // Consider betrayal for allies - if (shouldAttack && this.player.isAlliedWith(other)) { - return this.maybeConsiderBetrayal(other); - } - - return shouldAttack; - } - - private attackChance(other: Player): boolean { - if (this.player === null) throw new Error("not initialized"); - - if (this.player.isAlliedWith(other)) { - return this.shouldDiscourageAttack(other) - ? this.random.chance(200) - : this.random.chance(50); - } else { - return this.shouldDiscourageAttack(other) ? this.random.chance(4) : true; - } - } - - private shouldDiscourageAttack(other: Player) { - if (other.isTraitor()) { - return false; - } - const difficulty = this.mg.config().gameConfig().difficulty; - if ( - difficulty === Difficulty.Hard || - difficulty === Difficulty.Impossible - ) { - return false; - } - if (other.type() !== PlayerType.Human) { - return false; - } - // Only discourage attacks on Humans who are not traitors on easy or medium difficulty. - return true; - } - private maybeSendEmoji(enemy: Player) { if (this.player === null) throw new Error("not initialized"); if (enemy.type() !== PlayerType.Human) return; From d42fd9b5fc9dfd413eb4636d332ea5b3ee180260 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 7 Oct 2025 15:20:39 -0700 Subject: [PATCH 8/9] reduce global coverage --- jest.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.config.ts b/jest.config.ts index d1f6e3c7a..52d147124 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -17,7 +17,7 @@ export default { coverageThreshold: { global: { statements: 21.5, - branches: 17.0, + branches: 16.5, lines: 22.0, functions: 20.5, }, From b03f9778dbcada54e0f47232af4cd511a8629980 Mon Sep 17 00:00:00 2001 From: Abdallah Bahrawi <140177728+abdallahbahrawi1@users.noreply.github.com> Date: Thu, 2 Oct 2025 21:59:06 +0300 Subject: [PATCH 9/9] Fix nations break alliance too ealry bug (#2123) Betrayal was being considered too early (inside shouldAttack), causing alliances to break before calling attackChance. - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced regression is found: abodcraft1 --- src/core/execution/utils/BotBehavior.ts | 32 ++++++++++++++++++------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts index 3cff57227..2fd64cc71 100644 --- a/src/core/execution/utils/BotBehavior.ts +++ b/src/core/execution/utils/BotBehavior.ts @@ -83,17 +83,31 @@ export class BotBehavior { if (this.player.isOnSameTeam(other)) { return false; } - if (this.player.isFriendly(other)) { - if (this.shouldDiscourageAttack(other)) { - return this.random.chance(200); - } - return this.random.chance(50); - } else { - if (this.shouldDiscourageAttack(other)) { - return this.random.chance(4); - } + const shouldAttack = this.attackChance(other); + if (shouldAttack && this.player.isAlliedWith(other)) { + this.betray(other); return true; } + return shouldAttack; + } + + private betray(target: Player): void { + if (this.player === null) throw new Error("not initialized"); + const alliance = this.player.allianceWith(target); + if (!alliance) return; + this.player.breakAlliance(alliance); + } + + private attackChance(other: Player): boolean { + if (this.player === null) throw new Error("not initialized"); + + if (this.player.isAlliedWith(other)) { + return this.shouldDiscourageAttack(other) + ? this.random.chance(200) + : this.random.chance(50); + } else { + return this.shouldDiscourageAttack(other) ? this.random.chance(4) : true; + } } private shouldDiscourageAttack(other: Player) {