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:
345
src/commands.rs
345
src/commands.rs
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user