Files
Mykola 097c42740c Random spawn. Avoid spawning near water. (#3009)
## Description:

Fixing
https://discord.com/channels/1359946986937258015/1360078040222142564/1463898386854973642

Now, if not all tiles on the spawn circle can be owned, the algorithm
tries to select another random spawn tile.

## 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

## Please put your Discord username so you can be contacted if a bug or
regression is found:

nikolaj_mykola

---------

Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com>
2026-02-22 15:51:05 +00:00

282 lines
7.4 KiB
TypeScript

import { NukeMagnitude } from "../configuration/Config";
import { Game, Player, StructureTypes } from "../game/Game";
import { euclDistFN, GameMap, TileRef } from "../game/GameMap";
import { GameView } from "../game/GameView";
export interface NukeBlastParams {
gm: GameMap;
targetTile: TileRef;
magnitude: NukeMagnitude;
}
/**
* Counts how many tiles each player has in the nuke's blast zone.
*
* returns Map of player ID and weighted tile count
*/
export function computeNukeBlastCounts(
params: NukeBlastParams,
): Map<number, number> {
const { gm, targetTile, magnitude } = params;
const inner2 = magnitude.inner * magnitude.inner;
const counts = new Map<number, number>();
gm.circleSearch(targetTile, magnitude.outer, (tile: TileRef, d2: number) => {
const ownerSmallId = gm.ownerID(tile);
if (ownerSmallId > 0) {
const weight = d2 <= inner2 ? 1 : 0.5;
const prev = counts.get(ownerSmallId) ?? 0;
counts.set(ownerSmallId, prev + weight);
}
return true;
});
return counts;
}
export interface NukeAllianceCheckParams {
game: Game | GameView;
targetTile: TileRef;
magnitude: NukeMagnitude;
allySmallIds: Set<number>;
threshold: number;
}
// Checks if nuking this tile would break an alliance.
// Returns true if either:
// 1. The weighted tile count for any ally exceeds the threshold
// 2. Any allied structure would be destroyed
export function wouldNukeBreakAlliance(
params: NukeAllianceCheckParams,
): boolean {
const { game, targetTile, magnitude, allySmallIds, threshold } = params;
if (allySmallIds.size === 0) {
return false;
}
// Check if any allied structure would be destroyed
const wouldDestroyAlliedStructure = game.anyUnitNearby(
targetTile,
magnitude.outer,
StructureTypes,
(unit) =>
unit.owner().isPlayer() && allySmallIds.has(unit.owner().smallID()),
);
if (wouldDestroyAlliedStructure) return true;
const inner2 = magnitude.inner * magnitude.inner;
const allyTileCounts = new Map<number, number>();
let result = false;
game.circleSearch(
targetTile,
magnitude.outer,
(tile: TileRef, d2: number) => {
const ownerSmallId = game.ownerID(tile);
if (ownerSmallId > 0 && allySmallIds.has(ownerSmallId)) {
const weight = d2 <= inner2 ? 1 : 0.5;
const newCount = (allyTileCounts.get(ownerSmallId) ?? 0) + weight;
allyTileCounts.set(ownerSmallId, newCount);
if (newCount > threshold) {
result = true;
return false; // Found one! Stop searching.
}
}
return true;
},
);
return result;
}
// Same as wouldNukeBreakAlliance(), but takes time to find every player
// that would be "angered" from this nuke.
// This includes unallied players!
export function listNukeBreakAlliance(
params: NukeAllianceCheckParams,
): Set<number> {
const { game, targetTile, magnitude, threshold } = params;
// Collect all players that should have alliance broken:
// either exceeds tile threshold OR has a structure in blast radius
const playersToBreakAllianceWith = new Set<number>();
// compute tile breakage threshold
const blastCounts = computeNukeBlastCounts({
gm: game,
targetTile,
magnitude,
});
for (const [playerSmallId, totalWeight] of blastCounts) {
if (totalWeight > threshold) {
playersToBreakAllianceWith.add(playerSmallId);
}
}
// Also check if any allied structures would be destroyed
game
.nearbyUnits(targetTile, magnitude.outer, StructureTypes)
.forEach(({ unit }) =>
playersToBreakAllianceWith.add(unit.owner().smallID()),
);
return playersToBreakAllianceWith;
}
export function getSpawnTiles(
gm: GameMap,
tile: TileRef,
requireAllValid: true,
): TileRef[] | null;
export function getSpawnTiles(
gm: GameMap,
tile: TileRef,
requireAllValid?: false,
): TileRef[];
export function getSpawnTiles(
gm: GameMap,
tile: TileRef,
requireAllValid = false,
): TileRef[] | null {
const spawnTiles = Array.from(gm.bfs(tile, euclDistFN(tile, 4, true)));
const isInvalid = (t: TileRef) => gm.hasOwner(t) || !gm.isLand(t);
if (!requireAllValid) {
return spawnTiles.filter((t) => !isInvalid(t));
}
if (spawnTiles.some(isInvalid)) {
return null;
}
return spawnTiles;
}
export function closestTile(
gm: GameMap,
refs: Iterable<TileRef>,
tile: TileRef,
): [TileRef | null, number] {
let minDistance = Infinity;
let minRef: TileRef | null = null;
for (const ref of refs) {
const distance = gm.manhattanDist(ref, tile);
if (distance < minDistance) {
minDistance = distance;
minRef = ref;
}
}
return [minRef, minDistance];
}
export function closestTwoTiles(
gm: GameMap,
x: Iterable<TileRef>,
y: Iterable<TileRef>,
): { x: TileRef; y: TileRef } | null {
const xSorted = Array.from(x).sort((a, b) => gm.x(a) - gm.x(b));
const ySorted = Array.from(y).sort((a, b) => gm.x(a) - gm.x(b));
if (xSorted.length === 0 || ySorted.length === 0) {
return null;
}
let i = 0;
let j = 0;
let minDistance = Infinity;
let result = { x: xSorted[0], y: ySorted[0] };
while (i < xSorted.length && j < ySorted.length) {
const currentX = xSorted[i];
const currentY = ySorted[j];
const distance =
Math.abs(gm.x(currentX) - gm.x(currentY)) +
Math.abs(gm.y(currentX) - gm.y(currentY));
if (distance < minDistance) {
minDistance = distance;
result = { x: currentX, y: currentY };
}
// If we're at the end of X, must move Y forward
if (i === xSorted.length - 1) {
j++;
}
// If we're at the end of Y, must move X forward
else if (j === ySorted.length - 1) {
i++;
}
// Otherwise, move whichever pointer has smaller x value
else if (gm.x(currentX) < gm.x(currentY)) {
i++;
} else {
j++;
}
}
return result;
}
/**
* Calculates the center of a player's territory using geometric approach.
* Uses the bounding box center and verifies ownership, falling back to nearest border tile if necessary.
*
* @param game - The game instance
* @param target - The player whose territory center to calculate
* @returns The tile reference for the territory center, or null if no valid center found
*/
export function calculateTerritoryCenter(
game: Game,
target: Player,
): TileRef | null {
const borderTiles = target.borderTiles();
if (borderTiles.size === 0) return null;
// Calculate bounding box center in a single pass through border tiles
let minX = Infinity,
maxX = -Infinity;
let minY = Infinity,
maxY = -Infinity;
for (const tile of borderTiles) {
const x = game.x(tile);
const y = game.y(tile);
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
const centerX = Math.floor((minX + maxX) / 2);
const centerY = Math.floor((minY + maxY) / 2);
const centerTile = game.ref(centerX, centerY);
// Verify ownership of the center tile
if (game.owner(centerTile) === target) {
return centerTile;
}
// Fall back to nearest border tile if center is not owned
let closestTile: TileRef | null = null;
let closestDistanceSquared = Infinity;
for (const tile of borderTiles) {
const dx = game.x(tile) - centerX;
const dy = game.y(tile) - centerY;
const distSquared = dx * dx + dy * dy;
if (distSquared < closestDistanceSquared) {
closestDistanceSquared = distSquared;
closestTile = tile;
}
}
return closestTile;
}