313 lines
12 KiB
Rust
313 lines
12 KiB
Rust
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<usize, String> = 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<usize> = 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<usize> = 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<usize> = 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;
|
|
}
|
|
}
|
|
}
|