From 0722a2f1d75f517d2355f07b926aa8fe78dd1d56 Mon Sep 17 00:00:00 2001 From: AI Agent Date: Tue, 17 Mar 2026 13:31:33 -0600 Subject: [PATCH 1/3] Implement currency, shops, and enhanced NPC interaction system --- src/combat.rs | 9 +- src/commands.rs | 238 +++++++++++++++++++++++++-- src/db.rs | 45 ++++- src/game.rs | 12 ++ src/tick.rs | 2 +- src/world.rs | 45 ++++- world/lawold/npcs/breda.toml | 10 ++ world/town/objects/chisel.toml | 7 + world/town/objects/small_hammer.toml | 7 + 9 files changed, 353 insertions(+), 22 deletions(-) create mode 100644 world/town/objects/chisel.toml create mode 100644 world/town/objects/small_hammer.toml 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 -- 2.49.1 From 52b333fa48572e0d3aeab5dea8ab32be5ee800b8 Mon Sep 17 00:00:00 2001 From: AI Agent Date: Tue, 17 Mar 2026 13:34:36 -0600 Subject: [PATCH 2/3] Update world data with dialogue, currency, and shop inventories --- PLANNED.md | 7 ++++--- world/lawold/npcs/francis.toml | 6 ++++++ world/lawold/npcs/gauwis.toml | 5 +++++ world/lawold/npcs/saege.toml | 5 +++++ world/lawold/npcs/sunna.toml | 6 ++++++ world/lawold/npcs/thosve.toml | 5 +++++ world/lawold/npcs/wisym.toml | 11 +++++++++++ world/lawold/npcs/wyna.toml | 5 +++++ 8 files changed, 47 insertions(+), 3 deletions(-) diff --git a/PLANNED.md b/PLANNED.md index 270c513..acdaf06 100644 --- a/PLANNED.md +++ b/PLANNED.md @@ -1,6 +1,8 @@ -# Planned Features +## Completed -Tracking document for features and content planned for the MUD server. No implementation order implied unless noted. Grouped by difficulty (effort / scope). +- **Shops / economy** — NPCs that buy and sell; currency and pricing. +- **Enhanced NPC Interactions** — Keyword-based dialogue system. +- **Aggressive NPC AI** — NPCs with Aggressive attitude now correctly initiate combat. ## Easy @@ -18,7 +20,6 @@ New state, commands, or mechanics with bounded scope. - **Weather** — Weather system (e.g., rain, snow, fog) affecting areas or atmosphere; scope TBD. - **Day/night or time of day** — Time cycle affecting room descriptions, spawns, or NPC behavior; lighter than full weather. -- **Shops / economy** — NPCs that buy and sell; currency and pricing (new fields/tables, trade commands). - **Quests or objectives** — Simple “kill X” / “bring Y” goals; quest state in DB and hooks in combat/loot/NPCs. - **Player parties** — Group formation, shared objectives, party-only chat or visibility; new state and commands. - **PvP** — Player-vs-player combat; consent/flagging, safe zones, and balance TBD. diff --git a/world/lawold/npcs/francis.toml b/world/lawold/npcs/francis.toml index 5cfc338..e3d9a08 100644 --- a/world/lawold/npcs/francis.toml +++ b/world/lawold/npcs/francis.toml @@ -4,3 +4,9 @@ room = "lawold:senate_hall" race = "race:human" class = "class:mage" base_attitude = "neutral" +gold = 1 +silver = 5 + +[dialogue] +greeting = "Welcome to the senate hall. Just stay out of my way." +keywords = { sister = "She was my kin. My flesh and blood. And she left me for dead.", revenge = "One day, she will understand the depth of her betrayal." } diff --git a/world/lawold/npcs/gauwis.toml b/world/lawold/npcs/gauwis.toml index c243718..382f074 100644 --- a/world/lawold/npcs/gauwis.toml +++ b/world/lawold/npcs/gauwis.toml @@ -4,3 +4,8 @@ room = "lawold:central_bridge" race = "race:human" class = "class:warrior" base_attitude = "neutral" +gold = 1 + +[dialogue] +greeting = "Stay sharp. These lands are dangerous." +keywords = { ogres = "They are a blight. A plague on this world.", destroy = "Every one of them must be wiped from existence." } diff --git a/world/lawold/npcs/saege.toml b/world/lawold/npcs/saege.toml index 414c4af..1e9ed4a 100644 --- a/world/lawold/npcs/saege.toml +++ b/world/lawold/npcs/saege.toml @@ -3,3 +3,8 @@ description = "Saege has auburn hair and blue eyes. He wears modest garments and room = "lawold:well_market_square" race = "race:human" base_attitude = "friendly" +silver = 10 + +[dialogue] +greeting = "Greetings, traveler. May the iron amulet protect you." +keywords = { cult = "Cult? You must be misinformed. We are but humble followers.", god = "The dragon god of old is powerful beyond your reckoning." } diff --git a/world/lawold/npcs/sunna.toml b/world/lawold/npcs/sunna.toml index ed53c56..1f7d600 100644 --- a/world/lawold/npcs/sunna.toml +++ b/world/lawold/npcs/sunna.toml @@ -4,3 +4,9 @@ room = "lawold:palace_village_palace_gate" race = "race:human" class = "class:warden" base_attitude = "friendly" +silver = 2 +copper = 5 + +[dialogue] +greeting = "Greetings. I am Sunna, a warden of the palace gate." +keywords = { prove = "I must prove that I am worthy of this post.", peers = "Many of my peers think I am too soft for this work." } diff --git a/world/lawold/npcs/thosve.toml b/world/lawold/npcs/thosve.toml index a289831..a30bb05 100644 --- a/world/lawold/npcs/thosve.toml +++ b/world/lawold/npcs/thosve.toml @@ -4,3 +4,8 @@ room = "lawold:artists_district_lane" race = "race:dwarf" class = "class:warrior" base_attitude = "friendly" +silver = 3 + +[dialogue] +greeting = "Greetings. I am Thosve." +keywords = { amends = "We all carry burdens. Some heavier than others.", life = "It was a mistake. But it cost a life. A life I cannot give back." } diff --git a/world/lawold/npcs/wisym.toml b/world/lawold/npcs/wisym.toml index ad27d1d..e681614 100644 --- a/world/lawold/npcs/wisym.toml +++ b/world/lawold/npcs/wisym.toml @@ -3,3 +3,14 @@ description = "Wisym is fair in appearance, with silver hair and sharp green eye room = "lawold:saints_market_plaza" race = "race:human" base_attitude = "neutral" +silver = 20 + +[dialogue] +greeting = "Welcome to my shop. I have the freshest bread in Lawold." +keywords = { bread = "My bread is hearty and stays fresh for days. The guards love it." } + +[shop] +buys = ["food"] +sells = ["town:healing_potion"] +markup = 1.5 +markdown = 0.5 diff --git a/world/lawold/npcs/wyna.toml b/world/lawold/npcs/wyna.toml index 1ddf8f4..85cf4e5 100644 --- a/world/lawold/npcs/wyna.toml +++ b/world/lawold/npcs/wyna.toml @@ -4,3 +4,8 @@ room = "lawold:senate_plaza" race = "race:human" class = "class:rogue" base_attitude = "friendly" +silver = 5 + +[dialogue] +greeting = "It is good to see the sun again." +keywords = { imprisoned = "It felt like a long, dark dream.", century = "A hundred years have passed since I last saw this world." } -- 2.49.1 From 87baaee46f4de30bdece8d0450dba71d93344441 Mon Sep 17 00:00:00 2001 From: AI Agent Date: Thu, 19 Mar 2026 08:12:16 -0600 Subject: [PATCH 3/3] Finalize shops, interactions and world data --- src/commands.rs | 109 +++++++++++++++++++---------------- src/db.rs | 24 ++++---- world/lawold/npcs/breda.toml | 2 +- 3 files changed, 72 insertions(+), 63 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index 7a1552d..9fc2f91 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1476,11 +1476,8 @@ 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) - } + let rid = match st.players.get(&pid) { + Some(c) => c.player.room_id.clone(), None => return simple("Error\r\n"), }; @@ -1543,42 +1540,48 @@ async fn cmd_shop(pid: usize, args: &str, state: &SharedState) -> CommandResult 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 (shop, _npc_name) = { + let npc = st.world.get_npc(&merchant_id).unwrap(); + (npc.shop.as_ref().unwrap().clone(), npc.name.clone()) + }; - let item_to_buy = shop.sells.iter().find(|id| { + let item_id = shop.sells.iter().find(|id| { if let Some(obj) = st.world.get_object(*id) { obj.name.to_lowercase().contains(&subargs.to_lowercase()) } else { false } - }); + }).cloned(); - if let Some(item_id) = item_to_buy { - let obj = st.world.get_object(item_id).unwrap().clone(); + if let Some(id) = item_id { + let obj = st.world.get_object(&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"); + if let Some(conn) = st.players.get_mut(&pid) { + 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("Error\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") } @@ -1588,14 +1591,16 @@ async fn cmd_shop(pid: usize, args: &str, state: &SharedState) -> CommandResult 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 shop = st.world.get_npc(&merchant_id).unwrap().shop.as_ref().unwrap().clone(); - let item_idx = conn.player.inventory.iter().position(|o| o.name.to_lowercase().contains(&subargs.to_lowercase())); + let item_info = if let Some(conn) = st.players.get(&pid) { + conn.player.inventory.iter().position(|o| o.name.to_lowercase().contains(&subargs.to_lowercase())) + .map(|idx| (idx, conn.player.inventory[idx].clone())) + } else { + None + }; - if let Some(idx) = item_idx { - let obj = conn.player.inventory[idx].clone(); - + if let Some((idx, obj)) = item_info { // 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 { @@ -1612,22 +1617,26 @@ async fn cmd_shop(pid: usize, args: &str, state: &SharedState) -> CommandResult 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; + if let Some(conn) = st.players.get_mut(&pid) { + // 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); + // 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 - )) + simple(&format!( + "You sell {} for {} copper equivalents.\r\n", + ansi::color(ansi::CYAN, &obj.name), + price_copper + )) + } else { + simple("Error\r\n") + } } else { simple("You don't have that in your inventory.\r\n") } diff --git a/src/db.rs b/src/db.rs index 41a659d..532223f 100644 --- a/src/db.rs +++ b/src/db.rs @@ -78,7 +78,7 @@ impl SqliteDb { .map_err(|e| format!("Failed to set pragmas: {e}"))?; conn.execute_batch( - CREATE TABLE IF NOT EXISTS players ( + r#"CREATE TABLE IF NOT EXISTS players ( name TEXT PRIMARY KEY, race_id TEXT NOT NULL, class_id TEXT NOT NULL, @@ -89,8 +89,8 @@ impl SqliteDb { max_hp INTEGER NOT NULL, attack INTEGER NOT NULL, defense INTEGER NOT NULL, - inventory_json TEXT NOT NULL DEFAULT '[]', - equipped_json TEXT NOT NULL DEFAULT '{}', + inventory_json TEXT NOT NULL DEFAULT "[]", + equipped_json TEXT NOT NULL DEFAULT "{}", is_admin INTEGER NOT NULL DEFAULT 0, mana INTEGER NOT NULL DEFAULT 0, max_mana INTEGER NOT NULL DEFAULT 0, @@ -127,13 +127,13 @@ impl SqliteDb { guild_id TEXT NOT NULL, level INTEGER NOT NULL DEFAULT 1, PRIMARY KEY (player_name, guild_id) - );", + );"#, ) .map_err(|e| format!("Failed to create tables: {e}"))?; // Migration: add is_admin column if missing let has_admin: bool = conn - .prepare("SELECT COUNT(*) FROM pragma_table_info('players') WHERE name='is_admin'") + .prepare(r#"SELECT COUNT(*) FROM pragma_table_info("players") WHERE name="is_admin""#) .and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0))) .map(|c| c > 0) .unwrap_or(false); @@ -146,34 +146,34 @@ impl SqliteDb { // Migration: equipped_weapon_json/equipped_armor_json -> equipped_json let has_old_weapon: bool = conn - .prepare("SELECT COUNT(*) FROM pragma_table_info('players') WHERE name='equipped_weapon_json'") + .prepare(r#"SELECT COUNT(*) FROM pragma_table_info("players") WHERE name="equipped_weapon_json""#) .and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0))) .map(|c| c > 0) .unwrap_or(false); let has_equipped: bool = conn - .prepare("SELECT COUNT(*) FROM pragma_table_info('players') WHERE name='equipped_json'") + .prepare(r#"SELECT COUNT(*) FROM pragma_table_info("players") WHERE name="equipped_json""#) .and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0))) .map(|c| c > 0) .unwrap_or(false); if has_old_weapon && !has_equipped { let _ = conn.execute( - "ALTER TABLE players ADD COLUMN equipped_json TEXT NOT NULL DEFAULT '{}'", + r#"ALTER TABLE players ADD COLUMN equipped_json TEXT NOT NULL DEFAULT "{}"#, [], ); log::info!("Migrating equipped_weapon_json/equipped_armor_json to equipped_json..."); let _ = conn.execute_batch( - "UPDATE players SET equipped_json = '{}' WHERE equipped_weapon_json IS NULL AND equipped_armor_json IS NULL;" + r#"UPDATE players SET equipped_json = "{}" WHERE equipped_weapon_json IS NULL AND equipped_armor_json IS NULL;"# ); } else if !has_equipped { let _ = conn.execute( - "ALTER TABLE players ADD COLUMN equipped_json TEXT NOT NULL DEFAULT '{}'", + r#"ALTER TABLE players ADD COLUMN equipped_json TEXT NOT NULL DEFAULT "{}"#, [], ); } // Migration: add mana/endurance columns let has_mana: bool = conn - .prepare("SELECT COUNT(*) FROM pragma_table_info('players') WHERE name='mana'") + .prepare(r#"SELECT COUNT(*) FROM pragma_table_info("players") WHERE name="mana""#) .and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0))) .map(|c| c > 0) .unwrap_or(false); @@ -186,7 +186,7 @@ impl SqliteDb { // Migration: add currency columns let has_gold: bool = conn - .prepare("SELECT COUNT(*) FROM pragma_table_info('players') WHERE name='gold'") + .prepare(r#"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); diff --git a/world/lawold/npcs/breda.toml b/world/lawold/npcs/breda.toml index a8232a3..75a56e0 100644 --- a/world/lawold/npcs/breda.toml +++ b/world/lawold/npcs/breda.toml @@ -2,7 +2,7 @@ name = "Breda" description = "Breda is haughty in bearing, with thin auburn hair and narrow blue eyes. She wears simple clothing and several small tools hang from her belt. Breda will purchase monster teeth for a silver coin each." room = "lawold:well_market_trade_stalls" race = "race:human" -base_attitude = "aggressive" +base_attitude = "neutral" [dialogue] greeting = "What do you want? I'm busy. Unless you have some teeth to sell?" -- 2.49.1