diff --git a/TODO.txt b/TODO.txt index 0d1c99d1a..7b078ee07 100644 --- a/TODO.txt +++ b/TODO.txt @@ -60,15 +60,19 @@ * boats can go around the world DONE 8/29/2024 * max boats (3) DONE 8/30/2024 * PERF: more efficient spawns DONE 8/30/2024 -* PERF: load terrain map async +* PERF: load terrain map async DONE 8/30/2024 +* if completely surrended, lose piece of land DONE 8/30/2024 +* Add terrain elevation to map * PERF: enable CDN * enable load balancing metrics * end game when no players left (or after 1 hour or so?) -* if completely surrended, lose piece of land * use better favicon * BUG: tiles get left behind during conquer -* Add terrain elevation to map * REFACTOR: give terranullius an ID, game.player() returns terranullius * REFACTOR: ocean is considered TerraNullius ? * Create exit to menu button * Make fake humans +* Load terrain dataImage in background +* BUG: shore tiles left behind during conquer +* BUG: when sending boat to TerraNullius, only takes one tile +* directed expansion diff --git a/src/client/ClientGame.ts b/src/client/ClientGame.ts index 50091443b..f768d0b28 100644 --- a/src/client/ClientGame.ts +++ b/src/client/ClientGame.ts @@ -224,7 +224,7 @@ export class ClientGame { // Attack Terra Nullius if (tile.isLand()) { - const neighbors = Array.from(bfs(tile, and((r, t) => t.isLand(), dist(100)))); + const neighbors = Array.from(bfs(tile, and((t) => t.isLand(), dist(tile, 100)))); for (const n of neighbors) { if (this.myPlayer.borderTiles().has(n)) { this.sendAttackIntent(targetID, cell, this.gs.config().attackAmount(this.myPlayer, owner)) @@ -232,7 +232,7 @@ export class ClientGame { } } - const tn = Array.from(bfs(tile, dist(30))) + const tn = Array.from(bfs(tile, dist(tile, 30))) .filter(t => t.isOceanShore()) .filter(t => !t.hasOwner()) .sort((a, b) => manhattanDist(tile.cell(), a.cell()) - manhattanDist(tile.cell(), b.cell())) @@ -248,7 +248,7 @@ export class ClientGame { if (!bordersOcean) { return } - const tn = Array.from(bfs(tile, dist(3))) + const tn = Array.from(bfs(tile, dist(tile, 3))) .filter(t => t.isOceanShore()) .filter(t => !t.hasOwner()) .sort((a, b) => manhattanDist(tile.cell(), a.cell()) - manhattanDist(tile.cell(), b.cell())) diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index fe889d62c..76dd79fab 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -117,6 +117,9 @@ export class GameRenderer { renderTerritory() { let numToRender = Math.floor(this.tileToRenderQueue.size() / 10) + if (numToRender == 0) { + numToRender = this.tileToRenderQueue.size() + } while (numToRender > 0) { numToRender-- @@ -157,10 +160,10 @@ export class GameRenderer { } boatEvent(event: BoatEvent) { - bfs(event.oldTile, dist(2)).forEach(t => this.paintTerritory(t)) + bfs(event.oldTile, dist(event.oldTile, 2)).forEach(t => this.paintTerritory(t)) if (event.boat.isActive()) { - bfs(event.boat.tile(), dist(2)).forEach(t => this.paintCell(t.cell(), this.theme.borderColor(event.boat.owner().id()))) - bfs(event.boat.tile(), dist(1)).forEach(t => this.paintCell(t.cell(), this.theme.territoryColor(event.boat.owner().id()))) + bfs(event.boat.tile(), dist(event.boat.tile(), 2)).forEach(t => this.paintCell(t.cell(), this.theme.borderColor(event.boat.owner().id()))) + bfs(event.boat.tile(), dist(event.boat.tile(), 1)).forEach(t => this.paintCell(t.cell(), this.theme.territoryColor(event.boat.owner().id()))) } } diff --git a/src/client/graphics/NameBoxCalculator.ts b/src/client/graphics/NameBoxCalculator.ts index 361d27647..5c3d2399c 100644 --- a/src/client/graphics/NameBoxCalculator.ts +++ b/src/client/graphics/NameBoxCalculator.ts @@ -1,5 +1,5 @@ import {Game, Player, Tile, Cell} from '../../core/Game'; -import {within} from '../../core/Util'; +import {calculateBoundingBox, within} from '../../core/Util'; export interface Point { x: number; @@ -15,7 +15,7 @@ export interface Rectangle { export function placeName(game: Game, player: Player): [position: Cell, fontSize: number] { - const boundingBox = calculateBoundingBox(player); + const boundingBox = calculateBoundingBox(player.borderTiles()); const rawScalingFactor = (boundingBox.max.x - boundingBox.min.x) / 50 const scalingFactor = within(Math.floor(rawScalingFactor), 1, 100) @@ -37,20 +37,6 @@ export function placeName(game: Game, player: Player): [position: Cell, fontSize return [center, fontSize] } -export function calculateBoundingBox(player: Player): {min: Cell; max: Cell} { - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - - player.borderTiles().forEach((tile: Tile) => { - const cell = tile.cell(); - minX = Math.min(minX, cell.x); - minY = Math.min(minY, cell.y); - maxX = Math.max(maxX, cell.x); - maxY = Math.max(maxY, cell.y); - }); - - return {min: new Cell(minX, minY), max: new Cell(maxX, maxY)} -} - export function createGrid(game: Game, player: Player, boundingBox: {min: Point; max: Point}, scalingFactor: number): boolean[][] { const scaledBoundingBox: {min: Point; max: Point} = { min: { diff --git a/src/client/graphics/NameRenderer.ts b/src/client/graphics/NameRenderer.ts index f19a3c4fd..5c3ab2894 100644 --- a/src/client/graphics/NameRenderer.ts +++ b/src/client/graphics/NameRenderer.ts @@ -1,7 +1,8 @@ import {Cell, Game, Player} from "../../core/Game" import {PseudoRandom} from "../../core/PseudoRandom" +import {calculateBoundingBox} from "../../core/Util" import {Theme} from "../../core/configuration/Config" -import {calculateBoundingBox, placeName} from "./NameBoxCalculator" +import {placeName} from "./NameBoxCalculator" class RenderInfo { public isVisible = true @@ -62,7 +63,7 @@ export class NameRenderer { for (const render of this.renders) { const now = Date.now() if (now - render.lastBoundingCalculated > this.refreshRate) { - render.boundingBox = calculateBoundingBox(render.player); + render.boundingBox = calculateBoundingBox(render.player.borderTiles()); render.lastBoundingCalculated = now } if (render.isVisible && now - render.lastRenderCalc > this.refreshRate) { diff --git a/src/core/GameImpl.ts b/src/core/GameImpl.ts index e502ab5d2..17a99e3e7 100644 --- a/src/core/GameImpl.ts +++ b/src/core/GameImpl.ts @@ -156,8 +156,7 @@ export class BoatImpl implements MutableBoat { } export class PlayerImpl implements MutablePlayer { - public _borderTiles: Map = new Map() - public _borderTileSet: Set = new Set() + public _borderTiles: Set = new Set() public _boats: BoatImpl[] = [] public _tiles: Map = new Map() @@ -200,7 +199,7 @@ export class PlayerImpl implements MutablePlayer { } sharesBorderWith(other: Player | TerraNullius): boolean { - for (const border of this._borderTileSet) { + for (const border of this._borderTiles) { for (const neighbor of border.neighbors()) { if (neighbor.owner() == other) { return true @@ -218,7 +217,7 @@ export class PlayerImpl implements MutablePlayer { } borderTiles(): ReadonlySet { - return this._borderTileSet + return this._borderTiles } neighbors(): (MutablePlayer | TerraNullius)[] { @@ -488,21 +487,20 @@ export class GameImpl implements MutableGame { } conquer(owner: PlayerImpl, tile: Tile): void { - if (tile.owner() == owner) { - throw new Error(`Player ${owner} already owns cell ${tile.cell().toString()}`) - } - if (!owner.isPlayer()) { - throw new Error("Must be a player") - } - if (tile.isWater()) { - throw new Error("Cannot conquer water") - } + // if (tile.owner() == owner) { + // throw new Error(`Player ${owner} already owns cell ${tile.cell().toString()}`) + // } + // if (!owner.isPlayer()) { + // throw new Error("Must be a player") + // } + // if (tile.isWater()) { + // throw new Error("Cannot conquer water") + // } const tileImpl = tile as TileImpl let previousOwner = tileImpl._owner if (previousOwner.isPlayer()) { previousOwner._tiles.delete(tile.cell().toString()) - previousOwner._borderTiles.delete(tile.cell().toString()) - previousOwner._borderTileSet.delete(tile) + previousOwner._borderTiles.delete(tile) tileImpl._isBorder = false } tileImpl._owner = owner @@ -522,8 +520,7 @@ export class GameImpl implements MutableGame { const tileImpl = tile as TileImpl let previousOwner = tileImpl._owner as PlayerImpl previousOwner._tiles.delete(tile.cell().toString()) - previousOwner._borderTiles.delete(tile.cell().toString()) - previousOwner._borderTileSet.delete(tile) + previousOwner._borderTiles.delete(tile) tileImpl._isBorder = false tileImpl._owner = this._terraNullius @@ -532,22 +529,21 @@ export class GameImpl implements MutableGame { } private updateBorders(tile: Tile) { - const tiles: Tile[] = [] - tiles.push(tile) - tile.neighbors().forEach(t => tiles.push(t)) + const tiles: TileImpl[] = [] + tiles.push(tile as TileImpl) + tile.neighbors().forEach(t => tiles.push(t as TileImpl)) for (const t of tiles) { if (!t.hasOwner()) { + t._isBorder = false continue } if (this.isBorder(t)) { - (t.owner() as PlayerImpl)._borderTiles.set(t.cell().toString(), t); - (t.owner() as PlayerImpl)._borderTileSet.add(t); - (t as TileImpl)._isBorder = true + (t.owner() as PlayerImpl)._borderTiles.add(t); + t._isBorder = true } else { - (t.owner() as PlayerImpl)._borderTiles.delete(t.cell().toString()); - (t.owner() as PlayerImpl)._borderTileSet.delete(t); - (t as TileImpl)._isBorder = false + (t.owner() as PlayerImpl)._borderTiles.delete(t); + t._isBorder = false } } } diff --git a/src/core/Util.ts b/src/core/Util.ts index 8d2084948..409e262e7 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -1,5 +1,5 @@ import {functional} from "typia"; -import {Cell, Tile} from "./Game"; +import {Cell, Player, Tile} from "./Game"; export function manhattanDist(c1: Cell, c2: Cell): number { return Math.abs(c1.x - c2.x) + Math.abs(c1.y - c2.y); @@ -22,15 +22,15 @@ export function within(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } -export function dist(dist: number): (root: Tile, tile: Tile) => boolean { - return (root: Tile, n: Tile) => manhattanDist(root.cell(), n.cell()) <= dist; +export function dist(root: Tile, dist: number): (tile: Tile) => boolean { + return (n: Tile) => manhattanDist(root.cell(), n.cell()) <= dist; } -export function and(x: (root: Tile, tile: Tile) => boolean, y: (root: Tile, tile: Tile) => boolean): (root: Tile, tile: Tile) => boolean { - return (root: Tile, tile: Tile) => x(root, tile) && y(root, tile) +export function and(x: (tile: Tile) => boolean, y: (tile: Tile) => boolean): (tile: Tile) => boolean { + return (tile: Tile) => x(tile) && y(tile) } -export function bfs(tile: Tile, filter: (root: Tile, tile: Tile) => boolean): Set { +export function bfs(tile: Tile, filter: (tile: Tile) => boolean): Set { const seen = new Set const q: Tile[] = [] q.push(tile) @@ -38,7 +38,7 @@ export function bfs(tile: Tile, filter: (root: Tile, tile: Tile) => boolean): Se const curr = q.pop() seen.add(curr) for (const n of curr.neighbors()) { - if (!seen.has(n) && filter(tile, n)) { + if (!seen.has(n) && filter(n)) { q.push(n) } } @@ -54,4 +54,48 @@ export function simpleHash(str: string): number { hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash); +} + +export function calculateBoundingBox(borderTiles: ReadonlySet): {min: Cell; max: Cell} { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + + borderTiles.forEach((tile: Tile) => { + const cell = tile.cell(); + minX = Math.min(minX, cell.x); + minY = Math.min(minY, cell.y); + maxX = Math.max(maxX, cell.x); + maxY = Math.max(maxY, cell.y); + }); + + return {min: new Cell(minX, minY), max: new Cell(maxX, maxY)} +} + +export function inscribed(outer: { min: Cell; max: Cell }, inner: { min: Cell; max: Cell }): boolean { + return ( + outer.min.x <= inner.min.x && + outer.min.y <= inner.min.y && + outer.max.x >= inner.max.x && + outer.max.y >= inner.max.y + ); +} + +export function getMode(list: string[]): string { + // Count occurrences + const counts: {[key: string]: number} = {}; + for (const item of list) { + counts[item] = (counts[item] || 0) + 1; + } + + // Find the item with the highest count + let mode = ''; + let maxCount = 0; + + for (const item in counts) { + if (counts[item] > maxCount) { + maxCount = counts[item]; + mode = item; + } + } + + return mode; } \ No newline at end of file diff --git a/src/core/execution/BotSpawner.ts b/src/core/execution/BotSpawner.ts index 42e5dac8c..c2e70f823 100644 --- a/src/core/execution/BotSpawner.ts +++ b/src/core/execution/BotSpawner.ts @@ -12,7 +12,7 @@ export class BotSpawner { spawnBots(numBots: number): SpawnIntent[] { let tries = 0 - while (this.bots.length < numBots - 1) { + while (this.bots.length < numBots) { if (tries > 10000) { console.log('too many retries while spawning bots, giving up') return this.bots @@ -33,7 +33,7 @@ export class BotSpawner { return null } for (const spawn of this.bots) { - if (manhattanDist(new Cell(spawn.x, spawn.y), tile.cell()) < 50) { + if (manhattanDist(new Cell(spawn.x, spawn.y), tile.cell()) < 70) { return null } } diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 43d40d07e..d62497591 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -1,10 +1,19 @@ +import cluster from "cluster" import {Config} from "../configuration/Config" -import {Execution, MutableGame, MutablePlayer, PlayerID} from "../Game" +import {Execution, MutableGame, MutablePlayer, PlayerID, Tile} from "../Game" +import {bfs, calculateBoundingBox, getMode, inscribed, simpleHash} from "../Util" +import {GameImpl} from "../GameImpl" +import {gr} from "../../client/ClientGame" +import {AttackExecution} from "./AttackExecution" export class PlayerExecution implements Execution { + private readonly ticksPerIslandCalc = 50 + private player: MutablePlayer private config: Config + private lastCalc = 0 + private mg: MutableGame constructor(private playerID: PlayerID) { } @@ -14,8 +23,10 @@ export class PlayerExecution implements Execution { } init(mg: MutableGame, ticks: number) { + this.mg = mg this.config = mg.config() this.player = mg.player(this.playerID) + this.lastCalc = ticks + (simpleHash(this.player.name()) % this.ticksPerIslandCalc) } tick(ticks: number) { @@ -23,13 +34,103 @@ export class PlayerExecution implements Execution { return } this.player.setTroops(this.config.troopAdditionRate(this.player)) + + if (ticks - this.lastCalc > this.ticksPerIslandCalc) { + this.lastCalc = ticks + const start = performance.now() + this.removeIslands() + const end = performance.now() + if (end - start > 1000) { + console.log(`player ${this.player.name()}, took ${end - start}ms`) + } + } + } + + private removeIslands() { + const clusters = this.calculateClusters() + if (clusters.length <= 1) { + return + } + clusters.sort((a, b) => b.size - a.size); + const main = clusters.shift() + const mainBox = calculateBoundingBox(main) + for (const toRemove of clusters) { + const toRemoveBox = calculateBoundingBox(toRemove) + if (inscribed(mainBox, toRemoveBox)) { + return + } + + for (const tile of toRemove) { + if (tile.isOceanShore()) { + return + } + } + this.removeIsland(toRemove) + } + } + + private removeIsland(cluster: Set) { + console.log('removing island!') + const arr = Array.from(cluster) + const mode = getMode(arr.flatMap(t => t.neighbors()).filter(t => t.hasOwner() && t.owner() != this.player).map(t => t.owner().id())) + if (mode == null) { + console.warn('mode is null') + return + } + const firstTile = arr[0] + const filter = (n: Tile): boolean => n.owner() == firstTile.owner() + const tiles = bfs(firstTile, filter) + + const modePlayer = this.mg.player(mode) + for (const tile of tiles) { + modePlayer.conquer(tile) + } + } + + private calculateClusters(): Set[] { + const seen = new Set() + const border = this.player.borderTiles() + const clusters: Set[] = [] + for (const tile of border) { + if (seen.has(tile)) { + continue + } + + const cluster = new Set() + const queue: Tile[] = [tile] + seen.add(tile) + let loops = 0; + while (queue.length > 0) { + loops += 1 + const curr = queue.shift() + cluster.add(curr) + + const neighbors = (this.mg as GameImpl).neighborsWithDiag(curr) + for (const neighbor of neighbors) { + // if (this.mg.ticks() == 736 && loops > 580000) { + // // console.log(`got neighbor ${neighbor.cell().toString()}`) + // gr.paintBlack(neighbor) + // } + if (neighbor.isBorder() && border.has(neighbor)) { + if (!seen.has(neighbor)) { + queue.push(neighbor) + seen.add(neighbor) + } + } + } + } + clusters.push(cluster) + } + return clusters } owner(): MutablePlayer { return this.player } + private active = true isActive(): boolean { - return this.player.isAlive() + // return this.player.isAlive() + return this.active } } \ No newline at end of file