Fixed coprse bug

This commit is contained in:
2026-02-07 12:54:25 +11:00
parent 4b50e341a7
commit 02f850da35
12 changed files with 326 additions and 192 deletions

View File

@@ -11,36 +11,36 @@ describe('World Generator', () => {
describe('generateWorld', () => {
it('should generate a world with correct dimensions', () => {
const runState = {
stats: {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world } = generateWorld(1, runState);
expect(world.width).toBe(60);
expect(world.height).toBe(40);
expect(world.tiles.length).toBe(60 * 40);
expect(world.width).toBe(120);
expect(world.height).toBe(80);
expect(world.tiles.length).toBe(120 * 80);
});
it('should place player actor', () => {
const runState = {
stats: {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world, playerId, ecsWorld } = generateWorld(1, runState);
const accessor = new EntityAccessor(world, playerId, ecsWorld);
expect(playerId).toBeGreaterThan(0);
const player = accessor.getPlayer();
expect(player).toBeDefined();
@@ -53,36 +53,36 @@ describe('World Generator', () => {
it('should create walkable rooms', () => {
const runState = {
stats: {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world, playerId, ecsWorld } = generateWorld(1, runState);
const accessor = new EntityAccessor(world, playerId, ecsWorld);
const player = accessor.getPlayer()!;
// Player should spawn in a walkable area
expect(isWall(world, player.pos.x, player.pos.y)).toBe(false);
});
it('should place exit in valid location', () => {
const runState = {
stats: {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world } = generateWorld(1, runState);
expect(inBounds(world, world.exit.x, world.exit.y)).toBe(true);
// Exit should be on a floor tile
expect(isWall(world, world.exit.x, world.exit.y)).toBe(false);
@@ -90,21 +90,21 @@ describe('World Generator', () => {
it('should create enemies', () => {
const runState = {
stats: {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world, playerId, ecsWorld } = generateWorld(1, runState);
const accessor = new EntityAccessor(world, playerId, ecsWorld);
const enemies = accessor.getEnemies();
expect(enemies.length).toBeGreaterThan(0);
// Enemies should have stats
enemies.forEach(enemy => {
expect(enemy.stats).toBeDefined();
@@ -115,25 +115,25 @@ describe('World Generator', () => {
it('should generate deterministic maps for same level', () => {
const runState = {
stats: {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world: world1, playerId: player1, ecsWorld: ecs1 } = generateWorld(1, runState);
const { world: world2, playerId: player2, ecsWorld: ecs2 } = generateWorld(1, runState);
// Same level should generate identical layouts
expect(world1.tiles).toEqual(world2.tiles);
expect(world1.exit).toEqual(world2.exit);
const accessor1 = new EntityAccessor(world1, player1, ecs1);
const accessor2 = new EntityAccessor(world2, player2, ecs2);
const player1Pos = accessor1.getPlayer()!.pos;
const player2Pos = accessor2.getPlayer()!.pos;
expect(player1Pos).toEqual(player2Pos);
@@ -141,45 +141,45 @@ describe('World Generator', () => {
it('should generate different maps for different levels', () => {
const runState = {
stats: {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world: world1 } = generateWorld(1, runState);
const { world: world2 } = generateWorld(2, runState);
// Different levels should have different layouts
expect(world1.tiles).not.toEqual(world2.tiles);
});
it('should scale enemy difficulty with level', () => {
const runState = {
stats: {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world: world1, playerId: p1, ecsWorld: ecs1 } = generateWorld(1, runState);
const { world: world5, playerId: p5, ecsWorld: ecs5 } = generateWorld(5, runState);
const accessor1 = new EntityAccessor(world1, p1, ecs1);
const accessor5 = new EntityAccessor(world5, p5, ecs5);
const enemies1 = accessor1.getEnemies();
const enemies5 = accessor5.getEnemies();
// Higher level should have more enemies
expect(enemies5.length).toBeGreaterThan(enemies1.length);
// Higher level enemies should have higher stats
const avgHp1 = enemies1.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies1.length;
const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies5.length;
@@ -187,11 +187,11 @@ describe('World Generator', () => {
});
it('should generate doors on dungeon floors', () => {
const runState = {
stats: {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
@@ -205,17 +205,17 @@ describe('World Generator', () => {
break;
}
}
expect(foundDoor).toBe(true);
});
it('should ensure player spawns on safe tile (not grass)', () => {
const runState = {
stats: {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
@@ -225,7 +225,7 @@ describe('World Generator', () => {
const { world, playerId, ecsWorld } = generateWorld(1, runState);
const accessor = new EntityAccessor(world, playerId, ecsWorld);
const player = accessor.getPlayer()!;
// Check tile under player
const tileIdx = player.pos.y * world.width + player.pos.x;
const tile = world.tiles[tileIdx];
@@ -240,7 +240,7 @@ describe('World Generator', () => {
describe('Cave Generation (Floors 10+)', () => {
it('should generate cellular automata style maps', () => {
const runState = {
stats: {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
@@ -250,17 +250,17 @@ describe('World Generator', () => {
};
const { world } = generateWorld(10, runState);
// Basic validity checks
expect(world.width).toBe(60);
expect(world.height).toBe(40);
expect(world.width).toBe(120);
expect(world.height).toBe(80);
expect(world.tiles.some(t => t === TileType.EMPTY)).toBe(true);
expect(world.tiles.some(t => t === TileType.WALL)).toBe(true);
});
it('should place enemies in caves', () => {
const runState = {
stats: {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
@@ -277,7 +277,7 @@ describe('World Generator', () => {
it('should ensure the map is connected (Player can reach Exit)', () => {
const runState = {
stats: {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
@@ -301,26 +301,26 @@ describe('World Generator', () => {
pathfinder.compute(player.pos.x, player.pos.y, (x, y) => {
path.push([x, y]);
});
expect(path.length).toBeGreaterThan(0);
}
});
it('should verify safe spawn logic on caves', () => {
const runState = {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world, playerId, ecsWorld } = generateWorld(12, runState);
const accessor = new EntityAccessor(world, playerId, ecsWorld);
const player = accessor.getPlayer()!;
expect(world.tiles[player.pos.y * world.width + player.pos.x]).toBe(TileType.EMPTY);
const runState = {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world, playerId, ecsWorld } = generateWorld(12, runState);
const accessor = new EntityAccessor(world, playerId, ecsWorld);
const player = accessor.getPlayer()!;
expect(world.tiles[player.pos.y * world.width + player.pos.x]).toBe(TileType.EMPTY);
});
});
});

View File

@@ -602,4 +602,28 @@ describe('Combat Simulation', () => {
}
});
});
describe("Death Cleanup", () => {
it("should remove combatants with 0 HP during turn processing", () => {
const actors = new Map<EntityId, Actor>();
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, speed: 10, stats: createTestStats(), energy: 0 } as any;
// Enemy with 0 HP (e.g. killed by status effect prior to turn)
const enemy = {
id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, speed: 100,
stats: createTestStats({ hp: 0 }), energy: 0, type: "rat"
} as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
syncToECS(actors);
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
// This should trigger checkDeaths
const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor);
expect(accessor.hasActor(2 as EntityId)).toBe(false);
expect(result.events.some(e => e.type === "killed" && e.targetId === 2)).toBe(true);
});
});
});

View File

@@ -21,7 +21,6 @@ export const Prefabs = {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Rat")
.withSprite("rat", 0)
.asEnemy("rat")
.withStats({
maxHp: config.baseHp + floorBonus,
@@ -41,7 +40,6 @@ export const Prefabs = {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Bat")
.withSprite("bat", 0)
.asEnemy("bat")
.withStats({
maxHp: config.baseHp + floorBonus,
@@ -217,7 +215,6 @@ export const Prefabs = {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Player")
.withSprite("PriestessSouth", 0)
.asPlayer()
.withStats(config.initialStats)
.withEnergy(config.speed)

View File

@@ -48,7 +48,8 @@ export interface TriggerComponent {
onExit?: boolean; // Trigger when entity leaves this tile
onInteract?: boolean; // Trigger when entity interacts with this
oneShot?: boolean; // Destroy/disable after triggering once
triggered?: boolean; // Has already triggered (for oneShot triggers)
triggered?: boolean; // Is currently triggered/active
spent?: boolean; // Has already triggered (for oneShot triggers)
targetId?: EntityId; // Target entity for this trigger (e.g., mine cart for a switch)
damage?: number; // Damage to deal on trigger (for traps)

View File

@@ -37,7 +37,7 @@ export class TriggerSystem extends System {
const triggerPos = world.getComponent(triggerId, "position");
if (!trigger || !triggerPos) continue;
if (trigger.triggered && trigger.oneShot) continue; // Already triggered one-shot
if (trigger.spent && trigger.oneShot) continue; // Already spent one-shot
// Check for entities at this trigger's position
for (const entityId of allWithPosition) {
@@ -50,11 +50,11 @@ export class TriggerSystem extends System {
const wasOnTrigger = this.wasEntityOnTrigger(entityId, triggerPos);
// Handle enter or manual trigger
if ((trigger.onEnter && isOnTrigger && !wasOnTrigger) || (trigger.triggered && !trigger.oneShot)) {
if ((trigger.onEnter && isOnTrigger && !wasOnTrigger) || trigger.triggered) {
this.activateTrigger(triggerId, entityId, trigger, world);
// If it was manually triggered, we should probably reset the flag if its not oneShot
if (trigger.triggered && !trigger.oneShot) {
// If it was manually triggered, we should reset the flag
if (trigger.triggered) {
trigger.triggered = false;
}
}
@@ -88,7 +88,7 @@ export class TriggerSystem extends System {
triggered?: boolean;
targetId?: EntityId;
onInteract?: boolean;
spent?: boolean;
},
world: ECSWorld
): void {
@@ -147,7 +147,8 @@ export class TriggerSystem extends System {
// Mark as triggered for one-shot triggers and update sprite
if (trigger.oneShot) {
trigger.triggered = true;
trigger.spent = true;
trigger.triggered = false;
// Change sprite to triggered appearance if it's a dungeon sprite
const sprite = world.getComponent(triggerId, "sprite");

View File

@@ -29,6 +29,8 @@ export function applyAction(w: World, actorId: EntityId, action: Action, accesso
break;
}
checkDeaths(events, accessor);
return events;
}
@@ -192,44 +194,55 @@ function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, a
});
if (target.stats.hp <= 0) {
events.push({
type: "killed",
targetId: target.id,
killerId: actor.id,
x: target.pos.x,
y: target.pos.y,
victimType: target.type as ActorType
});
accessor.removeActor(target.id);
// Extinguish fire at the death position
const ecsWorld = accessor.context;
if (ecsWorld) {
const firesAtPos = ecsWorld.getEntitiesWith("position", "name").filter(id => {
const p = ecsWorld.getComponent(id, "position");
const n = ecsWorld.getComponent(id, "name");
return p?.x === target.pos.x && p?.y === target.pos.y && n?.name === "Fire";
});
for (const fireId of firesAtPos) {
ecsWorld.destroyEntity(fireId);
}
}
// Spawn EXP Orb
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
const expAmount = enemyDef?.expValue || 0;
if (ecsWorld) {
const orbId = Prefabs.expOrb(ecsWorld, target.pos.x, target.pos.y, expAmount);
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
}
killActor(target, events, accessor, actor.id);
}
return events;
}
return [{ type: "waited", actorId: actor.id }];
}
export function killActor(target: CombatantActor, events: SimEvent[], accessor: EntityAccessor, killerId?: EntityId): void {
events.push({
type: "killed",
targetId: target.id,
killerId: killerId ?? (0 as EntityId),
x: target.pos.x,
y: target.pos.y,
victimType: target.type as ActorType
});
accessor.removeActor(target.id);
// Extinguish fire at the death position
const ecsWorld = accessor.context;
if (ecsWorld) {
const firesAtPos = ecsWorld.getEntitiesWith("position", "name").filter(id => {
const p = ecsWorld.getComponent(id, "position");
const n = ecsWorld.getComponent(id, "name");
return p?.x === target.pos.x && p?.y === target.pos.y && n?.name === "Fire";
});
for (const fireId of firesAtPos) {
ecsWorld.destroyEntity(fireId);
}
// Spawn EXP Orb
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
const expAmount = enemyDef?.expValue || 0;
const orbId = Prefabs.expOrb(ecsWorld, target.pos.x, target.pos.y, expAmount);
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
}
}
export function checkDeaths(events: SimEvent[], accessor: EntityAccessor): void {
const combatants = accessor.getCombatants();
for (const c of combatants) {
if (c.stats.hp <= 0) {
killActor(c, events, accessor);
}
}
}
/**
@@ -308,6 +321,7 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId, accessor: Enti
}
events.push(...applyAction(w, actor.id, decision.action, accessor));
checkDeaths(events, accessor);
if (!accessor.isPlayerAlive()) {
return { awaitingPlayerId: null as any, events };