Improve random spawn (#2503)

## Description:

This is a previously approved PR with an additional commit that fixes
case when nations change spawn & jump around, their previous territory
wasn't getting deleted.

## 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: Evan <evanpelle@gmail.com>
This commit is contained in:
Mykola
2025-12-20 23:35:30 +02:00
committed by GitHub
parent 4ee3319397
commit 6112547273
22 changed files with 320 additions and 254 deletions
+1
View File
@@ -154,6 +154,7 @@ export interface Config {
defensePostRange(): number;
SAMCooldown(): number;
SiloCooldown(): number;
minDistanceBetweenPlayers(): number;
defensePostDefenseBonus(): number;
defensePostSpeedBonus(): number;
falloutDefenseModifier(percentOfFallout: number): number;
+3
View File
@@ -615,6 +615,9 @@ export class DefaultConfig implements Config {
temporaryEmbargoDuration(): Tick {
return 300 * 10; // 5 minutes.
}
minDistanceBetweenPlayers(): number {
return 30;
}
percentageTilesOwnedToWin(): number {
if (this._gameConfig.gameMode === GameMode.Team) {
+9 -34
View File
@@ -1,5 +1,4 @@
import { Game, PlayerInfo, PlayerType } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { GameID } from "../Schemas";
import { simpleHash } from "../Util";
@@ -17,46 +16,29 @@ export class BotSpawner {
constructor(
private gs: Game,
gameID: GameID,
private gameID: GameID,
) {
this.random = new PseudoRandom(simpleHash(gameID));
}
spawnBots(numBots: number): SpawnExecution[] {
let tries = 0;
while (this.bots.length < numBots) {
if (tries > 10000) {
console.log("too many retries while spawning bots, giving up");
return this.bots;
}
for (let i = 0; i < numBots; i++) {
const candidate = this.nextCandidateName();
const spawn = this.spawnBot(candidate.name);
if (spawn !== null) {
// Only use candidate name once bot successfully spawned
if (candidate.source === "list") {
this.nameIndex++;
}
this.bots.push(spawn);
} else {
tries++;
if (candidate.source === "list") {
this.nameIndex++;
}
this.bots.push(spawn);
}
return this.bots;
}
spawnBot(botName: string): SpawnExecution | null {
const tile = this.randTile();
if (!this.gs.isLand(tile)) {
return null;
}
for (const spawn of this.bots) {
if (this.gs.manhattanDist(spawn.tile, tile) < 30) {
return null;
}
}
spawnBot(botName: string): SpawnExecution {
return new SpawnExecution(
this.gameID,
new PlayerInfo(botName, PlayerType.Bot, null, this.random.nextID()),
tile,
);
}
@@ -97,11 +79,4 @@ export class BotSpawner {
const suffixNumber = this.random.nextInt(1, 10001);
return `Elf ${suffixNumber}`;
}
private randTile(): TileRef {
return this.gs.ref(
this.random.nextInt(0, this.gs.width()),
this.random.nextInt(0, this.gs.height()),
);
}
}
+3 -3
View File
@@ -69,7 +69,7 @@ export class Executor {
case "move_warship":
return new MoveWarshipExecution(player, intent.unitId, intent.tile);
case "spawn":
return new SpawnExecution(player.info(), intent.tile);
return new SpawnExecution(this.gameID, player.info(), intent.tile);
case "boat":
return new TransportShipExecution(
player,
@@ -128,11 +128,11 @@ export class Executor {
}
}
spawnBots(numBots: number): Execution[] {
spawnBots(numBots: number): SpawnExecution[] {
return new BotSpawner(this.mg, this.gameID).spawnBots(numBots);
}
spawnPlayers(): Execution[] {
spawnPlayers(): SpawnExecution[] {
return new PlayerSpawner(this.mg, this.gameID).spawnPlayers();
}
+13 -41
View File
@@ -9,7 +9,6 @@ import {
PlayerID,
PlayerType,
Relation,
TerrainType,
Tick,
Unit,
UnitType,
@@ -54,7 +53,7 @@ export class NationExecution implements Execution {
private trackedTradeShips: Set<Unit> = new Set();
constructor(
gameID: GameID,
private gameID: GameID,
private nation: Nation, // Nation contains PlayerInfo with PlayerType.Nation
) {
this.random = new PseudoRandom(
@@ -72,6 +71,12 @@ export class NationExecution implements Execution {
if (this.random.chance(10)) {
// this.isTraitor = true
}
if (!this.mg.hasPlayer(this.nation.playerInfo.id)) {
this.player = this.mg.addPlayer(this.nation.playerInfo);
} else {
this.player = this.mg.player(this.nation.playerInfo.id);
}
}
tick(ticks: number) {
@@ -89,23 +94,15 @@ export class NationExecution implements Execution {
return;
}
if (this.mg.inSpawnPhase()) {
const rl = this.randomSpawnLand();
if (rl === null) {
console.warn(`cannot spawn ${this.nation.playerInfo.name}`);
return;
}
this.mg.addExecution(new SpawnExecution(this.nation.playerInfo, rl));
if (this.player === null) {
return;
}
if (this.player === null) {
this.player =
this.mg.players().find((p) => p.id() === this.nation.playerInfo.id) ??
null;
if (this.player === null) {
return;
}
if (this.mg.inSpawnPhase()) {
this.mg.addExecution(
new SpawnExecution(this.gameID, this.nation.playerInfo),
);
return;
}
if (!this.player.isAlive()) {
@@ -224,31 +221,6 @@ export class NationExecution implements Execution {
}
}
randomSpawnLand(): TileRef | null {
const delta = 25;
let tries = 0;
while (tries < 50) {
tries++;
const cell = this.nation.spawnCell;
const x = this.random.nextInt(cell.x - delta, cell.x + delta);
const y = this.random.nextInt(cell.y - delta, cell.y + delta);
if (!this.mg.isValidCoord(x, y)) {
continue;
}
const tile = this.mg.ref(x, y);
if (this.mg.isLand(tile) && !this.mg.hasOwner(tile)) {
if (
this.mg.terrainType(tile) === TerrainType.Mountain &&
this.random.chance(2)
) {
continue;
}
return tile;
}
}
return null;
}
private updateRelationsFromEmbargos() {
const player = this.player;
if (player === null) return;
+70 -8
View File
@@ -1,17 +1,27 @@
import { Execution, Game, Player, PlayerInfo, PlayerType } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { GameID } from "../Schemas";
import { simpleHash } from "../Util";
import { BotExecution } from "./BotExecution";
import { PlayerExecution } from "./PlayerExecution";
import { getSpawnTiles } from "./Util";
export class SpawnExecution implements Execution {
private random: PseudoRandom;
active: boolean = true;
private mg: Game;
private static readonly MAX_SPAWN_TRIES = 1_000;
constructor(
gameID: GameID,
private playerInfo: PlayerInfo,
public readonly tile: TileRef,
) {}
public tile?: TileRef,
) {
this.random = new PseudoRandom(
simpleHash(playerInfo.id) + simpleHash(gameID),
);
}
init(mg: Game, ticks: number) {
this.mg = mg;
@@ -20,11 +30,6 @@ export class SpawnExecution implements Execution {
tick(ticks: number) {
this.active = false;
if (!this.mg.isValidRef(this.tile)) {
console.warn(`SpawnExecution: tile ${this.tile} not valid`);
return;
}
if (!this.mg.inSpawnPhase()) {
this.active = false;
return;
@@ -37,6 +42,13 @@ export class SpawnExecution implements Execution {
player = this.mg.addPlayer(this.playerInfo);
}
this.tile ??= this.randomSpawnLand();
if (this.tile === undefined) {
console.warn(`SpawnExecution: cannot spawn ${this.playerInfo.name}`);
return;
}
player.tiles().forEach((t) => player.relinquish(t));
getSpawnTiles(this.mg, this.tile).forEach((t) => {
player.conquer(t);
@@ -48,7 +60,8 @@ export class SpawnExecution implements Execution {
this.mg.addExecution(new BotExecution(player));
}
}
player.setHasSpawned(true);
player.setSpawnTile(this.tile);
}
isActive(): boolean {
@@ -58,4 +71,53 @@ export class SpawnExecution implements Execution {
activeDuringSpawnPhase(): boolean {
return true;
}
private randomSpawnLand(): TileRef | undefined {
let tries = 0;
while (tries < SpawnExecution.MAX_SPAWN_TRIES) {
tries++;
const tile = this.randTile();
if (
!this.mg.isLand(tile) ||
this.mg.hasOwner(tile) ||
this.mg.isBorder(tile)
) {
continue;
}
const isOtherPlayerSpawnedNearby = this.mg
.allPlayers()
.filter((player) => player.id() !== this.playerInfo.id)
.some((player) => {
const spawnTile = player.spawnTile();
if (spawnTile === undefined) {
return false;
}
return (
this.mg.manhattanDist(spawnTile, tile) <
this.mg.config().minDistanceBetweenPlayers()
);
});
if (isOtherPlayerSpawnedNearby) {
continue;
}
return tile;
}
return;
}
private randTile(): TileRef {
const x = this.random.nextInt(0, this.mg.width());
const y = this.random.nextInt(0, this.mg.height());
return this.mg.ref(x, y);
}
}
+3 -62
View File
@@ -1,66 +1,14 @@
import { Game, PlayerType } from "../../game/Game";
import { TileRef } from "../../game/GameMap";
import { PseudoRandom } from "../../PseudoRandom";
import { GameID } from "../../Schemas";
import { simpleHash } from "../../Util";
import { SpawnExecution } from "../SpawnExecution";
export class PlayerSpawner {
private random: PseudoRandom;
private players: SpawnExecution[] = [];
private static readonly MAX_SPAWN_TRIES = 10_000;
private static readonly MIN_SPAWN_DISTANCE = 30;
constructor(
private gm: Game,
gameID: GameID,
) {
this.random = new PseudoRandom(simpleHash(gameID));
}
private randTile(): TileRef {
const x = this.random.nextInt(0, this.gm.width());
const y = this.random.nextInt(0, this.gm.height());
return this.gm.ref(x, y);
}
private randomSpawnLand(): TileRef | null {
let tries = 0;
while (tries < PlayerSpawner.MAX_SPAWN_TRIES) {
tries++;
const tile = this.randTile();
if (
!this.gm.isLand(tile) ||
this.gm.hasOwner(tile) ||
this.gm.isBorder(tile)
) {
continue;
}
let tooCloseToOtherPlayer = false;
for (const spawn of this.players) {
if (
this.gm.manhattanDist(spawn.tile, tile) <
PlayerSpawner.MIN_SPAWN_DISTANCE
) {
tooCloseToOtherPlayer = true;
break;
}
}
if (tooCloseToOtherPlayer) {
continue;
}
return tile;
}
return null;
}
private gameID: GameID,
) {}
spawnPlayers(): SpawnExecution[] {
for (const player of this.gm.allPlayers()) {
@@ -68,14 +16,7 @@ export class PlayerSpawner {
continue;
}
const spawnLand = this.randomSpawnLand();
if (spawnLand === null) {
// TODO: this should normally not happen, additional logic may be needed, if this occurs
continue;
}
this.players.push(new SpawnExecution(player.info(), spawnLand));
this.players.push(new SpawnExecution(this.gameID, player.info()));
}
return this.players;
+2 -1
View File
@@ -550,7 +550,8 @@ export interface Player {
markDisconnected(isDisconnected: boolean): void;
hasSpawned(): boolean;
setHasSpawned(hasSpawned: boolean): void;
setSpawnTile(spawnTile: TileRef): void;
spawnTile(): TileRef | undefined;
// Territory
tiles(): ReadonlySet<TileRef>;
+8 -4
View File
@@ -100,7 +100,7 @@ export class PlayerImpl implements Player {
public _outgoingAttacks: Attack[] = [];
public _outgoingLandAttacks: Attack[] = [];
private _hasSpawned = false;
private _spawnTile: TileRef | undefined;
private _isDisconnected = false;
constructor(
@@ -343,11 +343,15 @@ export class PlayerImpl implements Player {
}
hasSpawned(): boolean {
return this._hasSpawned;
return this._spawnTile !== undefined;
}
setHasSpawned(hasSpawned: boolean): void {
this._hasSpawned = hasSpawned;
setSpawnTile(spawnTile: TileRef): void {
this._spawnTile = spawnTile;
}
spawnTile(): TileRef | undefined {
return this._spawnTile;
}
incomingAllianceRequests(): AllianceRequest[] {