NPCs can now optionally specify race and class in their TOML. When omitted, race is randomly selected from non-hidden races and class is determined by the race's default_class or picked randomly from compatible non-hidden classes. Race/class are re-rolled on each respawn for NPCs without fixed values, so killing the barkeep may bring back a different race next time. New hidden content (excluded from character creation): - Beast race: for animals (rats, etc.) with feral stats and no equipment slots - Peasant class: weak default for humanoid NPCs - Creature class: default for beasts/animals Existing races gain default_class fields (humanoids → peasant, beast → creature, dragon → random compatible). Look and examine commands now display NPC race and class. Made-with: Cursor
522 lines
16 KiB
Rust
522 lines
16 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
use tokio::sync::Mutex;
|
|
|
|
use russh::server::Handle;
|
|
use russh::ChannelId;
|
|
|
|
use crate::db::{GameDb, SavedPlayer};
|
|
use crate::world::{Attitude, Class, Object, Race, World};
|
|
|
|
#[derive(Clone)]
|
|
pub struct PlayerStats {
|
|
pub max_hp: i32,
|
|
pub hp: i32,
|
|
pub attack: i32,
|
|
pub defense: i32,
|
|
pub level: i32,
|
|
pub xp: i32,
|
|
pub xp_to_next: i32,
|
|
pub max_mana: i32,
|
|
pub mana: i32,
|
|
pub max_endurance: i32,
|
|
pub endurance: i32,
|
|
}
|
|
|
|
pub struct Player {
|
|
pub name: String,
|
|
pub race_id: String,
|
|
pub class_id: String,
|
|
pub room_id: String,
|
|
pub stats: PlayerStats,
|
|
pub inventory: Vec<Object>,
|
|
pub equipped: HashMap<String, Object>,
|
|
pub guilds: HashMap<String, i32>,
|
|
pub cooldowns: HashMap<String, i32>,
|
|
pub is_admin: bool,
|
|
}
|
|
|
|
impl Player {
|
|
pub fn equipped_in_slot(&self, slot: &str) -> Option<&Object> {
|
|
self.equipped.get(slot)
|
|
}
|
|
|
|
pub fn total_equipped_damage(&self) -> i32 {
|
|
self.equipped.values()
|
|
.filter_map(|o| o.stats.damage)
|
|
.sum()
|
|
}
|
|
|
|
pub fn total_equipped_armor(&self) -> i32 {
|
|
self.equipped.values()
|
|
.filter_map(|o| o.stats.armor)
|
|
.sum()
|
|
}
|
|
|
|
pub fn effective_attack(&self, world: &World) -> i32 {
|
|
let race_natural = world.races.iter()
|
|
.find(|r| r.id == self.race_id)
|
|
.map(|r| r.natural_attacks.iter().map(|a| a.damage).max().unwrap_or(0))
|
|
.unwrap_or(0);
|
|
let weapon_bonus = self.total_equipped_damage();
|
|
let unarmed_or_weapon = weapon_bonus.max(race_natural);
|
|
self.stats.attack + unarmed_or_weapon
|
|
}
|
|
|
|
pub fn effective_defense(&self, world: &World) -> i32 {
|
|
let race_natural = world.races.iter()
|
|
.find(|r| r.id == self.race_id)
|
|
.map(|r| r.natural_armor)
|
|
.unwrap_or(0);
|
|
self.stats.defense + self.total_equipped_armor() + race_natural
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum CombatAction {
|
|
Attack,
|
|
Defend,
|
|
Flee,
|
|
UseItem(usize),
|
|
Cast(String),
|
|
}
|
|
|
|
pub struct CombatState {
|
|
pub npc_id: String,
|
|
pub action: Option<CombatAction>,
|
|
pub defending: bool,
|
|
}
|
|
|
|
pub struct NpcInstance {
|
|
pub hp: i32,
|
|
pub alive: bool,
|
|
pub death_time: Option<Instant>,
|
|
pub race_id: String,
|
|
pub class_id: String,
|
|
}
|
|
|
|
pub struct PlayerConnection {
|
|
pub player: Player,
|
|
pub channel: ChannelId,
|
|
pub handle: Handle,
|
|
pub combat: Option<CombatState>,
|
|
}
|
|
|
|
pub struct XorShift64 {
|
|
state: u64,
|
|
}
|
|
|
|
impl XorShift64 {
|
|
pub fn new(seed: u64) -> Self {
|
|
XorShift64 {
|
|
state: if seed == 0 { 0xdeadbeefcafe1234 } else { seed },
|
|
}
|
|
}
|
|
|
|
pub fn next(&mut self) -> u64 {
|
|
let mut x = self.state;
|
|
x ^= x << 13;
|
|
x ^= x >> 7;
|
|
x ^= x << 17;
|
|
self.state = x;
|
|
x
|
|
}
|
|
|
|
pub fn next_range(&mut self, min: i32, max: i32) -> i32 {
|
|
if min >= max {
|
|
return min;
|
|
}
|
|
let range = (max - min + 1) as u64;
|
|
(self.next() % range) as i32 + min
|
|
}
|
|
}
|
|
|
|
pub struct GameState {
|
|
pub world: World,
|
|
pub db: Arc<dyn GameDb>,
|
|
pub players: HashMap<usize, PlayerConnection>,
|
|
pub npc_instances: HashMap<String, NpcInstance>,
|
|
pub rng: XorShift64,
|
|
pub tick_count: u64,
|
|
}
|
|
|
|
pub type SharedState = Arc<Mutex<GameState>>;
|
|
|
|
pub fn resolve_npc_race_class(
|
|
fixed_race: &Option<String>,
|
|
fixed_class: &Option<String>,
|
|
world: &World,
|
|
rng: &mut XorShift64,
|
|
) -> (String, String) {
|
|
let race_id = match fixed_race {
|
|
Some(rid) if world.races.iter().any(|r| r.id == *rid) => rid.clone(),
|
|
_ => {
|
|
// Pick a random non-hidden race
|
|
let candidates: Vec<&Race> = world.races.iter().filter(|r| !r.hidden).collect();
|
|
if candidates.is_empty() {
|
|
world.races.first().map(|r| r.id.clone()).unwrap_or_default()
|
|
} else {
|
|
let idx = rng.next_range(0, candidates.len() as i32) as usize;
|
|
candidates[idx].id.clone()
|
|
}
|
|
}
|
|
};
|
|
|
|
let class_id = match fixed_class {
|
|
Some(cid) if world.classes.iter().any(|c| c.id == *cid) => cid.clone(),
|
|
_ => {
|
|
let race = world.races.iter().find(|r| r.id == race_id);
|
|
// Try race default_class first
|
|
if let Some(ref dc) = race.and_then(|r| r.default_class.clone()) {
|
|
if world.classes.iter().any(|c| c.id == *dc) {
|
|
return (race_id, dc.clone());
|
|
}
|
|
}
|
|
// No default → pick random non-hidden class compatible with race
|
|
let restricted = race
|
|
.map(|r| &r.guild_compatibility.restricted)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
let candidates: Vec<&Class> = world.classes.iter()
|
|
.filter(|c| !c.hidden)
|
|
.filter(|c| {
|
|
c.guild.as_ref().map(|gid| !restricted.contains(gid)).unwrap_or(true)
|
|
})
|
|
.collect();
|
|
if candidates.is_empty() {
|
|
world.classes.first().map(|c| c.id.clone()).unwrap_or_default()
|
|
} else {
|
|
let idx = rng.next_range(0, candidates.len() as i32) as usize;
|
|
candidates[idx].id.clone()
|
|
}
|
|
}
|
|
};
|
|
|
|
(race_id, class_id)
|
|
}
|
|
|
|
impl GameState {
|
|
pub fn new(world: World, db: Arc<dyn GameDb>) -> Self {
|
|
let seed = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_nanos() as u64;
|
|
let mut rng = XorShift64::new(seed);
|
|
let mut npc_instances = HashMap::new();
|
|
for npc in world.npcs.values() {
|
|
let (race_id, class_id) = resolve_npc_race_class(
|
|
&npc.fixed_race, &npc.fixed_class, &world, &mut rng,
|
|
);
|
|
let hp = npc.combat.as_ref().map(|c| c.max_hp).unwrap_or(20);
|
|
npc_instances.insert(
|
|
npc.id.clone(),
|
|
NpcInstance { hp, alive: true, death_time: None, race_id, class_id },
|
|
);
|
|
}
|
|
GameState {
|
|
world,
|
|
db,
|
|
players: HashMap::new(),
|
|
npc_instances,
|
|
rng,
|
|
tick_count: 0,
|
|
}
|
|
}
|
|
|
|
pub fn spawn_room(&self) -> &str {
|
|
&self.world.spawn_room
|
|
}
|
|
|
|
pub fn is_registration_open(&self) -> bool {
|
|
self.db
|
|
.get_setting("registration_open")
|
|
.map(|v| v != "false")
|
|
.unwrap_or(true)
|
|
}
|
|
|
|
pub fn npc_attitude_toward(&self, npc_id: &str, player_name: &str) -> Attitude {
|
|
if let Some(val) = self.db.get_attitude(player_name, npc_id) {
|
|
return Attitude::from_value(val);
|
|
}
|
|
self.world
|
|
.get_npc(npc_id)
|
|
.map(|n| n.base_attitude)
|
|
.unwrap_or(Attitude::Neutral)
|
|
}
|
|
|
|
pub fn npc_attitude_value(&self, npc_id: &str, player_name: &str) -> i32 {
|
|
if let Some(val) = self.db.get_attitude(player_name, npc_id) {
|
|
return val;
|
|
}
|
|
self.world
|
|
.get_npc(npc_id)
|
|
.map(|n| n.base_attitude.default_value())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
pub fn shift_attitude(&self, npc_id: &str, player_name: &str, delta: i32) {
|
|
let current = self.npc_attitude_value(npc_id, player_name);
|
|
let new_val = (current + delta).clamp(-100, 100);
|
|
self.db.save_attitude(player_name, npc_id, new_val);
|
|
}
|
|
|
|
pub fn shift_faction_attitude(&self, faction: &str, player_name: &str, delta: i32) {
|
|
for npc in self.world.npcs.values() {
|
|
if npc.faction.as_deref() == Some(faction) {
|
|
self.shift_attitude(&npc.id, player_name, delta);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn create_new_player(
|
|
&mut self,
|
|
id: usize,
|
|
name: String,
|
|
race_id: String,
|
|
class_id: String,
|
|
channel: ChannelId,
|
|
handle: Handle,
|
|
) {
|
|
let room_id = self.world.spawn_room.clone();
|
|
let race = self.world.races.iter().find(|r| r.id == race_id);
|
|
let class = self.world.classes.iter().find(|c| c.id == class_id);
|
|
|
|
let (base_hp, base_atk, base_def) = match class {
|
|
Some(c) => (c.base_stats.max_hp, c.base_stats.attack, c.base_stats.defense),
|
|
None => (100, 10, 10),
|
|
};
|
|
let (con_mod, str_mod, dex_mod) = match race {
|
|
Some(r) => (r.stats.constitution, r.stats.strength, r.stats.dexterity),
|
|
None => (0, 0, 0),
|
|
};
|
|
|
|
let max_hp = base_hp + con_mod * 5;
|
|
let attack = base_atk + str_mod + dex_mod / 2;
|
|
let defense = base_def + con_mod / 2;
|
|
|
|
// Compute starting mana/endurance from initial guild
|
|
let initial_guild_id = class.and_then(|c| c.guild.clone());
|
|
let (base_mana, base_endurance) = initial_guild_id.as_ref()
|
|
.and_then(|gid| self.world.guilds.get(gid))
|
|
.map(|g| (g.base_mana, g.base_endurance))
|
|
.unwrap_or((0, 0));
|
|
let int_mod = race.map(|r| r.stats.intelligence).unwrap_or(0);
|
|
let wis_mod = race.map(|r| r.stats.wisdom).unwrap_or(0);
|
|
let max_mana = base_mana + int_mod * 5 + wis_mod * 3;
|
|
let max_endurance = base_endurance + con_mod * 3 + str_mod * 2;
|
|
|
|
let mut guilds = HashMap::new();
|
|
if let Some(ref gid) = initial_guild_id {
|
|
if self.world.guilds.contains_key(gid) {
|
|
guilds.insert(gid.clone(), 1);
|
|
self.db.save_guild_membership(&name, gid, 1);
|
|
}
|
|
}
|
|
|
|
let stats = PlayerStats {
|
|
max_hp,
|
|
hp: max_hp,
|
|
attack,
|
|
defense,
|
|
level: 1,
|
|
xp: 0,
|
|
xp_to_next: 100,
|
|
max_mana,
|
|
mana: max_mana,
|
|
max_endurance,
|
|
endurance: max_endurance,
|
|
};
|
|
|
|
self.players.insert(
|
|
id,
|
|
PlayerConnection {
|
|
player: Player {
|
|
name,
|
|
race_id,
|
|
class_id,
|
|
room_id,
|
|
stats,
|
|
inventory: Vec::new(),
|
|
equipped: HashMap::new(),
|
|
guilds,
|
|
cooldowns: HashMap::new(),
|
|
is_admin: false,
|
|
},
|
|
channel,
|
|
handle,
|
|
combat: None,
|
|
},
|
|
);
|
|
}
|
|
|
|
pub fn load_existing_player(
|
|
&mut self,
|
|
id: usize,
|
|
saved: SavedPlayer,
|
|
channel: ChannelId,
|
|
handle: Handle,
|
|
) {
|
|
let inventory: Vec<Object> =
|
|
serde_json::from_str(&saved.inventory_json).unwrap_or_default();
|
|
let equipped: HashMap<String, Object> =
|
|
serde_json::from_str(&saved.equipped_json).unwrap_or_default();
|
|
|
|
let room_id = if self.world.rooms.contains_key(&saved.room_id) {
|
|
saved.room_id
|
|
} else {
|
|
self.world.spawn_room.clone()
|
|
};
|
|
|
|
let guilds_vec = self.db.load_guild_memberships(&saved.name);
|
|
let guilds: HashMap<String, i32> = guilds_vec.into_iter().collect();
|
|
|
|
let stats = PlayerStats {
|
|
max_hp: saved.max_hp,
|
|
hp: saved.hp,
|
|
attack: saved.attack,
|
|
defense: saved.defense,
|
|
level: saved.level,
|
|
xp: saved.xp,
|
|
xp_to_next: saved.level * 100,
|
|
max_mana: saved.max_mana,
|
|
mana: saved.mana,
|
|
max_endurance: saved.max_endurance,
|
|
endurance: saved.endurance,
|
|
};
|
|
|
|
self.players.insert(
|
|
id,
|
|
PlayerConnection {
|
|
player: Player {
|
|
name: saved.name,
|
|
race_id: saved.race_id,
|
|
class_id: saved.class_id,
|
|
room_id,
|
|
stats,
|
|
inventory,
|
|
equipped,
|
|
guilds,
|
|
cooldowns: HashMap::new(),
|
|
is_admin: saved.is_admin,
|
|
},
|
|
channel,
|
|
handle,
|
|
combat: None,
|
|
},
|
|
);
|
|
}
|
|
|
|
pub fn save_player_to_db(&self, player_id: usize) {
|
|
if let Some(conn) = self.players.get(&player_id) {
|
|
let p = &conn.player;
|
|
let inv_json =
|
|
serde_json::to_string(&p.inventory).unwrap_or_else(|_| "[]".into());
|
|
let equipped_json =
|
|
serde_json::to_string(&p.equipped).unwrap_or_else(|_| "{}".into());
|
|
|
|
self.db.save_player(&SavedPlayer {
|
|
name: p.name.clone(),
|
|
race_id: p.race_id.clone(),
|
|
class_id: p.class_id.clone(),
|
|
room_id: p.room_id.clone(),
|
|
level: p.stats.level,
|
|
xp: p.stats.xp,
|
|
hp: p.stats.hp,
|
|
max_hp: p.stats.max_hp,
|
|
attack: p.stats.attack,
|
|
defense: p.stats.defense,
|
|
inventory_json: inv_json,
|
|
equipped_json,
|
|
mana: p.stats.mana,
|
|
max_mana: p.stats.max_mana,
|
|
endurance: p.stats.endurance,
|
|
max_endurance: p.stats.max_endurance,
|
|
is_admin: p.is_admin,
|
|
});
|
|
}
|
|
}
|
|
|
|
pub fn remove_player(&mut self, id: usize) -> Option<PlayerConnection> {
|
|
self.save_player_to_db(id);
|
|
self.players.remove(&id)
|
|
}
|
|
|
|
pub fn players_in_room(&self, room_id: &str, exclude_id: usize) -> Vec<&PlayerConnection> {
|
|
self.players
|
|
.iter()
|
|
.filter(|(&id, conn)| conn.player.room_id == room_id && id != exclude_id)
|
|
.map(|(_, conn)| conn)
|
|
.collect()
|
|
}
|
|
|
|
pub fn check_respawns(&mut self) {
|
|
let now = Instant::now();
|
|
let npc_ids: Vec<String> = self.npc_instances.keys().cloned().collect();
|
|
for npc_id in npc_ids {
|
|
let instance = match self.npc_instances.get(&npc_id) {
|
|
Some(i) => i,
|
|
None => continue,
|
|
};
|
|
if instance.alive { continue; }
|
|
let npc = match self.world.npcs.get(&npc_id) {
|
|
Some(n) => n,
|
|
None => continue,
|
|
};
|
|
let respawn_secs = match npc.respawn_secs {
|
|
Some(s) => s,
|
|
None => continue,
|
|
};
|
|
let should_respawn = instance.death_time
|
|
.map(|dt| now.duration_since(dt).as_secs() >= respawn_secs)
|
|
.unwrap_or(false);
|
|
if should_respawn {
|
|
let hp = npc.combat.as_ref().map(|c| c.max_hp).unwrap_or(20);
|
|
let fixed_race = npc.fixed_race.clone();
|
|
let fixed_class = npc.fixed_class.clone();
|
|
let (race_id, class_id) = resolve_npc_race_class(
|
|
&fixed_race, &fixed_class, &self.world, &mut self.rng,
|
|
);
|
|
if let Some(inst) = self.npc_instances.get_mut(&npc_id) {
|
|
inst.hp = hp;
|
|
inst.alive = true;
|
|
inst.death_time = None;
|
|
inst.race_id = race_id;
|
|
inst.class_id = class_id;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn check_level_up(&mut self, player_id: usize) -> Option<String> {
|
|
let conn = self.players.get_mut(&player_id)?;
|
|
let player = &mut conn.player;
|
|
if player.stats.xp < player.stats.xp_to_next {
|
|
return None;
|
|
}
|
|
|
|
player.stats.xp -= player.stats.xp_to_next;
|
|
player.stats.level += 1;
|
|
player.stats.xp_to_next = player.stats.level * 100;
|
|
|
|
let class = self.world.classes.iter().find(|c| c.id == player.class_id);
|
|
let (hp_g, atk_g, def_g) = match class {
|
|
Some(c) => (
|
|
c.growth.hp_per_level,
|
|
c.growth.attack_per_level,
|
|
c.growth.defense_per_level,
|
|
),
|
|
None => (10, 2, 1),
|
|
};
|
|
player.stats.max_hp += hp_g;
|
|
player.stats.hp = player.stats.max_hp;
|
|
player.stats.attack += atk_g;
|
|
player.stats.defense += def_g;
|
|
|
|
Some(format!(
|
|
"You are now level {}! HP:{} ATK:{} DEF:{}",
|
|
player.stats.level, player.stats.max_hp, player.stats.attack, player.stats.defense
|
|
))
|
|
}
|
|
}
|