Implement tick-based game loop, combat overhaul, and attack-any-NPC

Replace immediate combat with a 3-second tick engine that resolves
actions, NPC AI, status effects, respawns, and passive regeneration.
Players queue combat actions (attack/defend/flee/use) that resolve on
the next tick. Any NPC can now be attacked — non-hostile targets incur
attitude penalties instead of being blocked. Status effects persist in
the database and continue ticking while players are offline.

Made-with: Cursor
This commit is contained in:
AI Agent
2026-03-14 15:12:44 -06:00
parent a083c38326
commit 5fd2c10198
9 changed files with 890 additions and 181 deletions

View File

@@ -1,87 +1,203 @@
use std::time::Instant;
use crate::ansi;
use crate::game::GameState;
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 do_attack(player_id: usize, npc_id: &str, state: &mut GameState) -> Option<CombatRoundResult> {
let npc_template = state.world.get_npc(npc_id)?.clone();
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)?;
let instance = state.npc_instances.get(&npc_id)?;
if !instance.alive {
return None;
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 npc_hp_before = instance.hp;
let conn = state.players.get(&player_id)?;
let p_atk = conn.player.effective_attack();
let p_def = conn.player.effective_defense();
// Player attacks NPC
let roll: i32 = (simple_random() % 6) as i32 + 1;
let player_dmg = (p_atk - npc_combat.defense / 2 + roll).max(1);
let new_npc_hp = (npc_hp_before - player_dmg).max(0);
let _ = conn;
let mut out = String::new();
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()),
ansi::RESET,
));
let mut npc_died = false;
let mut player_died = false;
let mut xp_gained = 0;
let mut fled = false;
if new_npc_hp <= 0 {
// NPC dies
if let Some(inst) = state.npc_instances.get_mut(npc_id) {
inst.alive = false;
inst.hp = 0;
inst.death_time = Some(Instant::now());
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 {
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()),
));
if let Some(conn) = state.players.get_mut(&player_id) {
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,
));
}
}
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()),
));
// Clear combat state
if let Some(conn) = state.players.get_mut(&player_id) {
conn.combat = None;
conn.player.stats.xp += xp_gained;
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, "[]"),
));
}
} else {
// Update NPC HP
if let Some(inst) = state.npc_instances.get_mut(npc_id) {
inst.hp = new_npc_hp;
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::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, "!!"),
));
}
}
}
}
out.push_str(&format!(
" {} {} HP: {}/{}\r\n",
ansi::color(ansi::DIM, " "),
npc_template.name,
new_npc_hp,
npc_combat.max_hp,
));
// 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 attacks player
let npc_roll: i32 = (simple_random() % 6) as i32 + 1;
let npc_dmg = (npc_combat.attack - p_def / 2 + npc_roll).max(1);
// 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);
@@ -89,10 +205,11 @@ pub fn do_attack(player_id: usize, npc_id: &str, state: &mut GameState) -> Optio
let max_hp = conn.player.stats.max_hp;
out.push_str(&format!(
" {} {} strikes you for {} damage!\r\n",
" {} {} 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 {
@@ -115,6 +232,11 @@ pub fn do_attack(player_id: usize, npc_id: &str, state: &mut GameState) -> Optio
player_died = true;
conn.combat = None;
}
// Reset defending after the round
if let Some(ref mut combat) = conn.combat {
combat.defending = false;
}
}
}
@@ -123,32 +245,32 @@ pub fn do_attack(player_id: usize, npc_id: &str, state: &mut GameState) -> Optio
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_default();
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{}\r\n{}\r\n{}\r\n{}\r\n",
ansi::color(ansi::RED, " ╔═══════════════════════════╗"),
ansi::color(ansi::RED, " ║ YOU HAVE DIED! ║"),
ansi::color(ansi::RED, " ╚═══════════════════════════╝"),
) + &format!(
"{}\r\n",
ansi::system_msg("You awaken at the town square, fully healed.")
ansi::system_msg("You awaken at the town square, fully healed."),
)
}
fn simple_random() -> u32 {
use std::time::SystemTime;
let d = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
((d.as_nanos() >> 4) ^ (d.as_nanos() >> 16)) as u32
}