Files
OpenFrontIO/tests/GameInfoRanking.test.ts
T
Evan Pelle a7f992e9b0 refactor: convert to npm-workspaces monorepo (engine/core-public/shared/client/server)
Restructure the single src/ tree into an npm-workspaces monorepo under
packages/, rename core -> engine, extract a types-only core-public layer,
and break the pre-existing engine -> client dependency cycle.

Structure (packages/):
  core-public  public API/wire schemas + shared enums (clean leaf)
  shared       framework-agnostic helpers (clean leaf)
  engine       deterministic simulation (was src/core)
  client       rendering/UI (was src/client)
  server       coordination (was src/server)

Dependency DAG: engine -> {core-public, shared}; client -> {core-public,
shared, engine}; server -> {core-public, engine}.

- npm workspaces: root package.json workspaces + per-package package.json;
  tsconfig.base.json holds shared options + path aliases
  (core-public/* shared/* engine/* client/* server/*) resolved uniformly by
  tsc, Vite (resolve.tsconfigPaths), Vitest, and tsx. Lockfile regenerated.
- core-public: moved Schemas/ApiSchemas/CosmeticSchemas/StatsSchemas/
  ClanApiSchemas/WorkerSchemas/Base64/PatternDecoder; extracted the enums
  (GameTypes), GameEvent type, emoji table, and GraphicsOverrides schema.
  Engine re-exports the moved enums/types so existing imports keep working.
- Broke engine -> client cycle:
  - renderNumber/renderTroops -> shared/format
  - NameBoxCalculator moved into engine
  - username validation returns translation key + params; client translates
  - applyStateUpdate moved to client (operates on the render-only PlayerState)
  - Config/UnitGrid/execution-Util/GameImpl now use structural read
    interfaces (engine/game/ReadViews: PlayerLike/UnitLike/GameLike) instead
    of importing client view classes; client imports view classes from a new
    client/view barrel; deleted the engine/game/GameView re-export shim.
- Build/deploy updated: vite.config, index.html, eslint, Dockerfile
  (copies packages/ + tsconfig.base.json before npm ci), .vscode, tests.

Verified: tsc --noEmit clean; 1364 + 65 tests pass; production vite build
succeeds; engine has zero client/server imports; core-public and shared are
dependency leaves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:51:03 +00:00

198 lines
5.7 KiB
TypeScript

import {
Ranking,
RankType,
} from "client/components/baseComponents/ranking/GameInfoRanking";
import {
Difficulty,
GameMapSize,
GameMapType,
GameMode,
GameType,
} from "engine/game/Game";
import { AnalyticsRecord, GameConfig } from "core-public/Schemas";
import {
GOLD_INDEX_STEAL,
GOLD_INDEX_TRADE,
GOLD_INDEX_TRAIN_OTHER,
GOLD_INDEX_TRAIN_SELF,
GOLD_INDEX_WAR,
} from "core-public/StatsSchemas";
describe("Ranking class", () => {
const mockConfig: GameConfig = {
gameMap: GameMapType.Montreal,
difficulty: Difficulty.Medium,
donateGold: false,
donateTroops: false,
gameType: GameType.Public,
gameMode: GameMode.FFA,
gameMapSize: GameMapSize.Normal,
nations: "disabled",
bots: 0,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
maxPlayers: 40,
disabledUnits: [],
randomSpawn: false,
};
const gameTickDuration = 1000;
const gameDuration = gameTickDuration / 10;
function makeSession(
overrides: Partial<AnalyticsRecord> = {},
): AnalyticsRecord {
return {
version: "v0.0.2",
info: {
duration: gameTickDuration,
winner: ["player", "p2"],
players: [
{
clientID: "p1",
username: "Alice",
clanTag: "X",
cosmetics: { flag: "USA" },
stats: {
units: { port: [2n, 0n, 0n, 2n] },
conquests: [5n],
gold: [0n, 100n, 20n, 0n, 15n, 5n], // total 140
bombs: {
abomb: [1n],
hbomb: [1n],
mirv: [2n],
},
},
persistentID: null,
},
{
clientID: "p2",
username: "Bob",
clanTag: null,
stats: {
units: { city: [2n, 0n, 0n, 2n] },
conquests: [8n],
gold: [0n, 50n, 10n, 5n], // total 65, no train trade
bombs: {
abomb: [0n],
hbomb: [2n],
mirv: [0n],
},
},
persistentID: null,
},
{
clientID: "p3",
username: "Charlie",
clanTag: null,
stats: {
// no units, but has conquests/killedAt to count as played
conquests: [8n],
killedAt: BigInt(600),
gold: [0n, 10n, 2n, 10n, 0n, 5n], // total 27
bombs: {},
},
persistentID: null,
},
],
gameID: "",
lobbyCreatedAt: 0,
config: { ...mockConfig },
start: 0,
end: 0,
num_turns: 0,
lobbyFillTime: 0,
},
gitCommit: "DEV",
subdomain: "",
domain: "",
};
}
test("summarizes players correctly", () => {
const r = new Ranking(makeSession());
const players = r.sortedBy(RankType.ConquestHumans);
expect(players.length).toBe(3);
const p1 = players.find((p) => p.id === "p1")!;
expect(p1.username).toBe("Alice");
expect(p1.flag).toBe("USA");
expect(p1.conquests).toStrictEqual([5n]);
expect(p1.atoms).toBe(1);
expect(p1.mirv).toBe(2);
});
test("correctly identifies winner", () => {
const r = new Ranking(makeSession());
const p2 = r.sortedBy(RankType.ConquestHumans).find((p) => p.id === "p2")!;
expect(p2.winner).toBe(true);
});
test("rank by total gold", () => {
const r = new Ranking(makeSession());
const rankedPlayers = r.sortedBy(RankType.TotalGold);
expect(rankedPlayers.length).toBe(3);
expect(rankedPlayers[0].id).toBe("p1");
expect(rankedPlayers[1].id).toBe("p2");
expect(rankedPlayers[2].id).toBe("p3");
});
test("rank by stolen gold", () => {
const r = new Ranking(makeSession());
const rankedPlayers = r.sortedBy(RankType.StolenGold);
expect(rankedPlayers.length).toBe(3);
expect(rankedPlayers[0].id).toBe("p3");
expect(rankedPlayers[1].id).toBe("p2");
expect(rankedPlayers[2].id).toBe("p1");
});
test("rank by hydros", () => {
const r = new Ranking(makeSession());
const rankedPlayers = r.sortedBy(RankType.Hydros);
expect(rankedPlayers.length).toBe(3);
expect(rankedPlayers[0].id).toBe("p2");
expect(rankedPlayers[1].id).toBe("p1");
expect(rankedPlayers[2].id).toBe("p3");
});
test("lifetime score is percentage of duration", () => {
const r = new Ranking(makeSession());
const p3 = r.sortedBy(RankType.ConquestHumans).find((p) => p.id === "p3")!;
const expected = Number(BigInt(600)) / gameDuration;
expect(r.score(p3, RankType.Lifetime)).toBe(expected);
});
test("lifetime score gives 100 when alive", () => {
const r = new Ranking(makeSession());
const p1 = r.allPlayers.find((p) => p.id === "p1")!;
expect(r.score(p1, RankType.Lifetime)).toBe(100);
});
test("winners should be ahead of players with same score", () => {
const r = new Ranking(makeSession());
const sortedPlayers = r.sortedBy(RankType.ConquestHumans);
expect(sortedPlayers[0].id).toBe("p2"); // p2 & p3 same score but winner first
});
test("gold scores work correctly", () => {
const r = new Ranking(makeSession());
const p1 = r.sortedBy(RankType.TotalGold).find((p) => p.id === "p1")!;
expect(r.score(p1, RankType.StolenGold)).toBe(
Number(p1.gold[GOLD_INDEX_STEAL] ?? 0n),
);
expect(r.score(p1, RankType.NavalTrade)).toBe(
Number(p1.gold[GOLD_INDEX_TRADE] ?? 0n),
);
const ownTrain = p1.gold[GOLD_INDEX_TRAIN_SELF] ?? 0n;
const otherTrain = p1.gold[GOLD_INDEX_TRAIN_OTHER] ?? 0n;
expect(r.score(p1, RankType.TrainTrade)).toBe(
Number(ownTrain + otherTrain),
);
expect(r.score(p1, RankType.ConqueredGold)).toBe(
Number(p1.gold[GOLD_INDEX_WAR] ?? 0n),
);
});
});