Files
restitution/gameServer/tests/UnitManager.test.ts
kaykayyali 3fc29f728e feat: Colyseus authoritative server + invite-code lobby
- Replace socket.io relay with Colyseus 0.15 authoritative server
- GameRoom with GameState schema (players, units, resources)
- Pure TS services: CombatResolver, EconomyService, PathfindingService, UnitManager
- POST /api/create-room → 4-char invite code
- React/MUI LobbyScreen: Create (shows code + START GAME) / Join by code
- ColyseusClient: joinOrCreate/join by room type = invite code
- Nginx: static assets direct, all else proxied to Colyseus (WS upgrade)
- Content-hashed JS bundles for Cloudflare cache-busting
- 1-player lobbies: START GAME button bypasses 2-player wait
2026-05-30 02:49:20 +00:00

324 lines
11 KiB
TypeScript

import { UnitManager, UnitRecord } from "../src/systems/UnitManager";
import { UnitState, UnitEvent } from "../src/schema/unit-states";
// Helpers
function makePos(x: number, y: number) {
return { x, y };
}
function freshManager(): UnitManager {
return new UnitManager();
}
describe("UnitManager - spawnUnit", () => {
it("creates a unit with id, ownerId, type, team, position", () => {
const mgr = freshManager();
const unit = mgr.spawnUnit("p1", "tank", makePos(10, 20), "ukraine");
expect(unit.id).toMatch(/^unit-/);
expect(unit.ownerId).toBe("p1");
expect(unit.type).toBe("tank");
expect(unit.position).toEqual({ x: 10, y: 20 });
expect(unit.team).toBe("ukraine");
});
it("new unit starts IDLING with full health", () => {
const mgr = freshManager();
const unit = mgr.spawnUnit("p1", "infantry", makePos(5, 5), "russia");
expect(unit.state).toBe(UnitState.IDLING);
expect(unit.health).toEqual({ max: 100, current: 100 });
});
it("different types get the right max health (tank=150, infantry=100)", () => {
const mgr = freshManager();
const tank = mgr.spawnUnit("p1", "tank", makePos(0, 0), "ukraine");
const inf = mgr.spawnUnit("p1", "infantry", makePos(1, 1), "ukraine");
expect(tank.health.max).toBe(150);
expect(inf.health.max).toBe(100);
});
it("assigns unique IDs to each unit", () => {
const mgr = freshManager();
const a = mgr.spawnUnit("p1", "tank", makePos(0, 0), "ukraine");
const b = mgr.spawnUnit("p1", "infantry", makePos(1, 1), "ukraine");
expect(a.id).not.toBe(b.id);
});
it("stores units internally (getUnit retrieves by id)", () => {
const mgr = freshManager();
const unit = mgr.spawnUnit("p1", "tank", makePos(10, 10), "ukraine");
const retrieved = mgr.getUnit(unit.id);
expect(retrieved).toEqual(unit);
});
it("getUnit returns undefined for unknown id", () => {
const mgr = freshManager();
expect(mgr.getUnit("nonexistent")).toBeUndefined();
});
});
describe("UnitManager - moveUnit", () => {
it("sets path and transitions to MOVING", () => {
const mgr = freshManager();
const unit = mgr.spawnUnit("p1", "tank", makePos(0, 0), "ukraine");
const path = [{ x: 10, y: 0 }, { x: 10, y: 10 }];
mgr.moveUnit(unit.id, path);
const updated = mgr.getUnit(unit.id)!;
expect(updated.state).toBe(UnitState.MOVING);
expect(updated.path).toEqual(path);
});
it("does nothing for non-existent unit id", () => {
const mgr = freshManager();
expect(() => mgr.moveUnit("ghost", [makePos(0, 0)])).not.toThrow();
});
it("does nothing for dead unit", () => {
const mgr = freshManager();
const unit = mgr.spawnUnit("p1", "tank", makePos(0, 0), "ukraine");
mgr.damageUnit(unit.id, 999);
mgr.moveUnit(unit.id, [makePos(50, 50)]);
const updated = mgr.getUnit(unit.id)!;
// dead units stay in their current state (DESTROYED after cleanup would remove them)
expect(updated.state).toBe(UnitState.DYING);
});
});
describe("UnitManager - attackUnit", () => {
it("sets targetId and transitions to ATTACKING", () => {
const mgr = freshManager();
const attacker = mgr.spawnUnit("p1", "tank", makePos(0, 0), "ukraine");
const target = mgr.spawnUnit("p2", "infantry", makePos(50, 50), "russia");
mgr.attackUnit(attacker.id, target.id);
const updated = mgr.getUnit(attacker.id)!;
expect(updated.state).toBe(UnitState.ATTACKING);
expect(updated.targetId).toBe(target.id);
});
it("does nothing for non-existent attacker", () => {
const mgr = freshManager();
expect(() => mgr.attackUnit("ghost", "any")).not.toThrow();
});
it("does nothing for dead attacker", () => {
const mgr = freshManager();
const attacker = mgr.spawnUnit("p1", "tank", makePos(0, 0), "ukraine");
mgr.damageUnit(attacker.id, 999);
mgr.attackUnit(attacker.id, "any");
expect(mgr.getUnit(attacker.id)!.state).toBe(UnitState.DYING);
});
});
describe("UnitManager - damageUnit", () => {
it("reduces health.current by the damage amount", () => {
const mgr = freshManager();
const unit = mgr.spawnUnit("p1", "tank", makePos(0, 0), "ukraine");
mgr.damageUnit(unit.id, 30);
const updated = mgr.getUnit(unit.id)!;
expect(updated.health.current).toBe(120);
});
it("transitions to DYING when health reaches 0", () => {
const mgr = freshManager();
const unit = mgr.spawnUnit("p1", "tank", makePos(0, 0), "ukraine");
mgr.damageUnit(unit.id, 150);
const updated = mgr.getUnit(unit.id)!;
expect(updated.health.current).toBe(0);
expect(updated.state).toBe(UnitState.DYING);
});
it("clamps health.current to 0 on overkill", () => {
const mgr = freshManager();
const unit = mgr.spawnUnit("p1", "infantry", makePos(0, 0), "russia");
mgr.damageUnit(unit.id, 500);
const updated = mgr.getUnit(unit.id)!;
expect(updated.health.current).toBe(0);
expect(updated.state).toBe(UnitState.DYING);
});
it("does nothing for non-existent unit", () => {
const mgr = freshManager();
expect(() => mgr.damageUnit("ghost", 10)).not.toThrow();
});
it("does nothing for already dead unit", () => {
const mgr = freshManager();
const unit = mgr.spawnUnit("p1", "tank", makePos(0, 0), "ukraine");
mgr.damageUnit(unit.id, 999);
const before = mgr.getUnit(unit.id)!;
mgr.damageUnit(unit.id, 10);
const after = mgr.getUnit(unit.id)!;
expect(after.health.current).toBe(before.health.current);
expect(after.state).toBe(UnitState.DYING);
});
});
describe("UnitManager - removeDeadUnits", () => {
it("returns IDs of units in DYING state", () => {
const mgr = freshManager();
const a = mgr.spawnUnit("p1", "tank", makePos(0, 0), "ukraine");
const b = mgr.spawnUnit("p1", "infantry", makePos(1, 1), "ukraine");
mgr.damageUnit(a.id, 999);
const removed = mgr.removeDeadUnits();
expect(removed).toContain(a.id);
expect(removed).not.toContain(b.id);
});
it("removes dead units from internal storage", () => {
const mgr = freshManager();
const unit = mgr.spawnUnit("p1", "tank", makePos(0, 0), "ukraine");
mgr.damageUnit(unit.id, 999);
mgr.removeDeadUnits();
expect(mgr.getUnit(unit.id)).toBeUndefined();
});
it("returns empty array when no units are dead", () => {
const mgr = freshManager();
mgr.spawnUnit("p1", "tank", makePos(0, 0), "ukraine");
expect(mgr.removeDeadUnits()).toEqual([]);
});
it("returns multiple IDs when multiple units are dead", () => {
const mgr = freshManager();
const a = mgr.spawnUnit("p1", "tank", makePos(0, 0), "ukraine");
const b = mgr.spawnUnit("p1", "infantry", makePos(1, 1), "ukraine");
mgr.damageUnit(a.id, 999);
mgr.damageUnit(b.id, 999);
const removed = mgr.removeDeadUnits();
expect(removed.sort()).toEqual([a.id, b.id].sort());
expect(mgr.getUnit(a.id)).toBeUndefined();
expect(mgr.getUnit(b.id)).toBeUndefined();
});
});
describe("UnitManager - getUnitsInRange", () => {
it("returns units within the given range", () => {
const mgr = freshManager();
const center = mgr.spawnUnit("p1", "tank", makePos(50, 50), "ukraine");
const near = mgr.spawnUnit("p2", "infantry", makePos(60, 60), "russia");
const far = mgr.spawnUnit("p2", "infantry", makePos(200, 200), "russia");
const results = mgr.getUnitsInRange(makePos(50, 50), 20, "russia");
expect(results.map((u) => u.id)).toContain(near.id);
expect(results.map((u) => u.id)).not.toContain(far.id);
});
it("filters by team", () => {
const mgr = freshManager();
mgr.spawnUnit("p1", "tank", makePos(0, 0), "ukraine");
const enemy = mgr.spawnUnit("p2", "infantry", makePos(5, 5), "russia");
const results = mgr.getUnitsInRange(makePos(0, 0), 10, "russia");
expect(results.map((u) => u.id)).toEqual([enemy.id]);
});
it("returns empty array when no units in range", () => {
const mgr = freshManager();
mgr.spawnUnit("p2", "infantry", makePos(500, 500), "russia");
expect(mgr.getUnitsInRange(makePos(0, 0), 10, "russia")).toEqual([]);
});
it("does not return dead units", () => {
const mgr = freshManager();
const enemy = mgr.spawnUnit("p2", "infantry", makePos(5, 5), "russia");
mgr.damageUnit(enemy.id, 999);
const results = mgr.getUnitsInRange(makePos(0, 0), 100, "russia");
expect(results).toEqual([]);
});
it("excludes units from the querying team", () => {
const mgr = freshManager();
const friendly = mgr.spawnUnit("p1", "tank", makePos(2, 2), "ukraine");
const enemy = mgr.spawnUnit("p2", "infantry", makePos(3, 3), "russia");
const results = mgr.getUnitsInRange(makePos(0, 0), 10, "ukraine");
expect(results.map((u) => u.id)).toEqual([friendly.id]);
});
});
describe("UnitManager - full lifecycle", () => {
it("spawn → move → attack → damage → destroy cycle", () => {
const mgr = freshManager();
// spawn
const unit = mgr.spawnUnit("p1", "tank", makePos(0, 0), "ukraine");
expect(unit.state).toBe(UnitState.IDLING);
// move
mgr.moveUnit(unit.id, [makePos(100, 100)]);
expect(mgr.getUnit(unit.id)!.state).toBe(UnitState.MOVING);
// arrive (manual transition via nextState — UnitManager doesn't auto-arrive)
// attack
const target = mgr.spawnUnit("p2", "infantry", makePos(100, 100), "russia");
mgr.attackUnit(unit.id, target.id);
expect(mgr.getUnit(unit.id)!.state).toBe(UnitState.ATTACKING);
// damage
mgr.damageUnit(unit.id, 50);
expect(mgr.getUnit(unit.id)!.health.current).toBe(100);
// destroy
mgr.damageUnit(unit.id, 200);
expect(mgr.getUnit(unit.id)!.state).toBe(UnitState.DYING);
expect(mgr.getUnit(unit.id)!.health.current).toBe(0);
// cleanup
const removed = mgr.removeDeadUnits();
expect(removed).toContain(unit.id);
expect(mgr.getUnit(unit.id)).toBeUndefined();
});
});
describe("UnitManager - applyEvent", () => {
it("applies a valid state transition event", () => {
const mgr = freshManager();
const unit = mgr.spawnUnit("p1", "tank", makePos(0, 0), "ukraine");
const result = mgr.applyEvent(unit.id, UnitEvent.MOVE);
expect(result).not.toBeNull();
expect(result!.state).toBe(UnitState.MOVING);
});
it("ignores invalid transition (does not change state)", () => {
const mgr = freshManager();
const unit = mgr.spawnUnit("p1", "tank", makePos(0, 0), "ukraine");
const result = mgr.applyEvent(unit.id, UnitEvent.ARRIVED); // invalid from IDLING
expect(result).not.toBeNull();
expect(result!.state).toBe(UnitState.IDLING);
});
it("returns null for non-existent unit", () => {
const mgr = freshManager();
expect(mgr.applyEvent("ghost", UnitEvent.MOVE)).toBeNull();
});
});