Add in throwable items (dagger) from pixel dungeon
This commit is contained in:
@@ -48,7 +48,7 @@ describe('Pathfinding', () => {
|
||||
it('should respect ignoreBlockedTarget option', () => {
|
||||
const world = createTestWorld(10, 10);
|
||||
// Place an actor at target
|
||||
world.actors.set(1, { id: 1, pos: { x: 0, y: 3 }, type: 'rat' } as any);
|
||||
world.actors.set(1, { id: 1, pos: { x: 0, y: 3 }, type: 'rat', category: 'combatant' } as any);
|
||||
|
||||
const seen = new Uint8Array(100).fill(1);
|
||||
|
||||
|
||||
@@ -25,6 +25,14 @@ describe('Combat Simulation', () => {
|
||||
|
||||
|
||||
|
||||
describe('applyAction', () => {
|
||||
it('should return empty events if actor does not exist', () => {
|
||||
const world = createTestWorld(new Map());
|
||||
const events = applyAction(world, 999, { type: "wait" });
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyAction - success paths', () => {
|
||||
it('should deal damage when player attacks enemy', () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
@@ -87,6 +95,24 @@ describe('Combat Simulation', () => {
|
||||
// Tile should effectively be destroyed (turned to saplings/2)
|
||||
expect(world.tiles[grassIdx]).toBe(2); // TileType.GRASS_SAPLINGS
|
||||
});
|
||||
|
||||
it("should handle wait action", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
actors.set(1, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 } } as any);
|
||||
const world = createTestWorld(actors);
|
||||
|
||||
const events = applyAction(world, 1, { type: "wait" }, new EntityManager(world));
|
||||
expect(events).toEqual([{ type: "waited", actorId: 1 }]);
|
||||
});
|
||||
|
||||
it("should default to wait for unknown action type", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
actors.set(1, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 } } as any);
|
||||
const world = createTestWorld(actors);
|
||||
|
||||
const events = applyAction(world, 1, { type: "unknown_hack" } as any, new EntityManager(world));
|
||||
expect(events).toEqual([{ type: "waited", actorId: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decideEnemyAction - AI Logic", () => {
|
||||
@@ -239,6 +265,32 @@ describe('Combat Simulation', () => {
|
||||
expect(result.events.length).toBeGreaterThan(0);
|
||||
expect(result.awaitingPlayerId).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle player death during enemy turn", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, speed: 10, stats: createTestStats({ hp: 1 }), energy: 0 } as any;
|
||||
// Enemy that will kill player
|
||||
const enemy = {
|
||||
id: 2,
|
||||
category: "combatant",
|
||||
isPlayer: false,
|
||||
pos: { x: 1, y: 0 },
|
||||
speed: 100,
|
||||
stats: createTestStats({ attack: 100 }),
|
||||
aiState: "pursuing",
|
||||
energy: 100
|
||||
} as any;
|
||||
|
||||
actors.set(1, player);
|
||||
actors.set(2, enemy);
|
||||
const world = createTestWorld(actors);
|
||||
const em = new EntityManager(world);
|
||||
|
||||
const result = stepUntilPlayerTurn(world, 1, em);
|
||||
|
||||
expect(world.actors.has(1)).toBe(false); // Player dead
|
||||
expect(result.events.some(e => e.type === "killed" && e.targetId === 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Combat Mechanics - Detailed", () => {
|
||||
@@ -351,6 +403,26 @@ describe('Combat Simulation', () => {
|
||||
expect(player.stats.hp).toBe(15);
|
||||
expect(events.some(e => e.type === "healed")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not lifesteal beyond maxHp", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
const player = {
|
||||
id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 },
|
||||
stats: createTestStats({ accuracy: 100, attack: 10, lifesteal: 100, hp: 19, maxHp: 20 })
|
||||
} as any;
|
||||
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ hp: 20, defense: 0 }) } as any;
|
||||
|
||||
actors.set(1, player);
|
||||
actors.set(2, enemy);
|
||||
const world = createTestWorld(actors);
|
||||
|
||||
mockRandom.mockReturnValue(0.1);
|
||||
|
||||
applyAction(world, 1, { type: "attack", targetId: 2 }, new EntityManager(world));
|
||||
|
||||
// Damage 10. Heal 10. HP 19+10 = 29 > 20. Should be 20.
|
||||
expect(player.stats.hp).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Level Up Logic", () => {
|
||||
|
||||
53
src/engine/gameplay/CombatLogic.ts
Normal file
53
src/engine/gameplay/CombatLogic.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { type World, type Vec2, type EntityId } from "../../core/types";
|
||||
import { isBlocked } from "../world/world-logic";
|
||||
import { raycast } from "../../core/math";
|
||||
import { EntityManager } from "../EntityManager";
|
||||
|
||||
export interface ProjectileResult {
|
||||
path: Vec2[];
|
||||
blockedPos: Vec2;
|
||||
hitActorId?: EntityId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the path and impact of a projectile.
|
||||
*/
|
||||
export function traceProjectile(
|
||||
world: World,
|
||||
start: Vec2,
|
||||
target: Vec2,
|
||||
entityManager: EntityManager,
|
||||
shooterId?: EntityId
|
||||
): ProjectileResult {
|
||||
const points = raycast(start.x, start.y, target.x, target.y);
|
||||
let blockedPos = target;
|
||||
let hitActorId: EntityId | undefined;
|
||||
|
||||
// Iterate points (skip start)
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const p = points[i];
|
||||
|
||||
// Check for blocking
|
||||
if (isBlocked(world, p.x, p.y, entityManager)) {
|
||||
// Check if we hit a combatant
|
||||
const actors = entityManager.getActorsAt(p.x, p.y);
|
||||
const enemy = actors.find(a => a.category === "combatant" && a.id !== shooterId);
|
||||
|
||||
if (enemy) {
|
||||
hitActorId = enemy.id;
|
||||
blockedPos = p;
|
||||
} else {
|
||||
// Hit wall or other obstacle
|
||||
blockedPos = p;
|
||||
}
|
||||
break;
|
||||
}
|
||||
blockedPos = p;
|
||||
}
|
||||
|
||||
return {
|
||||
path: points,
|
||||
blockedPos,
|
||||
hitActorId
|
||||
};
|
||||
}
|
||||
125
src/engine/gameplay/__tests__/CombatLogic.test.ts
Normal file
125
src/engine/gameplay/__tests__/CombatLogic.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { traceProjectile } from '../CombatLogic';
|
||||
import type { World } from '../../../core/types';
|
||||
import { EntityManager } from '../../EntityManager';
|
||||
import { TileType } from '../../../core/terrain';
|
||||
|
||||
describe('CombatLogic', () => {
|
||||
// Mock World
|
||||
const mockWorld: World = {
|
||||
width: 10,
|
||||
height: 10,
|
||||
tiles: new Array(100).fill(TileType.EMPTY),
|
||||
actors: new Map(),
|
||||
exit: { x: 9, y: 9 }
|
||||
};
|
||||
|
||||
// Helper to set wall
|
||||
const setWall = (x: number, y: number) => {
|
||||
mockWorld.tiles[y * mockWorld.width + x] = TileType.WALL;
|
||||
};
|
||||
|
||||
// Helper to clear world
|
||||
const clearWorld = () => {
|
||||
mockWorld.tiles.fill(TileType.EMPTY);
|
||||
mockWorld.actors.clear();
|
||||
};
|
||||
|
||||
// Mock EntityManager
|
||||
const mockEntityManager = {
|
||||
getActorsAt: (x: number, y: number) => {
|
||||
return [...mockWorld.actors.values()].filter(a => a.pos.x === x && a.pos.y === y);
|
||||
}
|
||||
} as unknown as EntityManager;
|
||||
|
||||
beforeEach(() => {
|
||||
clearWorld();
|
||||
});
|
||||
|
||||
describe('traceProjectile', () => {
|
||||
it('should travel full path if no obstacles', () => {
|
||||
const start = { x: 0, y: 0 };
|
||||
const end = { x: 5, y: 0 };
|
||||
|
||||
const result = traceProjectile(mockWorld, start, end, mockEntityManager);
|
||||
|
||||
expect(result.blockedPos).toEqual(end);
|
||||
expect(result.hitActorId).toBeUndefined();
|
||||
// Path should be (0,0) -> (1,0) -> (2,0) -> (3,0) -> (4,0) -> (5,0)
|
||||
// But raycast implementation includes start?
|
||||
// CombatLogic logic: "skip start" -> loop i=1
|
||||
// So result.path is full array from raycast.
|
||||
expect(result.path).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('should stop at wall', () => {
|
||||
const start = { x: 0, y: 0 };
|
||||
const end = { x: 5, y: 0 };
|
||||
setWall(3, 0); // Wall at (3,0)
|
||||
|
||||
const result = traceProjectile(mockWorld, start, end, mockEntityManager);
|
||||
|
||||
expect(result.blockedPos).toEqual({ x: 3, y: 0 });
|
||||
expect(result.hitActorId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should stop at enemy', () => {
|
||||
const start = { x: 0, y: 0 };
|
||||
const end = { x: 5, y: 0 };
|
||||
|
||||
// Place enemy at (3,0)
|
||||
const enemyId = 2;
|
||||
mockWorld.actors.set(enemyId, {
|
||||
id: enemyId,
|
||||
type: 'rat',
|
||||
category: 'combatant',
|
||||
pos: { x: 3, y: 0 },
|
||||
isPlayer: false
|
||||
// ... other props mocked if needed
|
||||
} as any);
|
||||
|
||||
const result = traceProjectile(mockWorld, start, end, mockEntityManager, 1); // Shooter 1
|
||||
|
||||
expect(result.blockedPos).toEqual({ x: 3, y: 0 });
|
||||
expect(result.hitActorId).toBe(enemyId);
|
||||
});
|
||||
|
||||
it('should ignore shooter position', () => {
|
||||
const start = { x: 0, y: 0 };
|
||||
const end = { x: 5, y: 0 };
|
||||
|
||||
// Shooter at start
|
||||
mockWorld.actors.set(1, {
|
||||
id: 1,
|
||||
type: 'player',
|
||||
category: 'combatant',
|
||||
pos: { x: 0, y: 0 },
|
||||
isPlayer: true
|
||||
} as any);
|
||||
|
||||
const result = traceProjectile(mockWorld, start, end, mockEntityManager, 1);
|
||||
|
||||
// Should not hit self
|
||||
expect(result.hitActorId).toBeUndefined();
|
||||
expect(result.blockedPos).toEqual(end);
|
||||
});
|
||||
|
||||
it('should ignore non-combatant actors (e.g. items)', () => {
|
||||
const start = { x: 0, y: 0 };
|
||||
const end = { x: 5, y: 0 };
|
||||
|
||||
// Item at (3,0)
|
||||
mockWorld.actors.set(99, {
|
||||
id: 99,
|
||||
category: 'item_drop',
|
||||
pos: { x: 3, y: 0 },
|
||||
} as any);
|
||||
|
||||
const result = traceProjectile(mockWorld, start, end, mockEntityManager);
|
||||
|
||||
// Should pass through item
|
||||
expect(result.blockedPos).toEqual(end);
|
||||
expect(result.hitActorId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,7 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
||||
items: [
|
||||
...runState.inventory.items,
|
||||
// Add starting items for testing if empty
|
||||
...(runState.inventory.items.length === 0 ? [ITEMS["health_potion"], ITEMS["health_potion"], ITEMS["iron_sword"]] : [])
|
||||
...(runState.inventory.items.length === 0 ? [ITEMS["health_potion"], ITEMS["health_potion"], ITEMS["iron_sword"], ITEMS["throwing_dagger"], ITEMS["throwing_dagger"], ITEMS["throwing_dagger"]] : [])
|
||||
]
|
||||
},
|
||||
energy: 0
|
||||
|
||||
@@ -42,11 +42,13 @@ export function isBlocked(w: World, x: number, y: number, em?: EntityManager): b
|
||||
if (isBlockingTile(w, x, y)) return true;
|
||||
|
||||
if (em) {
|
||||
return em.isOccupied(x, y, "exp_orb");
|
||||
const actors = em.getActorsAt(x, y);
|
||||
// Only combatants block movement
|
||||
return actors.some(a => a.category === "combatant");
|
||||
}
|
||||
|
||||
for (const a of w.actors.values()) {
|
||||
if (a.pos.x === x && a.pos.y === y && a.type !== "exp_orb") return true;
|
||||
if (a.pos.x === x && a.pos.y === y && a.category === "combatant") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user