Merge branch 'evan-nations'

This commit is contained in:
evanpelle
2025-10-07 19:01:35 -07:00
10 changed files with 292 additions and 169 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ export default {
coverageThreshold: {
global: {
statements: 21.5,
branches: 17.0,
branches: 16.5,
lines: 22.0,
functions: 20.5,
},
+2 -2
View File
@@ -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();
+6 -3
View File
@@ -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();
@@ -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();
});
+5 -3
View File
@@ -257,7 +257,7 @@ export function createRandomName(
return randomName;
}
export const emojiTable: string[][] = [
export const emojiTable = [
["😀", "😊", "🥰", "😇", "😎"],
["😞", "🥺", "😭", "😱", "😡"],
["😈", "🤡", "🖕", "🥱", "🤦‍♂️"],
@@ -269,9 +269,11 @@ export const emojiTable: string[][] = [
["⬅️", "🎯", "➡️", "🥈", "🥉"],
["↙️", "⬇️", "↘️", "❤️", "💔"],
["💰", "⚓", "⛵", "🏡", "🛡️"],
];
] as const;
// 2d to 1d array
export const flattenedEmojiTable: string[] = emojiTable.flat();
export const flattenedEmojiTable = emojiTable.flat();
export type Emoji = (typeof flattenedEmojiTable)[number];
/**
* JSON.stringify replacer function that converts bigint values to strings.
+2 -2
View File
@@ -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;
}
+17 -137
View File
@@ -1,6 +1,5 @@
import {
Cell,
Difficulty,
Execution,
Game,
Gold,
@@ -14,17 +13,18 @@ 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";
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;
@@ -39,10 +39,9 @@ export class FakeHumanExecution implements Execution {
private reserveRatio: number;
private expandRatio: number;
private lastEmojiSent = new Map<Player, Tick>();
private lastNukeSent: [Tick, TileRef][] = [];
private embargoMalusApplied = new Set<PlayerID>();
private heckleEmoji: number[];
private readonly lastEmojiSent = new Map<Player, Tick>();
private readonly lastNukeSent: [Tick, TileRef][] = [];
private readonly embargoMalusApplied = new Set<PlayerID>();
constructor(
gameID: GameID,
@@ -53,10 +52,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.heckleEmoji = ["🤡", "😡"].map((e) => flattenedEmojiTable.indexOf(e));
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) {
@@ -224,23 +222,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);
@@ -251,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;
@@ -308,7 +248,7 @@ export class FakeHumanExecution implements Execution {
new EmojiExecution(
this.player,
enemy.id(),
this.random.randElement(this.heckleEmoji),
this.random.randElement(EMOJI_HECKLE),
),
);
}
@@ -460,6 +400,8 @@ 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)
);
}
@@ -486,7 +428,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 +437,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 +467,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<TileRef> = 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<TileRef> = 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)) {
+2 -1
View File
@@ -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;
}
@@ -0,0 +1,158 @@
import { Game, Player, Relation, 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.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<TileRef> = 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;
};
}
case UnitType.Port: {
return (tile) => {
let w = 0;
// Prefer to be away from other structures of the same type
const otherTiles: Set<TileRef> = 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<Player> = 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<TileRef> = 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<TileRef> = 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 away from other structures of the same type
const otherTiles: Set<TileRef> = 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}`);
}
}
+97 -18
View File
@@ -1,5 +1,6 @@
import {
AllianceRequest,
Difficulty,
Game,
Player,
PlayerType,
@@ -13,11 +14,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);
export class BotBehavior {
private enemy: Player | null = null;
private enemyUpdated: Tick;
private assistAcceptEmoji = flattenedEmojiTable.indexOf("👍");
private enemyUpdated: Tick | undefined;
constructor(
private random: PseudoRandom,
@@ -65,23 +72,80 @@ 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;
}
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) {
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;
}
forgetOldEnemies() {
// Forget old enemies
if (this.game.ticks() - this.enemyUpdated > 100) {
if (this.game.ticks() - (this.enemyUpdated ?? 0) > 100) {
this.clearEnemy();
}
}
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;
@@ -98,7 +162,7 @@ export class BotBehavior {
largestAttacker = attack.attacker();
}
if (largestAttacker !== undefined) {
this.setNewEnemy(largestAttacker);
this.setNewEnemy(largestAttacker, true);
}
}
@@ -110,34 +174,37 @@ 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;
}
}
}
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
@@ -165,11 +232,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 &&
@@ -178,6 +247,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
@@ -187,7 +266,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();