377 lines
15 KiB
Rust
377 lines
15 KiB
Rust
use std::time::Instant;
|
|
|
|
use crate::ansi;
|
|
use crate::game::{CombatAction, GameState};
|
|
|
|
pub struct CombatRoundResult {
|
|
pub output: String,
|
|
pub npc_died: bool,
|
|
pub player_died: bool,
|
|
pub xp_gained: i32,
|
|
pub fled: bool,
|
|
}
|
|
|
|
pub fn resolve_combat_tick(
|
|
player_id: usize,
|
|
state: &mut GameState,
|
|
) -> Option<CombatRoundResult> {
|
|
let (npc_id, action, was_defending) = {
|
|
let conn = state.players.get(&player_id)?;
|
|
let combat = conn.combat.as_ref()?;
|
|
let action = combat.action.clone().unwrap_or(CombatAction::Attack);
|
|
(combat.npc_id.clone(), action, combat.defending)
|
|
};
|
|
|
|
let npc_template = state.world.get_npc(&npc_id)?.clone();
|
|
let npc_combat = npc_template.combat.as_ref()?;
|
|
|
|
let instance = state.npc_instances.get(&npc_id)?;
|
|
if !instance.alive {
|
|
if let Some(conn) = state.players.get_mut(&player_id) {
|
|
conn.combat = None;
|
|
}
|
|
return Some(CombatRoundResult {
|
|
output: format!(
|
|
" {} {} is already dead. Combat ended.\r\n",
|
|
ansi::color(ansi::DIM, "--"),
|
|
npc_template.name,
|
|
),
|
|
npc_died: false,
|
|
player_died: false,
|
|
xp_gained: 0,
|
|
fled: false,
|
|
});
|
|
}
|
|
|
|
let npc_hp_before = instance.hp;
|
|
let conn = state.players.get(&player_id)?;
|
|
let p_atk = conn.player.effective_attack(&state.world);
|
|
let p_def = conn.player.effective_defense(&state.world);
|
|
let _ = conn;
|
|
|
|
let mut out = String::new();
|
|
let mut npc_died = false;
|
|
let mut player_died = false;
|
|
let mut xp_gained = 0;
|
|
let mut fled = false;
|
|
|
|
match action {
|
|
CombatAction::Attack => {
|
|
let roll = state.rng.next_range(1, 6);
|
|
let player_dmg = (p_atk - npc_combat.defense / 2 + roll).max(1);
|
|
let new_npc_hp = (npc_hp_before - player_dmg).max(0);
|
|
|
|
out.push_str(&format!(
|
|
" {} You strike {} for {} damage!\r\n",
|
|
ansi::color(ansi::YELLOW, ">>"),
|
|
ansi::color(ansi::RED, &npc_template.name),
|
|
ansi::bold(&player_dmg.to_string()),
|
|
));
|
|
|
|
if new_npc_hp <= 0 {
|
|
let player_name = state.players.get(&player_id).map(|c| c.player.name.clone()).unwrap_or_else(|| "Unknown".into());
|
|
log::info!("Combat: Player '{}' (ID {}) killed NPC '{}' ({})", player_name, player_id, npc_template.name, npc_id);
|
|
if let Some(inst) = state.npc_instances.get_mut(&npc_id) {
|
|
inst.alive = false;
|
|
inst.hp = 0;
|
|
inst.death_time = Some(Instant::now());
|
|
}
|
|
npc_died = true;
|
|
xp_gained = npc_combat.xp_reward;
|
|
let gold_gained = npc_template.gold;
|
|
let silver_gained = npc_template.silver;
|
|
let copper_gained = npc_template.copper;
|
|
|
|
out.push_str(&format!(
|
|
" {} {} collapses! You gain {} XP and {}g {}s {}c.\r\n",
|
|
ansi::color(ansi::GREEN, "**"),
|
|
ansi::color(ansi::RED, &npc_template.name),
|
|
ansi::bold(&xp_gained.to_string()),
|
|
gold_gained, silver_gained, copper_gained
|
|
));
|
|
|
|
if let Some(conn) = state.players.get_mut(&player_id) {
|
|
conn.combat = None;
|
|
conn.player.stats.xp += xp_gained;
|
|
conn.player.gold += gold_gained;
|
|
conn.player.silver += silver_gained;
|
|
conn.player.copper += copper_gained;
|
|
}
|
|
} else {
|
|
if let Some(inst) = state.npc_instances.get_mut(&npc_id) {
|
|
inst.hp = new_npc_hp;
|
|
}
|
|
out.push_str(&format!(
|
|
" {} {} HP: {}/{}\r\n",
|
|
ansi::color(ansi::DIM, " "),
|
|
npc_template.name,
|
|
new_npc_hp,
|
|
npc_combat.max_hp,
|
|
));
|
|
}
|
|
}
|
|
CombatAction::Defend => {
|
|
if let Some(conn) = state.players.get_mut(&player_id) {
|
|
if let Some(ref mut combat) = conn.combat {
|
|
combat.defending = true;
|
|
}
|
|
}
|
|
out.push_str(&format!(
|
|
" {} You brace yourself and raise your guard.\r\n",
|
|
ansi::color(ansi::CYAN, "[]"),
|
|
));
|
|
}
|
|
CombatAction::Flee => {
|
|
let flee_chance = 40 + (p_def / 2).min(30);
|
|
let roll = state.rng.next_range(1, 100);
|
|
if roll <= flee_chance {
|
|
if let Some(conn) = state.players.get_mut(&player_id) {
|
|
conn.combat = None;
|
|
}
|
|
fled = true;
|
|
out.push_str(&format!(
|
|
" {} You disengage and flee from combat!\r\n",
|
|
ansi::color(ansi::GREEN, "<<"),
|
|
));
|
|
} else {
|
|
out.push_str(&format!(
|
|
" {} You try to flee but {} blocks your escape!\r\n",
|
|
ansi::color(ansi::RED, "!!"),
|
|
ansi::color(ansi::RED, &npc_template.name),
|
|
));
|
|
}
|
|
}
|
|
CombatAction::Cast(ref spell_id) => {
|
|
let spell = state.world.get_spell(spell_id).cloned();
|
|
if let Some(spell) = spell {
|
|
if let Some(conn) = state.players.get_mut(&player_id) {
|
|
if spell.cost_mana > conn.player.stats.mana {
|
|
out.push_str(&format!(
|
|
" {} Not enough mana for {}!\r\n",
|
|
ansi::color(ansi::RED, "!!"), spell.name,
|
|
));
|
|
} else if spell.cost_endurance > conn.player.stats.endurance {
|
|
out.push_str(&format!(
|
|
" {} Not enough endurance for {}!\r\n",
|
|
ansi::color(ansi::RED, "!!"), spell.name,
|
|
));
|
|
} else {
|
|
conn.player.stats.mana -= spell.cost_mana;
|
|
conn.player.stats.endurance -= spell.cost_endurance;
|
|
if spell.cooldown_ticks > 0 {
|
|
conn.player.cooldowns.insert(spell_id.clone(), spell.cooldown_ticks);
|
|
}
|
|
|
|
if spell.spell_type == "heal" {
|
|
let old_hp = conn.player.stats.hp;
|
|
conn.player.stats.hp = (conn.player.stats.hp + spell.heal).min(conn.player.stats.max_hp);
|
|
let healed = conn.player.stats.hp - old_hp;
|
|
out.push_str(&format!(
|
|
" {} You cast {}! Restored {} HP.\r\n",
|
|
ansi::color(ansi::GREEN, "++"),
|
|
ansi::color(ansi::CYAN, &spell.name),
|
|
ansi::bold(&healed.to_string()),
|
|
));
|
|
} else {
|
|
// Offensive spell
|
|
let spell_dmg = spell.damage + state.rng.next_range(1, 4);
|
|
let new_npc_hp = (npc_hp_before - spell_dmg).max(0);
|
|
out.push_str(&format!(
|
|
" {} You cast {} on {} for {} {} damage!\r\n",
|
|
ansi::color(ansi::MAGENTA, "**"),
|
|
ansi::color(ansi::CYAN, &spell.name),
|
|
ansi::color(ansi::RED, &npc_template.name),
|
|
ansi::bold(&spell_dmg.to_string()),
|
|
spell.damage_type,
|
|
));
|
|
if new_npc_hp <= 0 {
|
|
if let Some(inst) = state.npc_instances.get_mut(&npc_id) {
|
|
inst.alive = false;
|
|
inst.hp = 0;
|
|
inst.death_time = Some(Instant::now());
|
|
}
|
|
npc_died = true;
|
|
xp_gained = npc_combat.xp_reward;
|
|
out.push_str(&format!(
|
|
" {} {} collapses! You gain {} XP.\r\n",
|
|
ansi::color(ansi::GREEN, "**"),
|
|
ansi::color(ansi::RED, &npc_template.name),
|
|
ansi::bold(&xp_gained.to_string()),
|
|
));
|
|
conn.combat = None;
|
|
conn.player.stats.xp += xp_gained;
|
|
} else {
|
|
if let Some(inst) = state.npc_instances.get_mut(&npc_id) {
|
|
inst.hp = new_npc_hp;
|
|
}
|
|
out.push_str(&format!(
|
|
" {} {} HP: {}/{}\r\n",
|
|
ansi::color(ansi::DIM, " "),
|
|
npc_template.name, new_npc_hp, npc_combat.max_hp,
|
|
));
|
|
}
|
|
}
|
|
|
|
// Apply status effect from spell
|
|
if let Some(ref eff) = spell.effect {
|
|
if spell.spell_type == "offensive" {
|
|
// Could apply effect to NPC in future, for now just note it
|
|
} else {
|
|
let pname = conn.player.name.clone();
|
|
state.db.save_effect(&pname, eff, spell.effect_duration, spell.effect_magnitude);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
out.push_str(&format!(
|
|
" {} Spell not found.\r\n",
|
|
ansi::color(ansi::RED, "!!"),
|
|
));
|
|
}
|
|
}
|
|
CombatAction::UseItem(idx) => {
|
|
if let Some(conn) = state.players.get_mut(&player_id) {
|
|
if idx < conn.player.inventory.len() {
|
|
let obj = &conn.player.inventory[idx];
|
|
if obj.kind.as_deref() == Some("consumable") {
|
|
let heal = obj.stats.heal_amount.unwrap_or(0);
|
|
let name = obj.name.clone();
|
|
conn.player.inventory.remove(idx);
|
|
let old_hp = 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_hp;
|
|
out.push_str(&format!(
|
|
" {} You use the {}. Restored {} HP.\r\n",
|
|
ansi::color(ansi::GREEN, "++"),
|
|
ansi::color(ansi::CYAN, &name),
|
|
ansi::bold(&healed.to_string()),
|
|
));
|
|
} else {
|
|
out.push_str(&format!(
|
|
" {} You can't use that in combat.\r\n",
|
|
ansi::color(ansi::RED, "!!"),
|
|
));
|
|
}
|
|
} else {
|
|
out.push_str(&format!(
|
|
" {} Item not found in inventory.\r\n",
|
|
ansi::color(ansi::RED, "!!"),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear the queued action
|
|
if let Some(conn) = state.players.get_mut(&player_id) {
|
|
if let Some(ref mut combat) = conn.combat {
|
|
combat.action = None;
|
|
}
|
|
}
|
|
|
|
// NPC counter-attack (if player is still in combat and NPC is alive)
|
|
let still_in_combat = state
|
|
.players
|
|
.get(&player_id)
|
|
.map(|c| c.combat.is_some())
|
|
.unwrap_or(false);
|
|
let npc_alive = state
|
|
.npc_instances
|
|
.get(&npc_id)
|
|
.map(|i| i.alive)
|
|
.unwrap_or(false);
|
|
|
|
if still_in_combat && npc_alive && !fled {
|
|
let is_defending = state
|
|
.players
|
|
.get(&player_id)
|
|
.and_then(|c| c.combat.as_ref())
|
|
.map(|c| c.defending)
|
|
.unwrap_or(was_defending);
|
|
|
|
let defense_mult = if is_defending { 2.0 } else { 1.0 };
|
|
let effective_def = (p_def as f32 * defense_mult) as i32;
|
|
|
|
let npc_roll = state.rng.next_range(1, 6);
|
|
let npc_dmg = (npc_combat.attack - effective_def / 2 + npc_roll).max(1);
|
|
|
|
if let Some(conn) = state.players.get_mut(&player_id) {
|
|
conn.player.stats.hp = (conn.player.stats.hp - npc_dmg).max(0);
|
|
let hp = conn.player.stats.hp;
|
|
let max_hp = conn.player.stats.max_hp;
|
|
|
|
out.push_str(&format!(
|
|
" {} {} strikes you for {} damage!{}\r\n",
|
|
ansi::color(ansi::RED, "<<"),
|
|
ansi::color(ansi::RED, &npc_template.name),
|
|
ansi::bold(&npc_dmg.to_string()),
|
|
if is_defending { " (blocked some)" } else { "" },
|
|
));
|
|
|
|
let hp_color = if hp * 3 < max_hp {
|
|
ansi::RED
|
|
} else if hp * 3 < max_hp * 2 {
|
|
ansi::YELLOW
|
|
} else {
|
|
ansi::GREEN
|
|
};
|
|
out.push_str(&format!(
|
|
" {} Your HP: {}{}/{}{}\r\n",
|
|
ansi::color(ansi::DIM, " "),
|
|
hp_color,
|
|
hp,
|
|
max_hp,
|
|
ansi::RESET,
|
|
));
|
|
|
|
if hp <= 0 {
|
|
player_died = true;
|
|
conn.combat = None;
|
|
}
|
|
|
|
// Reset defending after the round
|
|
if let Some(ref mut combat) = conn.combat {
|
|
combat.defending = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
Some(CombatRoundResult {
|
|
output: out,
|
|
npc_died,
|
|
player_died,
|
|
xp_gained,
|
|
fled,
|
|
})
|
|
}
|
|
|
|
pub fn player_death_respawn(player_id: usize, state: &mut GameState) -> String {
|
|
let spawn_room = state.spawn_room().to_string();
|
|
let player_name = state
|
|
.players
|
|
.get(&player_id)
|
|
.map(|c| c.player.name.clone())
|
|
.unwrap_or_else(|| "Unknown".into());
|
|
|
|
log::info!("Combat: Player '{}' (ID {}) died and respawned at {}", player_name, player_id, spawn_room);
|
|
|
|
if let Some(conn) = state.players.get_mut(&player_id) {
|
|
conn.player.stats.hp = conn.player.stats.max_hp;
|
|
conn.player.room_id = spawn_room;
|
|
conn.combat = None;
|
|
}
|
|
|
|
// Clear status effects on death
|
|
state.db.clear_effects(&player_name);
|
|
|
|
format!(
|
|
"\r\n{}\r\n{}\r\n{}\r\n{}\r\n",
|
|
ansi::color(ansi::RED, " ╔═══════════════════════════╗"),
|
|
ansi::color(ansi::RED, " ║ YOU HAVE DIED! ║"),
|
|
ansi::color(ansi::RED, " ╚═══════════════════════════╝"),
|
|
ansi::system_msg("You awaken at the town square, fully healed."),
|
|
)
|
|
}
|