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

@@ -3,8 +3,7 @@ use russh::{ChannelId, CryptoVec};
use crate::admin;
use crate::ansi;
use crate::combat;
use crate::game::{CombatState, SharedState};
use crate::game::{CombatAction, CombatState, SharedState};
use crate::world::Attitude;
pub struct BroadcastMsg {
@@ -61,14 +60,15 @@ pub async fn execute(
None => (input.to_lowercase(), String::new()),
};
// Combat lockout
// Combat lockout: only certain commands allowed
{
let st = state.lock().await;
if let Some(conn) = st.players.get(&player_id) {
if conn.combat.is_some()
&& !matches!(
cmd.as_str(),
"attack" | "a" | "flee" | "look" | "l" | "quit" | "exit"
"attack" | "a" | "defend" | "def" | "flee" | "use" | "look" | "l"
| "stats" | "st" | "inventory" | "inv" | "i" | "quit" | "exit"
)
{
drop(st);
@@ -77,7 +77,9 @@ pub async fn execute(
channel,
&format!(
"{}\r\n{}",
ansi::error_msg("You're in combat! Use 'attack', 'flee', or 'look'."),
ansi::error_msg(
"You're in combat! Use 'attack', 'defend', 'flee', 'use', 'look', 'stats', or 'inventory'."
),
ansi::prompt()
),
)?;
@@ -101,6 +103,7 @@ pub async fn execute(
"examine" | "ex" | "x" => cmd_examine(player_id, &args, state).await,
"talk" => cmd_talk(player_id, &args, state).await,
"attack" | "a" => cmd_attack(player_id, &args, state).await,
"defend" | "def" => cmd_defend(player_id, state).await,
"flee" => cmd_flee(player_id, state).await,
"stats" | "st" => cmd_stats(player_id, state).await,
"admin" => cmd_admin(player_id, &args, state).await,
@@ -236,8 +239,35 @@ async fn cmd_look(pid: usize, state: &SharedState) -> CommandResult {
Some(c) => c.player.room_id.clone(),
None => return simple("Error\r\n"),
};
let mut out = render_room_view(&rid, pid, &st);
// Show combat status if in combat
if let Some(conn) = st.players.get(&pid) {
if let Some(ref combat) = conn.combat {
if let Some(npc) = st.world.get_npc(&combat.npc_id) {
let npc_hp = st
.npc_instances
.get(&combat.npc_id)
.map(|i| i.hp)
.unwrap_or(0);
let npc_max = npc
.combat
.as_ref()
.map(|c| c.max_hp)
.unwrap_or(1);
out.push_str(&format!(
"\r\n {} In combat with {} (HP: {}/{})\r\n",
ansi::color(ansi::RED, "!!"),
ansi::color(ansi::RED, &npc.name),
npc_hp,
npc_max,
));
}
}
}
CommandResult {
output: render_room_view(&rid, pid, &st),
output: out,
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
@@ -249,6 +279,16 @@ async fn cmd_go(pid: usize, direction: &str, state: &SharedState) -> CommandResu
let direction = resolve_dir(&dl);
let mut st = state.lock().await;
// Block movement in combat
if let Some(conn) = st.players.get(&pid) {
if conn.combat.is_some() {
return simple(&format!(
"{}\r\n",
ansi::error_msg("You can't move while in combat! Use 'flee' to escape.")
));
}
}
let (old_rid, new_rid, pname) = {
let conn = match st.players.get(&pid) {
Some(c) => c,
@@ -380,12 +420,14 @@ async fn cmd_who(pid: usize, state: &SharedState) -> CommandResult {
.unwrap_or("???");
let m = if c.player.name == sn { " (you)" } else { "" };
let admin_tag = if c.player.is_admin { " [ADMIN]" } else { "" };
let combat_tag = if c.combat.is_some() { " [COMBAT]" } else { "" };
out.push_str(&format!(
" {}{}{}{}\r\n",
" {}{}{}{}{}\r\n",
ansi::player_name(&c.player.name),
ansi::room_name(rn),
ansi::system_msg(m),
ansi::color(ansi::YELLOW, admin_tag),
ansi::color(ansi::RED, combat_tag),
));
}
out.push_str(&format!(
@@ -635,6 +677,35 @@ async fn cmd_use(pid: usize, target: &str, state: &SharedState) -> CommandResult
))
}
};
// In combat: queue the use action for the next tick
if conn.combat.is_some() {
let obj = &conn.player.inventory[idx];
if obj.kind.as_deref() != Some("consumable") {
return simple(&format!(
"{}\r\n",
ansi::error_msg(&format!("You can't use the {} in combat.", obj.name))
));
}
if let Some(ref mut combat) = conn.combat {
combat.action = Some(CombatAction::UseItem(idx));
}
let name = obj.name.clone();
return CommandResult {
output: format!(
"{}\r\n",
ansi::system_msg(&format!(
"You prepare to use the {}... (resolves next tick)",
name
))
),
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
};
}
// Out of combat: use immediately
let obj = &conn.player.inventory[idx];
if obj.kind.as_deref() != Some("consumable") {
return simple(&format!(
@@ -800,118 +871,154 @@ async fn cmd_talk(pid: usize, target: &str, state: &SharedState) -> CommandResul
async fn cmd_attack(pid: usize, target: &str, state: &SharedState) -> CommandResult {
let mut st = state.lock().await;
// If already in combat, queue attack action
let already_in_combat = st
.players
.get(&pid)
.map(|c| c.combat.is_some())
.unwrap_or(false);
if already_in_combat {
let npc_name = st
.players
.get(&pid)
.and_then(|c| c.combat.as_ref())
.and_then(|combat| st.world.get_npc(&combat.npc_id))
.map(|n| n.name.clone())
.unwrap_or_else(|| "???".into());
if let Some(conn) = st.players.get_mut(&pid) {
if let Some(ref mut combat) = conn.combat {
combat.action = Some(CombatAction::Attack);
}
}
return CommandResult {
output: format!(
"{}\r\n",
ansi::system_msg(&format!(
"You ready an attack against {}... (resolves next tick)",
npc_name
))
),
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
};
}
// Not in combat: initiate combat
if target.is_empty() {
return simple("Attack what?\r\n");
}
let npc_id = {
let conn = match st.players.get(&pid) {
Some(c) => c,
None => return simple("Error\r\n"),
};
if let Some(ref combat) = conn.combat {
combat.npc_id.clone()
} else {
if target.is_empty() {
return simple("Attack what?\r\n");
let room = match st.world.get_room(&conn.player.room_id) {
Some(r) => r,
None => return simple("Void\r\n"),
};
let low = target.to_lowercase();
let found = room.npcs.iter().find(|nid| {
if let Some(npc) = st.world.get_npc(nid) {
npc.name.to_lowercase().contains(&low) && npc.combat.is_some()
} else {
false
}
let room = match st.world.get_room(&conn.player.room_id) {
Some(r) => r,
None => return simple("Void\r\n"),
};
let low = target.to_lowercase();
let pname = &conn.player.name;
let found = room.npcs.iter().find(|nid| {
if let Some(npc) = st.world.get_npc(nid) {
if !npc.name.to_lowercase().contains(&low) {
return false;
}
let att = st.npc_attitude_toward(nid, pname);
att.can_be_attacked() && npc.combat.is_some()
} else {
false
}
});
match found {
Some(id) => {
if !st
.npc_instances
.get(id)
.map(|i| i.alive)
.unwrap_or(false)
{
return simple(&format!(
"{}\r\n",
ansi::error_msg("That target is already dead.")
));
}
id.clone()
}
None => {
});
match found {
Some(id) => {
if !st
.npc_instances
.get(id)
.map(|i| i.alive)
.unwrap_or(false)
{
return simple(&format!(
"{}\r\n",
ansi::error_msg(&format!("No attackable target '{target}' here."))
))
ansi::error_msg("That target is already dead.")
));
}
id.clone()
}
None => {
return simple(&format!(
"{}\r\n",
ansi::error_msg(&format!("No attackable target '{target}' here."))
))
}
}
};
if st
.players
.get(&pid)
.map(|c| c.combat.is_none())
.unwrap_or(false)
{
if let Some(c) = st.players.get_mut(&pid) {
c.combat = Some(CombatState {
npc_id: npc_id.clone(),
});
}
}
let npc_name = st
.world
.get_npc(&npc_id)
.map(|n| n.name.clone())
.unwrap_or_default();
st.check_respawns();
let player_name = st
// Attitude penalty for attacking non-hostile NPCs
let pname = st
.players
.get(&pid)
.map(|c| c.player.name.clone())
.unwrap_or_default();
let result = combat::do_attack(pid, &npc_id, &mut st);
match result {
Some(round) => {
let mut out = round.output;
if round.npc_died {
st.shift_attitude(&npc_id, &player_name, -10);
if let Some(faction) = st.world.get_npc(&npc_id).and_then(|n| n.faction.clone()) {
st.shift_faction_attitude(&faction, &player_name, -5);
}
if let Some(msg) = st.check_level_up(pid) {
out.push_str(&format!(
"\r\n {} {}\r\n",
ansi::color(ansi::GREEN, "***"),
ansi::bold(&msg)
));
}
}
if round.player_died {
out.push_str(&combat::player_death_respawn(pid, &mut st));
let rid = st
.players
.get(&pid)
.map(|c| c.player.room_id.clone())
.unwrap_or_default();
out.push_str(&render_room_view(&rid, pid, &st));
}
st.save_player_to_db(pid);
CommandResult {
output: out,
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
}
let att = st.npc_attitude_toward(&npc_id, &pname);
let mut extra_msg = String::new();
if !att.is_hostile() {
st.shift_attitude(&npc_id, &pname, -30);
if let Some(faction) = st.world.get_npc(&npc_id).and_then(|n| n.faction.clone()) {
st.shift_faction_attitude(&faction, &pname, -15);
}
None => simple(&format!(
extra_msg = format!(
"{}\r\n",
ansi::error_msg("That target can't be attacked right now.")
)),
ansi::color(ansi::RED, " The locals look on in horror as you attack without provocation!")
);
}
if let Some(c) = st.players.get_mut(&pid) {
c.combat = Some(CombatState {
npc_id: npc_id.clone(),
action: Some(CombatAction::Attack),
defending: false,
});
}
CommandResult {
output: format!(
"{}\r\n{}\r\n{}",
ansi::system_msg(&format!("You engage {} in combat!", npc_name)),
ansi::system_msg("Your attack will resolve on the next tick. Use 'attack', 'defend', 'flee', or 'use <item>'."),
extra_msg,
),
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
}
}
async fn cmd_defend(pid: usize, state: &SharedState) -> CommandResult {
let mut st = state.lock().await;
let conn = match st.players.get_mut(&pid) {
Some(c) => c,
None => return simple("Error\r\n"),
};
if conn.combat.is_none() {
return simple(&format!(
"{}\r\n",
ansi::error_msg("You're not in combat.")
));
}
if let Some(ref mut combat) = conn.combat {
combat.action = Some(CombatAction::Defend);
}
CommandResult {
output: format!(
"{}\r\n",
ansi::system_msg("You prepare to defend... (resolves next tick)")
),
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
}
}
@@ -927,11 +1034,13 @@ async fn cmd_flee(pid: usize, state: &SharedState) -> CommandResult {
ansi::error_msg("You're not in combat.")
));
}
conn.combat = None;
if let Some(ref mut combat) = conn.combat {
combat.action = Some(CombatAction::Flee);
}
CommandResult {
output: format!(
"{}\r\n",
ansi::system_msg("You disengage and flee from combat!")
ansi::system_msg("You prepare to flee... (resolves next tick)")
),
broadcasts: Vec::new(),
kick_targets: Vec::new(),
@@ -1011,6 +1120,35 @@ async fn cmd_stats(pid: usize, state: &SharedState) -> CommandResult {
s.xp,
s.xp_to_next
));
// Show combat status
if let Some(ref combat) = conn.combat {
let npc_name = st
.world
.get_npc(&combat.npc_id)
.map(|n| n.name.clone())
.unwrap_or_else(|| "???".into());
out.push_str(&format!(
" {} {}\r\n",
ansi::color(ansi::RED, "Combat:"),
ansi::color(ansi::RED, &npc_name)
));
}
// Show active status effects
let effects = st.db.load_effects(&p.name);
if !effects.is_empty() {
out.push_str(&format!(" {}\r\n", ansi::color(ansi::DIM, "Effects:")));
for eff in &effects {
out.push_str(&format!(
" {} (mag: {}, {} ticks left)\r\n",
ansi::color(ansi::MAGENTA, &eff.kind),
eff.magnitude,
eff.remaining_ticks,
));
}
}
if p.is_admin {
out.push_str(&format!(
" {}\r\n",
@@ -1066,8 +1204,9 @@ async fn cmd_help(pid: usize, state: &SharedState) -> CommandResult {
("inventory, i", "View your inventory"),
("equip <item>", "Equip a weapon or armor"),
("use <item>", "Use a consumable item"),
("attack <target>, a", "Attack a hostile NPC"),
("flee", "Disengage from combat"),
("attack <target>, a", "Engage/attack a hostile NPC (tick-based)"),
("defend, def", "Defend next tick (reduces incoming damage)"),
("flee", "Attempt to flee combat (tick-based)"),
("stats, st", "View your character stats"),
("help, h, ?", "Show this help"),
("quit, exit", "Leave the game"),