import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types"; import { calculateDamage } from "../gameplay/CombatLogic"; import { isBlocked, tryDestructTile } from "../world/world-logic"; import { isDestructibleByWalk, TileType } from "../../core/terrain"; import { GAME_CONFIG } from "../../core/config/GameConfig"; import { type EntityAccessor } from "../EntityAccessor"; import { AISystem } from "../ecs/AISystem"; import { Prefabs } from "../ecs/Prefabs"; export function applyAction(w: World, actorId: EntityId, action: Action, accessor: EntityAccessor): SimEvent[] { const actor = accessor.getActor(actorId); if (!actor) return []; const events: SimEvent[] = []; switch (action.type) { case "move": events.push(...handleMove(w, actor, action, accessor)); break; case "attack": events.push(...handleAttack(w, actor, action, accessor)); break; case "throw": break; case "wait": default: events.push({ type: "waited", actorId }); break; } checkDeaths(events, accessor); return events; } function handleExpCollection(player: Actor, events: SimEvent[], accessor: EntityAccessor) { if (player.category !== "combatant") return; const actorsAtPos = accessor.getActorsAt(player.pos.x, player.pos.y); const orbs = actorsAtPos.filter(a => a.category === "collectible" && a.type === "exp_orb" ) as CollectibleActor[]; for (const orb of orbs) { const amount = orb.expAmount || 0; player.stats.exp += amount; events.push({ type: "exp-collected", actorId: player.id, amount, x: player.pos.x, y: player.pos.y }); checkLevelUp(player, events); accessor.removeActor(orb.id); } } function checkLevelUp(player: CombatantActor, events: SimEvent[]) { const s = player.stats; while (s.exp >= s.expToNextLevel) { s.level++; s.exp -= s.expToNextLevel; // Growth s.maxHp += GAME_CONFIG.leveling.hpGainPerLevel; s.hp = s.maxHp; // Heal on level up s.maxMana += GAME_CONFIG.leveling.manaGainPerLevel; s.mana = s.maxMana; // Restore mana on level up s.attack += GAME_CONFIG.leveling.attackGainPerLevel; s.statPoints += GAME_CONFIG.leveling.statPointsPerLevel; s.skillPoints += GAME_CONFIG.leveling.skillPointsPerLevel; // Scale requirement s.expToNextLevel = Math.floor(s.expToNextLevel * GAME_CONFIG.leveling.expMultiplier); events.push({ type: "leveled-up", actorId: player.id, level: s.level, x: player.pos.x, y: player.pos.y }); } } function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }, accessor: EntityAccessor): SimEvent[] { const from = { ...actor.pos }; const nx = actor.pos.x + action.dx; const ny = actor.pos.y + action.dy; if (!isBlocked(w, nx, ny, accessor)) { actor.pos.x = nx; actor.pos.y = ny; const to = { ...actor.pos }; const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }]; const tileIdx = ny * w.width + nx; const tile = w.tiles[tileIdx]; if (isDestructibleByWalk(tile)) { // Only open if it's currently closed. // tryDestructTile toggles, so we must be specific for doors. if (tile === TileType.DOOR_CLOSED) { tryDestructTile(w, nx, ny); } else if (tile !== TileType.DOOR_OPEN) { // For other destructibles like grass tryDestructTile(w, nx, ny); } } // Handle "from" tile - Close door if we just left it and no one else is there const fromIdx = from.y * w.width + from.x; if (w.tiles[fromIdx] === TileType.DOOR_OPEN) { const actorsLeft = accessor.getActorsAt(from.x, from.y); if (actorsLeft.length === 0) { console.log(`[simulation] Closing door at ${from.x},${from.y} - Actor ${actor.id} left`); w.tiles[fromIdx] = TileType.DOOR_CLOSED; } else { console.log(`[simulation] Door at ${from.x},${from.y} stays open - ${actorsLeft.length} actors remain`); } } if (actor.category === "combatant" && actor.isPlayer) { handleExpCollection(actor, events, accessor); } return events; } else { // If blocked, check if we can interact with an entity at the target position if (actor.category === "combatant" && actor.isPlayer && accessor?.context) { const ecsWorld = accessor.context; const interactables = ecsWorld.getEntitiesWith("position", "trigger").filter(id => { const p = ecsWorld.getComponent(id, "position"); const t = ecsWorld.getComponent(id, "trigger"); return p?.x === nx && p?.y === ny && t?.onInteract; }); if (interactables.length > 0) { // Trigger interaction by marking it as triggered // The TriggerSystem will pick this up on the next update ecsWorld.getComponent(interactables[0], "trigger")!.triggered = true; } } } return [{ type: "move-blocked", actorId: actor.id, x: nx, y: ny }]; } function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, accessor: EntityAccessor): SimEvent[] { const target = accessor.getActor(action.targetId); if (target && target.category === "combatant" && actor.category === "combatant") { const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }]; // 1. Calculate Damage const result = calculateDamage(actor.stats, target.stats); if (!result.hit) { events.push({ type: "dodged", targetId: action.targetId, x: target.pos.x, y: target.pos.y }); return events; } const dmg = result.dmg; const isCrit = result.isCrit; const isBlock = result.isBlock; target.stats.hp -= dmg; if (target.stats.hp > 0 && target.category === "combatant" && !target.isPlayer) { target.aiState = "pursuing"; target.alertedAt = Date.now(); if (actor.pos) { target.lastKnownPlayerPos = { ...actor.pos }; } } // 5. Lifesteal Logic if (actor.stats.lifesteal > 0 && dmg > 0) { const healAmount = Math.floor(dmg * (actor.stats.lifesteal / 100)); if (healAmount > 0) { actor.stats.hp = Math.min(actor.stats.maxHp, actor.stats.hp + healAmount); events.push({ type: "healed", actorId: actor.id, amount: healAmount, x: actor.pos.x, y: actor.pos.y }); } } events.push({ type: "damaged", targetId: action.targetId, amount: dmg, hp: target.stats.hp, x: target.pos.x, y: target.pos.y, isCrit, isBlock }); if (target.stats.hp <= 0) { 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); } } } /** * Enemy AI with state machine: * - Wandering: Random movement when can't see player * - Alerted: Brief period after spotting player (shows "!") * - Pursuing: Chase player while in FOV or toward last known position */ export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor, accessor: EntityAccessor): { action: Action; justAlerted: boolean } { const ecsWorld = accessor.context; if (ecsWorld) { const aiSystem = new AISystem(ecsWorld, w, accessor); const result = aiSystem.update(enemy.id, player.id); const aiComp = ecsWorld.getComponent(enemy.id, "ai"); if (aiComp) { enemy.aiState = aiComp.state; enemy.alertedAt = aiComp.alertedAt; enemy.lastKnownPlayerPos = aiComp.lastKnownPlayerPos; } return result; } return { action: { type: "wait" }, justAlerted: false }; } /** * Speed-based scheduler using rot-js: runs until it's the player's turn and the game needs input. * Returns enemy events accumulated along the way. */ export function stepUntilPlayerTurn(w: World, playerId: EntityId, accessor: EntityAccessor): { awaitingPlayerId: EntityId; events: SimEvent[] } { const THRESHOLD = 100; const player = accessor.getCombatant(playerId); if (!player) throw new Error("Player missing or invalid"); const events: SimEvent[] = []; if (player.energy >= THRESHOLD) { player.energy -= THRESHOLD; } while (true) { if (player.energy >= THRESHOLD) { return { awaitingPlayerId: playerId, events }; } const actors = [...accessor.getAllActors()]; for (const actor of actors) { if (actor.category === "combatant") { actor.energy += actor.speed; } } let actionsTaken = 0; while (true) { const eligibleActors = accessor.getEnemies().filter(a => a.energy >= THRESHOLD); if (eligibleActors.length === 0) break; eligibleActors.sort((a, b) => b.energy - a.energy); const actor = eligibleActors[0]; actor.energy -= THRESHOLD; const decision = decideEnemyAction(w, actor, player, accessor); if (decision.justAlerted) { events.push({ type: "enemy-alerted", enemyId: actor.id, x: actor.pos.x, y: actor.pos.y }); } events.push(...applyAction(w, actor.id, decision.action, accessor)); checkDeaths(events, accessor); if (!accessor.isPlayerAlive()) { return { awaitingPlayerId: null as any, events }; } actionsTaken++; if (actionsTaken > 1000) break; } } }