Compare commits

..

4 Commits

Author SHA1 Message Date
3baa0091f9 Merge pull request 'Feature: Shops, Economy and Enhanced NPC Interactions' (#2) from feature/shops-and-interactions into main
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m2s
Reviewed-on: #2
2026-03-19 09:59:10 -06:00
AI Agent
87baaee46f Finalize shops, interactions and world data
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m53s
2026-03-19 08:12:28 -06:00
AI Agent
52b333fa48 Update world data with dialogue, currency, and shop inventories 2026-03-17 13:34:36 -06:00
AI Agent
0722a2f1d7 Implement currency, shops, and enhanced NPC interaction system 2026-03-17 13:31:33 -06:00
17 changed files with 420 additions and 36 deletions

View File

@@ -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 ## 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. - **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. - **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. - **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. - **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. - **PvP** — Player-vs-player combat; consent/flagging, safe zones, and balance TBD.

View File

@@ -76,17 +76,24 @@ pub fn resolve_combat_tick(
} }
npc_died = true; npc_died = true;
xp_gained = npc_combat.xp_reward; 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!( 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::GREEN, "**"),
ansi::color(ansi::RED, &npc_template.name), ansi::color(ansi::RED, &npc_template.name),
ansi::bold(&xp_gained.to_string()), ansi::bold(&xp_gained.to_string()),
gold_gained, silver_gained, copper_gained
)); ));
if let Some(conn) = state.players.get_mut(&player_id) { if let Some(conn) = state.players.get_mut(&player_id) {
conn.combat = None; conn.combat = None;
conn.player.stats.xp += xp_gained; conn.player.stats.xp += xp_gained;
conn.player.gold += gold_gained;
conn.player.silver += silver_gained;
conn.player.copper += copper_gained;
} }
} else { } else {
if let Some(inst) = state.npc_instances.get_mut(&npc_id) { if let Some(inst) = state.npc_instances.get_mut(&npc_id) {

View File

@@ -134,6 +134,7 @@ pub async fn execute(
"spells" | "skills" => cmd_spells(player_id, state).await, "spells" | "skills" => cmd_spells(player_id, state).await,
"guild" => cmd_guild(player_id, &args, state).await, "guild" => cmd_guild(player_id, &args, state).await,
"stats" | "st" => cmd_stats(player_id, 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, "admin" => cmd_admin(player_id, &args, state).await,
"help" | "h" | "?" => cmd_help(player_id, state).await, "help" | "h" | "?" => cmd_help(player_id, state).await,
"quit" | "exit" => CommandResult { "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 { async fn cmd_talk(pid: usize, input: &str, state: &SharedState) -> CommandResult {
if target.is_empty() { if input.is_empty() {
return simple("Talk to whom?\r\n"); return simple("Talk to whom? (Usage: talk <npc> [keyword])\r\n");
} }
let st = state.lock().await; let st = state.lock().await;
let conn = match st.players.get(&pid) { 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, Some(r) => r,
None => return simple("Void\r\n"), 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; let pname = &conn.player.name;
for nid in &room.npcs { for nid in &room.npcs {
if let Some(npc) = st.world.get_npc(nid) { 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) { if !st.npc_instances.get(nid).map(|i| i.alive).unwrap_or(true) {
return simple(&format!( return simple(&format!(
"{}\r\n", "{}\r\n",
@@ -1031,17 +1037,58 @@ async fn cmd_talk(pid: usize, target: &str, state: &SharedState) -> CommandResul
ansi::color(ansi::RED, &npc.name) ansi::color(ansi::RED, &npc.name)
)); ));
} }
let greeting = npc.greeting.as_deref().unwrap_or("...");
if !keyword.is_empty() {
if let Some(response) = npc.keywords.get(&keyword) {
return CommandResult { return CommandResult {
output: format!( output: format!(
"\r\n{} says: \"{}\"\r\n", "\r\n{} says: \"{}\"\r\n",
ansi::color(ansi::YELLOW, &npc.name), ansi::color(ansi::YELLOW, &npc.name),
ansi::color(ansi::WHITE, greeting) ansi::color(ansi::WHITE, response)
), ),
broadcasts: Vec::new(), broadcasts: Vec::new(),
kick_targets: Vec::new(), kick_targets: Vec::new(),
quit: false, 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,
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
};
} }
} }
} }
@@ -1427,6 +1474,178 @@ 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 rid = match st.players.get(&pid) {
Some(c) => c.player.room_id.clone(),
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 (shop, _npc_name) = {
let npc = st.world.get_npc(&merchant_id).unwrap();
(npc.shop.as_ref().unwrap().clone(), npc.name.clone())
};
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(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;
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")
}
} else {
simple("The merchant doesn't sell that.\r\n")
}
}
"sell" => {
if subargs.is_empty() {
return simple("Sell what?\r\n");
}
let shop = st.world.get_npc(&merchant_id).unwrap().shop.as_ref().unwrap().clone();
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, 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 {
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;
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);
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")
}
}
_ => simple("Usage: shop list | shop buy <item> | shop sell <item>\r\n"),
}
}
async fn cmd_guild(pid: usize, args: &str, state: &SharedState) -> CommandResult { async fn cmd_guild(pid: usize, args: &str, state: &SharedState) -> CommandResult {
let (subcmd, subargs) = match args.split_once(' ') { let (subcmd, subargs) = match args.split_once(' ') {
Some((c, a)) => (c.to_lowercase(), a.trim().to_string()), Some((c, a)) => (c.to_lowercase(), a.trim().to_string()),
@@ -1665,6 +1884,14 @@ async fn cmd_stats(pid: usize, state: &SharedState) -> CommandResult {
s.xp, s.xp,
s.xp_to_next 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() { if !p.guilds.is_empty() {
out.push_str(&format!(" {}\r\n", ansi::color(ansi::DIM, "Guilds:"))); out.push_str(&format!(" {}\r\n", ansi::color(ansi::DIM, "Guilds:")));
let mut guild_list: Vec<_> = p.guilds.iter().collect(); let mut guild_list: Vec<_> = p.guilds.iter().collect();

View File

@@ -20,6 +20,9 @@ pub struct SavedPlayer {
pub endurance: i32, pub endurance: i32,
pub max_endurance: i32, pub max_endurance: i32,
pub is_admin: bool, pub is_admin: bool,
pub gold: i32,
pub silver: i32,
pub copper: i32,
} }
pub struct NpcAttitudeRow { pub struct NpcAttitudeRow {
@@ -75,7 +78,7 @@ impl SqliteDb {
.map_err(|e| format!("Failed to set pragmas: {e}"))?; .map_err(|e| format!("Failed to set pragmas: {e}"))?;
conn.execute_batch( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS players ( r#"CREATE TABLE IF NOT EXISTS players (
name TEXT PRIMARY KEY, name TEXT PRIMARY KEY,
race_id TEXT NOT NULL, race_id TEXT NOT NULL,
class_id TEXT NOT NULL, class_id TEXT NOT NULL,
@@ -86,11 +89,19 @@ impl SqliteDb {
max_hp INTEGER NOT NULL, max_hp INTEGER NOT NULL,
attack INTEGER NOT NULL, attack INTEGER NOT NULL,
defense INTEGER NOT NULL, defense INTEGER NOT NULL,
inventory_json TEXT NOT NULL DEFAULT '[]', inventory_json TEXT NOT NULL DEFAULT "[]",
equipped_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 ( CREATE TABLE IF NOT EXISTS npc_attitudes (
player_name TEXT NOT NULL, player_name TEXT NOT NULL,
npc_id TEXT NOT NULL, npc_id TEXT NOT NULL,
@@ -116,13 +127,13 @@ impl SqliteDb {
guild_id TEXT NOT NULL, guild_id TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 1, level INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (player_name, guild_id) PRIMARY KEY (player_name, guild_id)
);", );"#,
) )
.map_err(|e| format!("Failed to create tables: {e}"))?; .map_err(|e| format!("Failed to create tables: {e}"))?;
// Migration: add is_admin column if missing // Migration: add is_admin column if missing
let has_admin: bool = conn 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))) .and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0)))
.map(|c| c > 0) .map(|c| c > 0)
.unwrap_or(false); .unwrap_or(false);
@@ -135,34 +146,34 @@ impl SqliteDb {
// Migration: equipped_weapon_json/equipped_armor_json -> equipped_json // Migration: equipped_weapon_json/equipped_armor_json -> equipped_json
let has_old_weapon: bool = conn 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))) .and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0)))
.map(|c| c > 0) .map(|c| c > 0)
.unwrap_or(false); .unwrap_or(false);
let has_equipped: bool = conn 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))) .and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0)))
.map(|c| c > 0) .map(|c| c > 0)
.unwrap_or(false); .unwrap_or(false);
if has_old_weapon && !has_equipped { if has_old_weapon && !has_equipped {
let _ = conn.execute( 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..."); log::info!("Migrating equipped_weapon_json/equipped_armor_json to equipped_json...");
let _ = conn.execute_batch( 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 { } else if !has_equipped {
let _ = conn.execute( 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 // Migration: add mana/endurance columns
let has_mana: bool = conn 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))) .and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0)))
.map(|c| c > 0) .map(|c| c > 0)
.unwrap_or(false); .unwrap_or(false);
@@ -173,6 +184,18 @@ impl SqliteDb {
let _ = conn.execute("ALTER TABLE players ADD COLUMN max_endurance INTEGER NOT NULL DEFAULT 0", []); 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(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);
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()); log::info!("Database opened: {}", path.display());
Ok(SqliteDb { Ok(SqliteDb {
conn: std::sync::Mutex::new(conn), conn: std::sync::Mutex::new(conn),
@@ -186,7 +209,7 @@ impl GameDb for SqliteDb {
conn.query_row( conn.query_row(
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp, "SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
attack, defense, inventory_json, equipped_json, is_admin, 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", FROM players WHERE name = ?1",
[name], [name],
|row| { |row| {
@@ -208,6 +231,9 @@ impl GameDb for SqliteDb {
max_mana: row.get::<_, i32>(14).unwrap_or(0), max_mana: row.get::<_, i32>(14).unwrap_or(0),
endurance: row.get::<_, i32>(15).unwrap_or(0), endurance: row.get::<_, i32>(15).unwrap_or(0),
max_endurance: row.get::<_, i32>(16).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( let _ = conn.execute(
"INSERT INTO players (name, race_id, class_id, room_id, level, xp, hp, max_hp, "INSERT INTO players (name, race_id, class_id, room_id, level, xp, hp, max_hp,
attack, defense, inventory_json, equipped_json, is_admin, attack, defense, inventory_json, equipped_json, is_admin,
mana, max_mana, endurance, max_endurance) 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) 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 ON CONFLICT(name) DO UPDATE SET
room_id=excluded.room_id, level=excluded.level, xp=excluded.xp, room_id=excluded.room_id, level=excluded.level, xp=excluded.xp,
hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack, hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack,
defense=excluded.defense, inventory_json=excluded.inventory_json, defense=excluded.defense, inventory_json=excluded.inventory_json,
equipped_json=excluded.equipped_json, is_admin=excluded.is_admin, equipped_json=excluded.equipped_json, is_admin=excluded.is_admin,
mana=excluded.mana, max_mana=excluded.max_mana, 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![ rusqlite::params![
p.name, p.race_id, p.class_id, p.room_id, p.level, p.xp, 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.hp, p.max_hp, p.attack, p.defense,
p.inventory_json, p.equipped_json, p.is_admin as i32, p.inventory_json, p.equipped_json, p.is_admin as i32,
p.mana, p.max_mana, p.endurance, p.max_endurance, 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( .prepare(
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp, "SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
attack, defense, inventory_json, equipped_json, is_admin, 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", FROM players ORDER BY name",
) )
.unwrap(); .unwrap();
@@ -285,6 +313,9 @@ impl GameDb for SqliteDb {
max_mana: row.get::<_, i32>(14).unwrap_or(0), max_mana: row.get::<_, i32>(14).unwrap_or(0),
endurance: row.get::<_, i32>(15).unwrap_or(0), endurance: row.get::<_, i32>(15).unwrap_or(0),
max_endurance: row.get::<_, i32>(16).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() .unwrap()

View File

@@ -35,6 +35,9 @@ pub struct Player {
pub guilds: HashMap<String, i32>, pub guilds: HashMap<String, i32>,
pub cooldowns: HashMap<String, i32>, pub cooldowns: HashMap<String, i32>,
pub is_admin: bool, pub is_admin: bool,
pub gold: i32,
pub silver: i32,
pub copper: i32,
} }
impl Player { impl Player {
@@ -344,6 +347,9 @@ impl GameState {
guilds, guilds,
cooldowns: HashMap::new(), cooldowns: HashMap::new(),
is_admin: false, is_admin: false,
gold: 0,
silver: 0,
copper: 10, // Start with some copper
}, },
channel, channel,
handle, handle,
@@ -401,6 +407,9 @@ impl GameState {
guilds, guilds,
cooldowns: HashMap::new(), cooldowns: HashMap::new(),
is_admin: saved.is_admin, is_admin: saved.is_admin,
gold: saved.gold,
silver: saved.silver,
copper: saved.copper,
}, },
channel, channel,
handle, handle,
@@ -435,6 +444,9 @@ impl GameState {
endurance: p.stats.endurance, endurance: p.stats.endurance,
max_endurance: p.stats.max_endurance, max_endurance: p.stats.max_endurance,
is_admin: p.is_admin, is_admin: p.is_admin,
gold: p.gold,
silver: p.silver,
copper: p.copper,
}); });
} }
} }

View File

@@ -51,7 +51,7 @@ pub async fn run_tick_engine(state: SharedState) {
continue; continue;
} }
let att = st.npc_attitude_toward(npc_id, &conn.player.name); 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())); new_combats.push((*pid, npc_id.clone()));
break; break;
} }

View File

@@ -79,10 +79,22 @@ pub struct RoomFile {
pub exits: HashMap<String, String>, pub exits: HashMap<String, String>,
} }
#[derive(Deserialize)] #[derive(Deserialize, Clone)]
pub struct ShopFile {
pub buys: Vec<String>, // List of item kinds or IDs the shop buys
pub sells: Vec<String>, // 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 { pub struct NpcDialogue {
#[serde(default)] #[serde(default)]
pub greeting: Option<String>, pub greeting: Option<String>,
#[serde(default)]
pub keywords: HashMap<String, String>, // keyword -> response
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -113,6 +125,14 @@ pub struct NpcFile {
pub dialogue: Option<NpcDialogue>, pub dialogue: Option<NpcDialogue>,
#[serde(default)] #[serde(default)]
pub combat: Option<NpcCombatFile>, pub combat: Option<NpcCombatFile>,
#[serde(default)]
pub shop: Option<ShopFile>,
#[serde(default)]
pub gold: i32,
#[serde(default)]
pub silver: i32,
#[serde(default)]
pub copper: i32,
} }
fn default_attitude() -> Attitude { fn default_attitude() -> Attitude {
@@ -143,6 +163,12 @@ pub struct ObjectFile {
pub takeable: bool, pub takeable: bool,
#[serde(default)] #[serde(default)]
pub stats: Option<ObjectStatsFile>, pub stats: Option<ObjectStatsFile>,
#[serde(default)]
pub value_gold: i32,
#[serde(default)]
pub value_silver: i32,
#[serde(default)]
pub value_copper: i32,
} }
// --- Race TOML schema --- // --- Race TOML schema ---
@@ -409,7 +435,12 @@ pub struct Npc {
pub fixed_class: Option<String>, pub fixed_class: Option<String>,
pub respawn_secs: Option<u64>, pub respawn_secs: Option<u64>,
pub greeting: Option<String>, pub greeting: Option<String>,
pub keywords: HashMap<String, String>,
pub combat: Option<NpcCombatStats>, pub combat: Option<NpcCombatStats>,
pub shop: Option<ShopFile>,
pub gold: i32,
pub silver: i32,
pub copper: i32,
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
@@ -429,6 +460,9 @@ pub struct Object {
pub slot: Option<String>, pub slot: Option<String>,
pub takeable: bool, pub takeable: bool,
pub stats: ObjectStats, pub stats: ObjectStats,
pub value_gold: i32,
pub value_silver: i32,
pub value_copper: i32,
} }
pub const DEFAULT_HUMANOID_SLOTS: &[&str] = &[ pub const DEFAULT_HUMANOID_SLOTS: &[&str] = &[
@@ -637,14 +671,18 @@ impl World {
load_entities_from_dir(&region_path.join("npcs"), &region_name, &mut |id, content| { load_entities_from_dir(&region_path.join("npcs"), &region_name, &mut |id, content| {
let nf: NpcFile = toml::from_str(content).map_err(|e| format!("Bad npc {id}: {e}"))?; 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 }) 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 })); .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 { npcs.insert(id.clone(), Npc {
id: id.clone(), name: nf.name, description: nf.description, room: nf.room, id: id.clone(), name: nf.name, description: nf.description, room: nf.room,
base_attitude: nf.base_attitude, faction: nf.faction, base_attitude: nf.base_attitude, faction: nf.faction,
fixed_race: nf.race, fixed_class: nf.class, 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(()) Ok(())
})?; })?;
@@ -656,6 +694,7 @@ impl World {
id: id.clone(), name: of.name, description: of.description, room: of.room, id: id.clone(), name: of.name, description: of.description, room: of.room,
kind: of.kind, slot: of.slot, takeable: of.takeable, kind: of.kind, slot: of.slot, takeable: of.takeable,
stats: ObjectStats { damage: stats.damage, armor: stats.armor, heal_amount: stats.heal_amount }, 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(()) Ok(())
})?; })?;

