Files
OpenFrontIO/tests/core/game/GameImpl.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

173 lines
5.1 KiB
TypeScript

import { GameID } from "core-public/Schemas";
import { AttackExecution } from "engine/execution/AttackExecution";
import { SpawnExecution } from "engine/execution/SpawnExecution";
//import { TransportShipExecution } from "engine/execution/TransportShipExecution";
import { AllianceRequestExecution } from "engine/execution/alliance/AllianceRequestExecution";
import {
Game,
GameType,
Player,
PlayerInfo,
PlayerType,
} from "engine/game/Game";
import { TileRef } from "engine/game/GameMap";
import { GameUpdateType } from "engine/game/GameUpdates";
import { setup } from "../../util/Setup";
const gameID: GameID = "game_id";
let game: Game;
let attacker: Player;
let defender: Player;
let defenderSpawn: TileRef;
let attackerSpawn: TileRef;
describe("GameImpl", () => {
beforeEach(async () => {
game = await setup("ocean_and_land", {
infiniteGold: true,
instantBuild: true,
infiniteTroops: true,
});
const attackerInfo = new PlayerInfo(
"attacker dude",
PlayerType.Human,
null,
"attacker_id",
);
game.addPlayer(attackerInfo);
const defenderInfo = new PlayerInfo(
"defender dude",
PlayerType.Human,
null,
"defender_id",
);
game.addPlayer(defenderInfo);
defenderSpawn = game.ref(0, 15);
attackerSpawn = game.ref(0, 14);
game.addExecution(
new SpawnExecution(
gameID,
game.player(attackerInfo.id).info(),
attackerSpawn,
),
new SpawnExecution(
gameID,
game.player(defenderInfo.id).info(),
defenderSpawn,
),
);
attacker = game.player(attackerInfo.id);
defender = game.player(defenderInfo.id);
});
test("Don't become traitor when betraying inactive player", async () => {
vi.spyOn(attacker, "canSendAllianceRequest").mockReturnValue(true);
vi.spyOn(defender, "canSendAllianceRequest").mockReturnValue(true);
game.addExecution(new AllianceRequestExecution(attacker, defender.id()));
game.executeNextTick();
game.addExecution(new AllianceRequestExecution(defender, attacker.id()));
game.executeNextTick();
expect(attacker.allianceWith(defender)).toBeTruthy();
expect(defender.allianceWith(attacker)).toBeTruthy();
//Defender is marked disconnected
defender.markDisconnected(true);
game.executeNextTick();
game.executeNextTick();
// STEP 1: First betray (manually break alliance)
const alliance = attacker.allianceWith(defender);
expect(alliance).toBeTruthy();
attacker.breakAlliance(alliance!);
// STEP 2: Then attack after betrayal
game.addExecution(new AttackExecution(100, attacker, defender.id()));
do {
game.executeNextTick();
} while (attacker.outgoingAttacks().length > 0);
expect(attacker.isTraitor()).toBe(false);
expect(attacker.allianceWith(defender)).toBeFalsy();
});
test("Do become traitor when betraying active player", async () => {
vi.spyOn(attacker, "canSendAllianceRequest").mockReturnValue(true);
vi.spyOn(defender, "canSendAllianceRequest").mockReturnValue(true);
game.addExecution(new AllianceRequestExecution(attacker, defender.id()));
game.executeNextTick();
game.addExecution(new AllianceRequestExecution(defender, attacker.id()));
game.executeNextTick();
expect(attacker.allianceWith(defender)).toBeTruthy();
expect(defender.allianceWith(attacker)).toBeTruthy();
//Defender is NOT marked disconnected
game.executeNextTick();
game.executeNextTick();
// First betray (manually break alliance)
const alliance = attacker.allianceWith(defender);
expect(alliance).toBeTruthy();
attacker.breakAlliance(alliance!);
game.executeNextTick();
game.addExecution(new AttackExecution(100, attacker, defender.id()));
do {
game.executeNextTick();
} while (attacker.outgoingAttacks().length > 0);
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);
});
});