- ProjectileSprite.js: physics arcade sprite, faction tint, off-screen culling - CombatSystem: refactored enemy selection to use TeamManager instead of legacy containers - Death handling: DYING alpha tween (500ms), smoke puff (300ms), unit:killed event, cleanup - TeamManager: centralized team registry replacing goodGuys/badGuys containers - HealthBarSystem, ResourceBar, CaptureProgressUI, BuildMenu, BuildingPlacer, BuildingRenderer, ProductionPanel - Map_Player: wired new subsystems, removed legacy container creation - Tests: ProjectileSprite (4), DeathHandling (13), CombatSystem updated 47 tests passed at dev time (M2.3), 158/158 at dev time (M2.4)
244 lines
8.2 KiB
JavaScript
244 lines
8.2 KiB
JavaScript
/**
|
|
* ControlPointStateMachine.test.js — TeamManager-aware tests.
|
|
* Tests use Jest globals.
|
|
*/
|
|
|
|
// Local xstate mock with working state transitions for tick testing
|
|
jest.mock('xstate', () => {
|
|
function createService(initialState = 'NEUTRAL', initialContext = {}) {
|
|
const ctx = {
|
|
owner: null,
|
|
captureProgress: 0,
|
|
captureTime: 60000,
|
|
unitsInRadius: {},
|
|
...initialContext,
|
|
};
|
|
|
|
const svc = {
|
|
state: { value: initialState, context: ctx },
|
|
send: jest.fn((event) => {
|
|
const e = typeof event === 'string' ? { type: event } : event;
|
|
const current = svc.state.value;
|
|
if (current === 'NEUTRAL' && e.type === 'UNITS_ENTERED') {
|
|
svc.state.value = 'CONTESTED';
|
|
} else if (current === 'CONTESTED' && e.type === 'PROGRESS_COMPLETE') {
|
|
svc.state.value = 'CAPTURED';
|
|
ctx.owner = e.owner || e.teamId || null;
|
|
} else if (current === 'CONTESTED' && e.type === 'UNITS_LEFT') {
|
|
svc.state.value = 'NEUTRAL';
|
|
} else if (current === 'CAPTURED' && e.type === 'ENEMY_UNITS_ENTERED') {
|
|
svc.state.value = 'CONTESTED';
|
|
}
|
|
// Update context if event carries owner
|
|
if (e.owner != null) ctx.owner = e.owner;
|
|
if (e.teamId != null) ctx.owner = e.teamId;
|
|
}),
|
|
start: jest.fn(function() { return svc; }),
|
|
stop: jest.fn(),
|
|
status: 1, // not stopped
|
|
};
|
|
return svc;
|
|
}
|
|
|
|
return {
|
|
createMachine: jest.fn(() => ({})),
|
|
interpret: jest.fn((machine) => createService(machine?.config?.initial)),
|
|
assign: jest.fn((fn) => fn),
|
|
};
|
|
});
|
|
|
|
import ControlPointStateMachine, { ControlPointState } from 'Systems/ControlPointStateMachine.js';
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
function mockSceneWithTeamManager(teamManager) {
|
|
return {
|
|
add: {
|
|
zone: jest.fn((x, y, w, h) => ({
|
|
x: x ?? 0,
|
|
y: y ?? 0,
|
|
width: w ?? 0,
|
|
height: h ?? 0,
|
|
setName: jest.fn().mockReturnThis(),
|
|
destroy: jest.fn(),
|
|
body: { setCircle: jest.fn(), setOffset: jest.fn() },
|
|
})),
|
|
},
|
|
physics: {
|
|
world: { enableBody: jest.fn() },
|
|
},
|
|
events: { on: jest.fn(), emit: jest.fn(), off: jest.fn() },
|
|
teamManager,
|
|
children: { list: [] },
|
|
};
|
|
}
|
|
|
|
function createMockUnit(x, y, teamId) {
|
|
return {
|
|
x,
|
|
y,
|
|
setData: jest.fn((k, v) => { /* no-op */ }),
|
|
getData: jest.fn((key) => (key === 'teamId' ? teamId : null)),
|
|
};
|
|
}
|
|
|
|
function createTeamManagerMock(units = {}) {
|
|
// units: { teamId: [unit, unit], ... }
|
|
const map = new Map();
|
|
for (const [tid, list] of Object.entries(units)) {
|
|
const set = new Set(list);
|
|
map.set(tid, set);
|
|
}
|
|
|
|
return {
|
|
getAllUnitsGrouped: jest.fn(() => map),
|
|
getTeamUnits: jest.fn((tid) => map.get(tid) || new Set()),
|
|
getAllUnits: jest.fn(() => {
|
|
const all = [];
|
|
for (const list of map.values()) all.push(...list);
|
|
return all;
|
|
}),
|
|
};
|
|
}
|
|
|
|
// ── Tests ───────────────────────────────────────────────────────────
|
|
|
|
describe('ControlPointStateMachine (multi-team)', () => {
|
|
let scene;
|
|
let cp;
|
|
|
|
beforeEach(() => {
|
|
scene = mockSceneWithTeamManager(null);
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (cp) {
|
|
cp.destroy();
|
|
cp = null;
|
|
}
|
|
});
|
|
|
|
// ── Test 1: registerUnitContainers removed ─────────────────────
|
|
test('registerUnitContainers is NOT a method on the instance', () => {
|
|
cp = new ControlPointStateMachine(scene, { x: 100, y: 100 });
|
|
expect(typeof cp.registerUnitContainers).toBe('undefined');
|
|
});
|
|
|
|
// ── Test 2: counts units per team via TeamManager ──────────────
|
|
test('getUnitsInRadius counts units per teamId via TeamManager', () => {
|
|
const uA1 = createMockUnit(100, 100, 'team-A');
|
|
const uA2 = createMockUnit(102, 100, 'team-A');
|
|
const uB1 = createMockUnit(300, 300, 'team-B'); // outside radius > 160
|
|
const uB2 = createMockUnit(104, 100, 'team-B'); // inside radius
|
|
|
|
const tm = createTeamManagerMock({
|
|
'team-A': [uA1, uA2],
|
|
'team-B': [uB1, uB2],
|
|
});
|
|
scene.teamManager = tm;
|
|
|
|
cp = new ControlPointStateMachine(scene, { x: 100, y: 100, radius: 5, tileSize: 32 });
|
|
cp.teamManager = tm; // Ensure TeamManager is wired
|
|
|
|
const counts = cp.getUnitsInRadius();
|
|
expect(counts['team-A']).toBe(2);
|
|
expect(counts['team-B']).toBe(1);
|
|
expect(counts['team-C']).toBeUndefined();
|
|
});
|
|
|
|
// ── Test 3: NEUTRAL → CONTESTED when units from 2+ teams present ─
|
|
test('NEUTRAL → CONTESTED when units from multiple teams present', () => {
|
|
const uA = createMockUnit(100, 100, 'team-A');
|
|
const uB = createMockUnit(104, 100, 'team-B');
|
|
const tm = createTeamManagerMock({
|
|
'team-A': [uA],
|
|
'team-B': [uB],
|
|
});
|
|
scene.teamManager = tm;
|
|
|
|
cp = new ControlPointStateMachine(scene, { x: 100, y: 100, radius: 5, tileSize: 32 });
|
|
cp.teamManager = tm;
|
|
|
|
expect(cp.getState()).toBe(ControlPointState.NEUTRAL);
|
|
cp.tick(0, 16);
|
|
expect(cp.getState()).toBe(ControlPointState.CONTESTED);
|
|
expect(cp.service.send).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: 'UNITS_ENTERED' }),
|
|
);
|
|
});
|
|
|
|
// ── Test 4: CONTESTED → CAPTURED when one team has majority ─────
|
|
test('CONTESTED → CAPTURED when one team reaches majority (progress hits 100)', () => {
|
|
const uA = createMockUnit(100, 100, 'team-A');
|
|
const uB = createMockUnit(104, 100, 'team-B');
|
|
const tm = createTeamManagerMock({
|
|
'team-A': [uA],
|
|
'team-B': [uB],
|
|
});
|
|
scene.teamManager = tm;
|
|
|
|
cp = new ControlPointStateMachine(scene, { x: 100, y: 100, radius: 5, tileSize: 32 });
|
|
cp.teamManager = tm;
|
|
|
|
// Move to CONTESTED first
|
|
cp.tick(0, 16);
|
|
expect(cp.getState()).toBe(ControlPointState.CONTESTED);
|
|
|
|
// Set contesting team as dominant by giving team-A more units
|
|
const uA2 = createMockUnit(101, 101, 'team-A');
|
|
tm.getAllUnitsGrouped.mockReturnValue(
|
|
new Map([['team-A', new Set([uA, uA2])], ['team-B', new Set([uB])]]),
|
|
);
|
|
tm.getTeamUnits.mockImplementation((tid) => {
|
|
if (tid === 'team-A') return new Set([uA, uA2]);
|
|
if (tid === 'team-B') return new Set([uB]);
|
|
return new Set();
|
|
});
|
|
|
|
// Inject captureProgress near completion
|
|
cp.service.state.context.captureProgress = 99.9;
|
|
cp.tick(0, 100); // delta pushes over 100
|
|
expect(cp.getState()).toBe(ControlPointState.CAPTURED);
|
|
expect(cp.service.send).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ type: 'PROGRESS_COMPLETE', owner: 'team-A' }),
|
|
);
|
|
});
|
|
|
|
// ── Test 5: owner stored as teamId string ──────────────────────
|
|
test('owner stored as teamId string after capture', () => {
|
|
const uA = createMockUnit(100, 100, 'team-A');
|
|
const tm = createTeamManagerMock({ 'team-A': [uA] });
|
|
scene.teamManager = tm;
|
|
|
|
cp = new ControlPointStateMachine(scene, {
|
|
x: 100, y: 100, radius: 5, tileSize: 32,
|
|
});
|
|
cp.teamManager = tm;
|
|
|
|
// Move to CONTESTED then CAPTURED
|
|
cp.tick(0, 16);
|
|
cp.service.state.context.captureProgress = 99.9;
|
|
cp.tick(0, 100);
|
|
|
|
expect(cp.getState()).toBe(ControlPointState.CAPTURED);
|
|
expect(cp.getOwner()).toBe('team-A');
|
|
});
|
|
|
|
// ── Test 6: TeamManager passed via config ───────────────────────
|
|
test('constructor accepts teamManager via config', () => {
|
|
const tm = createTeamManagerMock({});
|
|
cp = new ControlPointStateMachine(scene, {
|
|
x: 100, y: 100, teamManager: tm,
|
|
});
|
|
expect(cp.teamManager).toBe(tm);
|
|
});
|
|
|
|
// ── Test 7: falls back to scene.teamManager ─────────────────────
|
|
test('falls back to scene.teamManager if no teamManager in config', () => {
|
|
const tm = createTeamManagerMock({});
|
|
scene.teamManager = tm;
|
|
cp = new ControlPointStateMachine(scene, { x: 100, y: 100 });
|
|
expect(cp.teamManager).toBe(tm);
|
|
});
|
|
});
|