View File

@@ -2,4 +2,14 @@ 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." 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" room = "lawold:well_market_trade_stalls"
race = "race:human" 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?"
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

View File

@@ -4,3 +4,9 @@ room = "lawold:senate_hall"
race = "race:human" race = "race:human"
class = "class:mage" class = "class:mage"
base_attitude = "neutral" 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." }

View File

@@ -4,3 +4,8 @@ room = "lawold:central_bridge"
race = "race:human" race = "race:human"
class = "class:warrior" class = "class:warrior"
base_attitude = "neutral" 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." }

View File

@@ -3,3 +3,8 @@ description = "Saege has auburn hair and blue eyes. He wears modest garments and
room = "lawold:well_market_square" room = "lawold:well_market_square"
race = "race:human" race = "race:human"
base_attitude = "friendly" 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." }

View File

@@ -4,3 +4,9 @@ room = "lawold:palace_village_palace_gate"
race = "race:human" race = "race:human"
class = "class:warden" class = "class:warden"
base_attitude = "friendly" 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." }

View File

@@ -4,3 +4,8 @@ room = "lawold:artists_district_lane"
race = "race:dwarf" race = "race:dwarf"
class = "class:warrior" class = "class:warrior"
base_attitude = "friendly" 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." }

View File

@@ -3,3 +3,14 @@ description = "Wisym is fair in appearance, with silver hair and sharp green eye
room = "lawold:saints_market_plaza" room = "lawold:saints_market_plaza"
race = "race:human" race = "race:human"
base_attitude = "neutral" 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

View File

@@ -4,3 +4,8 @@ room = "lawold:senate_plaza"
race = "race:human" race = "race:human"
class = "class:rogue" class = "class:rogue"
base_attitude = "friendly" 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." }

View File

@@ -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

View File

@@ -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