Merge origin/main and resolve conflicts

Co-authored-by: VariableVince <24507472+VariableVince@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-05-18 18:03:41 +00:00
committed by GitHub
co-authored by VariableVince
236 changed files with 8468 additions and 5840 deletions
-5
View File
@@ -47,11 +47,6 @@ describe("Ai Attack Behavior", () => {
testBot.addTroops(5000);
testHuman.addTroops(5000);
// Skip spawn phase
while (testGame.inSpawnPhase()) {
testGame.executeNextTick();
}
const behavior = new AiAttackBehavior(
new PseudoRandom(42),
testGame,
+1 -9
View File
@@ -20,11 +20,7 @@ describe("Alliance acceptance immediately destroys in-flight nukes", () => {
beforeEach(async () => {
game = await setup(
"plains",
{
infiniteGold: true,
instantBuild: true,
infiniteTroops: true,
},
{ infiniteGold: true, instantBuild: true, infiniteTroops: true },
[
new PlayerInfo("player1", PlayerType.Human, "c1", "p1"),
new PlayerInfo("player2", PlayerType.Human, "c2", "p2"),
@@ -34,10 +30,6 @@ describe("Alliance acceptance immediately destroys in-flight nukes", () => {
(game.config() as TestConfig).nukeAllianceBreakThreshold = () => 0;
while (game.inSpawnPhase()) {
game.executeNextTick();
}
player1 = game.player("p1");
player2 = game.player("p2");
player3 = game.player("p3");
-4
View File
@@ -33,10 +33,6 @@ describe("Alliance Donation", () => {
player2.conquer(game.ref(0, 1));
player2.addGold(100n);
player2.addTroops(100);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
});
test("Can donate gold after alliance formed by reply", () => {
+1 -9
View File
@@ -12,11 +12,7 @@ describe("AllianceExtensionExecution", () => {
beforeEach(async () => {
game = await setup(
"ocean_and_land",
{
infiniteGold: true,
instantBuild: true,
infiniteTroops: true,
},
{ infiniteGold: true, instantBuild: true, infiniteTroops: true },
[
playerInfo("player1", PlayerType.Human),
playerInfo("player2", PlayerType.Human),
@@ -27,10 +23,6 @@ describe("AllianceExtensionExecution", () => {
player1 = game.player("player1");
player2 = game.player("player2");
player3 = game.player("player3");
while (game.inSpawnPhase()) {
game.executeNextTick();
}
});
test("Successfully extends existing alliance between Humans", () => {
+1 -9
View File
@@ -13,11 +13,7 @@ describe("AllianceRequestExecution", () => {
beforeEach(async () => {
game = await setup(
"plains",
{
infiniteGold: true,
instantBuild: true,
infiniteTroops: true,
},
{ infiniteGold: true, instantBuild: true, infiniteTroops: true },
[
playerInfo("player1", PlayerType.Human),
playerInfo("player2", PlayerType.Human),
@@ -30,10 +26,6 @@ describe("AllianceRequestExecution", () => {
player2 = game.player("player2");
player2.conquer(game.ref(0, 1));
while (game.inSpawnPhase()) {
game.executeNextTick();
}
});
test("Can create alliance by counter-request", () => {
+22 -21
View File
@@ -69,10 +69,8 @@ describe("Attack", () => {
defenderSpawn,
),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
game.executeNextTick();
game.executeNextTick();
attacker = game.player(attackerInfo.id);
defender = game.player(defenderInfo.id);
@@ -184,10 +182,8 @@ describe("Attack race condition with alliance requests", () => {
"playerB_id",
);
playerB = addPlayerToGame(playerBInfo, game, game.ref(0, 11));
while (game.inSpawnPhase()) {
game.executeNextTick();
}
game.executeNextTick();
game.executeNextTick();
});
it("Should not mark attacker as traitor when alliance is formed after attack starts", async () => {
@@ -302,6 +298,8 @@ describe("Attack race condition with alliance requests", () => {
"playerB_id",
);
const playerC = addPlayerToGame(playerCInfo, game, game.ref(10, 10));
game.executeNextTick();
game.executeNextTick();
// Player A sends alliance request to Player B
const allianceRequestAtoB = playerA.createAllianceRequest(playerB);
@@ -357,10 +355,8 @@ describe("Transport ship alliance rejection", () => {
"playerB_id",
);
playerB = addPlayerToGame(playerBInfo, game, game.ref(7, 15));
while (game.inSpawnPhase()) {
game.executeNextTick();
}
game.executeNextTick();
game.executeNextTick();
});
test("Should cancel alliance requests if the recipient sends a transport ship", async () => {
@@ -383,11 +379,18 @@ describe("Transport ship alliance rejection", () => {
describe("Attack immunity", () => {
beforeEach(async () => {
game = await setup("ocean_and_land", {
infiniteGold: true,
instantBuild: true,
infiniteTroops: true,
});
game = await setup(
"ocean_and_land",
{
infiniteGold: true,
instantBuild: true,
infiniteTroops: true,
},
[],
undefined,
undefined,
false,
);
(game.config() as TestConfig).setSpawnImmunityDuration(immunityPhaseTicks);
@@ -407,10 +410,8 @@ describe("Attack immunity", () => {
"playerB_id",
);
playerB = addPlayerToGame(playerBInfo, game, game.ref(7, 15));
while (game.inSpawnPhase()) {
game.executeNextTick();
}
game.executeNextTick();
game.executeNextTick();
});
test("Should not be able to attack during immunity phase", async () => {
+18 -16
View File
@@ -1,35 +1,37 @@
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;
const player1Info = new PlayerInfo(
"player1",
PlayerType.Human,
"player1",
"player1",
);
const player2Info = new PlayerInfo(
"player2",
PlayerType.Human,
"player2",
"player2",
);
describe("AttackStats", () => {
beforeEach(async () => {
game = await setup("plains", { infiniteTroops: true }, [
new PlayerInfo("player1", PlayerType.Human, "player1", "player1"),
new PlayerInfo("player2", PlayerType.Human, "player2", "player2"),
player1Info,
player2Info,
]);
player1 = game.player("player1");
player2 = game.player("player2");
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();
}
player1.conquer(game.ref(50, 50));
player2.conquer(game.ref(50, 51));
player2.addGold(100n);
game.stats().goldWork(player2, 100n);
});
test("should increase war gold stat when a player is eliminated", () => {
+1 -1
View File
@@ -63,7 +63,7 @@ describe("username.ts functions", () => {
test("rejects too long clan tag", () => {
const res = validateClanTag("A".repeat(MAX_CLAN_TAG_LENGTH + 1));
expect(res.isValid).toBe(false);
expect(res.error).toBe("username.tag_too_short");
expect(res.error).toBe("username.tag_too_long");
});
test("accepts valid clan tag", () => {
-3
View File
@@ -56,9 +56,6 @@ describe("Conquest gold transfer", () => {
game.addExecution(
new SpawnExecution(gameID, conquerorInfo, game.ref(0, 10)),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
conqueror = game.player(conquerorInfo.id);
});
-4
View File
@@ -59,10 +59,6 @@ describe("DeleteUnitExecution Security Tests", () => {
),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
executeTicks(game, game.config().deleteUnitCooldown() + 1);
player = game.player(player1Info.id);
+30 -47
View File
@@ -1,35 +1,39 @@
import { AttackExecution } from "../src/core/execution/AttackExecution";
import { MarkDisconnectedExecution } from "../src/core/execution/MarkDisconnectedExecution";
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
import { PlayerExecution } from "../src/core/execution/PlayerExecution";
import { TransportShipExecution } from "../src/core/execution/TransportShipExecution";
import { getSpawnTiles } from "../src/core/execution/Util";
import { WarshipExecution } from "../src/core/execution/WarshipExecution";
import {
Game,
GameMode,
HumansVsNations,
Player,
PlayerInfo,
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;
function spawnPlayerForTest(game: Game, player: Player, x: number, y: number) {
const spawn = game.map().ref(x, y);
for (const tile of getSpawnTiles(game, spawn, false)) {
player.conquer(tile);
}
player.setSpawnTile(spawn);
game.addExecution(new PlayerExecution(player));
}
describe("Disconnected", () => {
beforeEach(async () => {
game = await setup("plains", {
infiniteGold: true,
instantBuild: true,
});
const player1Info = new PlayerInfo(
"Active Player",
PlayerType.Human,
@@ -44,17 +48,19 @@ describe("Disconnected", () => {
"player2_id",
);
player1 = game.addPlayer(player1Info);
player2 = game.addPlayer(player2Info);
game.addExecution(
new SpawnExecution(gameID, player1Info, game.ref(1, 1)),
new SpawnExecution(gameID, player2Info, game.ref(7, 7)),
game = await setup(
"plains",
{
infiniteGold: true,
instantBuild: true,
},
[player1Info, player2Info],
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
player1 = game.player(player1Info.id);
player2 = game.player(player2Info.id);
player1.conquer(game.ref(1, 1));
player2.conquer(game.ref(7, 7));
});
describe("Player disconnected state", () => {
@@ -201,24 +207,17 @@ describe("Disconnected", () => {
infiniteGold: true,
instantBuild: true,
gameMode: GameMode.Team,
playerTeams: 2, // ignore player2 "kicked" console warn
playerTeams: HumansVsNations,
},
[player1Info, player2Info],
undefined,
UseRealAttackLogic, // don't use TestConfig's mock attackLogic
);
game.addExecution(
new SpawnExecution(gameID, player1Info, game.map().ref(coastX - 2, 1)),
new SpawnExecution(gameID, player2Info, game.map().ref(coastX - 2, 4)),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
player1 = game.player(player1Info.id);
player2 = game.player(player2Info.id);
spawnPlayerForTest(game, player1, coastX - 2, 1);
spawnPlayerForTest(game, player2, coastX - 2, 4);
player2.markDisconnected(false);
expect(player1.team()).not.toBeNull();
@@ -290,32 +289,16 @@ describe("Disconnected", () => {
new AttackExecution(startTroops, player1, player2.id(), null),
);
let expectedTotalGrowth = 0n;
let afterTickZero = false;
while (player2.isAlive()) {
if (afterTickZero) {
// No growth on tick 0, troop additions start from tick 1
const troopIncThisTick = game.config().troopIncreaseRate(player1);
expectedTotalGrowth += toInt(troopIncThisTick);
}
game.executeNextTick();
afterTickZero = true;
}
// Tick for retreat() in AttackExecution to add back startTtoops to owner troops
const troopIncThisTick1 = game.config().troopIncreaseRate(player1);
expectedTotalGrowth += toInt(troopIncThisTick1);
// retreat() fires in the tick after player2's last tile is conquered
// (toConquer empties, refreshToConquer() finds nothing, then retreat).
game.executeNextTick();
const expectedFinalTroops = Number(
toInt(troopsBeforeAttack) + expectedTotalGrowth,
);
// Verify no troop loss
expect(player1.troops()).toBe(expectedFinalTroops);
// startTroops returned with no malus -> no net troop loss, only passive growth
expect(player1.troops()).toBeGreaterThanOrEqual(troopsBeforeAttack);
});
test("Conqueror gets conquered disconnected team member's transport- and warships", () => {
-16
View File
@@ -41,10 +41,6 @@ describe("Donate troops to an ally", () => {
new SpawnExecution(gameID, recipientInfo, spawnB),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
// donor sends alliance request to recipient
const allianceRequest = donor.createAllianceRequest(recipient);
expect(allianceRequest).not.toBeNull();
@@ -105,10 +101,6 @@ describe("Donate gold to an ally", () => {
new SpawnExecution(gameID, recipientInfo, spawnB),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
// donor sends alliance request to recipient
const allianceRequest = donor.createAllianceRequest(recipient);
expect(allianceRequest).not.toBeNull();
@@ -170,10 +162,6 @@ describe("Donate troops to a non ally", () => {
new SpawnExecution(gameID, recipientInfo, spawnB),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
// Donor sends alliance request to Recipient
const allianceRequest = donor.createAllianceRequest(recipient);
expect(allianceRequest).not.toBeNull();
@@ -231,10 +219,6 @@ describe("Donate Gold to a non ally", () => {
new SpawnExecution(gameID, recipientInfo, spawnB),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
// Donor sends alliance request to Recipient
const allianceRequest = donor.createAllianceRequest(recipient);
expect(allianceRequest).not.toBeNull();
+1 -1
View File
@@ -71,7 +71,7 @@ function getCategorizedMaps(): Set<string> {
function getFrequencyKeys(): Set<string> {
const content = fs.readFileSync(MAP_PLAYLIST, "utf8");
// Extract the frequency block
const freqMatch = content.match(/const frequency[\s\S]*?\{([\s\S]*?)\};/);
const freqMatch = content.match(/const FREQUENCY[\s\S]*?\{([\s\S]*?)\};/);
if (!freqMatch) {
throw new Error(
`Failed to parse frequency record from MapPlaylist.ts (first 200 chars: ${content.slice(0, 200)})`,
-4
View File
@@ -50,10 +50,6 @@ describe("MissileSilo", () => {
),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
attacker = game.player("attacker_id");
constructionExecution(game, attacker, 1, 1, UnitType.MissileSilo);
+1 -5
View File
@@ -51,10 +51,6 @@ describe("AllianceBehavior.handleAllianceRequests", () => {
player,
new NationEmojiBehavior(random, game, player),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
});
function setupAllianceRequest({
@@ -63,7 +59,7 @@ describe("AllianceBehavior.handleAllianceRequests", () => {
numTilesPlayer = 10,
numTilesRequestor = 10,
alliancesCount = 0,
createdAtTick = game.ticks() + 1,
createdAtTick = game.config().numSpawnPhaseTurns() + 2,
} = {}) {
if (isTraitor) requestor.markTraitor();
@@ -41,9 +41,6 @@ describe("Counter Warship Infestation", () => {
game.addPlayer(enemyInfo);
// Skip spawn phase
while (game.inSpawnPhase()) {
game.executeNextTick();
}
const nation = game.player("nation_id");
const enemy = game.player("enemy_id");
@@ -186,9 +183,6 @@ describe("Counter Warship Infestation", () => {
);
// Skip spawn phase
while (game.inSpawnPhase()) {
game.executeNextTick();
}
const nation = game.player("nation_id");
const ally = game.player("ally_id");
+263
View File
@@ -0,0 +1,263 @@
import {
Difficulty,
GameMapSize,
GameMapType,
GameMode,
GameType,
Nation,
} from "../src/core/game/Game";
import { createNationsForGame } from "../src/core/game/NationCreation";
import {
AdditionalNation,
Nation as ManifestNation,
} from "../src/core/game/TerrainMapLoader";
import { PseudoRandom } from "../src/core/PseudoRandom";
import { GameConfig, GameStartInfo } from "../src/core/Schemas";
function makeManifestNations(count: number): ManifestNation[] {
const result: ManifestNation[] = [];
for (let i = 0; i < count; i++) {
result.push({
coordinates: [i, i],
flag: "",
name: `Manifest${i}`,
});
}
return result;
}
function makeAdditionalNations(names: string[]): AdditionalNation[] {
return names.map((name) => ({ name }));
}
function makeGameStart(targetNations: number): GameStartInfo {
const config: GameConfig = {
gameMap: GameMapType.World,
gameMapSize: GameMapSize.Normal,
gameMode: GameMode.FFA,
gameType: GameType.Singleplayer,
difficulty: Difficulty.Medium,
nations: targetNations,
donateGold: false,
donateTroops: false,
bots: 0,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
randomSpawn: false,
};
return {
gameID: "test1234",
lobbyCreatedAt: 0,
config,
players: [],
};
}
function nationNames(nations: Nation[]): string[] {
return nations.map((n) => n.playerInfo.name);
}
describe("createNationsForGame: additionalNations pool", () => {
test("does not draw from the pool when manifest already covers the count", () => {
const manifest = makeManifestNations(4);
const extras = makeAdditionalNations(["ExtraA", "ExtraB", "ExtraC"]);
const random = new PseudoRandom(1);
const nations = createNationsForGame(
makeGameStart(3),
manifest,
extras,
0,
random,
);
expect(nations).toHaveLength(3);
for (const name of nationNames(nations)) {
expect(name.startsWith("Manifest")).toBe(true);
}
});
test("fills the deficit entirely from the pool when it is large enough", () => {
const manifest = makeManifestNations(2);
const extras = makeAdditionalNations([
"ExtraA",
"ExtraB",
"ExtraC",
"ExtraD",
"ExtraE",
]);
const random = new PseudoRandom(7);
const nations = createNationsForGame(
makeGameStart(5),
manifest,
extras,
0,
random,
);
expect(nations).toHaveLength(5);
const names = nationNames(nations);
expect(names.filter((n) => n.startsWith("Manifest"))).toHaveLength(2);
const fromPool = names.filter((n) => n.startsWith("Extra"));
expect(fromPool).toHaveLength(3);
for (const name of fromPool) {
expect(extras.some((e) => e.name === name)).toBe(true);
}
});
test("randomly selects from the pool when it has more entries than needed", () => {
const manifest = makeManifestNations(2);
const pool = [
"ExtraA",
"ExtraB",
"ExtraC",
"ExtraD",
"ExtraE",
"ExtraF",
"ExtraG",
"ExtraH",
];
const extras = makeAdditionalNations(pool);
const seen = new Set<string>();
for (let seed = 1; seed <= 25; seed++) {
const random = new PseudoRandom(seed);
const nations = createNationsForGame(
makeGameStart(4),
manifest,
extras,
0,
random,
);
expect(nations).toHaveLength(4);
const fromPool = nationNames(nations).filter((n) =>
n.startsWith("Extra"),
);
expect(fromPool).toHaveLength(2);
for (const name of fromPool) {
expect(pool).toContain(name);
}
fromPool.forEach((n) => seen.add(n));
}
expect(seen.size).toBeGreaterThan(2);
});
test("falls back to generated names when the pool is too small", () => {
const manifest = makeManifestNations(1);
const extras = makeAdditionalNations(["ExtraA", "ExtraB"]);
const random = new PseudoRandom(42);
const nations = createNationsForGame(
makeGameStart(5),
manifest,
extras,
0,
random,
);
expect(nations).toHaveLength(5);
const names = nationNames(nations);
expect(names.filter((n) => n.startsWith("Manifest"))).toHaveLength(1);
const fromPool = names.filter((n) => extras.some((e) => e.name === n));
expect(fromPool).toHaveLength(2);
const generated = names.filter(
(n) => !n.startsWith("Manifest") && !extras.some((e) => e.name === n),
);
expect(generated).toHaveLength(2);
});
test("falls back to generated names when the pool is empty", () => {
const manifest = makeManifestNations(2);
const random = new PseudoRandom(11);
const nations = createNationsForGame(
makeGameStart(4),
manifest,
[],
0,
random,
);
expect(nations).toHaveLength(4);
expect(
nationNames(nations).filter((n) => n.startsWith("Manifest")),
).toHaveLength(2);
});
test("skips pool entries whose name collides with a manifest nation", () => {
const manifest = makeManifestNations(2);
const extras = makeAdditionalNations([
"Manifest0",
"Manifest1",
"UniqueExtra",
]);
const random = new PseudoRandom(3);
const nations = createNationsForGame(
makeGameStart(3),
manifest,
extras,
0,
random,
);
const names = nationNames(nations);
expect(names).toHaveLength(3);
expect(new Set(names).size).toBe(3);
expect(names).toContain("UniqueExtra");
});
test("uses coordinates from additional nations when provided", () => {
const manifest = makeManifestNations(1);
const extras: AdditionalNation[] = [
{ name: "WithCoords", coordinates: [42, 99] },
{ name: "WithoutCoords" },
];
const random = new PseudoRandom(5);
const nations = createNationsForGame(
makeGameStart(3),
manifest,
extras,
0,
random,
);
const withCoords = nations.find((n) => n.playerInfo.name === "WithCoords");
const withoutCoords = nations.find(
(n) => n.playerInfo.name === "WithoutCoords",
);
expect(withCoords).toBeDefined();
expect(withoutCoords).toBeDefined();
expect(withCoords!.spawnCell?.x).toBe(42);
expect(withCoords!.spawnCell?.y).toBe(99);
expect(withoutCoords!.spawnCell).toBeUndefined();
});
test("produces unique nation names overall", () => {
const manifest = makeManifestNations(3);
const extras = makeAdditionalNations(["Ex1", "Ex2", "Ex3"]);
const random = new PseudoRandom(99);
const nations = createNationsForGame(
makeGameStart(8),
manifest,
extras,
0,
random,
);
const names = nationNames(nations);
expect(names).toHaveLength(8);
expect(new Set(names).size).toBe(8);
});
});
-15
View File
@@ -37,9 +37,6 @@ describe("Nation MIRV Retaliation", () => {
game.addPlayer(nationInfo);
// Skip spawn phase
while (game.inSpawnPhase()) {
game.executeNextTick();
}
const attacker = game.player("attacker_id");
const nation = game.player("nation_id");
@@ -167,9 +164,6 @@ describe("Nation MIRV Retaliation", () => {
game.addPlayer(nationInfo);
// Skip spawn phase
while (game.inSpawnPhase()) {
game.executeNextTick();
}
const dominantPlayer = game.player("dominant_id");
const nation = game.player("nation_id");
@@ -342,9 +336,6 @@ describe("Nation MIRV Retaliation", () => {
game.addPlayer(nationInfo);
// Skip spawn phase
while (game.inSpawnPhase()) {
game.executeNextTick();
}
const steamroller = game.player("steamroller_id");
const secondPlayer = game.player("second_id");
@@ -502,9 +493,6 @@ describe("Nation MIRV Retaliation", () => {
game.addPlayer(nationInfo);
// Skip spawn phase
while (game.inSpawnPhase()) {
game.executeNextTick();
}
const steamroller = game.player("steamroller_id");
const secondPlayer = game.player("second_id");
@@ -637,9 +625,6 @@ describe("Nation MIRV Retaliation", () => {
// Players already added via setup() with Team mode and shared clan for humans
// Skip spawn phase
while (game.inSpawnPhase()) {
game.executeNextTick();
}
const teamPlayer1 = game.player("team1_id");
const teamPlayer2 = game.player("team2_id");
-4
View File
@@ -40,10 +40,6 @@ describe("NationNukeBehavior - maybeDestroyEnemySam", () => {
game.addPlayer(nationInfo);
game.addPlayer(humanInfo);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
const nation = game.player("nation_id");
const human = game.player("human_id");
+4 -14
View File
@@ -13,20 +13,10 @@ let other: Player;
describe("PlayerImpl", () => {
beforeEach(async () => {
game = await setup(
"plains",
{
instantBuild: true,
},
[
new PlayerInfo("player", PlayerType.Human, null, "player_id"),
new PlayerInfo("other", PlayerType.Human, null, "other_id"),
],
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
game = await setup("plains", { instantBuild: true }, [
new PlayerInfo("player", PlayerType.Human, null, "player_id"),
new PlayerInfo("other", PlayerType.Human, null, "other_id"),
]);
player = game.player("player_id");
other = game.player("other_id");
+4 -14
View File
@@ -14,20 +14,10 @@ let other: Player;
describe("PortExecution", () => {
beforeEach(async () => {
game = await setup(
"half_land_half_ocean",
{
instantBuild: true,
},
[
new PlayerInfo("player", PlayerType.Human, null, "player_id"),
new PlayerInfo("other", PlayerType.Human, null, "other_id"),
],
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
game = await setup("half_land_half_ocean", { instantBuild: true }, [
new PlayerInfo("player", PlayerType.Human, null, "player_id"),
new PlayerInfo("other", PlayerType.Human, null, "other_id"),
]);
player = game.player("player_id");
player.addGold(BigInt(1000000));
+16 -5
View File
@@ -114,11 +114,9 @@ describe("UsernameCensor", () => {
expect(matcher.hasMatch("MyChairName")).toBe(true);
});
test("detects banned words with underscores/dots/numbers mixed in", () => {
// These should NOT bypass the filter (skipNonAlphabetic was intentionally removed)
// Words separated by non-alpha chars are treated as separate tokens
expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(false); // dots break the word
expect(matcher.hasMatch("hi_tler")).toBe(false); // underscore breaks it
test("detects banned words with non-alphabetic characters mixed in", () => {
expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(true);
expect(matcher.hasMatch("hi_tler")).toBe(true);
});
test("allows clean usernames", () => {
@@ -141,6 +139,19 @@ describe("UsernameCensor", () => {
expect(matcher.hasMatch("kkklover")).toBe(true);
expect(matcher.hasMatch("ilovekkkboys")).toBe(true);
});
test("catches slurs separated by periods (bypass attempt)", () => {
expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(true);
expect(matcher.hasMatch("N.I.G.G.E.R")).toBe(true);
expect(matcher.hasMatch("n.i.g.g.a")).toBe(true);
expect(matcher.hasMatch("h.i.t.l.e.r")).toBe(true);
expect(matcher.hasMatch("hello n.i.g.g.e.r world")).toBe(true);
});
test("censor replaces period-separated slur usernames", () => {
const result = checker.censor("n.i.g.g.e.r", null);
expect(shadowNames).toContain(result.username);
});
});
describe("censor", () => {
+1
View File
@@ -21,6 +21,7 @@ function makeUserMe(flares: string[] = []): UserMeResponse {
adfree: false,
flares,
achievements: { singleplayerMap: [] },
subscription: null,
},
} as UserMeResponse;
}
+1 -8
View File
@@ -19,20 +19,13 @@ describe("Shell Random Damage", () => {
beforeEach(async () => {
game = await setup(
"half_land_half_ocean",
{
infiniteGold: true,
instantBuild: true,
},
{ infiniteGold: true, instantBuild: true },
[
new PlayerInfo("attacker", PlayerType.Human, null, "player_1_id"),
new PlayerInfo("defender", PlayerType.Human, null, "player_2_id"),
],
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
player1 = game.player("player_1_id");
player2 = game.player("player_2_id");
});
-4
View File
@@ -23,10 +23,6 @@ describe("Stats", () => {
new PlayerInfo("boat dude", PlayerType.Human, "client2", "player_2_id"),
]);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
player1 = game.player("player_1_id");
player2 = game.player("player_2_id");
});
+4 -8
View File
@@ -21,22 +21,18 @@ describe("Warship", () => {
beforeEach(async () => {
game = await setup(
"half_land_half_ocean",
{
infiniteGold: true,
instantBuild: true,
},
{ infiniteGold: true, instantBuild: true },
[
new PlayerInfo("boat dude", PlayerType.Human, null, "player_1_id"),
new PlayerInfo("boat dude", PlayerType.Human, null, "player_2_id"),
],
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
player1 = game.player("player_1_id");
player2 = game.player("player_2_id");
// Advance past the manualMoveRetreatDisabledDuration window.
executeTicks(game, 50);
});
test("Warship heals only if player has port", async () => {
-1
View File
@@ -25,7 +25,6 @@ describe("Warship multi-selection (MoveWarshipExecution)", () => {
new PlayerInfo("p2", PlayerType.Human, null, "p2"),
],
);
while (game.inSpawnPhase()) game.executeNextTick();
player1 = game.player("p1");
player2 = game.player("p2");
});
@@ -0,0 +1,83 @@
import { installSafariPinchZoomBlocker } from "../../src/client/utilities/DisableSafariPinchZoom";
const GESTURE_EVENTS = ["gesturestart", "gesturechange", "gestureend"] as const;
function dispatchCancelableGestureEvent(
target: EventTarget,
type: string,
): Event {
// Safari's GestureEvent is not available in jsdom. Dispatch a plain
// cancelable Event of the same name so preventDefault() is observable via
// defaultPrevented.
const event = new Event(type, { bubbles: true, cancelable: true });
target.dispatchEvent(event);
return event;
}
describe("installSafariPinchZoomBlocker", () => {
it("prevents the default action of each Safari gesture event on document", () => {
const uninstall = installSafariPinchZoomBlocker();
try {
for (const type of GESTURE_EVENTS) {
const event = dispatchCancelableGestureEvent(document, type);
expect(event.defaultPrevented).toBe(true);
}
} finally {
uninstall();
}
});
it("attaches listeners to the provided target", () => {
const target = document.createElement("div");
const uninstall = installSafariPinchZoomBlocker(target);
try {
for (const type of GESTURE_EVENTS) {
const event = dispatchCancelableGestureEvent(target, type);
expect(event.defaultPrevented).toBe(true);
}
} finally {
uninstall();
}
});
it("removes the listeners when the returned disposer is called", () => {
const target = document.createElement("div");
const uninstall = installSafariPinchZoomBlocker(target);
uninstall();
for (const type of GESTURE_EVENTS) {
const event = dispatchCancelableGestureEvent(target, type);
expect(event.defaultPrevented).toBe(false);
}
});
it("does not affect events on unrelated targets", () => {
const scope = document.createElement("div");
const other = document.createElement("div");
const uninstall = installSafariPinchZoomBlocker(scope);
try {
const event = dispatchCancelableGestureEvent(other, "gesturestart");
expect(event.defaultPrevented).toBe(false);
} finally {
uninstall();
}
});
it("leaves unrelated event types alone", () => {
const uninstall = installSafariPinchZoomBlocker();
try {
const event = new Event("touchstart", {
bubbles: true,
cancelable: true,
});
document.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
} finally {
uninstall();
}
});
});
+9 -1
View File
@@ -101,6 +101,7 @@ beforeEach(() => {
);
});
import "../../src/client/components/baseComponents/Modal";
import { LeaderboardModal } from "../../src/client/LeaderboardModal";
describe("LeaderboardModal", () => {
@@ -334,7 +335,14 @@ describe("LeaderboardModal", () => {
}),
});
const tab = modal.querySelector("#clan-leaderboard-tab");
modal.inline = true;
await modal.updateComplete;
const oModal = modal.querySelector("o-modal");
await (oModal as unknown as { updateComplete: Promise<unknown> })
.updateComplete;
const tab = oModal!.shadowRoot!.querySelector(
'button[role="tab"][data-key="clans"]',
);
expect(tab).toBeTruthy();
tab!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+9
View File
@@ -83,6 +83,15 @@ describe("fetchClanStats", () => {
ffa: { wins: 7, losses: 3 },
team: { wins: 4, losses: 1 },
hvn: { wins: 1, losses: 0 },
duos: { wins: 2, losses: 0 },
trios: { wins: 1, losses: 1 },
quads: { wins: 1, losses: 0 },
"2": { wins: 2, losses: 0 },
"3": { wins: 1, losses: 1 },
"4": { wins: 1, losses: 0 },
"5": { wins: 0, losses: 0 },
"6": { wins: 0, losses: 0 },
"7": { wins: 0, losses: 0 },
ranked: { wins: 3, losses: 1 },
"1v1": { wins: 3, losses: 1 },
},
+18
View File
@@ -93,6 +93,15 @@ describe("ClanMemberSchema", () => {
ffa: { wins: 2, losses: 4 },
team: { wins: 5, losses: 1 },
hvn: { wins: 0, losses: 0 },
duos: { wins: 1, losses: 0 },
trios: { wins: 2, losses: 0 },
quads: { wins: 2, losses: 1 },
"2": { wins: 1, losses: 0 },
"3": { wins: 2, losses: 0 },
"4": { wins: 2, losses: 1 },
"5": { wins: 0, losses: 0 },
"6": { wins: 0, losses: 0 },
"7": { wins: 0, losses: 0 },
ranked: { wins: 1, losses: 3 },
"1v1": { wins: 1, losses: 3 },
},
@@ -155,6 +164,15 @@ describe("ClanStatsSchema", () => {
ffa: { wins: 3, losses: 2 },
team: { wins: 2, losses: 1 },
hvn: { wins: 1, losses: 0 },
duos: { wins: 1, losses: 0 },
trios: { wins: 0, losses: 1 },
quads: { wins: 1, losses: 0 },
"2": { wins: 1, losses: 0 },
"3": { wins: 0, losses: 1 },
"4": { wins: 1, losses: 0 },
"5": { wins: 0, losses: 0 },
"6": { wins: 0, losses: 0 },
"7": { wins: 0, losses: 0 },
ranked: { wins: 1, losses: 0 },
"1v1": { wins: 1, losses: 0 },
},
@@ -4,7 +4,6 @@ import {
apiMockFactory,
authMockFactory,
clanApiMockFactory,
configLoaderMockFactory,
crazyGamesSdkMockFactory,
flushAsync,
getElState,
@@ -22,9 +21,6 @@ vi.mock("../../../src/client/Api", () => apiMockFactory());
vi.mock("../../../src/client/ClanApi", () => clanApiMockFactory());
vi.mock("../../../src/client/Utils", () => utilsMockFactory());
vi.mock("../../../src/client/Auth", () => authMockFactory());
vi.mock("../../../src/core/configuration/ConfigLoader", () =>
configLoaderMockFactory(),
);
vi.mock("../../../src/client/CrazyGamesSDK", () => crazyGamesSdkMockFactory());
stubLocalStorage();
@@ -4,7 +4,6 @@ import {
apiMockFactory,
authMockFactory,
clanApiMockFactory,
configLoaderMockFactory,
crazyGamesSdkMockFactory,
getElState,
makeClan,
@@ -20,9 +19,6 @@ vi.mock("../../../src/client/Api", () => apiMockFactory());
vi.mock("../../../src/client/ClanApi", () => clanApiMockFactory());
vi.mock("../../../src/client/Utils", () => utilsMockFactory());
vi.mock("../../../src/client/Auth", () => authMockFactory());
vi.mock("../../../src/core/configuration/ConfigLoader", () =>
configLoaderMockFactory(),
);
vi.mock("../../../src/client/CrazyGamesSDK", () => crazyGamesSdkMockFactory());
stubLocalStorage();
+9 -6
View File
@@ -41,6 +41,15 @@ export function clanApiMockFactory() {
ffa: { wins: 3, losses: 2 },
team: { wins: 2, losses: 1 },
hvn: { wins: 1, losses: 0 },
duos: { wins: 1, losses: 0 },
trios: { wins: 0, losses: 1 },
quads: { wins: 1, losses: 0 },
"2": { wins: 1, losses: 0 },
"3": { wins: 0, losses: 1 },
"4": { wins: 1, losses: 0 },
"5": { wins: 0, losses: 0 },
"6": { wins: 0, losses: 0 },
"7": { wins: 0, losses: 0 },
ranked: { wins: 1, losses: 0 },
"1v1": { wins: 1, losses: 0 },
},
@@ -119,12 +128,6 @@ export function authMockFactory() {
};
}
export function configLoaderMockFactory() {
return {
getRuntimeClientServerConfig: vi.fn(() => ({})),
};
}
export function crazyGamesSdkMockFactory() {
return {
crazyGamesSDK: { isAvailable: false },
+90 -19
View File
@@ -57,66 +57,137 @@ describe("filterMembersBySearch", () => {
});
describe("renderMemberStats", () => {
const ZERO = { wins: 0, losses: 0 } as const;
const stats: ClanMemberStats = {
total: { wins: 7, losses: 5 },
ffa: { wins: 2, losses: 4 },
team: { wins: 5, losses: 1 },
hvn: { wins: 0, losses: 0 },
ranked: { wins: 0, losses: 0 },
"1v1": { wins: 0, losses: 0 },
hvn: { ...ZERO },
duos: { wins: 1, losses: 0 },
trios: { wins: 4, losses: 1 },
quads: { ...ZERO },
"2": { ...ZERO },
"3": { ...ZERO },
"4": { ...ZERO },
"5": { ...ZERO },
"6": { ...ZERO },
"7": { ...ZERO },
ranked: { ...ZERO },
"1v1": { ...ZERO },
};
function renderTo(result: ReturnType<typeof renderMemberStats>): HTMLElement {
async function renderTo(
result: ReturnType<typeof renderMemberStats>,
): Promise<HTMLElement> {
const host = document.createElement("div");
render(result, host);
document.body.appendChild(host);
// Allow Lit to upgrade the <clan-stats-breakdown> custom element.
await Promise.resolve();
await new Promise((r) => setTimeout(r, 0));
return host;
}
it("renders nothing when stats is undefined", () => {
const host = renderTo(renderMemberStats(undefined));
function findExpandableButton(
host: HTMLElement,
labelKey: string,
): HTMLButtonElement | undefined {
return Array.from(
host.querySelectorAll<HTMLButtonElement>("button[aria-expanded]"),
).find((b) => (b.textContent ?? "").includes(labelKey));
}
async function expandTotal(host: HTMLElement) {
const btn = findExpandableButton(host, "clan_modal.stats_total");
btn!.click();
await new Promise((r) => setTimeout(r, 0));
}
it("renders nothing when stats is undefined", async () => {
const host = await renderTo(renderMemberStats(undefined));
expect(host.textContent?.trim()).toBe("");
});
it("renders W/L labels inside bar segments and the win-rate per bucket", () => {
const host = renderTo(renderMemberStats(stats));
it("collapses everything except the total row by default", async () => {
const host = await renderTo(renderMemberStats(stats));
const text = host.textContent ?? "";
expect(text).toContain("clan_modal.stats_total");
expect(text).not.toContain("clan_modal.stats_ffa");
expect(text).not.toContain("clan_modal.stats_team");
expect(text).not.toContain("clan_modal.stats_hvn");
expect(text).not.toContain("clan_modal.stats_ranked");
});
it("renders W/L labels inside bar segments and the win-rate per bucket", async () => {
const host = await renderTo(renderMemberStats(stats));
await expandTotal(host);
const text = host.textContent?.replace(/\s+/g, " ") ?? "";
// Each bucket with games shows `{wins}W` and `{losses}L` inside segments
expect(text).toContain("2W");
expect(text).toContain("4L");
expect(text).toContain("5W");
expect(text).toContain("1L");
// Win-rate, and em-dash placeholder for empty bucket
expect(text).toContain("33%");
expect(text).toContain("83%");
expect(text).toContain("—");
});
it("renders a proportional win-loss bar when there are games", () => {
const host = renderTo(renderMemberStats(stats));
it("renders a proportional win-loss bar when there are games", async () => {
const host = await renderTo(renderMemberStats(stats));
await expandTotal(host);
const bars = host.querySelectorAll<HTMLDivElement>("[style*='width']");
// Two segments per bucket with games (total: 2, ffa: 2, team: 2). Ranked
// and 1v1 have 0 games → no segments.
// Top-level rows after expanding Total: total, ffa, team, hvn, ranked (5).
// Ranked and hvn have 0 games → no segments. Others contribute 2 each.
expect(bars.length).toBe(6);
const widths = Array.from(bars).map((b) =>
(b.getAttribute("style") ?? "").replace(/\s+/g, ""),
);
// total: 7/12 ≈ 58.3% wins, 41.7% losses
expect(widths[0]).toContain("width:58.33");
expect(widths[1]).toContain("width:41.66");
// ffa: 2/6 ≈ 33.3% wins, 66.7% losses
expect(widths[2]).toContain("width:33.33");
expect(widths[3]).toContain("width:66.66");
});
it("includes all six translated bucket labels", () => {
const host = renderTo(renderMemberStats(stats));
it("includes the visible top-level translated bucket labels", async () => {
const host = await renderTo(renderMemberStats(stats));
await expandTotal(host);
const text = host.textContent ?? "";
expect(text).toContain("clan_modal.stats_total");
expect(text).toContain("clan_modal.stats_ffa");
expect(text).toContain("clan_modal.stats_team");
expect(text).toContain("clan_modal.stats_hvn");
expect(text).toContain("clan_modal.stats_ranked");
expect(text).toContain("clan_modal.stats_1v1");
// 1v1 lives under the ranked dropdown — hidden until expanded.
expect(text).not.toContain("clan_modal.stats_1v1");
});
it("reveals team sub-buckets when the team row is expanded", async () => {
const host = await renderTo(renderMemberStats(stats));
await expandTotal(host);
const teamButton = findExpandableButton(host, "clan_modal.stats_team");
expect(teamButton).toBeDefined();
expect(teamButton!.disabled).toBe(false);
teamButton!.click();
await new Promise((r) => setTimeout(r, 0));
const text = host.textContent ?? "";
expect(text).toContain("clan_modal.stats_duos");
expect(text).toContain("clan_modal.stats_trios");
// Buckets with no games are hidden.
expect(text).not.toContain("clan_modal.stats_quads");
});
it("does not render an expandable button for ranked when no breakdown has games", async () => {
const host = await renderTo(renderMemberStats(stats));
await expandTotal(host);
const expandableLabels = Array.from(
host.querySelectorAll<HTMLButtonElement>("button[aria-expanded]"),
).map((b) => b.textContent ?? "");
expect(
expandableLabels.some((t) => t.includes("clan_modal.stats_ranked")),
).toBe(false);
// Sanity: team is still expandable since it has sub-bucket games.
expect(
expandableLabels.some((t) => t.includes("clan_modal.stats_team")),
).toBe(true);
});
});
@@ -1,66 +1,37 @@
import { describe, expect, test } from "vitest";
import {
alignClusterOrder,
computeBarStrength,
computeLabelScale,
} from "../../../../src/client/graphics/layers/AttackingTroopsOverlay";
import { Cell } from "../../../../src/core/game/Game";
describe("computeLabelScale", () => {
test("counter-scales the zoom when above the full-size threshold", () => {
// zoom = 2 → label rendered at 1/2 to stay at full screen size.
expect(computeLabelScale(2)).toBeCloseTo(0.5);
// LABEL_FULL_SIZE_ZOOM = 4, LABEL_MIN_RENDERED_SIZE = 0.63,
// LABEL_SIZE_MULTIPLIER = 1.0. Rendered size at zoom z:
// 1.0 * (0.63 + 0.37 * min(1, z/4)).
test("at the full-size threshold, rendered size is capped at the multiplier", () => {
// zoom = 4 → rendered = 1.0 → scale = 1.0 / 4.
expect(computeLabelScale(4)).toBeCloseTo(1.0 / 4);
});
test("counter-scales exactly at the full-size threshold", () => {
// zoom = 1.5 → label rendered at 1/1.5 ≈ 0.6667.
expect(computeLabelScale(1.5)).toBeCloseTo(1 / 1.5);
test("above the threshold, rendered size stays capped (counter-scales zoom)", () => {
// zoom = 8 → rendered still 1.0 → scale = 1.0 / 8.
expect(computeLabelScale(8)).toBeCloseTo(1.0 / 8);
});
test("rides the world transform between the floor and the threshold", () => {
// Below the threshold, netScale = zoom / 1.5, so the factor is constant 1/1.5.
expect(computeLabelScale(1)).toBeCloseTo(1 / 1.5);
expect(computeLabelScale(0.9)).toBeCloseTo(1 / 1.5);
test("at zoom = 0+, rendered size approaches the floor", () => {
// As zoom→0, t→0, rendered → 1.0 * 0.63 (the floor).
// At zoom = 0.001, rendered ≈ floor, so scale ≈ floor / zoom = huge.
const scale = computeLabelScale(0.001);
const floorRendered = 1.0 * 0.63;
// Within 1% of the floor-divided-by-zoom value.
expect(scale).toBeGreaterThan((floorRendered / 0.001) * 0.99);
expect(scale).toBeLessThan((floorRendered / 0.001) * 1.01);
});
test("floor engages exactly at zoom = 0.75 (LABEL_MIN_SCREEN_SCALE * LABEL_FULL_SIZE_ZOOM)", () => {
expect(computeLabelScale(0.75)).toBeCloseTo(1 / 1.5);
});
test("grows in screen space when zoomed out past the floor", () => {
// zoom = 0.5 → netScale clamped to 0.5, factor = 0.5 / 0.5 = 1.
expect(computeLabelScale(0.5)).toBeCloseTo(1);
// zoom = 0.25 → factor = 0.5 / 0.25 = 2.
expect(computeLabelScale(0.25)).toBeCloseTo(2);
});
});
describe("computeBarStrength", () => {
test("equal troops sit at the midpoint", () => {
// 1000 vs 1000 → ratio 1, divided by full-height ratio of 2 → 0.5.
expect(computeBarStrength(1000, 1000)).toBeCloseTo(0.5);
});
test("attacker with no troops yields a zero-height bar", () => {
expect(computeBarStrength(0, 1000)).toBe(0);
});
test("scales linearly between zero and the full-height threshold", () => {
// 500 vs 1000 → ratio 0.5 → 0.25.
expect(computeBarStrength(500, 1000)).toBeCloseTo(0.25);
// 1500 vs 1000 → ratio 1.5 → 0.75.
expect(computeBarStrength(1500, 1000)).toBeCloseTo(0.75);
});
test("clamps at full height when attacker has 2× the opposition", () => {
expect(computeBarStrength(2000, 1000)).toBeCloseTo(1);
expect(computeBarStrength(10_000, 1000)).toBeCloseTo(1);
});
test("returns full height when the opposing side has no troops", () => {
// Avoids division-by-zero: an undefended target is maximum strength.
expect(computeBarStrength(500, 0)).toBe(1);
expect(computeBarStrength(0, 0)).toBe(1);
test("interpolates linearly between floor and full-size threshold", () => {
// zoom = 2 → t = 0.5 → rendered = 1.0 * (0.63 + 0.185) = 0.815.
expect(computeLabelScale(2)).toBeCloseTo(0.815 / 2);
});
});
+69 -26
View File
@@ -1,44 +1,87 @@
import { ClientEnv } from "src/client/ClientEnv";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { GameEnv } from "../../../src/core/configuration/Config";
import {
clearCachedRuntimeClientServerConfig,
GameLogicEnv,
getBuildTimeGameLogicEnv,
getGameLogicConfig,
getRuntimeClientServerConfig,
getServerConfigForGameLogicEnv,
} from "../../../src/core/configuration/ConfigLoader";
import { GameEnv, parseGameEnv } from "../../../src/core/configuration/Config";
describe("ConfigLoader", () => {
const originalGameEnv = process.env.GAME_ENV;
describe("parseGameEnv", () => {
test("maps 'dev' to GameEnv.Dev", () => {
expect(parseGameEnv("dev")).toBe(GameEnv.Dev);
});
test("maps 'staging' to GameEnv.Preprod", () => {
expect(parseGameEnv("staging")).toBe(GameEnv.Preprod);
});
test("maps 'prod' to GameEnv.Prod", () => {
expect(parseGameEnv("prod")).toBe(GameEnv.Prod);
});
test("throws on undefined", () => {
expect(() => parseGameEnv(undefined)).toThrow(/unsupported game env/);
});
test("throws on unknown value", () => {
expect(() => parseGameEnv("production")).toThrow(/unsupported game env/);
});
});
describe("ClientEnv", () => {
beforeEach(() => {
vi.restoreAllMocks();
window.BOOTSTRAP_CONFIG = undefined;
process.env.GAME_ENV = originalGameEnv;
clearCachedRuntimeClientServerConfig();
ClientEnv.reset();
});
test("uses runtime bootstrap config without fetching /api/env", async () => {
window.BOOTSTRAP_CONFIG = { gameEnv: "staging" };
test("reads from window.BOOTSTRAP_CONFIG without fetching", () => {
window.BOOTSTRAP_CONFIG = {
gameEnv: "staging",
numWorkers: 4,
turnstileSiteKey: "test-key",
jwtAudience: "openfront.dev",
instanceId: "TEST_ID",
gitCommit: "abc123",
};
const fetchSpy = vi.spyOn(globalThis, "fetch");
const config = await getRuntimeClientServerConfig();
expect(config.env()).toBe(GameEnv.Preprod);
expect(ClientEnv.env()).toBe(GameEnv.Preprod);
expect(ClientEnv.numWorkers()).toBe(4);
expect(ClientEnv.turnstileSiteKey()).toBe("test-key");
expect(ClientEnv.jwtAudience()).toBe("openfront.dev");
expect(ClientEnv.instanceId()).toBe("TEST_ID");
expect(fetchSpy).not.toHaveBeenCalled();
});
test("maps staging builds to the default game logic config", async () => {
process.env.GAME_ENV = "staging";
test("throws when BOOTSTRAP_CONFIG is undefined", () => {
expect(() => ClientEnv.env()).toThrow(/Missing BOOTSTRAP_CONFIG/);
});
expect(getBuildTimeGameLogicEnv()).toBe(GameLogicEnv.Default);
expect(getServerConfigForGameLogicEnv(GameLogicEnv.Default).env()).toBe(
GameEnv.Prod,
);
test("throws when a required field is missing", () => {
window.BOOTSTRAP_CONFIG = {
gameEnv: "dev",
numWorkers: 1,
turnstileSiteKey: "k",
jwtAudience: "localhost",
// instanceId missing
};
expect(() => ClientEnv.instanceId()).toThrow(/Missing BOOTSTRAP_CONFIG/);
});
const config = await getGameLogicConfig({} as any, null);
test("jwtIssuer maps 'localhost' to http://localhost:8787", () => {
window.BOOTSTRAP_CONFIG = {
gameEnv: "dev",
numWorkers: 1,
turnstileSiteKey: "k",
jwtAudience: "localhost",
instanceId: "x",
gitCommit: "DEV",
};
expect(ClientEnv.jwtIssuer()).toBe("http://localhost:8787");
});
expect(config.serverConfig().env()).toBe(GameEnv.Prod);
test("jwtIssuer derives api.<audience> for non-localhost", () => {
window.BOOTSTRAP_CONFIG = {
gameEnv: "prod",
numWorkers: 1,
turnstileSiteKey: "k",
jwtAudience: "openfront.io",
instanceId: "x",
gitCommit: "abc123",
};
expect(ClientEnv.jwtIssuer()).toBe("https://api.openfront.io");
});
});
+9 -15
View File
@@ -27,13 +27,11 @@ describe("Spawn execution", () => {
spawnExecutions.push(new SpawnExecution("game_id", playerInfo));
}
const game = await setup(mapName, undefined, players);
const game = await setup(mapName, {}, players);
game.addExecution(...spawnExecutions);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
game.executeNextTick();
game.executeNextTick();
game.allPlayers().forEach((player) => {
const spawnTile = player.spawnTile()!;
@@ -73,13 +71,11 @@ describe("Spawn execution", () => {
spawnExecutions.push(new SpawnExecution("game_id", playerInfo));
}
const game = await setup("half_land_half_ocean", undefined, players);
const game = await setup("half_land_half_ocean", {}, players);
game.addExecution(...spawnExecutions);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
game.executeNextTick();
game.executeNextTick();
// Should spawn fewer than requested when map is too small
expect(
@@ -96,14 +92,12 @@ describe("Spawn execution", () => {
`player_id`,
);
const game = await setup("half_land_half_ocean", undefined, [playerInfo]);
const game = await setup("half_land_half_ocean", {}, [playerInfo]);
game.addExecution(new SpawnExecution("game_id", playerInfo, 10));
game.addExecution(new SpawnExecution("game_id", playerInfo, 20));
while (game.inSpawnPhase()) {
game.executeNextTick();
}
game.executeNextTick();
game.executeNextTick();
expect(game.playerByClientID("client_id")?.spawnTile()).toBe(20);
// Previous territory from first spawn should be relinquished
+1 -8
View File
@@ -18,20 +18,13 @@ describe("MIRVExecution", () => {
beforeEach(async () => {
game = await setup(
"big_plains",
{
infiniteGold: true,
instantBuild: true,
},
{ infiniteGold: true, instantBuild: true },
[
new PlayerInfo("player", PlayerType.Human, "client_id1", "player_id"),
new PlayerInfo("other", PlayerType.Human, "client_id2", "other_id"),
],
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
player = game.player("player_id");
otherPlayer = game.player("other_id");
@@ -17,20 +17,13 @@ describe("PlayerExecution Annexation Bug", () => {
beforeEach(async () => {
game = await setup(
"big_plains",
{
infiniteGold: true,
instantBuild: true,
},
{ infiniteGold: true, instantBuild: true },
[
new PlayerInfo("large", PlayerType.Human, "client1", "large_id"),
new PlayerInfo("small", PlayerType.Human, "client2", "small_id"),
],
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
largePlayer = game.player("large_id");
smallPlayer = game.player("small_id");
+1 -8
View File
@@ -18,10 +18,7 @@ describe("NukeExecution", () => {
beforeEach(async () => {
game = await setup(
"big_plains",
{
infiniteGold: true,
instantBuild: true,
},
{ infiniteGold: true, instantBuild: true },
[
new PlayerInfo("player", PlayerType.Human, "client_id1", "player_id"),
new PlayerInfo("other", PlayerType.Human, "client_id2", "other_id"),
@@ -34,10 +31,6 @@ describe("NukeExecution", () => {
}));
(game.config() as TestConfig).nukeAllianceBreakThreshold = vi.fn(() => 5);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
player = game.player("player_id");
otherPlayer = game.player("other_id");
@@ -17,20 +17,13 @@ describe("PlayerExecution", () => {
beforeEach(async () => {
game = await setup(
"big_plains",
{
infiniteGold: true,
instantBuild: true,
},
{ infiniteGold: true, instantBuild: true },
[
new PlayerInfo("player", PlayerType.Human, "client_id1", "player_id"),
new PlayerInfo("other", PlayerType.Human, "client_id2", "other_id"),
],
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
player = game.player("player_id");
otherPlayer = game.player("other_id");
@@ -78,10 +78,6 @@ describe("SAM", () => {
),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
attacker = game.player("attacker_id");
defender = game.player("defender_id");
middle_defender = game.player("middle_defender_id");
@@ -65,12 +65,8 @@ describe("WinCheckExecution", () => {
mg.numLandTiles = vi.fn(() => 100);
mg.numTilesWithFallout = vi.fn(() => 0);
mg.stats = vi.fn(() => ({ stats: () => ({ mocked: true }) }));
// Advance ticks until timeElapsed (in seconds) >= maxTimerValue * 60
// timeElapsed = (ticks - numSpawnPhaseTurns) / 10 =>
// ticks >= numSpawnPhaseTurns + maxTimerValue * 600
const threshold =
mg.config().numSpawnPhaseTurns() +
(mg.config().gameConfig().maxTimerValue ?? 0) * 600;
mg.endSpawnPhase();
const threshold = (mg.config().gameConfig().maxTimerValue ?? 0) * 600;
while (mg.ticks() < threshold) {
mg.executeNextTick();
}
@@ -109,9 +105,6 @@ describe("WinCheckExecution - Nation Winners", () => {
const nation = game.player("nation_id");
// Skip spawn phase
while (game.inSpawnPhase()) {
game.executeNextTick();
}
// Assign 81% of land to Nation
const totalLand = game.numLandTiles();
@@ -171,10 +164,7 @@ describe("WinCheckExecution - Nation Winners", () => {
game.addPlayer(nationInfo);
const nation = game.player("nation_id");
// Skip spawn phase
while (game.inSpawnPhase()) {
game.executeNextTick();
}
game.endSpawnPhase();
// Give Nation 60% territory (below 80% threshold)
// Give human 30% territory
@@ -200,9 +190,7 @@ describe("WinCheckExecution - Nation Winners", () => {
expect(nation.numTilesOwned()).toBeGreaterThan(human.numTilesOwned());
// Fast-forward game ticks past timer expiration
const threshold =
game.config().numSpawnPhaseTurns() +
(game.config().gameConfig().maxTimerValue ?? 0) * 600;
const threshold = (game.config().gameConfig().maxTimerValue ?? 0) * 600;
while (game.ticks() < threshold) {
game.executeNextTick();
}
@@ -258,9 +246,6 @@ describe("WinCheckExecution - Nation Winners", () => {
const nation3 = game.player("nation3_id");
// Skip spawn phase
while (game.inSpawnPhase()) {
game.executeNextTick();
}
// Assign territories: Nation1 (85%), Nation2 (10%), Nation3 (5%)
const totalLand = game.numLandTiles();
@@ -327,9 +312,6 @@ describe("WinCheckExecution - Nation Winners", () => {
expect(bot2.team()).toBe(ColoredTeams.Bot);
// Skip spawn phase
while (game.inSpawnPhase()) {
game.executeNextTick();
}
// Assign 96% of land to bot team (above 95% Team mode threshold)
const totalLand = game.numLandTiles();
@@ -392,9 +374,6 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => {
const human2 = game.player("Player2");
// Skip spawn phase
while (game.inSpawnPhase()) {
game.executeNextTick();
}
// Assign some territory to both players
let human1Count = 0;
@@ -447,9 +426,6 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => {
const human2 = game.player("Player2");
// Skip spawn phase
while (game.inSpawnPhase()) {
game.executeNextTick();
}
// Assign territory to both players
let human1Count = 0;
@@ -503,9 +479,6 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => {
const human2 = game.player("Player2");
// Skip spawn phase
while (game.inSpawnPhase()) {
game.executeNextTick();
}
// Both players disconnect
human1.markDisconnected(true);
@@ -547,9 +520,6 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => {
const nation = game.player("NationPlayer");
// Skip spawn phase
while (game.inSpawnPhase()) {
game.executeNextTick();
}
// Assign territory to all players
let humanCount = 0;
+41 -4
View File
@@ -5,11 +5,13 @@ import { SpawnExecution } from "../../../src/core/execution/SpawnExecution";
import { AllianceRequestExecution } from "../../../src/core/execution/alliance/AllianceRequestExecution";
import {
Game,
GameType,
Player,
PlayerInfo,
PlayerType,
} from "../../../src/core/game/Game";
import { TileRef } from "../../../src/core/game/GameMap";
import { GameUpdateType } from "../../../src/core/game/GameUpdates";
import { setup } from "../../util/Setup";
const gameID: GameID = "game_id";
@@ -57,10 +59,6 @@ describe("GameImpl", () => {
),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
attacker = game.player(attackerInfo.id);
defender = game.player(defenderInfo.id);
});
@@ -132,4 +130,43 @@ describe("GameImpl", () => {
expect(attacker.isTraitor()).toBe(true);
expect(attacker.allianceWith(defender)).toBeFalsy();
});
test("Singleplayer late human spawn gets spawn immunity", async () => {
const singleplayerGame = await setup(
"plains",
{
gameType: GameType.Singleplayer,
},
[],
undefined,
undefined,
false,
);
(singleplayerGame.config() as any).setSpawnImmunityDuration(100);
const pastSpawnCountdown =
singleplayerGame.config().numSpawnPhaseTurns() + 20;
for (let i = 0; i < pastSpawnCountdown; i++) {
singleplayerGame.executeNextTick();
}
const lateHumanInfo = new PlayerInfo(
"late human",
PlayerType.Human,
"late_client_id",
"late_player_id",
);
singleplayerGame.addExecution(
new SpawnExecution(gameID, lateHumanInfo, singleplayerGame.ref(5, 5)),
);
// First tick initializes the execution, second tick applies the spawn.
singleplayerGame.executeNextTick();
const spawnUpdates = singleplayerGame.executeNextTick();
expect(singleplayerGame.player(lateHumanInfo.id).hasSpawned()).toBe(true);
expect(spawnUpdates[GameUpdateType.SpawnPhaseEnd]).toHaveLength(1);
expect(singleplayerGame.isSpawnImmunityActive()).toBe(true);
});
});
+4 -11
View File
@@ -1,6 +1,6 @@
import { GameUpdateType } from "src/core/game/GameUpdates";
import { vi, type Mocked } from "vitest";
import { DefaultConfig } from "../../../src/core/configuration/DefaultConfig";
import { Config } from "../../../src/core/configuration/Config";
import { TrainExecution } from "../../../src/core/execution/TrainExecution";
import {
Difficulty,
@@ -16,7 +16,6 @@ import {
import { Cluster, TrainStation } from "../../../src/core/game/TrainStation";
import { UserSettings } from "../../../src/core/game/UserSettings";
import { GameConfig } from "../../../src/core/Schemas";
import { TestServerConfig } from "../../util/TestServerConfig";
vi.mock("../../../src/core/game/Game");
vi.mock("../../../src/core/execution/TrainExecution");
@@ -206,12 +205,11 @@ describe("TrainStation", () => {
});
});
describe("DefaultConfig.trainGold trade stop penalty", () => {
let config: DefaultConfig;
describe("Config.trainGold trade stop penalty", () => {
let config: Config;
let mockPlayer: Player;
beforeEach(() => {
const serverConfig = new TestServerConfig();
const gameConfig: GameConfig = {
gameMap: GameMapType.Asia,
gameMapSize: GameMapSize.Normal,
@@ -228,12 +226,7 @@ describe("DefaultConfig.trainGold trade stop penalty", () => {
disableNavMesh: false,
randomSpawn: false,
};
config = new DefaultConfig(
serverConfig,
gameConfig,
new UserSettings(),
false,
);
config = new Config(gameConfig, new UserSettings(), false);
mockPlayer = { isLobbyCreator: () => false } as unknown as Player;
});
+2 -1
View File
@@ -16,7 +16,8 @@ function addPlayer(game: Game, tile: TileRef): Player {
const info = new PlayerInfo("test", PlayerType.Human, null, "test_id");
game.addPlayer(info);
game.addExecution(new SpawnExecution("game_id", info, tile));
while (game.inSpawnPhase()) game.executeNextTick();
game.executeNextTick();
game.executeNextTick();
return game.player(info.id);
}
+1 -8
View File
@@ -13,7 +13,6 @@ import { GameMapImpl } from "../../../src/core/game/GameMap";
import { UserSettings } from "../../../src/core/game/UserSettings";
import { GameConfig } from "../../../src/core/Schemas";
import { TestConfig } from "../../util/TestConfig";
import { TestServerConfig } from "../../util/TestServerConfig";
export const W = "W"; // Water
export const L = "L"; // Land
@@ -131,7 +130,6 @@ export function createGame(data: TestMapData): Game {
miniNumLand,
);
const serverConfig = new TestServerConfig();
const gameConfig: GameConfig = {
gameMap: GameMapType.Asia,
gameMapSize: GameMapSize.Normal,
@@ -148,12 +146,7 @@ export function createGame(data: TestMapData): Game {
disableNavMesh: false,
randomSpawn: false,
};
const config = new TestConfig(
serverConfig,
gameConfig,
new UserSettings(),
false,
);
const config = new TestConfig(gameConfig, new UserSettings(), false);
return createGameImpl([], [], gameMap, miniGameMap, config);
}
+2 -9
View File
@@ -1,6 +1,5 @@
import { ConstructionExecution } from "../../src/core/execution/ConstructionExecution";
import { NukeExecution } from "../../src/core/execution/NukeExecution";
import { SpawnExecution } from "../../src/core/execution/SpawnExecution";
import {
Game,
Player,
@@ -8,12 +7,10 @@ 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(
@@ -34,14 +31,10 @@ describe("Construction economy", () => {
},
[builderInfo, otherInfo],
);
const spawn = game.ref(0, 10);
game.addExecution(new SpawnExecution(gameID, builderInfo, spawn));
game.addExecution(new SpawnExecution(gameID, otherInfo, spawn));
while (game.inSpawnPhase()) {
game.executeNextTick();
}
player = game.player(builderInfo.id);
other = game.player(otherInfo.id);
player.conquer(game.ref(0, 10));
other.conquer(game.ref(10, 10));
});
test("City charges gold once and no refund thereafter (allow passive income)", () => {
+11 -19
View File
@@ -1,5 +1,4 @@
import { ConstructionExecution } from "../../src/core/execution/ConstructionExecution";
import { SpawnExecution } from "../../src/core/execution/SpawnExecution";
import {
Game,
Player,
@@ -7,22 +6,18 @@ 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";
const info = new PlayerInfo("p", PlayerType.Human, null, "p");
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(gameID, info, game.ref(1, 1)));
while (game.inSpawnPhase()) game.executeNextTick();
game = await setup("plains", { infiniteGold: true, instantBuild: true }, [
info,
]);
player = game.player(info.id);
player.conquer(game.ref(1, 1));
});
@@ -52,17 +47,14 @@ describe("Hydrogen Bomb and MIRV flows", () => {
test("Hydrogen bomb launch fails when silo is under construction and succeeds after completion", async () => {
// Set up a game without instantBuild to test construction duration
const gameWithConstruction = await setup("plains", {
infiniteGold: false,
instantBuild: false,
});
const info = new PlayerInfo("p", PlayerType.Human, null, "p");
gameWithConstruction.addPlayer(info);
gameWithConstruction.addExecution(
new SpawnExecution(gameID, info, gameWithConstruction.ref(1, 1)),
const gameWithConstruction = await setup(
"plains",
{
infiniteGold: false,
instantBuild: false,
},
[info],
);
while (gameWithConstruction.inSpawnPhase())
gameWithConstruction.executeNextTick();
const playerWithConstruction = gameWithConstruction.player(info.id);
playerWithConstruction.conquer(gameWithConstruction.ref(1, 1));
+42 -43
View File
@@ -1,5 +1,4 @@
import { NukeExecution } from "../../src/core/execution/NukeExecution";
import { SpawnExecution } from "../../src/core/execution/SpawnExecution";
import {
Game,
Player,
@@ -8,12 +7,9 @@ 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 } from "../util/utils";
const gameID: GameID = "game_id";
function launchNukeAt(game: Game, player: Player, target: TileRef): void {
game.addExecution(new NukeExecution(UnitType.AtomBomb, player, target, null));
// init + build
@@ -30,19 +26,21 @@ function tickUntilNukeLands(game: Game, maxTicks = 50): void {
describe("Water Nukes", () => {
let game: Game;
let player: Player;
const info = new PlayerInfo("p", PlayerType.Human, null, "p");
describe("when waterNukes is enabled", () => {
beforeEach(async () => {
game = await setup("plains", {
infiniteGold: true,
instantBuild: true,
waterNukes: true,
});
const info = new PlayerInfo("p", PlayerType.Human, null, "p");
game.addPlayer(info);
game.addExecution(new SpawnExecution(gameID, info, game.ref(1, 1)));
while (game.inSpawnPhase()) game.executeNextTick();
game = await setup(
"plains",
{
infiniteGold: true,
instantBuild: true,
waterNukes: true,
},
[info],
);
player = game.player(info.id);
player.conquer(game.ref(1, 1));
// Build a missile silo
constructionExecution(game, player, 1, 1, UnitType.MissileSilo);
@@ -111,19 +109,18 @@ describe("Water Nukes", () => {
test("waterGraphVersion increments after water conversion", async () => {
// Need a game with nav mesh enabled for graph rebuilds
const navGame = await setup("plains", {
infiniteGold: true,
instantBuild: true,
waterNukes: true,
disableNavMesh: false,
});
const info2 = new PlayerInfo("p2", PlayerType.Human, null, "p2");
navGame.addPlayer(info2);
navGame.addExecution(
new SpawnExecution(gameID, info2, navGame.ref(1, 1)),
const navGame = await setup(
"plains",
{
infiniteGold: true,
instantBuild: true,
waterNukes: true,
disableNavMesh: false,
},
[info],
);
while (navGame.inSpawnPhase()) navGame.executeNextTick();
const player2 = navGame.player(info2.id);
const player2 = navGame.player(info.id);
player2.conquer(navGame.ref(1, 1));
constructionExecution(navGame, player2, 1, 1, UnitType.MissileSilo);
const versionBefore = navGame.waterGraphVersion();
@@ -143,16 +140,17 @@ describe("Water Nukes", () => {
describe("when waterNukes is disabled (default)", () => {
beforeEach(async () => {
game = await setup("plains", {
infiniteGold: true,
instantBuild: true,
waterNukes: false,
});
const info = new PlayerInfo("p", PlayerType.Human, null, "p");
game.addPlayer(info);
game.addExecution(new SpawnExecution(gameID, info, game.ref(1, 1)));
while (game.inSpawnPhase()) game.executeNextTick();
game = await setup(
"plains",
{
infiniteGold: true,
instantBuild: true,
waterNukes: false,
},
[info],
);
player = game.player(info.id);
player.conquer(game.ref(1, 1));
constructionExecution(game, player, 1, 1, UnitType.MissileSilo);
});
@@ -182,16 +180,17 @@ describe("Water Nukes", () => {
describe("updateTile terrain byte round-trip", () => {
test("terrain byte is packed and unpacked correctly", async () => {
game = await setup("plains", {
infiniteGold: true,
instantBuild: true,
waterNukes: true,
});
const info = new PlayerInfo("p", PlayerType.Human, null, "p");
game.addPlayer(info);
game.addExecution(new SpawnExecution(gameID, info, game.ref(1, 1)));
while (game.inSpawnPhase()) game.executeNextTick();
game = await setup(
"plains",
{
infiniteGold: true,
instantBuild: true,
waterNukes: true,
},
[info],
);
player = game.player(info.id);
player.conquer(game.ref(1, 1));
constructionExecution(game, player, 1, 1, UnitType.MissileSilo);
const target = game.ref(10, 10);
-2
View File
@@ -255,9 +255,7 @@ export async function setupFromPath(
const gameMap = await genTerrainFromBin(manifest.map, mapBinBuffer);
const miniGameMap = await genTerrainFromBin(manifest.map4x, miniMapBinBuffer);
// Configure the game
const config = new TestConfig(
new (await import("../util/TestServerConfig")).TestServerConfig(),
{
gameMap: GameMapType.Asia,
gameMapSize: GameMapSize.Normal,
-12
View File
@@ -16,10 +16,6 @@ const sparseTerritoryGame = await setup(
dirname(fileURLToPath(import.meta.url)),
);
while (sparseTerritoryGame.inSpawnPhase()) {
sparseTerritoryGame.executeNextTick();
}
const sparsePlayer = sparseTerritoryGame.player("player_id");
function claimRow(y: number, length: number) {
@@ -56,10 +52,6 @@ const denseTerritoryGame = await setup(
dirname(fileURLToPath(import.meta.url)),
);
while (denseTerritoryGame.inSpawnPhase()) {
denseTerritoryGame.executeNextTick();
}
const densePlayer = denseTerritoryGame.player("player_id");
for (let x = 0; x < 200; x++) {
@@ -84,10 +76,6 @@ const giantMapGame = await setup(
dirname(fileURLToPath(import.meta.url)),
);
while (giantMapGame.inSpawnPhase()) {
giantMapGame.executeNextTick();
}
const giantMapPlayer = giantMapGame.player("player_id");
// Conquer ALL available land tiles on the giant world map
+3 -3
View File
@@ -1,13 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../src/core/configuration/ConfigLoader", () => ({
getServerConfigFromServer: () => ({
vi.mock("../../src/server/ServerEnv", () => ({
ServerEnv: {
jwtIssuer: () => "https://archive.test.invalid",
apiKey: () => "test-key",
gitCommit: () => "DEV",
subdomain: () => "test",
domain: () => "test",
}),
},
}));
vi.mock("../../src/server/Logger", () => ({
+10
View File
@@ -28,6 +28,16 @@ describe("ClientMsgRateLimiter", () => {
}
expect(limiter.check(CLIENT_B, "intent", SMALL)).toBe("ok");
});
it("allows intents up to MAX_INTENT_SIZE", () => {
const limiter = new ClientMsgRateLimiter();
expect(limiter.check(CLIENT_A, "intent", 2000)).toBe("ok");
});
it("kicks intents exceeding MAX_INTENT_SIZE", () => {
const limiter = new ClientMsgRateLimiter();
expect(limiter.check(CLIENT_A, "intent", 2001)).toBe("kick");
});
});
describe("non-intent messages", () => {
+11 -44
View File
@@ -1,17 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../src/core/configuration/ConfigLoader", () => ({
getServerConfigFromServer: () => ({
otelEnabled: () => false,
otelAuthHeader: () => "",
otelEndpoint: () => "",
env: () => 0, // GameEnv.Dev
}),
getServerConfig: () => ({
otelEnabled: () => false,
}),
}));
vi.mock("../../src/core/Schemas", async () => {
const actual = (await vi.importActual("../../src/core/Schemas")) as any;
return {
@@ -25,13 +13,11 @@ vi.mock("../../src/core/Schemas", async () => {
};
});
import { GameEnv } from "../../src/core/configuration/Config";
import { GameType } from "../../src/core/game/Game";
import { GameServer } from "../../src/server/GameServer";
describe("GameLifecycle", () => {
let mockLogger: any;
let mockConfig: any;
beforeEach(() => {
vi.useFakeTimers();
@@ -41,11 +27,6 @@ describe("GameLifecycle", () => {
warn: vi.fn(),
error: vi.fn(),
};
mockConfig = {
turnIntervalMs: () => 100,
gameCreationRate: () => 1000,
env: () => GameEnv.Dev,
};
});
afterEach(() => {
@@ -54,13 +35,9 @@ describe("GameLifecycle", () => {
});
it("should not start turn interval if game has ended", async () => {
const game = new GameServer(
"test-game",
mockLogger,
Date.now(),
mockConfig,
{ gameType: GameType.Private } as any,
);
const game = new GameServer("test-game", mockLogger, Date.now(), {
gameType: GameType.Private,
} as any);
// Call end() first - this should set _hasEnded
await game.end();
@@ -77,17 +54,11 @@ describe("GameLifecycle", () => {
it("should clear turn interval and set _hasEnded on end()", async () => {
// We need to initialize the game such that start() can succeed
const game = new GameServer(
"test-game",
mockLogger,
Date.now(),
mockConfig,
{
gameType: GameType.Private,
gameMap: "plains",
gameMapSize: 100,
} as any,
);
const game = new GameServer("test-game", mockLogger, Date.now(), {
gameType: GameType.Private,
gameMap: "plains",
gameMapSize: 100,
} as any);
// Manually trigger prestart to fulfill some internal checks if necessary
game.prestart();
@@ -103,13 +74,9 @@ describe("GameLifecycle", () => {
});
it("should be resilient to multiple end() calls", async () => {
const game = new GameServer(
"test-game",
mockLogger,
Date.now(),
mockConfig,
{ gameType: GameType.Private } as any,
);
const game = new GameServer("test-game", mockLogger, Date.now(), {
gameType: GameType.Private,
} as any);
await game.end();
expect((game as any)._hasEnded).toBe(true);
@@ -1,17 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../src/core/configuration/ConfigLoader", () => ({
getServerConfigFromServer: () => ({
otelEnabled: () => false,
otelAuthHeader: () => "",
otelEndpoint: () => "",
env: () => 0, // GameEnv.Dev
}),
getServerConfig: () => ({
otelEnabled: () => false,
}),
}));
vi.mock("../../src/core/Schemas", async () => {
const actual = (await vi.importActual("../../src/core/Schemas")) as any;
return {
@@ -69,7 +57,6 @@ function makeClient(
describe("GameServer - kick_player authorization", () => {
let mockLogger: any;
let mockConfig: any;
beforeEach(() => {
vi.useFakeTimers();
@@ -79,11 +66,6 @@ describe("GameServer - kick_player authorization", () => {
warn: vi.fn(),
error: vi.fn(),
};
mockConfig = {
turnIntervalMs: () => 100,
gameCreationRate: () => 1000,
env: () => 0,
};
});
afterEach(() => {
@@ -96,7 +78,6 @@ describe("GameServer - kick_player authorization", () => {
"test-game",
mockLogger,
Date.now(),
mockConfig,
{ gameType: GameType.Private } as any,
creatorPersistentID,
);
@@ -1,7 +1,7 @@
import EventEmitter from "events";
import { describe, expect, it, vi } from "vitest";
import { MasterLobbyService } from "../../src/server/MasterLobbyService";
import { TestServerConfig } from "../util/TestServerConfig";
import { ServerEnv } from "../../src/server/ServerEnv";
vi.mock("../../src/server/Logger", () => ({
logger: {
@@ -27,10 +27,9 @@ function sendWorkerReady(worker: EventEmitter, workerId: number) {
}
function createService(numWorkers: number): MasterLobbyService {
const config = new TestServerConfig();
vi.spyOn(config, "numWorkers").mockReturnValue(numWorkers);
vi.spyOn(ServerEnv, "numWorkers").mockReturnValue(numWorkers);
const log = { info: vi.fn(), error: vi.fn() } as any;
return new MasterLobbyService(config, {} as any, log);
return new MasterLobbyService({} as any, log);
}
function startAllWorkers(
+8 -1
View File
@@ -1,7 +1,7 @@
import fs from "fs/promises";
import os from "os";
import path from "path";
import { afterEach, describe, expect, test } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
clearAppShellContentCache,
getAppShellContent,
@@ -12,7 +12,14 @@ describe("RenderHtml", () => {
const originalGitCommit = process.env.GIT_COMMIT;
let tempDir: string | null = null;
beforeEach(() => {
vi.stubEnv("NUM_WORKERS", "1");
vi.stubEnv("TURNSTILE_SITE_KEY", "test-key");
vi.stubEnv("DOMAIN", "localhost");
});
afterEach(async () => {
vi.unstubAllEnvs();
process.env.GIT_COMMIT = originalGitCommit;
clearAppShellContentCache();
+109
View File
@@ -0,0 +1,109 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { ServerEnv } from "../../src/server/ServerEnv";
describe("ServerEnv.numWorkers", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
test("returns parsed value when valid", () => {
vi.stubEnv("NUM_WORKERS", "4");
expect(ServerEnv.numWorkers()).toBe(4);
});
test("throws when unset", () => {
vi.stubEnv("NUM_WORKERS", "");
expect(() => ServerEnv.numWorkers()).toThrow(/NUM_WORKERS not set/);
});
test("throws on non-numeric", () => {
vi.stubEnv("NUM_WORKERS", "abc");
expect(() => ServerEnv.numWorkers()).toThrow(/Invalid NUM_WORKERS/);
});
test("throws on zero", () => {
vi.stubEnv("NUM_WORKERS", "0");
expect(() => ServerEnv.numWorkers()).toThrow(/Invalid NUM_WORKERS/);
});
test("throws on negative", () => {
vi.stubEnv("NUM_WORKERS", "-2");
expect(() => ServerEnv.numWorkers()).toThrow(/Invalid NUM_WORKERS/);
});
});
describe("ServerEnv.turnstileSiteKey", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
test("returns value when set", () => {
vi.stubEnv("TURNSTILE_SITE_KEY", "site-key");
expect(ServerEnv.turnstileSiteKey()).toBe("site-key");
});
test("throws when unset", () => {
vi.stubEnv("TURNSTILE_SITE_KEY", "");
expect(() => ServerEnv.turnstileSiteKey()).toThrow(
/TURNSTILE_SITE_KEY not set/,
);
});
});
describe("ServerEnv.jwtAudience", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
test("returns DOMAIN when set", () => {
vi.stubEnv("DOMAIN", "openfront.io");
expect(ServerEnv.jwtAudience()).toBe("openfront.io");
});
test("throws when DOMAIN unset", () => {
vi.stubEnv("DOMAIN", "");
expect(() => ServerEnv.jwtAudience()).toThrow(/DOMAIN not set/);
});
});
describe("ServerEnv.jwtIssuer", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
test("maps 'localhost' to http://localhost:8787", () => {
vi.stubEnv("DOMAIN", "localhost");
expect(ServerEnv.jwtIssuer()).toBe("http://localhost:8787");
});
test("derives api.<audience> for non-localhost", () => {
vi.stubEnv("DOMAIN", "openfront.io");
expect(ServerEnv.jwtIssuer()).toBe("https://api.openfront.io");
});
});
describe("ServerEnv.allowedFlares", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
test("returns undefined when unset", () => {
vi.stubEnv("ALLOWED_FLARES", "");
expect(ServerEnv.allowedFlares()).toBeUndefined();
});
test("parses a single value", () => {
vi.stubEnv("ALLOWED_FLARES", "admin");
expect(ServerEnv.allowedFlares()).toEqual(["admin"]);
});
test("parses CSV", () => {
vi.stubEnv("ALLOWED_FLARES", "admin,beta,internal");
expect(ServerEnv.allowedFlares()).toEqual(["admin", "beta", "internal"]);
});
test("trims whitespace and drops empties", () => {
vi.stubEnv("ALLOWED_FLARES", " admin , , beta ");
expect(ServerEnv.allowedFlares()).toEqual(["admin", "beta"]);
});
});
+5 -10
View File
@@ -18,7 +18,6 @@ import {
import { UserSettings } from "../../src/core/game/UserSettings";
import { GameConfig } from "../../src/core/Schemas";
import { TestConfig } from "./TestConfig";
import { TestServerConfig } from "./TestServerConfig";
export async function setup(
mapName: string,
@@ -26,6 +25,7 @@ export async function setup(
humans: PlayerInfo[] = [],
currentDir: string = __dirname,
ConfigClass: typeof TestConfig = TestConfig,
autoEndSpawnPhase: boolean = true,
): Promise<Game> {
// Suppress console.debug for tests.
console.debug = () => {};
@@ -53,8 +53,6 @@ export async function setup(
const gameMap = await genTerrainFromBin(manifest.map, mapBinBuffer);
const miniGameMap = await genTerrainFromBin(manifest.map4x, miniMapBinBuffer);
// Configure the game
const serverConfig = new TestServerConfig();
const gameConfig: GameConfig = {
gameMap: GameMapType.Asia,
gameMapSize: GameMapSize.Normal,
@@ -71,14 +69,11 @@ export async function setup(
randomSpawn: false,
..._gameConfig,
};
const config = new ConfigClass(
serverConfig,
gameConfig,
new UserSettings(),
false,
);
const config = new ConfigClass(gameConfig, new UserSettings(), false);
return createGame(humans, [], gameMap, miniGameMap, config);
const game = createGame(humans, [], gameMap, miniGameMap, config);
if (autoEndSpawnPhase) game.endSpawnPhase();
return game;
}
export function playerInfo(name: string, type: PlayerType): PlayerInfo {
+3 -5
View File
@@ -1,5 +1,4 @@
import { NukeMagnitude } from "../../src/core/configuration/Config";
import { DefaultConfig } from "../../src/core/configuration/DefaultConfig";
import { Config, NukeMagnitude } from "../../src/core/configuration/Config";
import {
Game,
Player,
@@ -9,7 +8,7 @@ import {
} from "../../src/core/game/Game";
import { TileRef } from "../../src/core/game/GameMap";
export class TestConfig extends DefaultConfig {
export class TestConfig extends Config {
private _proximityBonusPortsNb: number = 0;
private _defaultNukeSpeed: number = 4;
private _spawnImmunityDuration: number = 0;
@@ -100,7 +99,6 @@ export class TestConfig extends DefaultConfig {
}
}
export class UseRealAttackLogic extends TestConfig {
// Override to use DefaultConfig's real attackLogic
attackLogic(
gm: Game,
attackTroops: number,
@@ -112,7 +110,7 @@ export class UseRealAttackLogic extends TestConfig {
defenderTroopLoss: number;
tilesPerTickUsed: number;
} {
return DefaultConfig.prototype.attackLogic.call(
return Config.prototype.attackLogic.call(
this,
gm,
attackTroops,
-91
View File
@@ -1,91 +0,0 @@
import { JWK } from "jose";
import { GameEnv, ServerConfig } from "../../src/core/configuration/Config";
import { PublicGameModifiers } from "../../src/core/game/Game";
import { GameID } from "../../src/core/Schemas";
export class TestServerConfig implements ServerConfig {
turnstileSiteKey(): string {
throw new Error("Method not implemented.");
}
apiKey(): string {
throw new Error("Method not implemented.");
}
allowedFlares(): string[] | undefined {
throw new Error("Method not implemented.");
}
stripePublishableKey(): string {
throw new Error("Method not implemented.");
}
domain(): string {
throw new Error("Method not implemented.");
}
subdomain(): string {
throw new Error("Method not implemented.");
}
jwtAudience(): string {
throw new Error("Method not implemented.");
}
jwtIssuer(): string {
throw new Error("Method not implemented.");
}
jwkPublicKey(): Promise<JWK> {
throw new Error("Method not implemented.");
}
otelEnabled(): boolean {
throw new Error("Method not implemented.");
}
otelEndpoint(): string {
throw new Error("Method not implemented.");
}
otelAuthHeader(): string {
throw new Error("Method not implemented.");
}
turnIntervalMs(): number {
throw new Error("Method not implemented.");
}
gameCreationRate(): number {
throw new Error("Method not implemented.");
}
async lobbyMaxPlayers(): Promise<number> {
throw new Error("Method not implemented.");
}
numWorkers(): number {
throw new Error("Method not implemented.");
}
workerIndex(gameID: GameID): number {
throw new Error("Method not implemented.");
}
workerPath(gameID: GameID): string {
throw new Error("Method not implemented.");
}
workerPort(gameID: GameID): number {
throw new Error("Method not implemented.");
}
workerPortByIndex(workerID: number): number {
throw new Error("Method not implemented.");
}
env(): GameEnv {
throw new Error("Method not implemented.");
}
adminToken(): string {
throw new Error("Method not implemented.");
}
adminHeader(): string {
throw new Error("Method not implemented.");
}
gitCommit(): string {
throw new Error("Method not implemented.");
}
getRandomPublicGameModifiers(): PublicGameModifiers {
return {
isCompact: false,
isRandomSpawn: false,
isCrowded: false,
isHardNations: false,
isAlliancesDisabled: false,
};
}
async supportsCompactMapForTeams(): Promise<boolean> {
throw new Error("Method not implemented.");
}
}