- 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
324 lines
11 KiB
TypeScript
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();
|
|
});
|
|
});
|