mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-19 07:15:03 +00:00
Merge origin/main and resolve conflicts
Co-authored-by: VariableVince <24507472+VariableVince@users.noreply.github.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)})`,
|
||||
|
||||
@@ -50,10 +50,6 @@ describe("MissileSilo", () => {
|
||||
),
|
||||
);
|
||||
|
||||
while (game.inSpawnPhase()) {
|
||||
game.executeNextTick();
|
||||
}
|
||||
|
||||
attacker = game.player("attacker_id");
|
||||
|
||||
constructionExecution(game, attacker, 1, 1, UnitType.MissileSilo);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ function makeUserMe(flares: string[] = []): UserMeResponse {
|
||||
adfree: false,
|
||||
flares,
|
||||
achievements: { singleplayerMap: [] },
|
||||
subscription: null,
|
||||
},
|
||||
} as UserMeResponse;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user