use std::collections::HashMap; use std::time::Duration; use russh::CryptoVec; use crate::ansi; use crate::combat; use crate::commands::render_room_view; use crate::game::SharedState; const TICK_INTERVAL_MS: u64 = 3000; const REGEN_EVERY_N_TICKS: u64 = 5; const REGEN_PERCENT: i32 = 5; pub async fn run_tick_engine(state: SharedState) { log::info!( "Tick engine started (interval={}ms, regen every {} ticks)", TICK_INTERVAL_MS, REGEN_EVERY_N_TICKS, ); loop { tokio::time::sleep(Duration::from_millis(TICK_INTERVAL_MS)).await; let mut st = state.lock().await; st.tick_count += 1; let tick = st.tick_count; st.check_respawns(); // --- NPC auto-aggro: hostile NPCs initiate combat with players in their room --- let mut new_combats: Vec<(usize, String)> = Vec::new(); for (pid, conn) in st.players.iter() { if conn.combat.is_some() { continue; } let room = match st.world.get_room(&conn.player.room_id) { Some(r) => r, None => continue, }; for npc_id in &room.npcs { let npc = match st.world.get_npc(npc_id) { Some(n) => n, None => continue, }; if npc.combat.is_none() { continue; } let alive = st.npc_instances.get(npc_id).map(|i| i.alive).unwrap_or(false); if !alive { continue; } let att = st.npc_attitude_toward(npc_id, &conn.player.name); if att.is_hostile() { new_combats.push((*pid, npc_id.clone())); break; } } } let mut messages: HashMap = HashMap::new(); for (pid, npc_id) in &new_combats { let npc_name = st .world .get_npc(npc_id) .map(|n| n.name.clone()) .unwrap_or_default(); if let Some(conn) = st.players.get_mut(pid) { if conn.combat.is_none() { conn.combat = Some(crate::game::CombatState { npc_id: npc_id.clone(), action: None, defending: false, }); messages.entry(*pid).or_default().push_str(&format!( "\r\n {} {} attacks you!\r\n", ansi::color(ansi::RED, "!!"), ansi::color(ansi::RED, &npc_name), )); } } } // --- Resolve combat for all players in combat --- let combat_players: Vec = st .players .iter() .filter(|(_, c)| c.combat.is_some()) .map(|(&id, _)| id) .collect(); for pid in combat_players { if let Some(round) = combat::resolve_combat_tick(pid, &mut st) { messages.entry(pid).or_default().push_str(&round.output); if round.npc_died { let npc_id = { // NPC is dead, combat was cleared in resolve_combat_tick // Get the npc_id from the round context // We need to find which NPC just died - check npc_instances // Actually let's track it differently: get it before combat resolution String::new() }; // We handle attitude shifts and level-ups after resolve // The npc_id is already gone from combat state, so we need another approach // Let's get player name and check level up if let Some(msg) = st.check_level_up(pid) { messages.entry(pid).or_default().push_str(&format!( "\r\n {} {}\r\n", ansi::color(ansi::GREEN, "***"), ansi::bold(&msg), )); } let _ = npc_id; } if round.player_died { let death_msg = combat::player_death_respawn(pid, &mut st); messages.entry(pid).or_default().push_str(&death_msg); let rid = st .players .get(&pid) .map(|c| c.player.room_id.clone()) .unwrap_or_default(); if !rid.is_empty() { messages .entry(pid) .or_default() .push_str(&render_room_view(&rid, pid, &st)); } } st.save_player_to_db(pid); } } // --- Process status effects (ticks down ALL effects in DB, including offline players) --- let active_effects = st.db.tick_all_effects(); for eff in &active_effects { match eff.kind.as_str() { "poison" => { let dmg = eff.magnitude; let online_pid = st .players .iter() .find(|(_, c)| c.player.name == eff.player_name) .map(|(&id, _)| id); if let Some(pid) = online_pid { if let Some(conn) = st.players.get_mut(&pid) { conn.player.stats.hp = (conn.player.stats.hp - dmg).max(0); if eff.remaining_ticks > 0 { messages.entry(pid).or_default().push_str(&format!( "\r\n {} Poison deals {} damage! ({} ticks left)\r\n", ansi::color(ansi::GREEN, "~*"), dmg, eff.remaining_ticks, )); } else { messages.entry(pid).or_default().push_str(&format!( "\r\n {} The poison wears off.\r\n", ansi::color(ansi::GREEN, "~*"), )); } if conn.player.stats.hp <= 0 { let death_msg = combat::player_death_respawn(pid, &mut st); messages.entry(pid).or_default().push_str(&death_msg); } } st.save_player_to_db(pid); } else { // Offline player: apply damage directly to DB if let Some(mut saved) = st.db.load_player(&eff.player_name) { saved.hp = (saved.hp - dmg).max(0); if saved.hp <= 0 { saved.hp = saved.max_hp; saved.room_id = st.spawn_room().to_string(); st.db.clear_effects(&eff.player_name); } st.db.save_player(&saved); } } } "regen" => { let heal = eff.magnitude; let online_pid = st .players .iter() .find(|(_, c)| c.player.name == eff.player_name) .map(|(&id, _)| id); if let Some(pid) = online_pid { if let Some(conn) = st.players.get_mut(&pid) { let old = conn.player.stats.hp; conn.player.stats.hp = (conn.player.stats.hp + heal).min(conn.player.stats.max_hp); let healed = conn.player.stats.hp - old; if healed > 0 && eff.remaining_ticks > 0 { messages.entry(pid).or_default().push_str(&format!( "\r\n {} Regeneration heals {} HP.\r\n", ansi::color(ansi::GREEN, "++"), healed, )); } if eff.remaining_ticks <= 0 { messages.entry(pid).or_default().push_str(&format!( "\r\n {} The regeneration effect fades.\r\n", ansi::color(ansi::DIM, "~~"), )); } } st.save_player_to_db(pid); } else { if let Some(mut saved) = st.db.load_player(&eff.player_name) { saved.hp = (saved.hp + heal).min(saved.max_hp); st.db.save_player(&saved); } } } _ => {} } } // --- Tick cooldowns for all online players --- let cd_pids: Vec = st.players.keys().copied().collect(); for pid in cd_pids { if let Some(conn) = st.players.get_mut(&pid) { conn.player.cooldowns.retain(|_, cd| { *cd -= 1; *cd > 0 }); } } // --- Passive regen for online players not in combat --- if tick % REGEN_EVERY_N_TICKS == 0 { let regen_pids: Vec = st .players .iter() .filter(|(_, c)| c.combat.is_none()) .map(|(&id, _)| id) .collect(); for pid in regen_pids { if let Some(conn) = st.players.get_mut(&pid) { let s = &mut conn.player.stats; let mut regen_msg = String::new(); // HP regen if s.hp < s.max_hp { let heal = (s.max_hp * REGEN_PERCENT / 100).max(1); let old = s.hp; s.hp = (s.hp + heal).min(s.max_hp); let healed = s.hp - old; if healed > 0 { regen_msg.push_str(&format!( "\r\n {} You recover {} HP. ({}/{})", ansi::color(ansi::DIM, "~~"), healed, s.hp, s.max_hp, )); } } // Mana regen if s.mana < s.max_mana { let regen = (s.max_mana * REGEN_PERCENT / 100).max(1); s.mana = (s.mana + regen).min(s.max_mana); } // Endurance regen if s.endurance < s.max_endurance { let regen = (s.max_endurance * REGEN_PERCENT / 100).max(1); s.endurance = (s.endurance + regen).min(s.max_endurance); } if !regen_msg.is_empty() { regen_msg.push_str("\r\n"); messages.entry(pid).or_default().push_str(®en_msg); } } st.save_player_to_db(pid); } } // --- Send accumulated messages to online players --- let sends: Vec<(russh::ChannelId, russh::server::Handle, String)> = messages .into_iter() .filter_map(|(pid, msg)| { if msg.is_empty() { return None; } let conn = st.players.get(&pid)?; if let (Some(ch), Some(h)) = (conn.channel, &conn.handle) { Some(( ch, h.clone(), format!("{}{}", msg, ansi::prompt()), )) } else { None } }) .collect(); drop(st); for (ch, handle, text) in sends { let _ = handle.data(ch, CryptoVec::from(text.as_bytes())).await; } } }