Implement weather and time of day system
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1m20s
Smoke tests / Build and smoke test (pull_request) Successful in 1m24s

This commit is contained in:
AI Agent
2026-03-19 16:58:06 -06:00
parent 678543dd9a
commit 1f4955db82
9 changed files with 130 additions and 4 deletions

View File

@@ -3,6 +3,8 @@
- **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.
- **Weather** — Weather system (e.g., rain, snow, fog) affecting areas or atmosphere.
- **Day/night or time of day** — Time cycle affecting room descriptions, spawns, or NPC behavior.
## Easy
@@ -18,8 +20,7 @@ Content-only or minimal code; add TOML/data and existing systems already support
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.
- **Robust Logging** — Structured logging, rotation, and persistence; better visibility into server state and player actions.
- **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.

View File

@@ -180,6 +180,14 @@ fn attitude_color(att: Attitude) -> &'static str {
}
}
pub fn get_time_of_day(tick: u64) -> &'static str {
let day_tick = tick % 1440;
if day_tick < 360 { "Night" }
else if day_tick < 720 { "Morning" }
else if day_tick < 1080 { "Afternoon" }
else { "Evening" }
}
pub fn render_room_view(
room_id: &str,
player_id: usize,
@@ -195,13 +203,19 @@ pub fn render_room_view(
.map(|c| c.player.name.as_str())
.unwrap_or("");
let time_of_day = get_time_of_day(st.tick_count);
let mut out = format!(
"\r\n{} {}\r\n {}\r\n",
"\r\n{} {} {}\r\n {}\r\n",
ansi::room_name(&room.name),
ansi::system_msg(&format!("[{}]", room.region)),
ansi::color(ansi::YELLOW, &format!("[{}]", time_of_day)),
room.description
);
if room.outdoors {
out.push_str(&format!(" {}\r\n", ansi::color(ansi::CYAN, st.weather.kind.description())));
}
let npc_strs: Vec<String> = room
.npcs
.iter()

View File

@@ -9,6 +9,35 @@ use russh::ChannelId;
use crate::db::{GameDb, SavedPlayer};
use crate::world::{Attitude, Class, Object, Race, World};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WeatherKind {
Clear,
Cloudy,
Rain,
Storm,
Snow,
Fog,
}
impl WeatherKind {
pub fn description(&self) -> &'static str {
match self {
WeatherKind::Clear => "The sky is clear.",
WeatherKind::Cloudy => "The sky is overcast with clouds.",
WeatherKind::Rain => "It is raining.",
WeatherKind::Storm => "A powerful storm is raging.",
WeatherKind::Snow => "Soft snowflakes are falling from the sky.",
WeatherKind::Fog => "A thick fog blankets the area.",
}
}
}
#[derive(Debug, Clone)]
pub struct WeatherState {
pub kind: WeatherKind,
pub remaining_ticks: u32,
}
#[derive(Clone)]
pub struct PlayerStats {
pub max_hp: i32,
@@ -142,6 +171,7 @@ pub struct GameState {
pub npc_instances: HashMap<String, NpcInstance>,
pub rng: XorShift64,
pub tick_count: u64,
pub weather: WeatherState,
}
pub type SharedState = Arc<Mutex<GameState>>;
@@ -226,6 +256,10 @@ impl GameState {
npc_instances,
rng,
tick_count: 0,
weather: WeatherState {
kind: WeatherKind::Clear,
remaining_ticks: 100,
},
}
}

View File

@@ -26,6 +26,48 @@ pub async fn run_tick_engine(state: SharedState) {
st.tick_count += 1;
let tick = st.tick_count;
let mut weather_msg = None;
st.weather.remaining_ticks = st.weather.remaining_ticks.saturating_sub(1);
if st.weather.remaining_ticks == 0 {
let old_kind = st.weather.kind;
st.weather.kind = match st.rng.next_range(0, 5) {
0 => crate::game::WeatherKind::Clear,
1 => crate::game::WeatherKind::Cloudy,
2 => crate::game::WeatherKind::Rain,
3 => crate::game::WeatherKind::Storm,
4 => crate::game::WeatherKind::Snow,
5 => crate::game::WeatherKind::Fog,
_ => crate::game::WeatherKind::Clear,
};
st.weather.remaining_ticks = st.rng.next_range(100, 400) as u32;
if old_kind != st.weather.kind {
weather_msg = Some(format!(
"\r\n {}\r\n",
ansi::color(ansi::CYAN, st.weather.kind.description())
));
}
}
// Apply "wet" effect if raining/storming
if st.weather.kind == crate::game::WeatherKind::Rain
|| st.weather.kind == crate::game::WeatherKind::Storm
{
let wet_players: Vec<String> = st
.players
.values()
.filter_map(|c| {
st.world
.get_room(&c.player.room_id)
.filter(|r| r.outdoors)
.map(|_| c.player.name.clone())
})
.collect();
for name in wet_players {
st.db.save_effect(&name, "wet", 10, 0);
}
}
st.check_respawns();
// --- NPC auto-aggro: hostile NPCs initiate combat with players in their room ---
@@ -60,6 +102,16 @@ pub async fn run_tick_engine(state: SharedState) {
let mut messages: HashMap<usize, String> = HashMap::new();
if let Some(msg) = weather_msg {
for (&pid, conn) in st.players.iter() {
if let Some(room) = st.world.get_room(&conn.player.room_id) {
if room.outdoors {
messages.entry(pid).or_default().push_str(&msg);
}
}
}
}
for (pid, npc_id) in &new_combats {
let npc_name = st
.world
@@ -183,6 +235,24 @@ pub async fn run_tick_engine(state: SharedState) {
}
}
}
"wet" => {
let online_pid = st
.players
.iter()
.find(|(_, c)| c.player.name == eff.player_name)
.map(|(&id, _)| id);
if let Some(pid) = online_pid {
if let Some(_conn) = st.players.get_mut(&pid) {
if eff.remaining_ticks <= 0 {
messages.entry(pid).or_default().push_str(&format!(
"\r\n {} You dry off.\r\n",
ansi::color(ansi::CYAN, "~~"),
));
}
}
}
}
"regen" => {
let heal = eff.magnitude;
let online_pid = st

View File

@@ -77,6 +77,8 @@ pub struct RoomFile {
pub description: String,
#[serde(default)]
pub exits: HashMap<String, String>,
#[serde(default)]
pub outdoors: bool,
}
#[derive(Deserialize, Clone)]
@@ -413,6 +415,7 @@ pub struct Room {
pub exits: HashMap<String, String>,
pub npcs: Vec<String>,
pub objects: Vec<String>,
pub outdoors: bool,
}
#[derive(Clone)]
@@ -665,7 +668,7 @@ impl World {
load_entities_from_dir(&region_path.join("rooms"), &region_name, &mut |id, content| {
let rf: RoomFile = toml::from_str(content).map_err(|e| format!("Bad room {id}: {e}"))?;
rooms.insert(id.clone(), Room { id: id.clone(), region: region_name.clone(), name: rf.name, description: rf.description, exits: rf.exits, npcs: Vec::new(), objects: Vec::new() });
rooms.insert(id.clone(), Room { id: id.clone(), region: region_name.clone(), name: rf.name, description: rf.description, exits: rf.exits, npcs: Vec::new(), objects: Vec::new(), outdoors: rf.outdoors });
Ok(())
})?;

View File

@@ -3,6 +3,7 @@ description = """\
Towering iron-reinforced wooden gates mark the southern edge of town. \
Guards in dented armor lean on their spears, watching the dusty road \
that stretches into the wilderness beyond."""
outdoors = true
[exits]
north = "town:dark_alley"

View File

@@ -3,6 +3,7 @@ description = """\
Colorful stalls line both sides of a narrow street. Merchants hawk their \
wares — bolts of cloth, exotic spices, gleaming trinkets. The air is thick \
with competing smells and the chatter of commerce."""
outdoors = true
[exits]
west = "town:town_square"

View File

@@ -3,6 +3,7 @@ description = """\
You stand in the heart of Thornwall. A worn stone fountain sits at the \
center, water trickling quietly. Cobblestone paths branch in every \
direction. The sounds of merchants and travelers fill the air."""
outdoors = true
[exits]
north = "town:tavern"

View File

@@ -4,6 +4,7 @@ The well-maintained cobblestone of Thornwall yields to a winding dirt \
path that disappears into the dense, dark eaves of the forest. The air \
is cooler here, smelling of damp earth and pine needles. The city \
gates loom to the north."""
outdoors = true
[exits]
north = "town:gate"