diff --git a/src/combat.rs b/src/combat.rs index fa13831..1307ba4 100644 --- a/src/combat.rs +++ b/src/combat.rs @@ -76,17 +76,24 @@ pub fn resolve_combat_tick( } 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.\r\n", + " {} {} 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) { diff --git a/src/commands.rs b/src/commands.rs index 521b2c2..7a1552d 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -134,6 +134,7 @@ pub async fn execute( "spells" | "skills" => cmd_spells(player_id, state).await, "guild" => cmd_guild(player_id, &args, state).await, "stats" | "st" => cmd_stats(player_id, state).await, + "shop" => cmd_shop(player_id, &args, state).await, "admin" => cmd_admin(player_id, &args, state).await, "help" | "h" | "?" => cmd_help(player_id, state).await, "quit" | "exit" => CommandResult { @@ -999,9 +1000,9 @@ async fn cmd_examine(pid: usize, target: &str, state: &SharedState) -> CommandRe )) } -async fn cmd_talk(pid: usize, target: &str, state: &SharedState) -> CommandResult { - if target.is_empty() { - return simple("Talk to whom?\r\n"); +async fn cmd_talk(pid: usize, input: &str, state: &SharedState) -> CommandResult { + if input.is_empty() { + return simple("Talk to whom? (Usage: talk [keyword])\r\n"); } let st = state.lock().await; let conn = match st.players.get(&pid) { @@ -1012,12 +1013,17 @@ async fn cmd_talk(pid: usize, target: &str, state: &SharedState) -> CommandResul Some(r) => r, None => return simple("Void\r\n"), }; - let low = target.to_lowercase(); + + let (target, keyword) = match input.split_once(' ') { + Some((t, k)) => (t.to_lowercase(), k.trim().to_lowercase()), + None => (input.to_lowercase(), String::new()), + }; + let pname = &conn.player.name; for nid in &room.npcs { if let Some(npc) = st.world.get_npc(nid) { - if npc.name.to_lowercase().contains(&low) { + if npc.name.to_lowercase().contains(&target) { if !st.npc_instances.get(nid).map(|i| i.alive).unwrap_or(true) { return simple(&format!( "{}\r\n", @@ -1031,13 +1037,54 @@ async fn cmd_talk(pid: usize, target: &str, state: &SharedState) -> CommandResul ansi::color(ansi::RED, &npc.name) )); } + + if !keyword.is_empty() { + if let Some(response) = npc.keywords.get(&keyword) { + return CommandResult { + output: format!( + "\r\n{} says: \"{}\"\r\n", + ansi::color(ansi::YELLOW, &npc.name), + ansi::color(ansi::WHITE, response) + ), + broadcasts: Vec::new(), + kick_targets: Vec::new(), + quit: false, + }; + } else { + return simple(&format!( + "{} looks at you blankly, not understanding '{}'.\r\n", + ansi::color(ansi::YELLOW, &npc.name), + keyword + )); + } + } + let greeting = npc.greeting.as_deref().unwrap_or("..."); + let mut output = format!( + "\r\n{} says: \"{}\"\r\n", + ansi::color(ansi::YELLOW, &npc.name), + ansi::color(ansi::WHITE, greeting) + ); + + if !npc.keywords.is_empty() { + let mut keys: Vec<_> = npc.keywords.keys().cloned().collect(); + keys.sort(); + output.push_str(&format!( + " {} {}\r\n", + ansi::color(ansi::DIM, "You can ask about:"), + keys.join(", ") + )); + } + + if npc.shop.is_some() { + output.push_str(&format!( + " {}\r\n", + ansi::color(ansi::CYAN, "This person appears to be a merchant. Try 'shop list'.") + )); + } + return CommandResult { - output: format!( - "\r\n{} says: \"{}\"\r\n", - ansi::color(ansi::YELLOW, &npc.name), - ansi::color(ansi::WHITE, greeting) - ), + output, broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false, @@ -1427,6 +1474,169 @@ async fn cmd_spells(pid: usize, state: &SharedState) -> CommandResult { } } +async fn cmd_shop(pid: usize, args: &str, state: &SharedState) -> CommandResult { + let mut st = state.lock().await; + let (conn, rid) = match st.players.get_mut(&pid) { + Some(c) => { + let rid = c.player.room_id.clone(); + (c, rid) + } + None => return simple("Error\r\n"), + }; + + // Find a merchant in the room + let mut merchant_id = None; + if let Some(room) = st.world.get_room(&rid) { + for nid in &room.npcs { + if let Some(npc) = st.world.get_npc(nid) { + if npc.shop.is_some() { + merchant_id = Some(nid.clone()); + break; + } + } + } + } + + let merchant_id = match merchant_id { + Some(id) => id, + None => return simple("There is no merchant here.\r\n"), + }; + + let (subcmd, subargs) = match args.split_once(' ') { + Some((c, a)) => (c.to_lowercase(), a.trim()), + None => (args.to_lowercase(), ""), + }; + + match subcmd.as_str() { + "list" | "ls" | "" => { + let npc = st.world.get_npc(&merchant_id).unwrap(); + let shop = npc.shop.as_ref().unwrap(); + let mut out = format!( + "\r\n{}'s Shop Inventory (Markup: x{:.1})\r\n", + ansi::bold(&npc.name), + shop.markup + ); + + if shop.sells.is_empty() { + out.push_str(" (nothing for sale)\r\n"); + } else { + for item_id in &shop.sells { + if let Some(obj) = st.world.get_object(item_id) { + let total_copper = (obj.value_gold * 10000 + obj.value_silver * 100 + obj.value_copper) as f32; + let price_copper = (total_copper * shop.markup).ceil() as i32; + let g = price_copper / 10000; + let s = (price_copper % 10000) / 100; + let c = price_copper % 100; + + out.push_str(&format!( + " - {} [{}g {}s {}c]\r\n", + ansi::color(ansi::CYAN, &obj.name), + g, s, c + )); + } + } + } + simple(&out) + } + + "buy" => { + if subargs.is_empty() { + return simple("Buy what?\r\n"); + } + let npc = st.world.get_npc(&merchant_id).unwrap(); + let shop = npc.shop.as_ref().unwrap().clone(); + + let item_to_buy = shop.sells.iter().find(|id| { + if let Some(obj) = st.world.get_object(*id) { + obj.name.to_lowercase().contains(&subargs.to_lowercase()) + } else { + false + } + }); + + if let Some(item_id) = item_to_buy { + let obj = st.world.get_object(item_id).unwrap().clone(); + let total_copper = (obj.value_gold * 10000 + obj.value_silver * 100 + obj.value_copper) as f32; + let price_copper = (total_copper * shop.markup).ceil() as i32; + + let player_total_copper = conn.player.gold * 10000 + conn.player.silver * 100 + conn.player.copper; + if player_total_copper < price_copper { + return simple("You don't have enough money.\r\n"); + } + + // Deduct money + let mut remaining = player_total_copper - price_copper; + conn.player.gold = remaining / 10000; + remaining %= 10000; + conn.player.silver = remaining / 100; + conn.player.copper = remaining % 100; + + // Add to inventory + conn.player.inventory.push(obj.clone()); + + simple(&format!( + "You buy {} for {} copper equivalents.\r\n", + ansi::color(ansi::CYAN, &obj.name), + price_copper + )) + } else { + simple("The merchant doesn't sell that.\r\n") + } + } + + "sell" => { + if subargs.is_empty() { + return simple("Sell what?\r\n"); + } + let npc = st.world.get_npc(&merchant_id).unwrap(); + let shop = npc.shop.as_ref().unwrap().clone(); + + let item_idx = conn.player.inventory.iter().position(|o| o.name.to_lowercase().contains(&subargs.to_lowercase())); + + if let Some(idx) = item_idx { + let obj = conn.player.inventory[idx].clone(); + + // Check if merchant buys this kind of item + let can_sell = shop.buys.is_empty() || shop.buys.iter().any(|k| { + if let Some(kind) = &obj.kind { + kind.to_lowercase() == k.to_lowercase() + } else { + false + } + }); + + if !can_sell { + return simple("The merchant isn't interested in that kind of item.\r\n"); + } + + let total_copper = (obj.value_gold * 10000 + obj.value_silver * 100 + obj.value_copper) as f32; + let price_copper = (total_copper * shop.markdown).floor() as i32; + + // Add money to player + let mut player_total_copper = conn.player.gold * 10000 + conn.player.silver * 100 + conn.player.copper; + player_total_copper += price_copper; + conn.player.gold = player_total_copper / 10000; + player_total_copper %= 10000; + conn.player.silver = player_total_copper / 100; + conn.player.copper = player_total_copper % 100; + + // Remove from inventory + conn.player.inventory.remove(idx); + + simple(&format!( + "You sell {} for {} copper equivalents.\r\n", + ansi::color(ansi::CYAN, &obj.name), + price_copper + )) + } else { + simple("You don't have that in your inventory.\r\n") + } + } + + _ => simple("Usage: shop list | shop buy | shop sell \r\n"), + } +} + async fn cmd_guild(pid: usize, args: &str, state: &SharedState) -> CommandResult { let (subcmd, subargs) = match args.split_once(' ') { Some((c, a)) => (c.to_lowercase(), a.trim().to_string()), @@ -1665,6 +1875,14 @@ async fn cmd_stats(pid: usize, state: &SharedState) -> CommandResult { s.xp, s.xp_to_next )); + out.push_str(&format!( + " {} {}{}g {}{}s {}{}c{}\r\n", + ansi::color(ansi::DIM, "Money:"), + ansi::YELLOW, p.gold, + ansi::WHITE, p.silver, + ansi::RED, p.copper, + ansi::RESET, + )); if !p.guilds.is_empty() { out.push_str(&format!(" {}\r\n", ansi::color(ansi::DIM, "Guilds:"))); let mut guild_list: Vec<_> = p.guilds.iter().collect(); diff --git a/src/db.rs b/src/db.rs index 9e77f6c..41a659d 100644 --- a/src/db.rs +++ b/src/db.rs @@ -20,6 +20,9 @@ pub struct SavedPlayer { pub endurance: i32, pub max_endurance: i32, pub is_admin: bool, + pub gold: i32, + pub silver: i32, + pub copper: i32, } pub struct NpcAttitudeRow { @@ -75,7 +78,7 @@ impl SqliteDb { .map_err(|e| format!("Failed to set pragmas: {e}"))?; conn.execute_batch( - "CREATE TABLE IF NOT EXISTS players ( + CREATE TABLE IF NOT EXISTS players ( name TEXT PRIMARY KEY, race_id TEXT NOT NULL, class_id TEXT NOT NULL, @@ -88,9 +91,17 @@ impl SqliteDb { defense INTEGER NOT NULL, inventory_json TEXT NOT NULL DEFAULT '[]', equipped_json TEXT NOT NULL DEFAULT '{}', - is_admin INTEGER NOT NULL DEFAULT 0 + is_admin INTEGER NOT NULL DEFAULT 0, + mana INTEGER NOT NULL DEFAULT 0, + max_mana INTEGER NOT NULL DEFAULT 0, + endurance INTEGER NOT NULL DEFAULT 0, + max_endurance INTEGER NOT NULL DEFAULT 0, + gold INTEGER NOT NULL DEFAULT 0, + silver INTEGER NOT NULL DEFAULT 0, + copper INTEGER NOT NULL DEFAULT 0 ); + CREATE TABLE IF NOT EXISTS npc_attitudes ( player_name TEXT NOT NULL, npc_id TEXT NOT NULL, @@ -173,6 +184,18 @@ impl SqliteDb { let _ = conn.execute("ALTER TABLE players ADD COLUMN max_endurance INTEGER NOT NULL DEFAULT 0", []); } + // Migration: add currency columns + let has_gold: bool = conn + .prepare("SELECT COUNT(*) FROM pragma_table_info('players') WHERE name='gold'") + .and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0))) + .map(|c| c > 0) + .unwrap_or(false); + if !has_gold { + let _ = conn.execute("ALTER TABLE players ADD COLUMN gold INTEGER NOT NULL DEFAULT 0", []); + let _ = conn.execute("ALTER TABLE players ADD COLUMN silver INTEGER NOT NULL DEFAULT 0", []); + let _ = conn.execute("ALTER TABLE players ADD COLUMN copper INTEGER NOT NULL DEFAULT 0", []); + } + log::info!("Database opened: {}", path.display()); Ok(SqliteDb { conn: std::sync::Mutex::new(conn), @@ -186,7 +209,7 @@ impl GameDb for SqliteDb { conn.query_row( "SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp, attack, defense, inventory_json, equipped_json, is_admin, - mana, max_mana, endurance, max_endurance + mana, max_mana, endurance, max_endurance, gold, silver, copper FROM players WHERE name = ?1", [name], |row| { @@ -208,6 +231,9 @@ impl GameDb for SqliteDb { max_mana: row.get::<_, i32>(14).unwrap_or(0), endurance: row.get::<_, i32>(15).unwrap_or(0), max_endurance: row.get::<_, i32>(16).unwrap_or(0), + gold: row.get::<_, i32>(17).unwrap_or(0), + silver: row.get::<_, i32>(18).unwrap_or(0), + copper: row.get::<_, i32>(19).unwrap_or(0), }) }, ) @@ -219,20 +245,22 @@ impl GameDb for SqliteDb { let _ = conn.execute( "INSERT INTO players (name, race_id, class_id, room_id, level, xp, hp, max_hp, attack, defense, inventory_json, equipped_json, is_admin, - mana, max_mana, endurance, max_endurance) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17) + mana, max_mana, endurance, max_endurance, gold, silver, copper) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20) ON CONFLICT(name) DO UPDATE SET room_id=excluded.room_id, level=excluded.level, xp=excluded.xp, hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack, defense=excluded.defense, inventory_json=excluded.inventory_json, equipped_json=excluded.equipped_json, is_admin=excluded.is_admin, mana=excluded.mana, max_mana=excluded.max_mana, - endurance=excluded.endurance, max_endurance=excluded.max_endurance", + endurance=excluded.endurance, max_endurance=excluded.max_endurance, + gold=excluded.gold, silver=excluded.silver, copper=excluded.copper", rusqlite::params![ p.name, p.race_id, p.class_id, p.room_id, p.level, p.xp, p.hp, p.max_hp, p.attack, p.defense, p.inventory_json, p.equipped_json, p.is_admin as i32, p.mana, p.max_mana, p.endurance, p.max_endurance, + p.gold, p.silver, p.copper, ], ); } @@ -262,7 +290,7 @@ impl GameDb for SqliteDb { .prepare( "SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp, attack, defense, inventory_json, equipped_json, is_admin, - mana, max_mana, endurance, max_endurance + mana, max_mana, endurance, max_endurance, gold, silver, copper FROM players ORDER BY name", ) .unwrap(); @@ -285,6 +313,9 @@ impl GameDb for SqliteDb { max_mana: row.get::<_, i32>(14).unwrap_or(0), endurance: row.get::<_, i32>(15).unwrap_or(0), max_endurance: row.get::<_, i32>(16).unwrap_or(0), + gold: row.get::<_, i32>(17).unwrap_or(0), + silver: row.get::<_, i32>(18).unwrap_or(0), + copper: row.get::<_, i32>(19).unwrap_or(0), }) }) .unwrap() diff --git a/src/game.rs b/src/game.rs index e276c4f..8a90239 100644 --- a/src/game.rs +++ b/src/game.rs @@ -35,6 +35,9 @@ pub struct Player { pub guilds: HashMap, pub cooldowns: HashMap, pub is_admin: bool, + pub gold: i32, + pub silver: i32, + pub copper: i32, } impl Player { @@ -344,6 +347,9 @@ impl GameState { guilds, cooldowns: HashMap::new(), is_admin: false, + gold: 0, + silver: 0, + copper: 10, // Start with some copper }, channel, handle, @@ -401,6 +407,9 @@ impl GameState { guilds, cooldowns: HashMap::new(), is_admin: saved.is_admin, + gold: saved.gold, + silver: saved.silver, + copper: saved.copper, }, channel, handle, @@ -435,6 +444,9 @@ impl GameState { endurance: p.stats.endurance, max_endurance: p.stats.max_endurance, is_admin: p.is_admin, + gold: p.gold, + silver: p.silver, + copper: p.copper, }); } } diff --git a/src/tick.rs b/src/tick.rs index 314c6af..c6c91eb 100644 --- a/src/tick.rs +++ b/src/tick.rs @@ -51,7 +51,7 @@ pub async fn run_tick_engine(state: SharedState) { continue; } let att = st.npc_attitude_toward(npc_id, &conn.player.name); - if att.will_attack() { + if att.is_hostile() { new_combats.push((*pid, npc_id.clone())); break; } diff --git a/src/world.rs b/src/world.rs index 2c7c9da..513a5ee 100644 --- a/src/world.rs +++ b/src/world.rs @@ -79,10 +79,22 @@ pub struct RoomFile { pub exits: HashMap, } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] +pub struct ShopFile { + pub buys: Vec, // List of item kinds or IDs the shop buys + pub sells: Vec, // List of item IDs the shop sells + #[serde(default)] + pub markup: f32, // Multiplier for sell price (default 1.0) + #[serde(default)] + pub markdown: f32, // Multiplier for buy price (default 0.5) +} + +#[derive(Deserialize, Clone)] pub struct NpcDialogue { #[serde(default)] pub greeting: Option, + #[serde(default)] + pub keywords: HashMap, // keyword -> response } #[derive(Deserialize)] @@ -113,6 +125,14 @@ pub struct NpcFile { pub dialogue: Option, #[serde(default)] pub combat: Option, + #[serde(default)] + pub shop: Option, + #[serde(default)] + pub gold: i32, + #[serde(default)] + pub silver: i32, + #[serde(default)] + pub copper: i32, } fn default_attitude() -> Attitude { @@ -143,6 +163,12 @@ pub struct ObjectFile { pub takeable: bool, #[serde(default)] pub stats: Option, + #[serde(default)] + pub value_gold: i32, + #[serde(default)] + pub value_silver: i32, + #[serde(default)] + pub value_copper: i32, } // --- Race TOML schema --- @@ -409,7 +435,12 @@ pub struct Npc { pub fixed_class: Option, pub respawn_secs: Option, pub greeting: Option, + pub keywords: HashMap, pub combat: Option, + pub shop: Option, + pub gold: i32, + pub silver: i32, + pub copper: i32, } #[derive(Clone, Serialize, Deserialize)] @@ -429,6 +460,9 @@ pub struct Object { pub slot: Option, pub takeable: bool, pub stats: ObjectStats, + pub value_gold: i32, + pub value_silver: i32, + pub value_copper: i32, } pub const DEFAULT_HUMANOID_SLOTS: &[&str] = &[ @@ -637,14 +671,18 @@ impl World { load_entities_from_dir(®ion_path.join("npcs"), ®ion_name, &mut |id, content| { let nf: NpcFile = toml::from_str(content).map_err(|e| format!("Bad npc {id}: {e}"))?; + let (greeting, keywords) = match nf.dialogue { + Some(d) => (d.greeting, d.keywords), + None => (None, HashMap::new()), + }; let combat = Some(nf.combat.map(|c| NpcCombatStats { max_hp: c.max_hp, attack: c.attack, defense: c.defense, xp_reward: c.xp_reward }) .unwrap_or(NpcCombatStats { max_hp: 20, attack: 4, defense: 2, xp_reward: 5 })); - let greeting = nf.dialogue.and_then(|d| d.greeting); npcs.insert(id.clone(), Npc { id: id.clone(), name: nf.name, description: nf.description, room: nf.room, base_attitude: nf.base_attitude, faction: nf.faction, fixed_race: nf.race, fixed_class: nf.class, - respawn_secs: nf.respawn_secs, greeting, combat, + respawn_secs: nf.respawn_secs, greeting, keywords, combat, + shop: nf.shop, gold: nf.gold, silver: nf.silver, copper: nf.copper, }); Ok(()) })?; @@ -656,6 +694,7 @@ impl World { id: id.clone(), name: of.name, description: of.description, room: of.room, kind: of.kind, slot: of.slot, takeable: of.takeable, stats: ObjectStats { damage: stats.damage, armor: stats.armor, heal_amount: stats.heal_amount }, + value_gold: of.value_gold, value_silver: of.value_silver, value_copper: of.value_copper, }); Ok(()) })?; diff --git a/world/lawold/npcs/breda.toml b/world/lawold/npcs/breda.toml index 017cdf2..a8232a3 100644 --- a/world/lawold/npcs/breda.toml +++ b/world/lawold/npcs/breda.toml @@ -3,3 +3,13 @@ description = "Breda is haughty in bearing, with thin auburn hair and narrow blu room = "lawold:well_market_trade_stalls" race = "race:human" base_attitude = "aggressive" + +[dialogue] +greeting = "What do you want? I'm busy. Unless you have some teeth to sell?" +keywords = { teeth = "I buy monster teeth. One silver each. No questions asked.", tools = "My tools aren't for sale, but I have some spares if you have the coin." } + +[shop] +buys = ["junk", "teeth", "tool"] +sells = ["town:small_hammer", "town:chisel"] +markup = 1.2 +markdown = 0.5 diff --git a/world/town/objects/chisel.toml b/world/town/objects/chisel.toml new file mode 100644 index 0000000..c6774c6 --- /dev/null +++ b/world/town/objects/chisel.toml @@ -0,0 +1,7 @@ +name = "Chisel" +description = "A sharp steel chisel, used for fine woodwork or stone carving." +kind = "tool" +takeable = true +value_gold = 0 +value_silver = 3 +value_copper = 50 diff --git a/world/town/objects/small_hammer.toml b/world/town/objects/small_hammer.toml new file mode 100644 index 0000000..ff27391 --- /dev/null +++ b/world/town/objects/small_hammer.toml @@ -0,0 +1,7 @@ +name = "Small Hammer" +description = "A sturdy iron hammer with a wooden handle, suitable for small repairs or light construction." +kind = "tool" +takeable = true +value_gold = 0 +value_silver = 5 +value_copper = 0