Files
OpenFrontIO/tests/ShellRandom.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

293 lines
8.2 KiB
TypeScript

import { DefensePostExecution } from "engine/execution/DefensePostExecution";
import { ShellExecution } from "engine/execution/ShellExecution";
import { WarshipExecution } from "engine/execution/WarshipExecution";
import {
Game,
Player,
PlayerInfo,
PlayerType,
UnitType,
} from "engine/game/Game";
import { setup } from "./util/Setup";
const coastX = 7;
let game: Game;
let player1: Player;
let player2: Player;
describe("Shell Random Damage", () => {
beforeEach(async () => {
game = await setup(
"half_land_half_ocean",
{ infiniteGold: true, instantBuild: true },
[
new PlayerInfo("attacker", PlayerType.Human, null, "player_1_id"),
new PlayerInfo("defender", PlayerType.Human, null, "player_2_id"),
],
);
player1 = game.player("player_1_id");
player2 = game.player("player_2_id");
});
test("Shell damage varies randomly between 200-300 base damage", () => {
const target = player2.buildUnit(
UnitType.Warship,
game.ref(coastX + 5, 10),
{
patrolTile: game.ref(coastX + 5, 10),
},
);
const initialHealth = target.health();
const damages: number[] = [];
const numShells = 50;
for (let i = 0; i < numShells; i++) {
const shell = new ShellExecution(
game.ref(coastX, 10),
player1,
player1.buildUnit(UnitType.Warship, game.ref(coastX, 10), {
patrolTile: game.ref(coastX, 10),
}),
target,
);
shell.init(game, game.ticks() + i);
const healthBefore = target.health();
target.modifyHealth(-shell.getEffectOnTargetForTesting(), player1);
const healthAfter = target.health();
const damage = healthBefore - healthAfter;
if (damage > 0) {
damages.push(damage);
}
target.modifyHealth(-(healthBefore - initialHealth));
}
expect(damages.length).toBeGreaterThan(0);
const baseDamage = game.config().unitInfo(UnitType.Shell).damage ?? 250;
const minExpectedDamage = Math.round((baseDamage / 250) * 200);
const maxExpectedDamage = Math.round((baseDamage / 250) * 300);
damages.forEach((damage) => {
expect(damage).toBeGreaterThanOrEqual(minExpectedDamage);
expect(damage).toBeLessThanOrEqual(maxExpectedDamage);
});
expect(damages.length).toBeGreaterThan(0);
});
test("Warship shell attacks have random damage", () => {
player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
const warship = player1.buildUnit(
UnitType.Warship,
game.ref(coastX + 1, 10),
{
patrolTile: game.ref(coastX + 1, 10),
},
);
const target = player2.buildUnit(
UnitType.Warship,
game.ref(coastX + 2, 10),
{
patrolTile: game.ref(coastX + 2, 10),
},
);
const initialHealth = target.health();
warship.setTargetUnit(target);
game.addExecution(new WarshipExecution(warship));
const damages: number[] = [];
const maxAttempts = 100;
let attempts = 0;
while (damages.length < 10 && attempts < maxAttempts) {
const healthBefore = target.health();
game.executeNextTick();
const healthAfter = target.health();
if (healthAfter < healthBefore) {
damages.push(healthBefore - healthAfter);
target.modifyHealth(-(healthBefore - initialHealth));
}
attempts++;
}
expect(damages.length).toBeGreaterThan(0);
const uniqueDamages = new Set(damages);
expect(uniqueDamages.size).toBeGreaterThan(1);
const baseDamage = game.config().unitInfo(UnitType.Shell).damage ?? 250;
const minExpectedDamage = Math.round((baseDamage / 250) * 200);
const maxExpectedDamage = Math.round((baseDamage / 250) * 300);
damages.forEach((damage) => {
expect(damage).toBeGreaterThanOrEqual(minExpectedDamage);
expect(damage).toBeLessThanOrEqual(maxExpectedDamage);
});
});
test("Defense post shell attacks have random damage", () => {
player1.conquer(game.ref(coastX, 5));
const spawn = player1.canBuild(UnitType.DefensePost, game.ref(coastX, 5));
if (spawn === false) {
throw new Error("Unable to build defense post for test");
}
const defensePostUnit = player1.buildUnit(UnitType.DefensePost, spawn, {});
const defensePost = new DefensePostExecution(defensePostUnit);
const target = player2.buildUnit(
UnitType.Warship,
game.ref(coastX + 1, 10),
{
patrolTile: game.ref(coastX + 1, 10),
},
);
const initialHealth = target.health();
defensePost.init(game, game.ticks());
const damages: number[] = [];
const maxAttempts = 100;
let attempts = 0;
while (damages.length < 5 && attempts < maxAttempts) {
const healthBefore = target.health();
defensePost.tick(game.ticks());
game.executeNextTick();
const healthAfter = target.health();
if (healthAfter < healthBefore) {
damages.push(healthBefore - healthAfter);
target.modifyHealth(-(healthBefore - initialHealth));
}
attempts++;
}
if (damages.length > 0) {
const uniqueDamages = new Set(damages);
expect(uniqueDamages.size).toBeGreaterThan(1);
const baseDamage = game.config().unitInfo(UnitType.Shell).damage ?? 250;
const minExpectedDamage = Math.round((baseDamage / 250) * 200);
const maxExpectedDamage = Math.round((baseDamage / 250) * 300);
damages.forEach((damage) => {
expect(damage).toBeGreaterThanOrEqual(minExpectedDamage);
expect(damage).toBeLessThanOrEqual(maxExpectedDamage);
});
}
});
test("Shell damage distribution follows expected pattern", () => {
const target = player2.buildUnit(
UnitType.Warship,
game.ref(coastX + 5, 10),
{
patrolTile: game.ref(coastX + 5, 10),
},
);
const initialHealth = target.health();
const damages: number[] = [];
const numShells = 1000;
for (let i = 0; i < numShells; i++) {
const shell = new ShellExecution(
game.ref(coastX, 10),
player1,
player1.buildUnit(UnitType.Warship, game.ref(coastX, 10), {
patrolTile: game.ref(coastX, 10),
}),
target,
);
shell.init(game, game.ticks() + i);
const healthBefore = target.health();
target.modifyHealth(-shell.getEffectOnTargetForTesting(), player1);
const healthAfter = target.health();
const damage = healthBefore - healthAfter;
if (damage > 0) {
damages.push(damage);
}
target.modifyHealth(-(healthBefore - initialHealth));
}
expect(damages.length).toBeGreaterThan(0);
const uniqueDamages = new Set(damages);
expect(uniqueDamages.size).toBeGreaterThan(0);
const damageCounts = new Map<number, number>();
damages.forEach((damage) => {
damageCounts.set(damage, (damageCounts.get(damage) ?? 0) + 1);
});
const maxCount = Math.max(...damageCounts.values());
const minCount = Math.min(...damageCounts.values());
expect(maxCount - minCount).toBeLessThan(damages.length * 0.8);
});
test("Shell damage is consistent with same random seed", () => {
const target = player2.buildUnit(
UnitType.Warship,
game.ref(coastX + 5, 10),
{
patrolTile: game.ref(coastX + 5, 10),
},
);
const initialHealth = target.health();
const shell1 = new ShellExecution(
game.ref(coastX, 10),
player1,
player1.buildUnit(UnitType.Warship, game.ref(coastX, 10), {
patrolTile: game.ref(coastX, 10),
}),
target,
);
const shell2 = new ShellExecution(
game.ref(coastX, 10),
player1,
player1.buildUnit(UnitType.Warship, game.ref(coastX, 10), {
patrolTile: game.ref(coastX, 10),
}),
target,
);
game.executeNextTick();
const currentTicks = game.ticks();
shell1.init(game, currentTicks);
shell2.init(game, currentTicks);
const healthBefore1 = target.health();
target.modifyHealth(-shell1.getEffectOnTargetForTesting(), player1);
const damage1 = healthBefore1 - target.health();
target.modifyHealth(-(healthBefore1 - initialHealth));
const healthBefore2 = target.health();
target.modifyHealth(-shell2.getEffectOnTargetForTesting(), player1);
const damage2 = healthBefore2 - target.health();
expect(damage1).toBe(damage2);
});
});