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 13:35:30 -08:00
committed by GitHub
co-authored by Evan
parent 4ee3319397
commit 6112547273
22 changed files with 320 additions and 254 deletions
+13 -3
View File
@@ -9,11 +9,13 @@ import {
UnitType,
} from "../src/core/game/Game";
import { TileRef } from "../src/core/game/GameMap";
import { GameID } from "../src/core/Schemas";
import { setup } from "./util/Setup";
import { TestConfig } from "./util/TestConfig";
import { constructionExecution } from "./util/utils";
let game: Game;
const gameID: GameID = "game_id";
let attacker: Player;
let defender: Player;
let defenderSpawn: TileRef;
@@ -51,8 +53,16 @@ describe("Attack", () => {
attackerSpawn = game.ref(0, 10);
game.addExecution(
new SpawnExecution(game.player(attackerInfo.id).info(), attackerSpawn),
new SpawnExecution(game.player(defenderInfo.id).info(), defenderSpawn),
new SpawnExecution(
gameID,
game.player(attackerInfo.id).info(),
attackerSpawn,
),
new SpawnExecution(
gameID,
game.player(defenderInfo.id).info(),
defenderSpawn,
),
);
while (game.inSpawnPhase()) {
@@ -142,7 +152,7 @@ function addPlayerToGame(
tile: TileRef,
): Player {
game.addPlayer(playerInfo);
game.addExecution(new SpawnExecution(playerInfo, tile));
game.addExecution(new SpawnExecution(gameID, playerInfo, tile));
return game.player(playerInfo.id);
}
+8 -2
View File
@@ -1,10 +1,12 @@
import { AttackExecution } from "../src/core/execution/AttackExecution";
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game";
import { GameID } from "../src/core/Schemas";
import { GOLD_INDEX_WAR, GOLD_INDEX_WORK } from "../src/core/StatsSchemas";
import { setup } from "./util/Setup";
let game: Game;
const gameID: GameID = "game_id";
let player1: Player;
let player2: Player;
@@ -18,8 +20,12 @@ describe("AttackStats", () => {
player1 = game.player("player1");
player2 = game.player("player2");
game.addExecution(new SpawnExecution(player1.info(), game.ref(50, 50)));
game.addExecution(new SpawnExecution(player2.info(), game.ref(50, 55)));
game.addExecution(
new SpawnExecution(gameID, player1.info(), game.ref(50, 50)),
);
game.addExecution(
new SpawnExecution(gameID, player2.info(), game.ref(50, 55)),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
+12 -2
View File
@@ -9,11 +9,13 @@ import {
UnitType,
} from "../src/core/game/Game";
import { TileRef } from "../src/core/game/GameMap";
import { GameID } from "../src/core/Schemas";
import { setup } from "./util/Setup";
import { executeTicks } from "./util/utils";
describe("DeleteUnitExecution Security Tests", () => {
let game: Game;
const gameID: GameID = "game_id";
let player: Player;
let enemyPlayer: Player;
let unit: Unit;
@@ -45,8 +47,16 @@ describe("DeleteUnitExecution Security Tests", () => {
const enemySpawn: TileRef = game.ref(0, 15);
game.addExecution(
new SpawnExecution(game.player(player1Info.id).info(), playerSpawn),
new SpawnExecution(game.player(player2Info.id).info(), enemySpawn),
new SpawnExecution(
gameID,
game.player(player1Info.id).info(),
playerSpawn,
),
new SpawnExecution(
gameID,
game.player(player2Info.id).info(),
enemySpawn,
),
);
while (game.inSpawnPhase()) {
+6 -4
View File
@@ -11,12 +11,14 @@ import {
PlayerType,
UnitType,
} from "../src/core/game/Game";
import { GameID } from "../src/core/Schemas";
import { toInt } from "../src/core/Util";
import { setup } from "./util/Setup";
import { UseRealAttackLogic } from "./util/TestConfig";
import { executeTicks } from "./util/utils";
let game: Game;
const gameID: GameID = "game_id";
let player1: Player;
let player2: Player;
let enemy: Player;
@@ -46,8 +48,8 @@ describe("Disconnected", () => {
player2 = game.addPlayer(player2Info);
game.addExecution(
new SpawnExecution(player1Info, game.ref(1, 1)),
new SpawnExecution(player2Info, game.ref(7, 7)),
new SpawnExecution(gameID, player1Info, game.ref(1, 1)),
new SpawnExecution(gameID, player2Info, game.ref(7, 7)),
);
while (game.inSpawnPhase()) {
@@ -203,8 +205,8 @@ describe("Disconnected", () => {
);
game.addExecution(
new SpawnExecution(player1Info, game.map().ref(coastX - 2, 1)),
new SpawnExecution(player2Info, game.map().ref(coastX - 2, 4)),
new SpawnExecution(gameID, player1Info, game.map().ref(coastX - 2, 1)),
new SpawnExecution(gameID, player2Info, game.map().ref(coastX - 2, 4)),
);
while (game.inSpawnPhase()) {
+13 -8
View File
@@ -2,10 +2,12 @@ import { DonateGoldExecution } from "../src/core/execution/DonateGoldExecution";
import { DonateTroopsExecution } from "../src/core/execution/DonateTroopExecution";
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
import { PlayerInfo, PlayerType } from "../src/core/game/Game";
import { GameID } from "../src/core/Schemas";
import { setup } from "./util/Setup";
describe("Donate troops to an ally", () => {
it("Troops should be successfully donated", async () => {
const gameID: GameID = "game_id";
const game = await setup("ocean_and_land", {
infiniteTroops: false,
donateTroops: true,
@@ -35,8 +37,8 @@ describe("Donate troops to an ally", () => {
const spawnB = game.ref(0, 15);
game.addExecution(
new SpawnExecution(donorInfo, spawnA),
new SpawnExecution(recipientInfo, spawnB),
new SpawnExecution(gameID, donorInfo, spawnA),
new SpawnExecution(gameID, recipientInfo, spawnB),
);
while (game.inSpawnPhase()) {
@@ -73,6 +75,7 @@ describe("Donate gold to an ally", () => {
infiniteGold: false,
donateGold: true,
});
const gameID: GameID = "game_id";
const donorInfo = new PlayerInfo(
"donor",
@@ -98,8 +101,8 @@ describe("Donate gold to an ally", () => {
const spawnB = game.ref(0, 15);
game.addExecution(
new SpawnExecution(donorInfo, spawnA),
new SpawnExecution(recipientInfo, spawnB),
new SpawnExecution(gameID, donorInfo, spawnA),
new SpawnExecution(gameID, recipientInfo, spawnB),
);
while (game.inSpawnPhase()) {
@@ -137,6 +140,7 @@ describe("Donate troops to a non ally", () => {
infiniteTroops: false,
donateTroops: true,
});
const gameID: GameID = "game_id";
const donorInfo = new PlayerInfo(
"donor",
@@ -162,8 +166,8 @@ describe("Donate troops to a non ally", () => {
const spawnB = game.ref(0, 15);
game.addExecution(
new SpawnExecution(donorInfo, spawnA),
new SpawnExecution(recipientInfo, spawnB),
new SpawnExecution(gameID, donorInfo, spawnA),
new SpawnExecution(gameID, recipientInfo, spawnB),
);
while (game.inSpawnPhase()) {
@@ -197,6 +201,7 @@ describe("Donate Gold to a non ally", () => {
infiniteGold: false,
donateGold: true,
});
const gameID: GameID = "game_id";
const donorInfo = new PlayerInfo(
"donor",
@@ -222,8 +227,8 @@ describe("Donate Gold to a non ally", () => {
const spawnB = game.ref(0, 15);
game.addExecution(
new SpawnExecution(donorInfo, spawnA),
new SpawnExecution(recipientInfo, spawnB),
new SpawnExecution(gameID, donorInfo, spawnA),
new SpawnExecution(gameID, recipientInfo, spawnB),
);
while (game.inSpawnPhase()) {
+7 -1
View File
@@ -9,9 +9,11 @@ import {
UnitType,
} from "../src/core/game/Game";
import { TileRef } from "../src/core/game/GameMap";
import { GameID } from "../src/core/Schemas";
import { setup } from "./util/Setup";
import { constructionExecution, executeTicks } from "./util/utils";
const gameID: GameID = "game_id";
let game: Game;
let attacker: Player;
@@ -41,7 +43,11 @@ describe("MissileSilo", () => {
game.addPlayer(attacker_info);
game.addExecution(
new SpawnExecution(game.player(attacker_info.id).info(), game.ref(1, 1)),
new SpawnExecution(
gameID,
game.player(attacker_info.id).info(),
game.ref(1, 1),
),
);
while (game.inSpawnPhase()) {
+3 -1
View File
@@ -1,16 +1,18 @@
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
import { Player, PlayerInfo, PlayerType } from "../src/core/game/Game";
import { GameID } from "../src/core/Schemas";
import { setup } from "./util/Setup";
describe("Territory management", () => {
test("player owns the tile it spawns on", async () => {
const game = await setup("plains");
const gameID: GameID = "game_id";
game.addPlayer(
new PlayerInfo("test_player", PlayerType.Human, null, "test_id"),
);
const spawnTile = game.map().ref(50, 50);
game.addExecution(
new SpawnExecution(game.player("test_id").info(), spawnTile),
new SpawnExecution(gameID, game.player("test_id").info(), spawnTile),
);
// Init the execution
game.executeNextTick();
+112
View File
@@ -0,0 +1,112 @@
import { SpawnExecution } from "../../../src/core/execution/SpawnExecution";
import { PlayerInfo, PlayerType } from "../../../src/core/game/Game";
import { setup } from "../../util/Setup";
describe("Spawn execution", () => {
// Manually calculated based on number of tiles in manifest of each map
// and minimum distance between players in PlayerSpawner
test.each([
["big_plains", 49],
["half_land_half_ocean", 1],
["ocean_and_land", 1],
["plains", 9],
])(
"Spawn location is found for all players in %s map with %i players",
async (mapName, maxPlayers) => {
const players: PlayerInfo[] = [];
const spawnExecutions: SpawnExecution[] = [];
for (let i = 0; i < maxPlayers; i++) {
const playerInfo = new PlayerInfo(
`player${i}`,
PlayerType.Human,
`client_id${i}`,
`player_id${i}`,
);
players.push(playerInfo);
spawnExecutions.push(new SpawnExecution("game_id", playerInfo));
}
const game = await setup(mapName, undefined, players);
game.addExecution(...spawnExecutions);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
game.allPlayers().forEach((player) => {
const spawnTile = player.spawnTile()!;
expect(spawnTile).toEqual(expect.any(Number));
expect(game.isLand(spawnTile)).toBe(true);
expect(game.isBorder(spawnTile)).toBe(false);
});
for (let i = 0; i < game.allPlayers().length; i++) {
for (let j = i + 1; j < game.allPlayers().length; j++) {
const distance = game.manhattanDist(
game.allPlayers()[i].spawnTile()!,
game.allPlayers()[j].spawnTile()!,
);
expect(distance).toBeGreaterThanOrEqual(
game.config().minDistanceBetweenPlayers(),
);
}
}
},
);
test("Handles spawn failure when map is too crowded", async () => {
const players: PlayerInfo[] = [];
const spawnExecutions: SpawnExecution[] = [];
// Try to spawn more players than possible on a small map
for (let i = 0; i < 5; i++) {
const playerInfo = new PlayerInfo(
`player${i}`,
PlayerType.Human,
`client_id${i}`,
`player_id${i}`,
);
players.push(playerInfo);
spawnExecutions.push(new SpawnExecution("game_id", playerInfo));
}
const game = await setup("half_land_half_ocean", undefined, players);
game.addExecution(...spawnExecutions);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
// Should spawn fewer than requested when map is too small
expect(
game.allPlayers().filter((player) => player.spawnTile() !== undefined)
.length,
).toBe(1);
});
test("Spawn on specific tile", async () => {
const playerInfo = new PlayerInfo(
`player`,
PlayerType.Human,
`client_id`,
`player_id`,
);
const game = await setup("half_land_half_ocean", undefined, [playerInfo]);
game.addExecution(new SpawnExecution("game_id", playerInfo, 50));
game.addExecution(new SpawnExecution("game_id", playerInfo, 60));
while (game.inSpawnPhase()) {
game.executeNextTick();
}
expect(game.playerByClientID("client_id")?.spawnTile()).toBe(60);
// Previous territory from first spawn should be relinquished
expect(game.owner(50).isPlayer()).toBe(false);
});
});
@@ -1,72 +0,0 @@
import { PlayerSpawner } from "../../../../src/core/execution/utils/PlayerSpawner";
import { PlayerInfo, PlayerType } from "../../../../src/core/game/Game";
import { setup } from "../../../util/Setup";
describe("PlayerSpawner", () => {
// Manually calculated based on number of tiles in manifest of each map
// and minimum distance between players in PlayerSpawner
test.each([
["big_plains", 49],
["half_land_half_ocean", 1],
["ocean_and_land", 1],
["plains", 9],
])(
"Spawn location is found for all players in %s map with %i players",
async (mapName, maxPlayers) => {
const players: PlayerInfo[] = [];
for (let i = 0; i < maxPlayers; i++) {
players.push(
new PlayerInfo(
`player${i}`,
PlayerType.Human,
`client_id${i}`,
`player_id${i}`,
),
);
}
const game = await setup(mapName, undefined, players);
const executors = new PlayerSpawner(game, "game_id").spawnPlayers();
expect(executors.length).toBe(maxPlayers);
for (const executor of executors) {
expect(game.isLand(executor.tile)).toBe(true);
expect(game.isBorder(executor.tile)).toBe(false);
}
for (let i = 0; i < executors.length; i++) {
for (let j = i + 1; j < executors.length; j++) {
const distance = game.manhattanDist(
executors[i].tile,
executors[j].tile,
);
expect(distance).toBeGreaterThanOrEqual(30);
}
}
},
);
test("Handles spawn failure when map is too crowded", async () => {
const players: PlayerInfo[] = [];
// Try to spawn more players than possible on a small map
for (let i = 0; i < 5; i++) {
players.push(
new PlayerInfo(
`player${i}`,
PlayerType.Human,
`client_id${i}`,
`player_id${i}`,
),
);
}
const game = await setup("half_land_half_ocean", undefined, players);
const executors = new PlayerSpawner(game, "game_id").spawnPlayers();
// Should spawn fewer than requested when map is too small
expect(executors.length).toBe(1);
});
});
@@ -9,10 +9,12 @@ import {
PlayerType,
UnitType,
} from "../../../src/core/game/Game";
import { GameID } from "../../../src/core/Schemas";
import { setup } from "../../util/Setup";
import { constructionExecution, executeTicks } from "../../util/utils";
let game: Game;
const gameID: GameID = "game_id";
let attacker: Player;
let defender: Player;
let far_defender: Player;
@@ -54,16 +56,26 @@ describe("SAM", () => {
game.addPlayer(attacker_info);
game.addExecution(
new SpawnExecution(game.player(defender_info.id).info(), game.ref(1, 1)),
new SpawnExecution(
gameID,
game.player(defender_info.id).info(),
game.ref(1, 1),
),
new SpawnExecution(
gameID,
game.player(middle_defender_info.id).info(),
game.ref(50, 1),
),
new SpawnExecution(
gameID,
game.player(far_defender_info.id).info(),
game.ref(199, 1),
),
new SpawnExecution(game.player(attacker_info.id).info(), game.ref(7, 7)),
new SpawnExecution(
gameID,
game.player(attacker_info.id).info(),
game.ref(7, 7),
),
);
while (game.inSpawnPhase()) {
+12 -2
View File
@@ -1,3 +1,4 @@
import { GameID } from "../../../src/core/Schemas";
import { AttackExecution } from "../../../src/core/execution/AttackExecution";
import { SpawnExecution } from "../../../src/core/execution/SpawnExecution";
//import { TransportShipExecution } from "../../../src/core/execution/TransportShipExecution";
@@ -12,6 +13,7 @@ import {
import { TileRef } from "../../../src/core/game/GameMap";
import { setup } from "../../util/Setup";
const gameID: GameID = "game_id";
let game: Game;
let attacker: Player;
let defender: Player;
@@ -44,8 +46,16 @@ describe("GameImpl", () => {
attackerSpawn = game.ref(0, 14);
game.addExecution(
new SpawnExecution(game.player(attackerInfo.id).info(), attackerSpawn),
new SpawnExecution(game.player(defenderInfo.id).info(), defenderSpawn),
new SpawnExecution(
gameID,
game.player(attackerInfo.id).info(),
attackerSpawn,
),
new SpawnExecution(
gameID,
game.player(defenderInfo.id).info(),
defenderSpawn,
),
);
while (game.inSpawnPhase()) {
+4 -2
View File
@@ -8,10 +8,12 @@ import {
PlayerType,
UnitType,
} from "../../src/core/game/Game";
import { GameID } from "../../src/core/Schemas";
import { setup } from "../util/Setup";
describe("Construction economy", () => {
let game: Game;
const gameID: GameID = "game_id";
let player: Player;
let other: Player;
const builderInfo = new PlayerInfo(
@@ -33,8 +35,8 @@ describe("Construction economy", () => {
[builderInfo, otherInfo],
);
const spawn = game.ref(0, 10);
game.addExecution(new SpawnExecution(builderInfo, spawn));
game.addExecution(new SpawnExecution(otherInfo, spawn));
game.addExecution(new SpawnExecution(gameID, builderInfo, spawn));
game.addExecution(new SpawnExecution(gameID, otherInfo, spawn));
while (game.inSpawnPhase()) {
game.executeNextTick();
}
+4 -2
View File
@@ -7,17 +7,19 @@ import {
PlayerType,
UnitType,
} from "../../src/core/game/Game";
import { GameID } from "../../src/core/Schemas";
import { setup } from "../util/Setup";
describe("Hydrogen Bomb and MIRV flows", () => {
let game: Game;
let player: Player;
const gameID: GameID = "game_id";
beforeEach(async () => {
game = await setup("plains", { infiniteGold: true, instantBuild: true });
const info = new PlayerInfo("p", PlayerType.Human, null, "p");
game.addPlayer(info);
game.addExecution(new SpawnExecution(info, game.ref(1, 1)));
game.addExecution(new SpawnExecution(gameID, info, game.ref(1, 1)));
while (game.inSpawnPhase()) game.executeNextTick();
player = game.player(info.id);
@@ -57,7 +59,7 @@ describe("Hydrogen Bomb and MIRV flows", () => {
const info = new PlayerInfo("p", PlayerType.Human, null, "p");
gameWithConstruction.addPlayer(info);
gameWithConstruction.addExecution(
new SpawnExecution(info, gameWithConstruction.ref(1, 1)),
new SpawnExecution(gameID, info, gameWithConstruction.ref(1, 1)),
);
while (gameWithConstruction.inSpawnPhase())
gameWithConstruction.executeNextTick();