/** * Player Systems Tests — Phase 4.1 * * Tests pure logic functions: input → velocity, tile collision, * spawn position finding, and player entity creation. */ import { describe, it, expect } from 'vitest'; import { createWorld, addEntity, addComponent, query } from 'bitecs'; import { Position, Velocity, PlayerTag, Health, SpriteRef } from '../src/ecs/components'; import { calculatePlayerVelocity, playerInputSystem } from '../src/player/input'; import { PLAYER_SPEED, PLAYER_COLLISION_RADIUS } from '../src/player/types'; import { isTileWalkable, isAreaWalkable, resolveCollision, buildWalkableSet, tileCollisionSystem, } from '../src/player/collision'; import { findSpawnPosition } from '../src/player/spawn'; import { createPlayerEntity } from '../src/player/factory'; import type { InputState } from '../src/player/types'; // === Helpers === function makeInput(moveX = 0, moveY = 0, interact = false): InputState { return { moveX, moveY, interact }; } // === Input: calculatePlayerVelocity === describe('calculatePlayerVelocity', () => { it('moves right when moveX = 1', () => { const v = calculatePlayerVelocity(makeInput(1, 0), PLAYER_SPEED); expect(v.vx).toBe(PLAYER_SPEED); expect(v.vy).toBe(0); }); it('moves left when moveX = -1', () => { const v = calculatePlayerVelocity(makeInput(-1, 0), PLAYER_SPEED); expect(v.vx).toBe(-PLAYER_SPEED); expect(v.vy).toBe(0); }); it('moves down when moveY = 1', () => { const v = calculatePlayerVelocity(makeInput(0, 1), PLAYER_SPEED); expect(v.vx).toBe(0); expect(v.vy).toBe(PLAYER_SPEED); }); it('moves up when moveY = -1', () => { const v = calculatePlayerVelocity(makeInput(0, -1), PLAYER_SPEED); expect(v.vx).toBe(0); expect(v.vy).toBe(-PLAYER_SPEED); }); it('stops when no input', () => { const v = calculatePlayerVelocity(makeInput(0, 0), PLAYER_SPEED); expect(v.vx).toBe(0); expect(v.vy).toBe(0); }); it('normalizes diagonal movement to same speed', () => { const v = calculatePlayerVelocity(makeInput(1, 1), PLAYER_SPEED); const mag = Math.sqrt(v.vx * v.vx + v.vy * v.vy); expect(mag).toBeCloseTo(PLAYER_SPEED); }); it('normalizes all four diagonal directions', () => { for (const [mx, my] of [[-1, -1], [-1, 1], [1, -1], [1, 1]]) { const v = calculatePlayerVelocity(makeInput(mx, my), PLAYER_SPEED); const mag = Math.sqrt(v.vx * v.vx + v.vy * v.vy); expect(mag).toBeCloseTo(PLAYER_SPEED); } }); it('diagonal components are equal in magnitude', () => { const v = calculatePlayerVelocity(makeInput(1, -1), PLAYER_SPEED); expect(Math.abs(v.vx)).toBeCloseTo(Math.abs(v.vy)); }); }); // === Input: playerInputSystem === describe('playerInputSystem', () => { it('sets player velocity from input', () => { const world = createWorld(); const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Velocity); addComponent(world, eid, PlayerTag); playerInputSystem(world, makeInput(1, 0)); expect(Velocity.vx[eid]).toBe(PLAYER_SPEED); expect(Velocity.vy[eid]).toBe(0); }); it('does not affect non-player entities', () => { const world = createWorld(); // Player const player = addEntity(world); addComponent(world, player, Position); addComponent(world, player, Velocity); addComponent(world, player, PlayerTag); // Non-player with existing velocity const other = addEntity(world); addComponent(world, other, Position); addComponent(world, other, Velocity); Velocity.vx[other] = 50; Velocity.vy[other] = -30; playerInputSystem(world, makeInput(0, 1)); expect(Velocity.vx[player]).toBe(0); expect(Velocity.vy[player]).toBe(PLAYER_SPEED); // Non-player unchanged expect(Velocity.vx[other]).toBe(50); expect(Velocity.vy[other]).toBe(-30); }); it('stops player when input is zero', () => { const world = createWorld(); const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Velocity); addComponent(world, eid, PlayerTag); Velocity.vx[eid] = 999; Velocity.vy[eid] = 999; playerInputSystem(world, makeInput(0, 0)); expect(Velocity.vx[eid]).toBe(0); expect(Velocity.vy[eid]).toBe(0); }); }); // === Collision: isTileWalkable === describe('isTileWalkable', () => { const grid = [[0, 0, 0], [0, 3, 0], [0, 0, 0]]; const walkable = new Set([0]); const ts = 32; it('returns true for walkable tile', () => { expect(isTileWalkable(16, 16, grid, ts, walkable)).toBe(true); }); it('returns false for non-walkable tile', () => { expect(isTileWalkable(48, 48, grid, ts, walkable)).toBe(false); }); it('returns false for negative coordinates', () => { expect(isTileWalkable(-1, 16, grid, ts, walkable)).toBe(false); expect(isTileWalkable(16, -1, grid, ts, walkable)).toBe(false); }); it('returns false for out-of-bounds coordinates', () => { expect(isTileWalkable(96, 16, grid, ts, walkable)).toBe(false); expect(isTileWalkable(16, 96, grid, ts, walkable)).toBe(false); }); it('handles tile edge boundaries', () => { expect(isTileWalkable(31.9, 16, grid, ts, walkable)).toBe(true); // still tile 0 expect(isTileWalkable(32, 16, grid, ts, walkable)).toBe(true); // tile 1 column, row 0 = walkable }); }); // === Collision: isAreaWalkable === describe('isAreaWalkable', () => { const grid = [[0, 0, 0], [0, 3, 0], [0, 0, 0]]; const walkable = new Set([0]); const ts = 32; const r = 6; it('returns true when area is fully on walkable tiles', () => { expect(isAreaWalkable(16, 16, r, grid, ts, walkable)).toBe(true); }); it('returns false when area overlaps non-walkable tile', () => { // (26, 26) with r=6 → corner at (32, 32) enters tile (1,1) which is non-walkable expect(isAreaWalkable(26, 26, r, grid, ts, walkable)).toBe(false); }); it('returns true when near wall but not overlapping', () => { // (24, 16) with r=6 → rightmost point at 30, still in tile 0 column expect(isAreaWalkable(24, 16, r, grid, ts, walkable)).toBe(true); }); }); // === Collision: resolveCollision === describe('resolveCollision', () => { const ts = 32; const r = 6; const walkable = new Set([0]); it('allows movement to valid position', () => { const grid = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]; const result = resolveCollision(48, 48, 16, 16, r, grid, ts, walkable); expect(result).toEqual({ x: 48, y: 48 }); }); it('blocks movement into non-walkable tile (slides X when Y row is clear)', () => { // Center tile non-walkable but row 0 at X=48 is walkable → slides X const grid = [[0, 0, 0], [0, 3, 0], [0, 0, 0]]; const result = resolveCollision(48, 48, 16, 16, r, grid, ts, walkable); expect(result).toEqual({ x: 48, y: 16 }); // slides along X }); it('fully blocks when both axes lead to non-walkable', () => { // "+" shaped wall: column 1 and row 1 are non-walkable const grid = [[0, 3, 0], [3, 3, 3], [0, 3, 0]]; const result = resolveCollision(48, 48, 16, 16, r, grid, ts, walkable); expect(result).toEqual({ x: 16, y: 16 }); // can't move at all }); it('wall-slides along X when Y is blocked', () => { // Top row is wall, rows 1-2 walkable const grid = [[3, 3, 3], [0, 0, 0], [0, 0, 0]]; // From (48, 48) try to go to (60, 22) — Y into wall, X along wall // Full: blocked (y-r=16 hits row 0) // X-only: (60, 48) → all in row 1 → valid const result = resolveCollision(60, 22, 48, 48, r, grid, ts, walkable); expect(result).toEqual({ x: 60, y: 48 }); }); it('wall-slides along Y when X is blocked', () => { // Left column is wall, columns 1-2 walkable const grid = [[3, 0, 0], [3, 0, 0], [3, 0, 0]]; // From (48, 48) try to go to (22, 60) — X into wall, Y along wall // Full: blocked (x-r=16 hits col 0) // X-only: (22, 48) → blocked // Y-only: (48, 60) → all in col 1 → valid const result = resolveCollision(22, 60, 48, 48, r, grid, ts, walkable); expect(result).toEqual({ x: 48, y: 60 }); }); it('reverts completely when both axes blocked', () => { // Only center tile walkable, rest walls const grid = [[3, 3, 3], [3, 0, 3], [3, 3, 3]]; // From (48, 48) try to go to (80, 80) — off walkable area const result = resolveCollision(80, 80, 48, 48, r, grid, ts, walkable); expect(result).toEqual({ x: 48, y: 48 }); }); it('blocks out-of-bounds movement', () => { const grid = [[0, 0], [0, 0]]; const result = resolveCollision(-5, 16, 16, 16, r, grid, ts, walkable); expect(result).toEqual({ x: 16, y: 16 }); }); }); // === Collision: tileCollisionSystem === describe('tileCollisionSystem', () => { it('corrects player position after invalid movement', () => { const world = createWorld(); const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Velocity); addComponent(world, eid, PlayerTag); // Player at (48, 48) with velocity that would move into wall // After movementSystem: Position was updated. We simulate the post-movement state. Velocity.vx[eid] = 150; // moving right Velocity.vy[eid] = 0; // After 0.2s movement: moved 30px right to x=78 which is in tile (2,1) Position.x[eid] = 78; Position.y[eid] = 48; // Grid where tile (2,1) is non-walkable const grid = [[0, 0, 3], [0, 0, 3], [0, 0, 3]]; const walkable = new Set([0]); tileCollisionSystem(world, 200, grid, 32, walkable); // Should revert X (back to 48) expect(Position.x[eid]).toBe(48); expect(Position.y[eid]).toBe(48); }); }); // === Collision: buildWalkableSet === describe('buildWalkableSet', () => { it('includes walkable tiles', () => { const tiles = [ { id: 0, walkable: true }, { id: 1, walkable: true }, { id: 3, walkable: false }, ]; const set = buildWalkableSet(tiles); expect(set.has(0)).toBe(true); expect(set.has(1)).toBe(true); expect(set.has(3)).toBe(false); }); it('returns empty set when no walkable tiles', () => { const tiles = [{ id: 0, walkable: false }]; const set = buildWalkableSet(tiles); expect(set.size).toBe(0); }); }); // === Spawn Position === describe('findSpawnPosition', () => { const walkable = new Set([0]); const ts = 32; it('finds walkable spawn position', () => { const grid = [[3, 3, 3], [3, 0, 3], [3, 3, 3]]; const pos = findSpawnPosition(grid, ts, walkable); expect(pos).not.toBeNull(); const tx = Math.floor(pos!.x / ts); const ty = Math.floor(pos!.y / ts); expect(walkable.has(grid[ty][tx])).toBe(true); }); it('prefers center of map', () => { const grid = [ [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], ]; const pos = findSpawnPosition(grid, ts, walkable); expect(pos).not.toBeNull(); const tx = Math.floor(pos!.x / ts); const ty = Math.floor(pos!.y / ts); expect(tx).toBe(2); expect(ty).toBe(2); }); it('returns null for fully non-walkable grid', () => { const grid = [[3, 3], [3, 3]]; const pos = findSpawnPosition(grid, ts, walkable); expect(pos).toBeNull(); }); it('spirals outward to find walkable tile', () => { // Center (2,2) is non-walkable, but neighbors are const grid = [ [0, 0, 0, 0, 0], [0, 3, 3, 3, 0], [0, 3, 3, 3, 0], [0, 3, 3, 3, 0], [0, 0, 0, 0, 0], ]; const pos = findSpawnPosition(grid, ts, walkable); expect(pos).not.toBeNull(); // Should find one of the ring-1 tiles (first walkable on spiral) const tx = Math.floor(pos!.x / ts); const ty = Math.floor(pos!.y / ts); expect(walkable.has(grid[ty][tx])).toBe(true); }); it('returns center of tile in pixels', () => { const grid = [[0]]; const pos = findSpawnPosition(grid, ts, walkable); expect(pos).toEqual({ x: 16, y: 16 }); // center of tile (0,0) = (16, 16) }); }); // === Player Entity Factory === describe('createPlayerEntity', () => { it('creates entity with correct position', () => { const world = createWorld(); const eid = createPlayerEntity(world, 100, 200); expect(Position.x[eid]).toBe(100); expect(Position.y[eid]).toBe(200); }); it('creates entity with health', () => { const world = createWorld(); const eid = createPlayerEntity(world, 0, 0); expect(Health.current[eid]).toBeGreaterThan(0); expect(Health.max[eid]).toBeGreaterThan(0); expect(Health.current[eid]).toBe(Health.max[eid]); }); it('creates entity with sprite', () => { const world = createWorld(); const eid = createPlayerEntity(world, 0, 0); expect(SpriteRef.color[eid]).toBeDefined(); expect(SpriteRef.radius[eid]).toBeGreaterThan(0); }); it('creates entity queryable with PlayerTag', () => { const world = createWorld(); const eid = createPlayerEntity(world, 50, 50); const players = query(world, [PlayerTag]); expect(players).toContain(eid); expect(players.length).toBe(1); }); it('creates entity with velocity component', () => { const world = createWorld(); const eid = createPlayerEntity(world, 0, 0); expect(Velocity.vx[eid]).toBe(0); expect(Velocity.vy[eid]).toBe(0); }); });