diff --git a/YpDaemon/Cargo.toml b/YpDaemon/Cargo.toml new file mode 100644 index 0000000..ee7cc3a --- /dev/null +++ b/YpDaemon/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "YpDaemon" +version = "0.1.0" +edition = "2024" + +[dependencies] +rand = "0.8" +postgres = "0.19" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1.48", features = ["rt-multi-thread", "macros", "net", "sync", "time"] } +tokio-tungstenite = "0.23" +futures-util = "0.3" +ctrlc = "3" +tokio-rustls = "0.25" +rustls-pemfile = "2" +libsystemd = "0.7" +chrono = "0.4" \ No newline at end of file diff --git a/YpDaemon/src/worker/base.rs b/YpDaemon/src/worker/base.rs new file mode 100644 index 0000000..bea48bd --- /dev/null +++ b/YpDaemon/src/worker/base.rs @@ -0,0 +1,250 @@ +use crate::db::{ConnectionPool, DbError}; +use crate::worker::sql::{QUERY_UPDATE_MONEY, QUERY_GET_MONEY}; +use crate::message_broker::MessageBroker; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +pub trait Worker: Send { + fn start_worker_thread(&mut self); + fn stop_worker_thread(&mut self); + fn enable_watchdog(&mut self); +} + +pub(crate) struct WorkerState { + pub(crate) running_worker: AtomicBool, + pub(crate) running_watchdog: AtomicBool, + pub(crate) current_step: Mutex, +} + +// Default tax percent and treasury user id used if no external config is available. +// Percent, e.g. 10.0 => 10%. +pub const DEFAULT_TAX_PERCENT: f64 = 10.0; +pub const DEFAULT_TREASURY_USER_ID: i32 = 1; + +impl WorkerState { + pub(crate) fn new(name: &str) -> Self { + Self { + running_worker: AtomicBool::new(false), + running_watchdog: AtomicBool::new(false), + current_step: Mutex::new(format!("{name}: idle")), + } + } +} + +pub struct BaseWorker { + pub name: String, + pub pool: ConnectionPool, + pub broker: MessageBroker, + pub(crate) state: Arc, + worker_thread: Option>, + watchdog_thread: Option>, +} + +impl BaseWorker { + pub fn new(name: &str, pool: ConnectionPool, broker: MessageBroker) -> Self { + Self { + name: name.to_string(), + pool, + broker, + state: Arc::new(WorkerState::new(name)), + worker_thread: None, + watchdog_thread: None, + } + } + + pub fn set_current_step>(&self, step: S) { + if let Ok(mut guard) = self.state.current_step.lock() { + *guard = step.into(); + } + } + + pub(crate) fn start_worker_with_loop(&mut self, loop_fn: F) + where + F: Fn(Arc) + Send + 'static, + { + if self.state.running_worker.swap(true, Ordering::SeqCst) { + eprintln!("[{}] Worker thread already running, skipping start.", self.name); + return; + } + + let state = Arc::clone(&self.state); + + self.worker_thread = Some(thread::spawn(move || { + loop_fn(state); + })); + } + + pub(crate) fn stop_worker(&mut self) { + // Erst den Worker stoppen, dann auch den Watchdog beenden, damit keine + // Hintergrund-Threads weiterlaufen. + self.state.running_worker.store(false, Ordering::Relaxed); + self.stop_watchdog(); + if let Some(handle) = self.worker_thread.take() { + let _ = handle.join(); + } + } + + pub(crate) fn start_watchdog(&mut self) { + if self + .state + .running_watchdog + .swap(true, Ordering::SeqCst) + { + eprintln!("[{}] Watchdog already enabled, skipping.", self.name); + return; + } + + let state = Arc::clone(&self.state); + + self.watchdog_thread = Some(thread::spawn(move || { + while state.running_watchdog.load(Ordering::Relaxed) { + // Nicht in einem großen 10s-Sleep blockieren, damit der + // Shutdown (stop_watchdog) zügig reagieren kann. Stattdessen + // in 1s-Scheiben schlafen und dazwischen das Flag prüfen. + for _ in 0..10 { + if !state.running_watchdog.load(Ordering::Relaxed) { + break; + } + thread::sleep(Duration::from_secs(1)); + } + + let step = state.current_step.lock().unwrap().clone(); + + // "idle"-Meldungen sind im Dauerbetrieb eher Spam und helfen + // beim Debuggen selten. Deshalb nur loggen, wenn der Worker + // sich nicht im Idle-Zustand befindet. + if !step.ends_with(" idle") { + // keine Info-Logs im Watchdog + } + } + })); + } + + pub(crate) fn stop_watchdog(&mut self) { + self.state.running_watchdog.store(false, Ordering::Relaxed); + if let Some(handle) = self.watchdog_thread.take() { + let _ = handle.join(); + } + } + + // Bei Bedarf kann hier später wieder ein expliziter Statuszugriff ergänzt werden. +} + +impl BaseWorker { + /// Aktualisiert das Geld eines Falukant-Users über die DB-Funktion `falukant_data.update_money`. + /// `action` entspricht dem Log-/Aktions-Tag (z.B. "credit pay rate", "debitor_prism"). + pub fn change_falukant_user_money( + &self, + falukant_user_id: i32, + money_change: f64, + action: &str, + ) -> Result<(), DbError> { + let mut conn = self + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + // Verwende parametrisierte Queries für Sicherheit gegen SQL-Injection + conn.prepare("update_money", QUERY_UPDATE_MONEY)?; + + // Validate float to avoid passing NaN/inf which the postgres client + // may fail to serialize with an unclear error message. + if !money_change.is_finite() { + return Err(DbError::new(format!( + "Ungültiger money_change: {} (not finite)", + money_change + ))); + } + + // We must ensure the resulting money fits in numeric(10,2). + // numeric(10,2) max absolute value is < 10^8 (100_000_000) before rounding. + // Fetch current money for the user and clamp the delta if needed. + conn.prepare("get_money_for_clamp", QUERY_GET_MONEY)?; + let rows = conn.execute("get_money_for_clamp", &[&falukant_user_id])?; + + let current_money: f64 = rows + .first() + .and_then(|r| r.get("money")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0); + + // compute tentative result + let tentative = current_money + money_change; + + // numeric(10,2) allows values with absolute < 10^8 (100_000_000) + const MAX_ABS: f64 = 100_000_000.0 - 0.01; // leave room for scale + + let adjusted_money_change = if tentative >= MAX_ABS { + let clipped = MAX_ABS - current_money; + eprintln!( + "[BaseWorker] Clamping money_change: tentative {} exceeds numeric(10,2) max, clipping to {}", + tentative, clipped + ); + clipped + } else if tentative <= -MAX_ABS { + let clipped = -MAX_ABS - current_money; + eprintln!( + "[BaseWorker] Clamping money_change: tentative {} below min, clipping to {}", + tentative, clipped + ); + clipped + } else { + money_change + }; + + // Send exact types matching the DB function signature: + let uid_i32: i32 = falukant_user_id; + let money_str = format!("{:.2}", adjusted_money_change); + + // Note: we intentionally avoid parameterized call due to serialization + // issues in this environment and instead execute a literal SQL below. + fn escape_sql_literal(s: &str) -> String { + s.replace('\'', "''") + } + let escaped_action = escape_sql_literal(action); + let sql = format!( + "SELECT falukant_data.update_money({uid}, {money}::numeric, '{act}');", + uid = uid_i32, + money = money_str, + act = escaped_action + ); + + let _ = conn.query(&sql)?; + + // Best-effort: insert a money history entry so the UI/history views + // can show the change even if the DB-function doesn't write it. + // We don't want to fail the whole operation if this insert fails, + // so log errors and continue. + // Ensure money_history table exists (best-effort). If this fails, + // we still don't want to abort the money update. + let create_sql = r#" + CREATE TABLE IF NOT EXISTS falukant_log.money_history ( + id BIGSERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + change NUMERIC(10,2) NOT NULL, + action TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + "#; + let _ = conn.query(create_sql); + + let history_sql = format!( + "INSERT INTO falukant_log.money_history (user_id, change, action, created_at) VALUES ({uid}, {money}::numeric, '{act}', NOW());", + uid = uid_i32, + money = money_str, + act = escaped_action + ); + if let Err(err) = conn.query(&history_sql) { + eprintln!( + "[BaseWorker] Warning: inserting money_history failed for user {}: {}", + uid_i32, err + ); + } + + Ok(()) + } +} + + diff --git a/YpDaemon/src/worker/character_creation.rs b/YpDaemon/src/worker/character_creation.rs new file mode 100644 index 0000000..704338a --- /dev/null +++ b/YpDaemon/src/worker/character_creation.rs @@ -0,0 +1,493 @@ +use crate::db::{ConnectionPool, DbError, Rows}; +use crate::message_broker::MessageBroker; +use rand::distributions::{Distribution, Uniform}; +use rand::rngs::StdRng; +use rand::{thread_rng, Rng, SeedableRng}; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use super::base::{BaseWorker, Worker, WorkerState}; +use crate::worker::sql::{ + QUERY_IS_PREVIOUS_DAY_CHARACTER_CREATED, + QUERY_GET_TOWN_REGION_IDS, + QUERY_LOAD_FIRST_NAMES, + QUERY_LOAD_LAST_NAMES, + QUERY_INSERT_CHARACTER, + QUERY_GET_ELIGIBLE_NPC_FOR_DEATH, + QUERY_DELETE_DIRECTOR, + QUERY_DELETE_RELATIONSHIP, + QUERY_DELETE_CHILD_RELATION, + QUERY_INSERT_NOTIFICATION, + QUERY_MARK_CHARACTER_DECEASED, +}; + +pub struct CharacterCreationWorker { + pub(crate) base: BaseWorker, + rng: StdRng, + dist: Uniform, + first_name_cache: HashMap>, + last_name_cache: HashSet, + death_check_running: Arc, + death_thread: Option>, +} + + + +impl CharacterCreationWorker { + pub fn new(pool: ConnectionPool, broker: MessageBroker) -> Self { + Self::new_internal(pool, broker, true) + } + + /// Interner Konstruktor, der optional den NPC-Todes-Monitor startet. + fn new_internal(pool: ConnectionPool, broker: MessageBroker, start_death_thread: bool) -> Self { + let base = BaseWorker::new("CharacterCreationWorker", pool.clone(), broker.clone()); + let rng = StdRng::from_entropy(); + let dist = Uniform::from(2..=3); + let death_check_running = Arc::new(AtomicBool::new(start_death_thread)); + + let death_thread = if start_death_thread { + let death_flag = Arc::clone(&death_check_running); + let pool_clone = pool; + let broker_clone = broker; + Some(thread::spawn(move || { + while death_flag.load(Ordering::Relaxed) { + if let Err(err) = + CharacterCreationWorker::monitor_character_deaths(&pool_clone, &broker_clone) + { + eprintln!( + "[CharacterCreationWorker] Fehler beim Überprüfen von NPC-Todesfällen: {err}" + ); + } + + // Warte 1 Stunde, aber mit frühem Abbruch, wenn death_flag false wird + for _ in 0..3600 { + if !death_flag.load(Ordering::Relaxed) { + break; + } + thread::sleep(Duration::from_secs(1)); + } + } + })) + } else { + None + }; + + Self { + base, + rng, + dist, + first_name_cache: HashMap::new(), + last_name_cache: HashSet::new(), + death_check_running, + death_thread, + } + } + + /// Variante ohne separaten Todes-Monitor-Thread – wird nur in der Worker-Loop benutzt. + fn new_for_loop(pool: ConnectionPool, broker: MessageBroker) -> Self { + Self::new_internal(pool, broker, false) + } + + fn is_today_character_created(&self) -> bool { + match self.fetch_today_characters() { + Ok(rows) => !rows.is_empty(), + Err(err) => { + eprintln!( + "[CharacterCreationWorker] Fehler in is_today_character_created: {err}" + ); + false + } + } + } + + fn fetch_today_characters(&self) -> Result { + const STMT_NAME: &str = "is_previous_day_character_created"; + + let mut conn = self + .base + .pool + .get() + .map_err(|e| crate::db::DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare(STMT_NAME, QUERY_IS_PREVIOUS_DAY_CHARACTER_CREATED)?; + conn.execute(STMT_NAME, &[]) + } + + fn create_characters_for_today(&mut self) { + self.load_names(); + if self.first_name_cache.is_empty() || self.last_name_cache.is_empty() { + eprintln!( + "[CharacterCreationWorker] Fehler: Namen konnten nicht geladen werden (Stub-Implementierung)." + ); + return; + } + + let town_ids = self.get_town_region_ids(); + for region_id in town_ids { + self.create_characters_for_region(region_id); + } + } + + fn create_characters_for_region(&mut self, region_id: i32) { + let nobility_stands = [1, 2, 3]; + let genders = ["male", "female"]; + + for &nobility in &nobility_stands { + for &gender in &genders { + let num_chars = self.rng.sample(self.dist); + for _ in 0..num_chars { + self.create_character(region_id, gender, nobility); + } + } + } + } + + fn create_character(&mut self, region_id: i32, gender: &str, title_of_nobility: i32) { + let first_set = self + .first_name_cache + .get(gender) + .cloned() + .unwrap_or_default(); + let first_name_id = Self::get_random_from_set(&first_set); + if first_name_id == -1 { + eprintln!("[CharacterCreationWorker] Fehler: Kein passender Vorname gefunden."); + return; + } + + let last_name_id = Self::get_random_from_set(&self.last_name_cache); + if last_name_id == -1 { + eprintln!("[CharacterCreationWorker] Fehler: Kein passender Nachname gefunden."); + return; + } + + if let Err(err) = Self::insert_character( + &self.base.pool, + region_id, + first_name_id, + last_name_id, + gender, + title_of_nobility, + ) { + eprintln!("[CharacterCreationWorker] Fehler in createCharacter: {err}"); + } + } + + fn get_town_region_ids(&self) -> Vec { + match self.load_town_region_ids() { + Ok(rows) => rows + .into_iter() + .filter_map(|row| row.get("id")?.parse::().ok()) + .collect(), + Err(err) => { + eprintln!( + "[CharacterCreationWorker] Fehler in getTownRegionIds: {err}" + ); + Vec::new() + } + } + } + + fn load_town_region_ids(&self) -> Result { + const STMT_NAME: &str = "get_town_region_ids"; + + let mut conn = self + .base + .pool + .get() + .map_err(|e| crate::db::DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare(STMT_NAME, QUERY_GET_TOWN_REGION_IDS)?; + conn.execute(STMT_NAME, &[]) + } + + fn load_names(&mut self) { + if (self.first_name_cache.is_empty() || self.last_name_cache.is_empty()) + && let Err(err) = self.load_first_and_last_names() + { + eprintln!("[CharacterCreationWorker] Fehler in loadNames: {err}"); + } + } + + fn load_first_and_last_names(&mut self) -> Result<(), crate::db::DbError> { + let mut conn = self + .base + .pool + .get() + .map_err(|e| crate::db::DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + // Vornamen + conn.prepare("load_first_names", QUERY_LOAD_FIRST_NAMES)?; + let first_rows = conn.execute("load_first_names", &[])?; + for row in first_rows { + let id = match row.get("id").and_then(|v| v.parse::().ok()) { + Some(id) => id, + None => continue, + }; + let gender = row.get("gender").cloned().unwrap_or_default(); + self.first_name_cache.entry(gender).or_default().insert(id); + } + + // Nachnamen + conn.prepare("load_last_names", QUERY_LOAD_LAST_NAMES)?; + let last_rows = conn.execute("load_last_names", &[])?; + for row in last_rows { + if let Some(id) = row.get("id").and_then(|v| v.parse::().ok()) { + self.last_name_cache.insert(id); + } + } + + Ok(()) + } + + fn get_random_from_set(set: &HashSet) -> i32 { + if set.is_empty() { + return -1; + } + let mut rng = thread_rng(); + let idx = rng.gen_range(0..set.len()); + *set.iter().nth(idx).unwrap_or(&-1) + } + + fn run_iteration(&mut self, state: &WorkerState) { + self.base + .set_current_step("Check if previous day character was created"); + + if !self.is_today_character_created() { + self.base + .set_current_step("Create characters for today"); + self.create_characters_for_today(); + } + + self.sleep_one_minute(state); + } + + fn sleep_one_minute(&self, state: &WorkerState) { + self.base + .set_current_step("Sleep for 60 seconds"); + + for _ in 0..60 { + if !state.running_worker.load(Ordering::Relaxed) { + break; + } + thread::sleep(Duration::from_secs(1)); + } + + self.base.set_current_step("Loop done"); + } + +} + +impl Worker for CharacterCreationWorker { + fn start_worker_thread(&mut self) { + let pool = self.base.pool.clone(); + let broker = self.base.broker.clone(); + + self.base + .start_worker_with_loop(move |state: Arc| { + let mut worker = CharacterCreationWorker::new_for_loop(pool.clone(), broker.clone()); + while state.running_worker.load(Ordering::Relaxed) { + worker.run_iteration(&state); + } + }); + } + + fn stop_worker_thread(&mut self) { + self.base.stop_worker(); + } + + fn enable_watchdog(&mut self) { + self.base.start_watchdog(); + } +} + +impl Drop for CharacterCreationWorker { + fn drop(&mut self) { + self.death_check_running + .store(false, Ordering::Relaxed); + if let Some(handle) = self.death_thread.take() { + let _ = handle.join(); + } + } +} + +// Zusätzliche Logik: NPC-Todesfälle überwachen und verarbeiten + +impl CharacterCreationWorker { + fn insert_character( + pool: &ConnectionPool, + region_id: i32, + first_name_id: i32, + last_name_id: i32, + gender: &str, + title_of_nobility: i32, + ) -> Result<(), DbError> { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("insert_character", QUERY_INSERT_CHARACTER)?; + conn.execute( + "insert_character", + &[ + ®ion_id, + &first_name_id, + &last_name_id, + &gender, + &title_of_nobility, + ], + )?; + Ok(()) + } + + fn monitor_character_deaths( + pool: &ConnectionPool, + broker: &MessageBroker, + ) -> Result<(), DbError> { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare( + "get_eligible_npc_for_death", + QUERY_GET_ELIGIBLE_NPC_FOR_DEATH, + )?; + let rows = conn.execute("get_eligible_npc_for_death", &[])?; + + for row in rows { + let character_id = row + .get("id") + .and_then(|v| v.parse::().ok()) + .unwrap_or(-1); + let age = row + .get("age") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + if character_id > 0 && Self::calculate_death_probability(age) + && let Err(err) = Self::handle_character_death(pool, broker, character_id) + { + eprintln!( + "[CharacterCreationWorker] Fehler beim Bearbeiten des NPC-Todes (id={character_id}): {err}" + ); + } + } + + Ok(()) + } + + fn calculate_death_probability(age: i32) -> bool { + if age < 60 { + return false; + } + + let base_probability = 0.01_f64; + let increase_per_year = 0.01_f64; + let death_probability = + base_probability + increase_per_year * (age.saturating_sub(60) as f64); + + let mut rng = thread_rng(); + let dist = Uniform::from(0.0..1.0); + let roll: f64 = dist.sample(&mut rng); + roll < death_probability + } + + fn handle_character_death( + pool: &ConnectionPool, + broker: &MessageBroker, + character_id: i32, + ) -> Result<(), DbError> { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + // 1) Director löschen und User benachrichtigen + conn.prepare("delete_director", QUERY_DELETE_DIRECTOR)?; + let dir_result = conn.execute("delete_director", &[&character_id])?; + if let Some(row) = dir_result.first() + && let Some(user_id) = row + .get("employer_user_id") + .and_then(|v| v.parse::().ok()) + { + Self::notify_user(pool, broker, user_id, "director_death")?; + } + + // 2) Relationships löschen und betroffene User benachrichtigen + conn.prepare("delete_relationship", QUERY_DELETE_RELATIONSHIP)?; + let rel_result = conn.execute("delete_relationship", &[&character_id])?; + for row in rel_result { + if let Some(related_user_id) = row + .get("related_user_id") + .and_then(|v| v.parse::().ok()) + { + Self::notify_user(pool, broker, related_user_id, "relationship_death")?; + } + } + + // 3) Child-Relations löschen und Eltern benachrichtigen + conn.prepare("delete_child_relation", QUERY_DELETE_CHILD_RELATION)?; + let child_result = conn.execute("delete_child_relation", &[&character_id])?; + for row in child_result { + if let Some(father_user_id) = row + .get("father_user_id") + .and_then(|v| v.parse::().ok()) + { + Self::notify_user(pool, broker, father_user_id, "child_death")?; + } + if let Some(mother_user_id) = row + .get("mother_user_id") + .and_then(|v| v.parse::().ok()) + { + Self::notify_user(pool, broker, mother_user_id, "child_death")?; + } + } + + // 4) Charakter als verstorben markieren + Self::mark_character_as_deceased(pool, character_id)?; + + Ok(()) + } + + fn notify_user( + pool: &ConnectionPool, + broker: &MessageBroker, + user_id: i32, + event_type: &str, + ) -> Result<(), DbError> { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("insert_notification", QUERY_INSERT_NOTIFICATION)?; + conn.execute("insert_notification", &[&user_id])?; + + // falukantUpdateStatus + let update_message = + format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id); + broker.publish(update_message); + + // ursprüngliche Benachrichtigung + let message = + format!(r#"{{"event":"{event_type}","user_id":{}}}"#, user_id); + broker.publish(message); + + Ok(()) + } + + fn mark_character_as_deceased( + pool: &ConnectionPool, + character_id: i32, + ) -> Result<(), DbError> { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("mark_character_deceased", QUERY_MARK_CHARACTER_DECEASED)?; + conn.execute("mark_character_deceased", &[&character_id])?; + Ok(()) + } +} + + diff --git a/YpDaemon/src/worker/director.rs b/YpDaemon/src/worker/director.rs new file mode 100644 index 0000000..c3d3555 --- /dev/null +++ b/YpDaemon/src/worker/director.rs @@ -0,0 +1,1418 @@ +use crate::db::{DbConnection, DbError, Row}; +use std::collections::HashMap; +use crate::message_broker::MessageBroker; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crate::db::ConnectionPool; +use super::base::{BaseWorker, Worker, WorkerState, DEFAULT_TAX_PERCENT, DEFAULT_TREASURY_USER_ID}; +use crate::worker::sql::{ + QUERY_GET_DIRECTORS, + QUERY_GET_BEST_PRODUCTION, + QUERY_INSERT_PRODUCTION, + QUERY_GET_BRANCH_CAPACITY, + QUERY_GET_INVENTORY, + QUERY_REMOVE_INVENTORY, + QUERY_ADD_SELL_LOG, + QUERY_GET_REGION_WORTH_FOR_PRODUCT, + QUERY_GET_TRANSPORT_VEHICLES_FOR_ROUTE, + QUERY_INSERT_TRANSPORT, + QUERY_INSERT_EMPTY_TRANSPORT, + QUERY_GET_USER_BRANCHES, + QUERY_GET_FREE_VEHICLES_IN_REGION, + QUERY_GET_SALARY_TO_PAY, + QUERY_SET_SALARY_PAYED, + QUERY_UPDATE_SATISFACTION, + QUERY_GET_DIRECTOR_USER, + QUERY_COUNT_VEHICLES_IN_BRANCH_REGION, + QUERY_COUNT_VEHICLES_IN_REGION, + QUERY_CHECK_ROUTE, + QUERY_GET_BRANCH_REGION, + QUERY_GET_AVERAGE_WORTH, + QUERY_UPDATE_INVENTORY_QTY, + QUERY_GET_PRODUCT_COST, + QUERY_GET_USER_OFFICES, + QUERY_CUMULATIVE_TAX_NO_EXEMPT, + QUERY_CUMULATIVE_TAX_WITH_EXEMPT, +}; + +#[derive(Debug, Clone)] +struct Director { + id: i32, + branch_id: i32, + may_produce: bool, + may_sell: bool, + may_start_transport: bool, +} + +#[derive(Debug, Clone)] +struct ProductionPlan { + falukant_user_id: i32, + money: f64, + certificate: i32, + branch_id: i32, + product_id: i32, + region_id: i32, + stock_size: i32, + used_in_stock: i32, + running_productions: i32, + running_productions_quantity: i32, +} + +#[derive(Debug, Clone)] +struct InventoryItem { + id: i32, + product_id: i32, + quantity: i32, + quality: i32, + sell_cost: f64, + user_id: i32, + region_id: i32, + branch_id: i32, + worth_percent: f64, // Regionaler worth_percent-Wert für die Preisberechnung +} + +#[derive(Debug, Clone)] +struct SalaryItem { + id: i32, + employer_user_id: i32, + income: i32, +} + +#[derive(Debug, Clone)] +struct TransportVehicle { + id: i32, + capacity: i32, +} + +pub struct DirectorWorker { + base: BaseWorker, + last_run: Option, +} + +// Maximale Anzahl paralleler Produktionen pro Branch +const MAX_PARALLEL_PRODUCTIONS: i32 = 2; + +// ...existing code... + +// Verfügbare Transportmittel für eine Route (source_region -> target_region) +// ...existing code... + +impl DirectorWorker { + pub fn new(pool: ConnectionPool, broker: MessageBroker) -> Self { + Self { + base: BaseWorker::new("DirectorWorker", pool, broker), + last_run: None, + } + } + + fn run_iteration(&mut self, _state: &WorkerState) { + self.base.set_current_step("DirectorWorker iteration"); + + let now = Instant::now(); + let should_run = match self.last_run { + None => true, + Some(last) => now.saturating_duration_since(last) >= Duration::from_secs(60), + }; + + if should_run { + if let Err(err) = self.perform_all_tasks() { + eprintln!("[DirectorWorker] Fehler beim Ausführen der Aufgabe: {err}"); + } + self.last_run = Some(now); + } + + std::thread::sleep(Duration::from_secs(1)); + } + + fn perform_all_tasks(&mut self) -> Result<(), DbError> { + // Produktions-/Verkaufs-/Transportlogik für alle Direktoren + self.perform_task()?; + self.pay_salary()?; + self.calculate_satisfaction()?; + Ok(()) + } + + fn perform_task(&mut self) -> Result<(), DbError> { + self.base + .set_current_step("Get director actions from DB"); + + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("get_directors", QUERY_GET_DIRECTORS)?; + let directors_rows = conn.execute("get_directors", &[])?; + + let directors: Vec = directors_rows + .into_iter() + .filter_map(Self::map_row_to_director) + .collect(); + + if directors.is_empty() { + // keine Info-Logs + } + + for director in directors { + if director.may_produce { + eprintln!( + "[DirectorWorker] Starte Produktionsprüfung für Director {} (branch_id={})", + director.id, director.branch_id + ); + self.start_productions(&director)?; + } + if director.may_start_transport { + eprintln!( + "[DirectorWorker] Starte Transportprüfung für Director {} (branch_id={})", + director.id, director.branch_id + ); + if let Err(err) = self.start_transports_stub(&director) { + eprintln!( + "[DirectorWorker] Fehler bei start_transports für Director {}: {err}", + director.id + ); + } + } + if director.may_sell { + eprintln!( + "[DirectorWorker] Starte Verkaufsprüfung für Director {} (branch_id={})", + director.id, director.branch_id + ); + self.start_sellings(&director)?; + } + } + + Ok(()) + } + + fn map_row_to_director(row: Row) -> Option { + Some(Director { + id: row.get("id")?.parse().ok()?, + branch_id: row.get("branch_id")?.parse().ok()?, + may_produce: row.get("may_produce").map(|v| v == "t" || v == "true").unwrap_or(false), + may_sell: row.get("may_sell").map(|v| v == "t" || v == "true").unwrap_or(false), + may_start_transport: row + .get("may_start_transport") + .map(|v| v == "t" || v == "true") + .unwrap_or(false), + }) + } + + fn start_productions(&mut self, director: &Director) -> Result<(), DbError> { + self.base + .set_current_step("DirectorWorker: start_productions"); + + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + // Initial: Bestes Produkt für diesen Branch ermitteln + conn.prepare("get_to_produce", QUERY_GET_BEST_PRODUCTION)?; + let rows = conn.execute("get_to_produce", &[&director.id, &director.branch_id])?; + if rows.is_empty() { + eprintln!( + "[DirectorWorker] Keine Produktionskandidaten für Director {} gefunden.", + director.id + ); + return Ok(()); + } + + let mut base_plan = match rows.first().and_then(Self::map_row_to_production_plan) { + Some(p) => p, + None => { + eprintln!( + "[DirectorWorker] Produktionsplan für Director {} konnte nicht gemappt werden.", + director.id + ); + return Ok(()); + } + }; + + eprintln!( + "[DirectorWorker] Produktionsplan: director_user_id={}, branch_id={}, product_id={}, money={}, certificate={}", + base_plan.falukant_user_id, + base_plan.branch_id, + base_plan.product_id, + base_plan.money, + base_plan.certificate + ); + + // Query zum Abfragen der aktuellen Kapazitätswerte vorbereiten + conn.prepare("get_branch_capacity", QUERY_GET_BRANCH_CAPACITY)?; + + // Schleife: Starte Produktionen, bis entweder die maximale Anzahl erreicht ist + // oder kein freier Lagerplatz mehr vorhanden ist + loop { + // Aktuelle Kapazitätswerte abfragen + let capacity_rows = conn.execute("get_branch_capacity", &[&director.branch_id])?; + if capacity_rows.is_empty() { + break; + } + + let row = &capacity_rows[0]; + let stock_size: i32 = row + .get("stock_size") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let used_in_stock: i32 = row + .get("used_in_stock") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let running_productions: i32 = row + .get("running_productions") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let running_productions_quantity: i32 = row + .get("running_productions_quantity") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + // Prüfen, ob noch Produktionen gestartet werden können + if running_productions >= MAX_PARALLEL_PRODUCTIONS { + eprintln!( + "[DirectorWorker] Maximale Anzahl an Produktionen ({}) erreicht für Branch {}.", + MAX_PARALLEL_PRODUCTIONS, + director.branch_id + ); + break; + } + + // Freie Kapazität berechnen + let free_capacity = stock_size - used_in_stock - running_productions_quantity; + if free_capacity <= 0 { + eprintln!( + "[DirectorWorker] Kein freier Lagerplatz mehr für Branch {} (stock_size={}, used={}, running_qty={}).", + director.branch_id, + stock_size, + used_in_stock, + running_productions_quantity + ); + break; + } + + // Plan mit aktuellen Werten aktualisieren + base_plan.stock_size = stock_size; + base_plan.used_in_stock = used_in_stock; + base_plan.running_productions = running_productions; + base_plan.running_productions_quantity = running_productions_quantity; + + // Eine neue Produktion starten (max. 100 Stück) + if let Err(err) = self.create_single_production(&mut conn, &base_plan) { + eprintln!( + "[DirectorWorker] Fehler beim Starten einer Produktion: {err}" + ); + break; + } + } + + Ok(()) + } + + fn map_row_to_production_plan(row: &Row) -> Option { + // Pflichtfelder: ohne diese können wir keinen sinnvollen Plan erstellen. + let falukant_user_id: i32 = row.get("falukant_user_id")?.parse().ok()?; + let certificate: i32 = row.get("certificate")?.parse().ok()?; + let branch_id: i32 = row.get("branch_id")?.parse().ok()?; + let product_id: i32 = row.get("product_id")?.parse().ok()?; + + // Optionale/abgeleitete Felder: hier sind wir tolerant und verwenden + // Default-Werte, falls NULL oder nicht parsbar. + let money: f64 = row + .get("money") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0); + let stock_size: i32 = row + .get("stock_size") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let used_in_stock: i32 = row + .get("used_in_stock") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let running_productions: i32 = row + .get("running_productions") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let running_productions_quantity: i32 = row + .get("running_productions_quantity") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let region_id: i32 = row + .get("region_id") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + Some(ProductionPlan { + falukant_user_id, + money, + certificate, + branch_id, + product_id, + region_id, + stock_size, + used_in_stock, + running_productions, + running_productions_quantity, + }) + } + + /// Startet eine einzelne Produktion (max. 100 Stück) basierend auf dem aktuellen Plan. + /// Die äußere Schleife in `start_productions` sorgt dafür, dass mehrere Produktionen + /// gestartet werden können, bis entweder die maximale Anzahl erreicht ist oder + /// kein freier Lagerplatz mehr vorhanden ist. + fn create_single_production( + &mut self, + conn: &mut DbConnection, + plan: &ProductionPlan, + ) -> Result<(), DbError> { + let free_capacity = Self::calc_free_capacity(plan); + let one_piece_cost = Self::calc_one_piece_cost(plan); + let max_money_production = Self::calc_max_money_production(plan, one_piece_cost); + + let to_produce = (free_capacity.min(max_money_production)).clamp(0, 100); + + eprintln!( + "[DirectorWorker] Produktionsberechnung: free_capacity={}, one_piece_cost={}, max_money_production={}, to_produce={}, running_productions={}", + free_capacity, + one_piece_cost, + max_money_production, + to_produce, + plan.running_productions + ); + + if to_produce < 1 { + eprintln!( + "[DirectorWorker] Keine Produktion gestartet: free_capacity={}, max_money_production={}, running_productions={}, running_qty={}", + free_capacity, + max_money_production, + plan.running_productions, + plan.running_productions_quantity + ); + return Ok(()); + } + + let production_cost = to_produce as f64 * one_piece_cost; + + if let Err(err) = self.base.change_falukant_user_money( + plan.falukant_user_id, + -production_cost, + "director starts production", + ) { + eprintln!( + "[DirectorWorker] Fehler bei change_falukant_user_money: {err}" + ); + } + + // Debug: Log vor dem DB-Aufruf + eprintln!( + "[DirectorWorker] calling change_falukant_user_money for start production: user_id={}, money_change={}", + plan.falukant_user_id, -production_cost + ); + + conn.prepare("insert_production", QUERY_INSERT_PRODUCTION)?; + + // Eine einzelne Produktion mit max. 100 Stück anlegen + // Das aktuelle Wetter der Region wird automatisch aus der weather-Tabelle geholt + conn.execute( + "insert_production", + &[&plan.branch_id, &plan.product_id, &to_produce, &plan.region_id], + )?; + + eprintln!( + "[DirectorWorker] Produktion angelegt: branch_id={}, product_id={}, quantity={}", + plan.branch_id, plan.product_id, to_produce + ); + + let message = format!( + r#"{{"event":"production_started","branch_id":{}}}"#, + plan.branch_id + ); + self.base.broker.publish(message); + + Ok(()) + } + + fn calc_free_capacity(plan: &ProductionPlan) -> i32 { + plan.stock_size - plan.used_in_stock - plan.running_productions_quantity + } + + fn calc_one_piece_cost(plan: &ProductionPlan) -> f64 { + (plan.certificate * 6) as f64 + } + + fn calc_max_money_production(plan: &ProductionPlan, one_piece_cost: f64) -> i32 { + if one_piece_cost > 0.0 { + if plan.money > 0.0 { + (plan.money / one_piece_cost).floor() as i32 + } else { + eprintln!( + "[DirectorWorker] Warnung: money=0 für falukant_user_id={}, verwende nur Lagerkapazität als Limit.", + plan.falukant_user_id + ); + i32::MAX + } + } else { + 0 + } + } + + fn start_transports_stub(&mut self, director: &Director) -> Result<(), DbError> { + self.base + .set_current_step("DirectorWorker: start_transports"); + + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("get_to_transport", QUERY_GET_INVENTORY)?; + let rows = conn.execute("get_to_transport", &[&director.id, &director.branch_id])?; + + let mut items: Vec = + rows.into_iter().filter_map(Self::map_row_to_inventory_item).collect(); + + eprintln!( + "[DirectorWorker] Transportprüfung für Director {} (branch_id={}): {} Inventar-Items gefunden", + director.id, director.branch_id, items.len() + ); + + // Für alle Items dieses Directors sollten die user_id-Felder identisch + // sein (Arbeitgeber des Directors). + let falukant_user_id = if items.is_empty() { + // Wenn keine Items vorhanden sind, müssen wir die user_id anders ermitteln + conn.prepare("get_director_user", QUERY_GET_DIRECTOR_USER)?; + let user_rows = conn.execute("get_director_user", &[&director.id])?; + user_rows + .into_iter() + .next() + .and_then(|row| row.get("employer_user_id").and_then(|v| v.parse::().ok())) + .ok_or_else(|| DbError::new("Konnte employer_user_id nicht ermitteln"))? + } else { + items[0].user_id + }; + + // Prüfe, ob Transportmittel im aktuellen Branch vorhanden sind + // Ein Transport ist aktiv, wenn er noch in der Tabelle existiert + conn.prepare("count_vehicles_in_branch", QUERY_COUNT_VEHICLES_IN_BRANCH_REGION)?; + let vehicle_count_rows = conn.execute( + "count_vehicles_in_branch", + &[&falukant_user_id, &director.branch_id], + )?; + + let vehicles_in_branch = vehicle_count_rows + .into_iter() + .next() + .and_then(|row| row.get("count").and_then(|v| v.parse::().ok())) + .unwrap_or(0); + + // Falls es nichts zu transportieren gibt, prüfe auf leere Transporte + if items.is_empty() { + eprintln!( + "[DirectorWorker] Keine Inventar-Items für Transporte gefunden für Director {} (branch_id={})", + director.id, director.branch_id + ); + + // Wenn keine Transportmittel im Branch vorhanden sind, versuche leere Transporte zu planen + if vehicles_in_branch == 0 { + eprintln!( + "[DirectorWorker] Keine Transportmittel im Branch {} vorhanden, prüfe auf leere Transporte zum Zurückholen", + director.branch_id + ); + if let Err(err) = self.plan_empty_transports_for_vehicle_retrieval( + &mut conn, + falukant_user_id, + director.branch_id, + ) { + eprintln!( + "[DirectorWorker] Fehler beim Planen leerer Transporte: {err}" + ); + } + } + return Ok(()); + } + + // Wenn keine Transportmittel im Branch vorhanden sind, aber Items vorhanden sind, + // versuche leere Transporte zu planen, um Fahrzeuge zurückzuholen + if vehicles_in_branch == 0 && !items.is_empty() { + eprintln!( + "[DirectorWorker] Keine Transportmittel im Branch {} vorhanden, aber {} Items vorhanden. Prüfe auf leere Transporte zum Zurückholen", + director.branch_id, items.len() + ); + if let Err(err) = self.plan_empty_transports_for_vehicle_retrieval( + &mut conn, + falukant_user_id, + director.branch_id, + ) { + eprintln!( + "[DirectorWorker] Fehler beim Planen leerer Transporte: {err}" + ); + } + // Nach dem Planen leerer Transporte erneut prüfen, ob jetzt Transportmittel vorhanden sind + let vehicle_count_rows_after = conn.execute( + "count_vehicles_in_branch", + &[&falukant_user_id, &director.branch_id], + )?; + let vehicles_in_branch_after = vehicle_count_rows_after + .into_iter() + .next() + .and_then(|row| row.get("count").and_then(|v| v.parse::().ok())) + .unwrap_or(0); + + if vehicles_in_branch_after == 0 { + eprintln!( + "[DirectorWorker] Nach Planen leerer Transporte immer noch keine Transportmittel im Branch {} vorhanden. Überspringe normale Transportplanung.", + director.branch_id + ); + return Ok(()); + } + } + + // Lohnende Transporte planen. Dabei werden: + // - ggf. Transport-Einträge erzeugt + // - Inventar-Mengen reduziert + for item in items.iter_mut() { + eprintln!( + "[DirectorWorker] Prüfe Transport für Item: product_id={}, quantity={}, quality={}, region_id={}, branch_id={}", + item.product_id, item.quantity, item.quality, item.region_id, item.branch_id + ); + + let shipped = self.plan_transports_for_item( + &mut conn, + falukant_user_id, + item, + )?; + + // Inventar wird bereits in plan_transports_for_item reduziert + if shipped > 0 { + eprintln!( + "[DirectorWorker] Transport geplant: {} Einheiten von Produkt {} transportiert (Inventar bereits reduziert)", + shipped, item.product_id + ); + } else { + eprintln!( + "[DirectorWorker] Kein lohnender Transport gefunden für Produkt {} (region_id={})", + item.product_id, item.region_id + ); + } + } + + // Nach normalen Transporten: Wenn keine Transportmittel mehr im Branch vorhanden sind, + // aber bessere Verkaufspreise in anderen Branches existieren, plane leere Transporte + let vehicle_count_rows_final = conn.execute( + "count_vehicles_in_branch", + &[&falukant_user_id, &director.branch_id], + )?; + let vehicles_in_branch_final = vehicle_count_rows_final + .into_iter() + .next() + .and_then(|row| row.get("count").and_then(|v| v.parse::().ok())) + .unwrap_or(0); + + if vehicles_in_branch_final == 0 { + eprintln!( + "[DirectorWorker] Nach Transporten keine Transportmittel mehr im Branch {} vorhanden, prüfe auf leere Transporte zum Zurückholen", + director.branch_id + ); + if let Err(err) = self.plan_empty_transports_for_vehicle_retrieval( + &mut conn, + falukant_user_id, + director.branch_id, + ) { + eprintln!( + "[DirectorWorker] Fehler beim Planen leerer Transporte: {err}" + ); + } + } + + Ok(()) + } + + fn start_sellings(&mut self, director: &Director) -> Result<(), DbError> { + self.base + .set_current_step("DirectorWorker: start_sellings"); + + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("get_to_sell", QUERY_GET_INVENTORY)?; + let rows = conn.execute("get_to_sell", &[&director.id, &director.branch_id])?; + + let mut items: Vec = + rows.into_iter().filter_map(Self::map_row_to_inventory_item).collect(); + + conn.prepare("remove_inventory", QUERY_REMOVE_INVENTORY)?; + conn.prepare("add_sell_log", QUERY_ADD_SELL_LOG)?; + + // Falls es nichts zu verkaufen gibt, können wir sofort zurückkehren. + if items.is_empty() { + return Ok(()); + } + + // Für alle Items dieses Directors sollten die user_id-Felder identisch + // sein (Arbeitgeber des Directors). + let falukant_user_id = items[0].user_id; + + // Vor dem eigentlichen Verkauf versucht der Director, lohnende + // Transporte zu planen. Dabei werden: + // - ggf. Transport-Einträge erzeugt + // - Inventar-Mengen reduziert (geschieht bereits in plan_transports_for_item) + // Die zurückgegebenen Mengen werden dann lokal verkauft. + for item in items.iter_mut() { + let _shipped = self.plan_transports_for_item( + &mut conn, + falukant_user_id, + item, + )?; + + // Inventar wird bereits in plan_transports_for_item reduziert + // item.quantity wurde dort bereits aktualisiert + } + + // Anschließend lokale Verkäufe für die verbleibenden Mengen durchführen. + for item in items.drain(..) { + if item.quantity > 0 { + self.sell_single_inventory_item(&mut conn, &item)?; + } else { + // Falls die Menge auf 0 gesetzt wurde, das Inventar ggf. aufräumen. + conn.execute("remove_inventory", &[&item.id])?; + } + } + + Ok(()) + } + + fn map_row_to_inventory_item(row: Row) -> Option { + Some(InventoryItem { + id: row.get("id")?.parse().ok()?, + product_id: row.get("product_id")?.parse().ok()?, + quantity: row.get("quantity")?.parse().ok()?, + quality: row.get("quality")?.parse().ok()?, + sell_cost: row.get("sell_cost")?.parse().ok()?, + user_id: row.get("user_id")?.parse().ok()?, + region_id: row.get("region_id")?.parse().ok()?, + branch_id: row.get("branch_id")?.parse().ok()?, + worth_percent: row + .get("worth_percent") + .and_then(|v| v.parse::().ok()) + .unwrap_or(100.0), + }) + } + + // Helper: compute piece sell price from item fields + fn compute_piece_sell_price(item: &InventoryItem) -> f64 { + let base_price = item.sell_cost * (item.worth_percent / 100.0); + let min_price = base_price * 0.6; + let max_price = base_price; + let knowledge_factor = item.quality as f64; + min_price + (max_price - min_price) * (knowledge_factor / 100.0) + } + + // Helper: get one_piece_cost from DB row fallback logic + fn resolve_one_piece_cost(conn: &mut DbConnection, product_id: i32, fallback: f64) -> Result { + conn.prepare("get_product_cost", QUERY_GET_PRODUCT_COST)?; + let rows = conn.execute("get_product_cost", &[&product_id])?; + if let Some(row) = rows.first() + && let Some(sc) = row.get("sell_cost") + && let Ok(v) = sc.parse::() + { + return Ok(v); + } + Ok(fallback) + } + + // Helper: determine cumulative tax percent for a branch/user + fn get_cumulative_tax_percent(conn: &mut DbConnection, branch_id: i32, user_id: i32) -> Result { + // Default + let mut cumulative_tax_percent = DEFAULT_TAX_PERCENT; + + conn.prepare("get_branch_region", QUERY_GET_BRANCH_REGION) + .map_err(|e| DbError::new(format!("[DirectorWorker] prepare get_branch_region: {e}")))?; + let branch_rows = conn.execute("get_branch_region", &[&branch_id]) + .map_err(|e| DbError::new(format!("[DirectorWorker] exec get_branch_region branch_id={}: {e}", branch_id)))?; + let branch_region_id: Option = branch_rows.first().and_then(|r| r.get("region_id")).and_then(|v| v.parse().ok()); + + if let Some(region_id) = branch_region_id { + conn.prepare("get_user_offices", QUERY_GET_USER_OFFICES) + .map_err(|e| DbError::new(format!("[DirectorWorker] prepare get_user_offices: {e}")))?; + let offices = conn.execute("get_user_offices", &[&user_id]) + .map_err(|e| DbError::new(format!("[DirectorWorker] exec get_user_offices user_id={}: {e}", user_id)))?; + + let mut exempt_types: Vec = Vec::new(); + let mut has_chancellor = false; + for row in &offices { + if let Some(name) = row.get("office_name") { + match name.as_str() { + "chancellor" => { has_chancellor = true; break; } + "council" => { exempt_types.push("city".to_string()); } + "taxman" => { exempt_types.extend(["city","county"].into_iter().map(String::from)); } + "treasurerer" => { exempt_types.extend(["city","county","shire"].into_iter().map(String::from)); } + "super-state-administrator" => { exempt_types.extend(["city","county","shire","markgrave","duchy"].into_iter().map(String::from)); } + _ => {} + } + } + } + + if has_chancellor { + return Ok(0.0); + } + + if exempt_types.is_empty() { + conn.prepare("cumulative_tax_no_exempt", QUERY_CUMULATIVE_TAX_NO_EXEMPT) + .map_err(|e| DbError::new(format!("[DirectorWorker] prepare cumulative_tax_no_exempt: {e}")))?; + let res = conn.execute("cumulative_tax_no_exempt", &[®ion_id]) + .map_err(|e| DbError::new(format!("[DirectorWorker] exec cumulative_tax_no_exempt region_id={}: {e}", region_id)))?; + if let Some(row) = res.first() + && let Some(tp) = row.get("total_percent") + { + cumulative_tax_percent = tp.parse::().unwrap_or(DEFAULT_TAX_PERCENT); + } + } else { + conn.prepare("cumulative_tax_with_exempt", QUERY_CUMULATIVE_TAX_WITH_EXEMPT) + .map_err(|e| DbError::new(format!("[DirectorWorker] prepare cumulative_tax_with_exempt: {e}")))?; + let exempt_array: Vec<&str> = exempt_types.iter().map(|s| s.as_str()).collect(); + let res = conn.execute("cumulative_tax_with_exempt", &[®ion_id, &exempt_array]) + .map_err(|e| DbError::new(format!("[DirectorWorker] exec cumulative_tax_with_exempt region_id={} exempt={:?}: {}", region_id, exempt_array, e)))?; + if let Some(row) = res.first() && let Some(tp) = row.get("total_percent") { + cumulative_tax_percent = tp.parse::().unwrap_or(DEFAULT_TAX_PERCENT); + } + } + } + + Ok(cumulative_tax_percent) + } + + fn sell_single_inventory_item( + &mut self, + conn: &mut DbConnection, + item: &InventoryItem, + ) -> Result<(), DbError> { + if item.quantity <= 0 { + conn.execute("remove_inventory", &[&item.id])?; + return Ok(()); + } + + // compute piece price and full sell price + let piece_price = Self::compute_piece_sell_price(item); + let sell_price = piece_price * item.quantity as f64; + + let one_piece_cost = Self::resolve_one_piece_cost(conn, item.product_id, item.sell_cost)?; + let cumulative_tax_percent = Self::get_cumulative_tax_percent(conn, item.branch_id, item.user_id)?; + + let revenue_cents = (sell_price * 100.0).round() as i64; + let cost_cents = (one_piece_cost * item.quantity as f64 * 100.0).round() as i64; + let profit_cents = (revenue_cents - cost_cents).max(0); + let tax_cents = ((profit_cents as f64) * cumulative_tax_percent / 100.0).round() as i64; + let payout_cents = revenue_cents - tax_cents; + + eprintln!("[DirectorWorker] sell: revenue={:.2}, cost={:.2}, profit_cents={}, tax%={:.2}, tax_cents={}, payout_cents={}", sell_price, one_piece_cost * item.quantity as f64, profit_cents, cumulative_tax_percent, tax_cents, payout_cents); + + if tax_cents > 0 { + let tax_amount = (tax_cents as f64) / 100.0; + if let Err(err) = self.base.change_falukant_user_money(DEFAULT_TREASURY_USER_ID, tax_amount, &format!("tax from sale product {}", item.product_id)) { + eprintln!("[DirectorWorker] Fehler bei change_falukant_user_money (tax): {err}"); + } + } + + let payout_amount = (payout_cents as f64) / 100.0; + if payout_cents != 0 && let Err(err) = self.base.change_falukant_user_money(item.user_id, payout_amount, "sell products") { + eprintln!("[DirectorWorker] Fehler bei change_falukant_user_money (sell products): {err}"); + } + + // Debug: Log vor dem DB-Aufruf + eprintln!( + "[DirectorWorker] sell: user_id={}, revenue={:.2}, tax={:.2}, payout={:.2}, product_id={}", + item.user_id, + sell_price, + (tax_cents as f64) / 100.0, + payout_amount, + item.product_id + ); + + conn.execute( + "add_sell_log", + &[ + &item.region_id, + &item.product_id, + &item.quantity, + &item.user_id, + ], + )?; + + conn.execute("remove_inventory", &[&item.id])?; + + let message = format!( + r#"{{"event":"selled_items","branch_id":{}}}"#, + item.branch_id + ); + self.base.broker.publish(message); + + Ok(()) + } + + /// Plant ggf. Transporte für ein einzelnes Inventar-Item und gibt die + /// Menge zurück, die tatsächlich in Transporte umgewandelt wurde. + /// + /// Logik: + /// - Ermittle regionale "worth_percent"-Werte für das Produkt in allen + /// Branch-Regionen des Users. + /// - Berechne lokalen Stückpreis (inkl. Qualität) und für jede andere + /// Region einen potentiellen Stückpreis. + /// - Prüfe für jede Zielregion: + /// * Gibt es verfügbare Transportmittel für diese Route? + /// * Ist der Mehrerlös (deltaPrice * Menge) größer als die + /// Transportkosten (max(1, totalValue * 0.01))? + /// - Wähle die Zielregion mit dem größten positiven Nettogewinn und + /// erzeuge entsprechende Transporte (begrenzt durch Fahrzeugkapazität). + fn plan_transports_for_item( + &mut self, + conn: &mut DbConnection, + falukant_user_id: i32, + item: &mut InventoryItem, + ) -> Result { + // Sicherheitscheck + if item.quantity <= 0 { + return Ok(0); + } + + // Load worth_percent by region for this product + let worth_by_region = Self::get_worth_by_region(conn, falukant_user_id, item.product_id)?; + if worth_by_region.is_empty() { + eprintln!("[DirectorWorker] Keine worth_percent-Werte für Produkt {} gefunden", item.product_id); + return Ok(0); + } + + eprintln!( + "[DirectorWorker] Gefundene Regionen für Produkt {}: {} Regionen", + item.product_id, + worth_by_region.len() + ); + + // Compute local piece price + let local_percent = worth_by_region.get(&item.region_id).copied().unwrap_or(100.0); + let local_piece_price = Self::compute_piece_price_for_percent(item, local_percent); + eprintln!( + "[DirectorWorker] Lokaler Preis für Produkt {}: {:.2} (worth_percent={:.2}, quality={})", + item.product_id, local_piece_price, local_percent, item.quality + ); + + let mut best_target_region: Option = None; + let mut best_quantity: i32 = 0; + let mut best_remote_piece_price: f64 = 0.0; + let mut best_gain: f64 = 0.0; + + // Für jede andere Region prüfen, ob sich ein Transport lohnt. + for (®ion_id, &remote_percent) in &worth_by_region { + if region_id == item.region_id { + continue; + } + + let remote_piece_price = Self::compute_piece_price_for_percent(item, remote_percent); + let delta_per_unit = remote_piece_price - local_piece_price; + eprintln!( + "[DirectorWorker] Region {}: Preis {:.2}, Delta {:.2}", + region_id, remote_piece_price, delta_per_unit + ); + + if delta_per_unit <= 0.0 { + eprintln!( + "[DirectorWorker] Region {}: Kein Preisvorteil (Delta <= 0)", + region_id + ); + continue; + } + + // Verfügbare Transportmittel für diese Route abfragen + let vehicles = Self::get_transport_vehicles_for_route( + conn, + falukant_user_id, + item.region_id, + region_id, + )?; + + eprintln!( + "[DirectorWorker] Region {}: {} verfügbare Transportmittel", + region_id, vehicles.len() + ); + + if vehicles.is_empty() { + eprintln!( + "[DirectorWorker] Region {}: Keine verfügbaren Transportmittel", + region_id + ); + continue; + } + + // Maximale transportierbare Menge anhand der Kapazität ermitteln + let max_capacity = Self::calc_max_capacity(&vehicles); + + if max_capacity <= 0 { + continue; + } + + let qty = std::cmp::min(item.quantity, max_capacity); + if qty <= 0 { + continue; + } + + let extra_revenue = delta_per_unit * qty as f64; + let transport_cost = Self::calc_transport_cost(remote_piece_price, qty); + let net_gain = extra_revenue - transport_cost; + eprintln!( + "[DirectorWorker] Region {}: extra_revenue={:.2}, transport_cost={:.2}, net_gain={:.2}, qty={}", + region_id, extra_revenue, transport_cost, net_gain, qty + ); + + if net_gain <= 0.0 { + eprintln!( + "[DirectorWorker] Region {}: Netto-Gewinn <= 0, überspringe", + region_id + ); + continue; + } + + if net_gain > best_gain { + eprintln!( + "[DirectorWorker] Region {}: Neuer bester Transport (Gewinn {:.2})", + region_id, net_gain + ); + best_gain = net_gain; + best_target_region = Some(region_id); + best_quantity = qty; + best_remote_piece_price = remote_piece_price; + } + } + + // Kein lohnender Transport gefunden + let target_region = match best_target_region { + Some(r) => r, + None => return Ok(0), + }; + + if best_quantity <= 0 { + return Ok(0); + } + + // Build and insert transports for chosen route + let shipped = Self::insert_transports_for_route(conn, item, target_region, best_quantity)?; + + // Inventar sofort reduzieren, nachdem Transporte erfolgreich angelegt wurden + // Dies stellt sicher, dass Inventar und Transporte immer konsistent sind + if shipped > 0 { + if shipped >= item.quantity { + // Alles wurde in Transporte umgewandelt, Inventar komplett entfernen + conn.prepare("remove_inventory", QUERY_REMOVE_INVENTORY)?; + conn.execute("remove_inventory", &[&item.id])?; + item.quantity = 0; + } else { + // Inventar-Menge in der DB reduzieren und im Item anpassen + let remaining_quantity = item.quantity - shipped; + Self::update_inventory_quantity(conn, item.id, remaining_quantity)?; + item.quantity = remaining_quantity; + } + + eprintln!( + "[DirectorWorker] Transport geplant: {} Einheiten von Produkt {} von Region {} nach Region {} (Stückpreis lokal {:.2}, remote {:.2}). Inventar reduziert.", + shipped, item.product_id, item.region_id, target_region, local_piece_price, best_remote_piece_price + ); + } + + Ok(shipped) + } + + fn get_transport_vehicles_for_route( + conn: &mut DbConnection, + falukant_user_id: i32, + source_region: i32, + target_region: i32, + ) -> Result, DbError> { + // Debug: Prüfe zuerst, ob Fahrzeuge in der Quellregion existieren + conn.prepare("count_vehicles_in_region", QUERY_COUNT_VEHICLES_IN_REGION)?; + let vehicle_count_rows = conn.execute( + "count_vehicles_in_region", + &[&falukant_user_id, &source_region], + )?; + + let vehicle_count = vehicle_count_rows + .into_iter() + .next() + .and_then(|row| row.get("count").and_then(|v| v.parse::().ok())) + .unwrap_or(0); + + eprintln!( + "[DirectorWorker] Fahrzeuge in Region {} für User {}: {}", + source_region, falukant_user_id, vehicle_count + ); + + // Debug: Prüfe, ob eine Route existiert + conn.prepare("check_route", QUERY_CHECK_ROUTE)?; + let route_rows = conn.execute( + "check_route", + &[&source_region, &target_region], + )?; + + let route_exists = route_rows + .into_iter() + .next() + .and_then(|row| row.get("count").and_then(|v| v.parse::().ok())) + .unwrap_or(0) > 0; + + eprintln!( + "[DirectorWorker] Route von Region {} nach Region {} existiert: {}", + source_region, target_region, route_exists + ); + + conn.prepare( + "get_transport_vehicles_for_route", + QUERY_GET_TRANSPORT_VEHICLES_FOR_ROUTE, + )?; + let rows = conn.execute( + "get_transport_vehicles_for_route", + &[&falukant_user_id, &source_region, &target_region], + )?; + + let mut result = Vec::with_capacity(rows.len()); + for row in rows { + let id = row + .get("vehicle_id") + .and_then(|v| v.parse::().ok()) + .unwrap_or(-1); + let capacity = row + .get("capacity") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + if id >= 0 && capacity > 0 { + result.push(TransportVehicle { id, capacity }); + } + } + + eprintln!( + "[DirectorWorker] Gefundene Transportmittel für Route {} -> {}: {}", + source_region, target_region, result.len() + ); + + Ok(result) + } + + // Helper: load worth_percent values for a product across all regions of a user's branches + fn get_worth_by_region(conn: &mut DbConnection, falukant_user_id: i32, product_id: i32) -> Result, DbError> { + conn.prepare("get_region_worth_for_product", QUERY_GET_REGION_WORTH_FOR_PRODUCT)?; + let rows = conn.execute("get_region_worth_for_product", &[&falukant_user_id, &product_id])?; + let mut map = HashMap::new(); + for row in rows { + if let Some(rid) = row.get("region_id").and_then(|v| v.parse::().ok()) { + let percent = row.get("worth_percent").and_then(|v| v.parse::().ok()).unwrap_or(100.0); + map.insert(rid, percent); + } + } + Ok(map) + } + + // Helper: compute piece price for an arbitrary worth_percent + fn compute_piece_price_for_percent(item: &InventoryItem, percent: f64) -> f64 { + let base_price = item.sell_cost * (percent / 100.0); + let min_price = base_price * 0.6; + let max_price = base_price; + let knowledge_factor = item.quality as f64; + min_price + (max_price - min_price) * (knowledge_factor / 100.0) + } + + fn calc_max_capacity(vehicles: &[TransportVehicle]) -> i32 { + vehicles.iter().fold(0i32, |acc, v| acc.saturating_add(v.capacity)) + } + + fn calc_transport_cost(remote_piece_price: f64, qty: i32) -> f64 { + let total_value = remote_piece_price * qty as f64; + let transport_cost = total_value * 0.01_f64; + if transport_cost < 0.01 { 0.01 } else { transport_cost } + } + + fn insert_transports_for_route(conn: &mut DbConnection, item: &InventoryItem, target_region: i32, desired: i32) -> Result { + let vehicles = Self::get_transport_vehicles_for_route(conn, item.user_id, item.region_id, target_region)?; + if vehicles.is_empty() { return Ok(0); } + + conn.prepare("insert_transport", QUERY_INSERT_TRANSPORT)?; + let mut remaining = desired; + for v in &vehicles { + if remaining <= 0 { break; } + let size = std::cmp::min(remaining, v.capacity); + if size <= 0 { continue; } + conn.execute("insert_transport", &[&item.region_id, &target_region, &item.product_id, &size, &v.id])?; + remaining -= size; + } + Ok(desired - remaining.max(0)) + } + + /// Plant leere Transporte, um Fahrzeuge zurückzuholen, wenn: + /// - Keine Transportmittel im aktuellen Branch vorhanden sind + /// - Aber bessere Verkaufspreise in anderen Branches existieren + /// - Freie Transportmittel in anderen Regionen verfügbar sind + fn plan_empty_transports_for_vehicle_retrieval( + &mut self, + conn: &mut DbConnection, + falukant_user_id: i32, + current_branch_id: i32, + ) -> Result<(), DbError> { + // Aktuelle Branch-Region ermitteln + conn.prepare("get_branch_region", QUERY_GET_BRANCH_REGION)?; + let branch_rows = conn.execute("get_branch_region", &[¤t_branch_id])?; + + let current_region_id = match branch_rows.into_iter().next() { + Some(row) => row + .get("region_id") + .and_then(|v| v.parse::().ok()) + .ok_or_else(|| DbError::new("Konnte region_id nicht ermitteln"))?, + None => return Ok(()), // Branch nicht gefunden, nichts zu tun + }; + + // Alle anderen Branches des Users finden + conn.prepare("get_user_branches", QUERY_GET_USER_BRANCHES)?; + let branch_rows = conn.execute( + "get_user_branches", + &[&falukant_user_id, ¤t_region_id], + )?; + + if branch_rows.is_empty() { + eprintln!( + "[DirectorWorker] Keine anderen Branches für User {} gefunden", + falukant_user_id + ); + return Ok(()); + } + + // Für jeden anderen Branch prüfen, ob freie Transportmittel verfügbar sind + // und ob bessere Verkaufspreise existieren (zur Priorisierung) + conn.prepare("get_free_vehicles_in_region", QUERY_GET_FREE_VEHICLES_IN_REGION)?; + conn.prepare("get_region_worth_for_product", QUERY_GET_REGION_WORTH_FOR_PRODUCT)?; + conn.prepare("insert_empty_transport", QUERY_INSERT_EMPTY_TRANSPORT)?; + + // Sammle alle Branches mit freien Transportmitteln und berechne Preisvorteil + let mut branches_with_vehicles: Vec<(i32, i32, i32, f64)> = Vec::new(); // (branch_id, region_id, vehicle_count, price_delta) + + for branch_row in &branch_rows { + let target_region_id = branch_row + .get("region_id") + .and_then(|v| v.parse::().ok()) + .unwrap_or(-1); + let target_branch_id = branch_row + .get("branch_id") + .and_then(|v| v.parse::().ok()) + .unwrap_or(-1); + + if target_region_id < 0 || target_branch_id < 0 { + continue; + } + + // Prüfe auf freie Transportmittel in dieser Region + let vehicle_rows = conn.execute( + "get_free_vehicles_in_region", + &[&falukant_user_id, &target_region_id], + )?; + + if vehicle_rows.is_empty() { + continue; + } + + // Prüfe, ob eine Route zurück zum aktuellen Branch existiert + let vehicles = Self::get_transport_vehicles_for_route( + conn, + falukant_user_id, + target_region_id, + current_region_id, + )?; + + if vehicles.is_empty() { + continue; + } + + // Berechne Preisvorteil (vereinfacht: verwende worth_percent-Differenz) + // Hole worth_percent für beide Regionen (für ein beliebiges Produkt) + let mut price_delta = 0.0; + conn.prepare("get_average_worth", QUERY_GET_AVERAGE_WORTH)?; + let worth_rows = conn.execute( + "get_average_worth", + &[¤t_region_id, &target_region_id], + )?; + + if let Some(worth_row) = worth_rows.into_iter().next() { + let current_worth = worth_row + .get("current_worth") + .and_then(|v| v.parse::().ok()) + .unwrap_or(100.0); + let target_worth = worth_row + .get("target_worth") + .and_then(|v| v.parse::().ok()) + .unwrap_or(100.0); + price_delta = target_worth - current_worth; + } + + branches_with_vehicles.push(( + target_branch_id, + target_region_id, + vehicles.len() as i32, + price_delta, + )); + } + + if branches_with_vehicles.is_empty() { + eprintln!( + "[DirectorWorker] Keine Branches mit freien Transportmitteln gefunden" + ); + return Ok(()); + } + + // Wähle den Branch mit dem besten Preisvorteil (oder einfach den ersten, wenn alle gleich sind) + branches_with_vehicles.sort_by(|a, b| b.3.partial_cmp(&a.3).unwrap_or(std::cmp::Ordering::Equal)); + + let (target_branch_id, target_region_id, vehicle_count, price_delta) = branches_with_vehicles[0]; + + eprintln!( + "[DirectorWorker] Bester Branch für Fahrzeug-Rückholung: Branch {} (Region {}), {} Fahrzeuge, Preisvorteil: {:.2}%", + target_branch_id, target_region_id, vehicle_count, price_delta + ); + + // Hole die Fahrzeuge nochmal für diesen Branch + let vehicles = Self::get_transport_vehicles_for_route( + conn, + falukant_user_id, + target_region_id, + current_region_id, + )?; + + // Leere Transporte für alle verfügbaren Fahrzeuge anlegen + let mut transport_count = 0; + for vehicle in &vehicles { + conn.execute( + "insert_empty_transport", + &[&target_region_id, ¤t_region_id, &vehicle.id], + )?; + transport_count += 1; + } + + eprintln!( + "[DirectorWorker] {} leere Transporte geplant: Region {} -> Region {}", + transport_count, target_region_id, current_region_id + ); + + Ok(()) + } + + fn update_inventory_quantity( + conn: &mut DbConnection, + inventory_id: i32, + new_quantity: i32, + ) -> Result<(), DbError> { + conn.prepare("update_inventory_qty", QUERY_UPDATE_INVENTORY_QTY)?; + conn.execute("update_inventory_qty", &[&inventory_id, &new_quantity])?; + + Ok(()) + } + + fn pay_salary(&mut self) -> Result<(), DbError> { + self.base.set_current_step("DirectorWorker: pay_salary"); + + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("get_salary_to_pay", QUERY_GET_SALARY_TO_PAY)?; + conn.prepare("set_salary_payed", QUERY_SET_SALARY_PAYED)?; + + let rows = conn.execute("get_salary_to_pay", &[])?; + let salaries: Vec = + rows.into_iter().filter_map(Self::map_row_to_salary_item).collect(); + + for item in salaries { + if let Err(err) = self.base.change_falukant_user_money( + item.employer_user_id, + -(item.income as f64), + "director payed out", + ) { + eprintln!( + "[DirectorWorker] Fehler bei change_falukant_user_money (director payed out): {err}" + ); + } + + conn.execute("set_salary_payed", &[&item.id])?; + + let message = + format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, item.employer_user_id); + self.base.broker.publish(message); + } + + Ok(()) + } + + fn map_row_to_salary_item(row: Row) -> Option { + Some(SalaryItem { + id: row.get("id")?.parse().ok()?, + employer_user_id: row.get("employer_user_id")?.parse().ok()?, + income: row.get("income")?.parse().ok()?, + }) + } + + fn calculate_satisfaction(&mut self) -> Result<(), DbError> { + self.base + .set_current_step("DirectorWorker: calculate_satisfaction"); + + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("update_satisfaction", QUERY_UPDATE_SATISFACTION)?; + let rows = conn.execute("update_satisfaction", &[])?; + + for row in rows { + if let Some(employer_id) = row + .get("employer_user_id") + .and_then(|v| v.parse::().ok()) + { + let message = format!( + r#"{{"event":"directorchanged","user_id":{}}}"#, + employer_id + ); + self.base.broker.publish(message); + } + } + + Ok(()) + } +} + +impl Worker for DirectorWorker { + fn start_worker_thread(&mut self) { + let pool = self.base.pool.clone(); + let broker = self.base.broker.clone(); + + self.base + .start_worker_with_loop(move |state: Arc| { + let mut worker = DirectorWorker::new(pool.clone(), broker.clone()); + while state.running_worker.load(Ordering::Relaxed) { + worker.run_iteration(&state); + } + }); + } + + fn stop_worker_thread(&mut self) { + self.base.stop_worker(); + } + + fn enable_watchdog(&mut self) { + self.base.start_watchdog(); + } +} + + diff --git a/YpDaemon/src/worker/events.rs b/YpDaemon/src/worker/events.rs new file mode 100644 index 0000000..b86f9b3 --- /dev/null +++ b/YpDaemon/src/worker/events.rs @@ -0,0 +1,2046 @@ +use crate::db::{ConnectionPool, DbConnection, DbError}; +use crate::message_broker::MessageBroker; +use rand::Rng; +use rand::seq::SliceRandom; +use serde_json::json; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use super::base::{BaseWorker, Worker, WorkerState}; +use crate::worker::sql::{ + QUERY_GET_RANDOM_USER, + QUERY_GET_RANDOM_INFANT, + QUERY_GET_RANDOM_CITY, + QUERY_GET_AFFECTED_USERS, + QUERY_INSERT_NOTIFICATION, + QUERY_GET_MONEY, + QUERY_UPDATE_MONEY, + QUERY_GET_REGION_STOCKS, + QUERY_GET_USER_STOCKS, + QUERY_UPDATE_STOCK_CAPACITY, + QUERY_UPDATE_STOCK_CAPACITY_REGIONAL, + QUERY_GET_REGION_HOUSES, + QUERY_UPDATE_HOUSE_QUALITY, + QUERY_CHANGE_WEATHER, + QUERY_GET_RANDOM_CHARACTER, + QUERY_UPDATE_HEALTH, + QUERY_GET_REGION_CHARACTERS, + QUERY_GET_INVENTORY_ITEMS, + QUERY_REDUCE_INVENTORY, + QUERY_DELETE_INVENTORY, + QUERY_DELETE_STOCK, + QUERY_GET_STOCK_INVENTORY, + QUERY_CAP_INVENTORY, + QUERY_GET_USER_INVENTORY_ITEMS, + QUERY_GET_STOCK_TYPE_ID, + QUERY_REDUCE_INVENTORY_PERSONAL, + QUERY_DELETE_INVENTORY_PERSONAL, + QUERY_DELETE_STOCK_PERSONAL, + QUERY_GET_STOCK_INVENTORY_PERSONAL, + QUERY_CAP_INVENTORY_PERSONAL, + QUERY_DELETE_DIRECTOR, + QUERY_DELETE_RELATIONSHIP, + QUERY_GET_USER_ID, + QUERY_DELETE_CHILD_RELATION, + QUERY_DELETE_CHARACTER, + QUERY_GET_HEIR, + QUERY_SET_CHARACTER_USER, + QUERY_GET_CURRENT_MONEY, + QUERY_GET_HOUSE_VALUE, + QUERY_GET_SETTLEMENT_VALUE, + QUERY_GET_INVENTORY_VALUE, + QUERY_GET_CREDIT_DEBT, + QUERY_COUNT_CHILDREN, +}; + +/// Typisierung von Ereignissen +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum EventType { + /// Persönliches Ereignis für einen einzelnen Spieler + Personal, + /// Regionales Ereignis, das eine ganze Region betrifft + Regional, +} + +/// Mögliche Effekte, die ein Ereignis haben kann +#[derive(Debug, Clone)] +pub enum EventEffect { + /// Änderung des Geldes eines Spielers (in Prozent) + MoneyChange { probability: f64, min_percent: f64, max_percent: f64 }, + /// Änderung der Produktionsqualität in einer Region (in Prozentpunkten) + ProductionQualityChange { probability: f64, min_change: i32, max_change: i32 }, + /// Änderung der Verkaufspreise in einer Region (in Prozent) + PriceChange { probability: f64, min_percent: f64, max_percent: f64 }, + /// Änderung der Wetterbedingungen in einer Region + WeatherChange { probability: f64 }, + /// Änderung der Lagerkapazität eines Spielers (in Prozent) + StorageCapacityChange { probability: f64, min_percent: f64, max_percent: f64 }, + /// Änderung der Transportgeschwindigkeit (in Prozent) + TransportSpeedChange { probability: f64, min_percent: f64, max_percent: f64 }, + /// Änderung der Gesundheit eines Charakters (in Punkten, kann negativ sein) + CharacterHealthChange { probability: f64, min_change: i32, max_change: i32 }, + /// Ein Charakter stirbt (kann persönlich oder regional sein) + CharacterDeath { probability: f64 }, + /// Beschädigung von Lagern und Lagerbestand in einer Region + StorageDamage { + probability: f64, + /// Stock-Typ Label (z.B. "field" oder "wood") + stock_type_label: String, + /// Prozent des Lagerbestands, der betroffen ist (min-max) + inventory_damage_min_percent: f64, + inventory_damage_max_percent: f64, + /// Prozent der Lager, die zerstört werden (min-max) + storage_destruction_min_percent: f64, + storage_destruction_max_percent: f64, + }, + /// Änderung der Hausqualität (in Punkten, kann negativ sein) + HouseQualityChange { probability: f64, min_change: i32, max_change: i32 }, +} + +/// Definition eines zufälligen Ereignisses +#[derive(Debug, Clone)] +pub struct RandomEvent { + /// Eindeutiger Identifikations-String für das Ereignis + pub id: String, + /// Wahrscheinlichkeit pro Minute, dass dieses Ereignis auftritt (0.0 - 1.0) + pub probability_per_minute: f64, + /// Typ des Ereignisses (persönlich oder regional) + pub event_type: EventType, + /// Liste der möglichen Effekte dieses Ereignisses + pub effects: Vec, + /// Titel/Beschreibung des Ereignisses für Benachrichtigungen + pub title: String, + /// Detaillierte Beschreibung des Ereignisses + pub description: String, +} + +pub struct EventsWorker { + base: BaseWorker, +} + +/// Informationen über Lagerzerstörung durch ein Ereignis +struct StorageDamageInfo { + inventory_damage_percent: f64, + storage_destruction_percent: f64, + affected_stocks: i32, + destroyed_stocks: i32, +} + +/// Parameter für regionale Lager-Schäden +pub struct StorageDamageParams<'a> { + pub region_id: i32, + pub stock_type_label: &'a str, + pub inventory_damage_min_percent: f64, + pub inventory_damage_max_percent: f64, + pub storage_destruction_min_percent: f64, + pub storage_destruction_max_percent: f64, +} + +/// Parameter für persönliche Lager-Schäden +pub struct PersonalStorageDamageParams<'a> { + pub user_id: i32, + pub stock_type_label: &'a str, + pub inventory_damage_min_percent: f64, + pub inventory_damage_max_percent: f64, + pub storage_destruction_min_percent: f64, + pub storage_destruction_max_percent: f64, +} + +impl EventsWorker { + pub fn new(pool: ConnectionPool, broker: MessageBroker) -> Self { + Self { + base: BaseWorker::new("EventsWorker", pool, broker), + } + } + + /// Initialisiert die Liste aller möglichen Ereignisse + fn initialize_events() -> Vec { + vec![ + RandomEvent { + id: "windfall".to_string(), + probability_per_minute: 0.01, // 1% pro Minute + event_type: EventType::Personal, + title: "Unerwarteter Geldsegen".to_string(), + description: "Du findest eine vergessene Geldbörse auf der Straße.".to_string(), + effects: vec![ + EventEffect::MoneyChange { + probability: 1.0, + min_percent: 5.0, + max_percent: 15.0, + }, + ], + }, + RandomEvent { + id: "theft".to_string(), + probability_per_minute: 0.008, // 0.8% pro Minute + event_type: EventType::Personal, + title: "Diebstahl".to_string(), + description: "Ein Dieb hat einen Teil deines Geldes gestohlen.".to_string(), + effects: vec![ + EventEffect::MoneyChange { + probability: 1.0, + min_percent: -10.0, + max_percent: -5.0, + }, + ], + }, + RandomEvent { + id: "regional_storm".to_string(), + probability_per_minute: 0.005, // 0.5% pro Minute + event_type: EventType::Regional, + title: "Sturm in der Region".to_string(), + description: "Ein schwerer Sturm hat die Region getroffen.".to_string(), + effects: vec![ + EventEffect::WeatherChange { + probability: 1.0, + }, + EventEffect::ProductionQualityChange { + probability: 0.8, + min_change: -10, + max_change: -5, + }, + EventEffect::TransportSpeedChange { + probability: 0.6, + min_percent: -20.0, + max_percent: -10.0, + }, + EventEffect::StorageDamage { + probability: 1.0, + stock_type_label: "field".to_string(), + inventory_damage_min_percent: 5.0, + inventory_damage_max_percent: 75.0, + storage_destruction_min_percent: 0.0, + storage_destruction_max_percent: 50.0, + }, + EventEffect::StorageDamage { + probability: 1.0, + stock_type_label: "wood".to_string(), + inventory_damage_min_percent: 0.0, + inventory_damage_max_percent: 25.0, + storage_destruction_min_percent: 0.0, + storage_destruction_max_percent: 10.0, + }, + // Verbleibende Lager können durch den Sturm beschädigt werden und Kapazität verlieren + EventEffect::StorageCapacityChange { + probability: 0.7, // 70% Chance, dass verbleibende Lager beschädigt werden + min_percent: -10.0, + max_percent: -3.0, + }, + ], + }, + RandomEvent { + id: "regional_festival".to_string(), + probability_per_minute: 0.003, // 0.3% pro Minute + event_type: EventType::Regional, + title: "Regionales Fest".to_string(), + description: "Ein großes Fest findet in der Region statt.".to_string(), + effects: vec![ + EventEffect::PriceChange { + probability: 0.9, + min_percent: 5.0, + max_percent: 15.0, + }, + EventEffect::ProductionQualityChange { + probability: 0.5, + min_change: 2, + max_change: 5, + }, + ], + }, + RandomEvent { + id: "warehouse_fire".to_string(), + probability_per_minute: 0.002, // 0.2% pro Minute + event_type: EventType::Personal, + title: "Lagerbrand".to_string(), + description: "Ein Feuer hat Teile deines Lagers beschädigt.".to_string(), + effects: vec![ + // Feldlager: Lagerbestand kann zerstört werden, Lager können zerstört werden + EventEffect::StorageDamage { + probability: 1.0, + stock_type_label: "field".to_string(), + inventory_damage_min_percent: 0.0, + inventory_damage_max_percent: 100.0, + storage_destruction_min_percent: 0.0, + storage_destruction_max_percent: 50.0, + }, + // Holzlager: Lagerbestand kann zerstört werden, Lager können zerstört werden + EventEffect::StorageDamage { + probability: 1.0, + stock_type_label: "wood".to_string(), + inventory_damage_min_percent: 0.0, + inventory_damage_max_percent: 100.0, + storage_destruction_min_percent: 0.0, + storage_destruction_max_percent: 50.0, + }, + // Verbleibende Lager können durch das Feuer beschädigt werden und Kapazität verlieren + EventEffect::StorageCapacityChange { + probability: 0.8, // 80% Chance, dass verbleibende Lager beschädigt werden + min_percent: -15.0, + max_percent: -5.0, + }, + ], + }, + RandomEvent { + id: "character_illness".to_string(), + probability_per_minute: 0.01, // 1% pro Minute + event_type: EventType::Personal, + title: "Krankheit".to_string(), + description: "Ein Charakter ist erkrankt und hat an Gesundheit verloren.".to_string(), + effects: vec![ + EventEffect::CharacterHealthChange { + probability: 1.0, + min_change: -20, + max_change: -5, + }, + ], + }, + RandomEvent { + id: "character_recovery".to_string(), + probability_per_minute: 0.008, // 0.8% pro Minute + event_type: EventType::Personal, + title: "Genesung".to_string(), + description: "Ein Charakter hat sich von einer Krankheit erholt.".to_string(), + effects: vec![ + EventEffect::CharacterHealthChange { + probability: 1.0, + min_change: 5, + max_change: 15, + }, + ], + }, + RandomEvent { + id: "character_accident".to_string(), + probability_per_minute: 0.003, // 0.3% pro Minute + event_type: EventType::Personal, + title: "Unfall".to_string(), + description: "Ein schwerer Unfall hat einen Charakter schwer verletzt.".to_string(), + effects: vec![ + EventEffect::CharacterHealthChange { + probability: 1.0, + min_change: -50, + max_change: -30, + }, + EventEffect::CharacterDeath { + probability: 0.2, // 20% Chance auf Tod + }, + ], + }, + RandomEvent { + id: "regional_epidemic".to_string(), + probability_per_minute: 0.002, // 0.2% pro Minute + event_type: EventType::Regional, + title: "Epidemie".to_string(), + description: "Eine Seuche hat die Region erfasst.".to_string(), + effects: vec![ + EventEffect::CharacterHealthChange { + probability: 0.8, // 80% der Charaktere betroffen + min_change: -15, + max_change: -5, + }, + EventEffect::CharacterDeath { + probability: 0.1, // 10% Chance auf Tod pro Charakter + }, + ], + }, + RandomEvent { + id: "sudden_infant_death".to_string(), + // Wahrscheinlichkeit pro Minute: 0.0005 (0.05%) + // Im Mittelalter starben etwa 30-40% der Kinder vor dem 2. Geburtstag + // Diese Wahrscheinlichkeit führt bei regelmäßiger Prüfung zu einer + // realistischen mittelalterlichen Säuglingssterblichkeit + probability_per_minute: 0.0005, + event_type: EventType::Personal, + title: "Plötzlicher Kindstod".to_string(), + description: "Ein Kleinkind ist plötzlich verstorben.".to_string(), + effects: vec![ + EventEffect::CharacterDeath { + probability: 1.0, // Wenn das Ereignis auftritt, stirbt das Kind + }, + ], + }, + RandomEvent { + id: "earthquake".to_string(), + probability_per_minute: 0.001, // 0.1% pro Minute (sehr selten) + event_type: EventType::Regional, + title: "Erdbeben".to_string(), + description: "Ein Erdbeben hat die Region erschüttert.".to_string(), + effects: vec![ + EventEffect::CharacterHealthChange { + probability: 0.3, // 30% Chance auf Gesundheitsschäden (geringe Wahrscheinlichkeit) + min_change: -20, + max_change: -5, + }, + EventEffect::CharacterDeath { + probability: 0.05, // 5% Chance auf Tod (sehr geringe Wahrscheinlichkeit) + }, + EventEffect::StorageCapacityChange { + probability: 1.0, // Alle Lager werden beschädigt + min_percent: -20.0, + max_percent: -5.0, + }, + EventEffect::HouseQualityChange { + probability: 1.0, // Alle Häuser werden beschädigt + min_change: -15, + max_change: -5, + }, + ], + }, + ] + } + + fn run_loop(pool: ConnectionPool, broker: MessageBroker, state: Arc) { + let mut last_event_check = None; + let mut rng = rand::thread_rng(); + let events = Self::initialize_events(); + + loop { + if !state.running_worker.load(Ordering::Relaxed) { + break; + } + + let now = Instant::now(); + + // Ereignisse einmal pro Minute prüfen + if should_run_interval(last_event_check, now, Duration::from_secs(60)) { + if let Err(err) = Self::check_and_trigger_events_inner( + &pool, + &broker, + &state, + &mut rng, + &events, + ) { + eprintln!("[EventsWorker] Fehler beim Prüfen von Ereignissen: {err}"); + } + last_event_check = Some(now); + } + + // 10-Sekunden-Wartezeit in kurze Scheiben aufteilen, damit ein Shutdown + // (running_worker = false) schnell greift. + const SLICE_MS: u64 = 500; + let total_ms = 10_000; + let mut slept = 0; + while slept < total_ms { + if !state.running_worker.load(Ordering::Relaxed) { + break; + } + let remaining = total_ms - slept; + let slice = SLICE_MS.min(remaining); + std::thread::sleep(Duration::from_millis(slice)); + slept += slice; + } + } + } + + // Globaler Skalierungsfaktor für Ereignisfrequenz (1.0 = unverändert). + // Setze auf 0.05, um Ereignisse auf 1/20 der ursprünglichen Häufigkeit zu reduzieren. + const EVENT_RATE_SCALE: f64 = 0.05; + + fn check_and_trigger_events_inner( + pool: &ConnectionPool, + broker: &MessageBroker, + _state: &Arc, + rng: &mut impl Rng, + events: &[RandomEvent], + ) -> Result<(), DbError> { + // Prüfe jedes mögliche Ereignis + for event in events { + // Zufällige Prüfung basierend auf Wahrscheinlichkeit + let roll = rng.gen_range(0.0..=1.0); + let effective_prob = event.probability_per_minute * Self::EVENT_RATE_SCALE; + if roll < effective_prob { + eprintln!( + "[EventsWorker] Ereignis '{}' wurde ausgelöst (Wahrscheinlichkeit: {:.4}% -> skaliert {:.4}%)", + event.id, + event.probability_per_minute * 100.0, + effective_prob * 100.0 + ); + + match event.event_type { + EventType::Personal => { + Self::trigger_personal_event(pool, broker, event, rng)?; + } + EventType::Regional => { + Self::trigger_regional_event(pool, broker, event, rng)?; + } + } + } + } + + Ok(()) + } + + fn trigger_personal_event( + pool: &ConnectionPool, + broker: &MessageBroker, + event: &RandomEvent, + rng: &mut impl Rng, + ) -> Result<(), DbError> { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + // Spezielle Behandlung für plötzlichen Kindstod: Finde ein zufälliges Kind unter 2 Jahren + if event.id == "sudden_infant_death" { + return Self::trigger_sudden_infant_death(pool, broker, event, rng); + } + + conn.prepare("get_random_user", QUERY_GET_RANDOM_USER)?; + let rows = conn.execute("get_random_user", &[])?; + + let user_id: Option = rows + .first() + .and_then(|r| r.get("id")) + .and_then(|v| v.parse::().ok()); + + let user_id = match user_id { + Some(id) => id, + None => { + eprintln!("[EventsWorker] Kein Spieler gefunden für persönliches Ereignis"); + return Ok(()); + } + }; + + // Wende Effekte an + let mut effect_results = Vec::new(); + for effect in &event.effects { + let effect_roll = rng.gen_range(0.0..=1.0); + match effect { + EventEffect::MoneyChange { + probability, + min_percent, + max_percent, + } => { + if effect_roll < *probability { + let percent_change = rng.gen_range(*min_percent..=*max_percent); + if let Ok(absolute_change) = Self::apply_money_change(&mut conn, user_id, percent_change) { + effect_results.push(json!({ + "type": "money_change", + "percent": percent_change, + "absolute": absolute_change + })); + } + } + } + EventEffect::StorageCapacityChange { + probability, + min_percent, + max_percent, + } => { + if effect_roll < *probability { + let percent_change = rng.gen_range(*min_percent..=*max_percent); + Self::apply_storage_capacity_change(&mut conn, user_id, percent_change)?; + effect_results.push(json!({ + "type": "storage_capacity_change", + "percent": percent_change + })); + } + } + EventEffect::CharacterHealthChange { + probability, + min_change, + max_change, + } => { + if effect_roll < *probability + && let Ok((character_id, health_change)) = Self::apply_character_health_change( + &mut conn, + user_id, + *min_change, + *max_change, + rng, + ) + { + effect_results.push(json!({ + "type": "character_health_change", + "character_id": character_id, + "change": health_change + })); + } + } + EventEffect::CharacterDeath { probability } => { + if effect_roll < *probability + && let Ok(character_id) = Self::apply_character_death(&mut conn, user_id, pool, broker) + { + effect_results.push(json!({ + "type": "character_death", + "character_id": character_id + })); + } + } + EventEffect::StorageDamage { + probability, + stock_type_label, + inventory_damage_min_percent, + inventory_damage_max_percent, + storage_destruction_min_percent, + storage_destruction_max_percent, + } => { + if effect_roll < *probability + && let Ok(damage_info) = Self::apply_personal_storage_damage( + &mut conn, + PersonalStorageDamageParams { + user_id, + stock_type_label, + inventory_damage_min_percent: *inventory_damage_min_percent, + inventory_damage_max_percent: *inventory_damage_max_percent, + storage_destruction_min_percent: *storage_destruction_min_percent, + storage_destruction_max_percent: *storage_destruction_max_percent, + }, + rng, + ) + { + effect_results.push(json!({ + "type": "storage_damage", + "stock_type": stock_type_label, + "inventory_damage_percent": damage_info.inventory_damage_percent, + "storage_destruction_percent": damage_info.storage_destruction_percent, + "affected_stocks": damage_info.affected_stocks, + "destroyed_stocks": damage_info.destroyed_stocks, + })); + } + } + _ => { + eprintln!( + "[EventsWorker] Effekt {:?} wird für persönliche Ereignisse noch nicht unterstützt", + effect + ); + } + } + } + + // Schreibe Benachrichtigung in die Datenbank mit Event-Details + // If any effect contains a character_id, include it at top-level for the notification + let top_character_id = effect_results.iter().find_map(|eff| { + eff.get("character_id").and_then(|v| v.as_i64()).map(|n| n as i32) + }); + + let mut notification_json = serde_json::json!({ + "tr": format!("random_event.{}", event.id), + "event_id": event.id, + "event_type": "personal", + "effects": effect_results + }); + + if let Some(cid) = top_character_id { + notification_json["character_id"] = serde_json::json!(cid); + } + + Self::notify_user(pool, broker, user_id, ¬ification_json.to_string())?; + + // Sende Benachrichtigung über WebSocket + let mut notification = json!({ + "event": "random_event", + "event_id": event.id, + "event_type": "personal", + "user_id": user_id, + "title": event.title, + "description": event.description, + "effects": effect_results + }); + + if let Some(cid) = top_character_id { + notification["character_id"] = json!(cid); + } + + broker.publish(notification.to_string()); + eprintln!( + "[EventsWorker] Persönliches Ereignis '{}' für Spieler {} verarbeitet", + event.id, user_id + ); + + Ok(()) + } + + fn trigger_sudden_infant_death( + pool: &ConnectionPool, + broker: &MessageBroker, + event: &RandomEvent, + rng: &mut impl Rng, + ) -> Result<(), DbError> { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + // Finde ein zufälliges Kind unter 2 Jahren + // Maximalalter: 730 Tage (2 Jahre) - festgelegt in der WHERE-Klausel unten + conn.prepare("get_random_infant", QUERY_GET_RANDOM_INFANT)?; + let rows = conn.execute("get_random_infant", &[])?; + + let character_id: Option = rows + .first() + .and_then(|r| r.get("character_id")) + .and_then(|v| v.parse::().ok()); + + let character_id = match character_id { + Some(id) => id, + None => { + eprintln!("[EventsWorker] Kein Kind unter 2 Jahren gefunden für plötzlichen Kindstod"); + return Ok(()); + } + }; + + let user_id: Option = rows + .first() + .and_then(|r| r.get("user_id")) + .and_then(|v| v.parse::().ok()); + + let user_id = match user_id { + Some(id) => id, + None => { + eprintln!("[EventsWorker] Kein user_id für Kind {} gefunden", character_id); + return Ok(()); + } + }; + + // Wende Effekte an (in diesem Fall nur CharacterDeath) + let mut effect_results = Vec::new(); + for effect in &event.effects { + let effect_roll = rng.gen_range(0.0..=1.0); + match effect { + EventEffect::CharacterDeath { probability } => { + if effect_roll < *probability + && Self::handle_character_death(pool, broker, character_id).is_ok() + { + effect_results.push(json!({ + "type": "character_death", + "character_id": character_id + })); + } + } + _ => { + eprintln!( + "[EventsWorker] Effekt {:?} wird für plötzlichen Kindstod nicht unterstützt", + effect + ); + } + } + } + + // Schreibe Benachrichtigung in die Datenbank mit Event-Details + let notification_json = serde_json::json!({ + "tr": format!("random_event.{}", event.id), + "event_id": event.id, + "event_type": "personal", + "character_id": character_id, + "effects": effect_results + }); + Self::notify_user(pool, broker, user_id, ¬ification_json.to_string())?; + + // Sende Benachrichtigung über WebSocket + let notification = json!({ + "event": "random_event", + "event_id": event.id, + "event_type": "personal", + "user_id": user_id, + "character_id": character_id, + "title": event.title, + "description": event.description, + "effects": effect_results + }); + + broker.publish(notification.to_string()); + eprintln!( + "[EventsWorker] Plötzlicher Kindstod für Charakter {} (Spieler {}) verarbeitet", + character_id, user_id + ); + + Ok(()) + } + + fn trigger_regional_event( + pool: &ConnectionPool, + broker: &MessageBroker, + event: &RandomEvent, + rng: &mut impl Rng, + ) -> Result<(), DbError> { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + // Hole eine zufällige Stadt-Region + conn.prepare("get_random_city", QUERY_GET_RANDOM_CITY)?; + let rows = conn.execute("get_random_city", &[])?; + + let region_id: Option = rows + .first() + .and_then(|r| r.get("region_id")) + .and_then(|v| v.parse::().ok()); + + let region_id = match region_id { + Some(id) => id, + None => { + eprintln!("[EventsWorker] Keine Stadt-Region gefunden für regionales Ereignis"); + return Ok(()); + } + }; + + // Wende Effekte an + let mut effect_results = Vec::new(); + for effect in &event.effects { + let effect_roll = rng.gen_range(0.0..=1.0); + match effect { + EventEffect::WeatherChange { probability } => { + if effect_roll < *probability { + Self::apply_weather_change(&mut conn, region_id)?; + effect_results.push(json!({ + "type": "weather_change" + })); + } + } + EventEffect::ProductionQualityChange { + probability, + min_change, + max_change, + } => { + if effect_roll < *probability { + let change = rng.gen_range(*min_change..=*max_change); + Self::apply_production_quality_change(&mut conn, region_id, change)?; + effect_results.push(json!({ + "type": "production_quality_change", + "change": change + })); + } + } + EventEffect::PriceChange { + probability, + min_percent, + max_percent, + } => { + if effect_roll < *probability { + let percent_change = rng.gen_range(*min_percent..=*max_percent); + Self::apply_price_change(&mut conn, region_id, percent_change)?; + effect_results.push(json!({ + "type": "price_change", + "percent": percent_change + })); + } + } + EventEffect::TransportSpeedChange { + probability, + min_percent, + max_percent, + } => { + if effect_roll < *probability { + let percent_change = rng.gen_range(*min_percent..=*max_percent); + Self::apply_transport_speed_change(&mut conn, region_id, percent_change)?; + effect_results.push(json!({ + "type": "transport_speed_change", + "percent": percent_change + })); + } + } + EventEffect::CharacterHealthChange { + probability, + min_change, + max_change, + } => { + if effect_roll < *probability { + // Für regionale Ereignisse: Betrifft alle Charaktere in der Region + if let Ok(affected_characters) = Self::apply_regional_character_health_change( + &mut conn, + region_id, + *min_change, + *max_change, + rng, + ) { + effect_results.push(json!({ + "type": "character_health_change", + "affected_count": affected_characters.len(), + "characters": affected_characters + })); + } + } + } + EventEffect::CharacterDeath { probability } => { + if effect_roll < *probability { + // Für regionale Ereignisse: Betrifft alle Charaktere in der Region + if let Ok(dead_characters) = Self::apply_regional_character_death( + &mut conn, + region_id, + pool, + broker, + ) { + effect_results.push(json!({ + "type": "character_death", + "dead_count": dead_characters.len(), + "characters": dead_characters + })); + } + } + } + EventEffect::StorageDamage { + probability, + stock_type_label, + inventory_damage_min_percent, + inventory_damage_max_percent, + storage_destruction_min_percent, + storage_destruction_max_percent, + } => { + if effect_roll < *probability + && let Ok(damage_info) = Self::apply_storage_damage( + &mut conn, + StorageDamageParams { + region_id, + stock_type_label, + inventory_damage_min_percent: *inventory_damage_min_percent, + inventory_damage_max_percent: *inventory_damage_max_percent, + storage_destruction_min_percent: *storage_destruction_min_percent, + storage_destruction_max_percent: *storage_destruction_max_percent, + }, + rng, + ) + { + effect_results.push(json!({ + "type": "storage_damage", + "stock_type": stock_type_label, + "inventory_damage_percent": damage_info.inventory_damage_percent, + "storage_destruction_percent": damage_info.storage_destruction_percent, + "affected_stocks": damage_info.affected_stocks, + "destroyed_stocks": damage_info.destroyed_stocks, + })); + } + } + EventEffect::StorageCapacityChange { + probability, + min_percent, + max_percent, + } => { + if effect_roll < *probability + && let Ok((affected_stocks, percent_change)) = Self::apply_regional_storage_capacity_change( + &mut conn, + region_id, + *min_percent, + *max_percent, + rng, + ) + { + effect_results.push(json!({ + "type": "storage_capacity_change", + "percent": percent_change, + "affected_stocks": affected_stocks, + })); + } + } + EventEffect::HouseQualityChange { + probability, + min_change, + max_change, + } => { + if effect_roll < *probability + && let Ok((affected_houses, quality_change)) = Self::apply_regional_house_quality_change( + &mut conn, + region_id, + *min_change, + *max_change, + rng, + ) + { + effect_results.push(json!({ + "type": "house_quality_change", + "change": quality_change, + "affected_houses": affected_houses, + })); + } + } + _ => { + eprintln!( + "[EventsWorker] Effekt {:?} wird für regionale Ereignisse noch nicht unterstützt", + effect + ); + } + } + } + + // Finde alle betroffenen User in dieser Region (User mit Branches) + conn.prepare("get_affected_users", QUERY_GET_AFFECTED_USERS)?; + let user_rows = conn.execute("get_affected_users", &[®ion_id])?; + + // Sende Benachrichtigung an jeden betroffenen User einzeln + let mut notified_users = 0; + for row in user_rows { + let user_id: Option = row + .get("user_id") + .and_then(|v| v.parse::().ok()); + + if let Some(uid) = user_id { + // Schreibe Benachrichtigung in die Datenbank mit Event-Details + let notification_json = serde_json::json!({ + "tr": format!("random_event.{}", event.id), + "event_id": event.id, + "event_type": "regional", + "region_id": region_id, + "effects": effect_results + }); + if let Err(err) = Self::notify_user(pool, broker, uid, ¬ification_json.to_string()) { + eprintln!("[EventsWorker] Fehler beim Schreiben der Benachrichtigung für User {}: {}", uid, err); + } + + // Sende Benachrichtigung über WebSocket + let notification = json!({ + "event": "random_event", + "event_id": event.id, + "event_type": "regional", + "region_id": region_id, + "user_id": uid, + "title": event.title, + "description": event.description, + "effects": effect_results + }); + + broker.publish(notification.to_string()); + notified_users += 1; + } + } + + eprintln!( + "[EventsWorker] Regionales Ereignis '{}' für Region {} verarbeitet, {} User benachrichtigt", + event.id, region_id, notified_users + ); + + Ok(()) + } + + // Hilfsfunktionen zum Anwenden von Effekten + + fn apply_money_change( + conn: &mut DbConnection, + user_id: i32, + percent_change: f64, + ) -> Result { + // Hole aktuelles Geld + conn.prepare("get_money", QUERY_GET_MONEY)?; + let rows = conn.execute("get_money", &[&user_id])?; + + let current_money: Option = rows + .first() + .and_then(|r| r.get("money")) + .and_then(|v| v.parse::().ok()); + + let current_money = match current_money { + Some(m) => m, + None => { + eprintln!("[EventsWorker] Spieler {} nicht gefunden", user_id); + return Err(DbError::new("Spieler nicht gefunden".to_string())); + } + }; + + let change = current_money * (percent_change / 100.0); + let action = format!("Zufallsereignis: Geldänderung {:.2}%", percent_change); + + // Verwende parametrisierte Queries für Sicherheit gegen SQL-Injection + conn.prepare("update_money_event", QUERY_UPDATE_MONEY)?; + let _ = conn.execute("update_money_event", &[&user_id, &change, &action])?; + + // Best-effort money_history insert for UI/history visibility. + let money_str = format!("{:.2}", change); + fn escape_sql_literal(s: &str) -> String { s.replace('\'', "''") } + let escaped_action = escape_sql_literal(&action); + let create_sql = r#" + CREATE TABLE IF NOT EXISTS falukant_log.money_history ( + id BIGSERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + change NUMERIC(10,2) NOT NULL, + action TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + "#; + let _ = conn.query(create_sql); + + let history_sql = format!( + "INSERT INTO falukant_log.money_history (user_id, change, action, created_at) VALUES ({uid}, {money}::numeric, '{act}', NOW());", + uid = user_id, + money = money_str, + act = escaped_action + ); + if let Err(err) = conn.query(&history_sql) { + eprintln!( + "[EventsWorker] Warning: inserting money_history failed for user {}: {}", + user_id, err + ); + } + + Ok(change) + } + + fn apply_storage_capacity_change( + conn: &mut DbConnection, + user_id: i32, + percent_change: f64, + ) -> Result<(), DbError> { + // Hole alle Stocks des Spielers + conn.prepare("get_user_stocks_capacity", QUERY_GET_USER_STOCKS)?; + let stock_rows = conn.execute("get_user_stocks_capacity", &[&user_id])?; + + if stock_rows.is_empty() { + eprintln!( + "[EventsWorker] Keine Stocks für Spieler {} gefunden", + user_id + ); + return Ok(()); + } + + // Reduziere die Kapazität aller Stocks + conn.prepare("update_stock_capacity", QUERY_UPDATE_STOCK_CAPACITY)?; + + let mut affected_stocks = 0; + for row in stock_rows { + let stock_id: Option = row + .get("stock_id") + .and_then(|v| v.parse::().ok()); + + let stock_id = match stock_id { + Some(id) => id, + None => continue, + }; + + let current_capacity: i32 = row + .get("current_capacity") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + if current_capacity > 0 { + conn.execute("update_stock_capacity", &[&percent_change, &stock_id])?; + affected_stocks += 1; + } + } + + eprintln!( + "[EventsWorker] Lagerkapazitätsänderung für Spieler {}: {:.2}% bei {} Stocks", + user_id, percent_change, affected_stocks + ); + + Ok(()) + } + + fn apply_regional_storage_capacity_change( + conn: &mut DbConnection, + region_id: i32, + min_percent: f64, + max_percent: f64, + rng: &mut impl Rng, + ) -> Result<(i32, f64), DbError> { + // Hole alle Stocks in der Region + conn.prepare("get_region_stocks_capacity", QUERY_GET_REGION_STOCKS)?; + let stock_rows = conn.execute("get_region_stocks_capacity", &[®ion_id])?; + + if stock_rows.is_empty() { + eprintln!( + "[EventsWorker] Keine Stocks in Region {} gefunden", + region_id + ); + return Ok((0, 0.0)); + } + + // Berechne die prozentuale Änderung + let percent_change = rng.gen_range(min_percent..=max_percent); + + // Reduziere die Kapazität aller Stocks + conn.prepare("update_stock_capacity_regional", QUERY_UPDATE_STOCK_CAPACITY_REGIONAL)?; + + let mut affected_stocks = 0; + for row in stock_rows { + let stock_id: Option = row + .get("stock_id") + .and_then(|v| v.parse::().ok()); + + let stock_id = match stock_id { + Some(id) => id, + None => continue, + }; + + let current_capacity: i32 = row + .get("current_capacity") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + if current_capacity > 0 { + conn.execute("update_stock_capacity_regional", &[&percent_change, &stock_id])?; + affected_stocks += 1; + } + } + + eprintln!( + "[EventsWorker] Regionale Lagerkapazitätsänderung für Region {}: {:.2}% bei {} Stocks", + region_id, percent_change, affected_stocks + ); + + Ok((affected_stocks, percent_change)) + } + + fn apply_regional_house_quality_change( + conn: &mut DbConnection, + region_id: i32, + min_change: i32, + max_change: i32, + rng: &mut impl Rng, + ) -> Result<(i32, i32), DbError> { + // Hole alle Häuser in der Region + conn.prepare("get_region_houses", QUERY_GET_REGION_HOUSES)?; + let house_rows = conn.execute("get_region_houses", &[®ion_id])?; + + if house_rows.is_empty() { + eprintln!( + "[EventsWorker] Keine Häuser in Region {} gefunden", + region_id + ); + return Ok((0, 0)); + } + + // Berechne die Änderung + let quality_change = rng.gen_range(min_change..=max_change); + + // Reduziere die Qualität aller Häuser + conn.prepare("update_house_quality", QUERY_UPDATE_HOUSE_QUALITY)?; + + let mut affected_houses = 0; + for row in house_rows { + let house_id: Option = row + .get("house_id") + .and_then(|v| v.parse::().ok()); + + let house_id = match house_id { + Some(id) => id, + None => continue, + }; + + conn.execute("update_house_quality", &[&quality_change, &house_id])?; + affected_houses += 1; + } + + eprintln!( + "[EventsWorker] Regionale Hausqualitätsänderung für Region {}: {} Punkte bei {} Häusern", + region_id, quality_change, affected_houses + ); + + Ok((affected_houses, quality_change)) + } + + fn apply_weather_change( + conn: &mut DbConnection, + region_id: i32, + ) -> Result<(), DbError> { + // Wähle ein zufälliges Wetter + conn.prepare("change_weather", QUERY_CHANGE_WEATHER)?; + conn.execute("change_weather", &[®ion_id])?; + + Ok(()) + } + + fn apply_production_quality_change( + _conn: &mut DbConnection, + region_id: i32, + change: i32, + ) -> Result<(), DbError> { + // TODO: Implementierung für temporäre Produktionsqualitätsänderung + // Dies könnte eine temporäre Modifikation sein, die nach einer bestimmten Zeit abläuft + eprintln!( + "[EventsWorker] Produktionsqualitätsänderung für Region {}: {} (noch nicht implementiert)", + region_id, change + ); + Ok(()) + } + + fn apply_price_change( + _conn: &mut DbConnection, + region_id: i32, + percent_change: f64, + ) -> Result<(), DbError> { + // TODO: Implementierung für temporäre Preisänderung + // Dies könnte eine temporäre Modifikation der worth_percent Werte sein + eprintln!( + "[EventsWorker] Preisänderung für Region {}: {:.2}% (noch nicht implementiert)", + region_id, percent_change + ); + Ok(()) + } + + fn apply_transport_speed_change( + _conn: &mut DbConnection, + region_id: i32, + percent_change: f64, + ) -> Result<(), DbError> { + // TODO: Implementierung für temporäre Transportgeschwindigkeitsänderung + eprintln!( + "[EventsWorker] Transportgeschwindigkeitsänderung für Region {}: {:.2}% (noch nicht implementiert)", + region_id, percent_change + ); + Ok(()) + } + + fn apply_character_health_change( + conn: &mut DbConnection, + user_id: i32, + min_change: i32, + max_change: i32, + rng: &mut impl Rng, + ) -> Result<(i32, i32), DbError> { + // Hole einen zufälligen Charakter des Spielers + conn.prepare("get_random_character", QUERY_GET_RANDOM_CHARACTER)?; + let rows = conn.execute("get_random_character", &[&user_id])?; + + let character_id: Option = rows + .first() + .and_then(|r| r.get("id")) + .and_then(|v| v.parse::().ok()); + + let character_id = match character_id { + Some(id) => id, + None => { + eprintln!("[EventsWorker] Kein lebender Charakter für Spieler {} gefunden", user_id); + return Err(DbError::new("Kein Charakter gefunden".to_string())); + } + }; + + let current_health: i32 = rows + .first() + .and_then(|r| r.get("health")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(100); + + let health_change = rng.gen_range(min_change..=max_change); + let new_health = (current_health + health_change).clamp(0, 100); + + // Update Gesundheit + conn.prepare("update_health", QUERY_UPDATE_HEALTH)?; + conn.execute("update_health", &[&new_health, &character_id])?; + + Ok((character_id, health_change)) + } + + fn apply_character_death( + conn: &mut DbConnection, + user_id: i32, + pool: &ConnectionPool, + broker: &MessageBroker, + ) -> Result { + // Hole einen zufälligen Charakter des Spielers + conn.prepare("get_random_character_death", QUERY_GET_RANDOM_CHARACTER)?; + let rows = conn.execute("get_random_character_death", &[&user_id])?; + + let character_id: Option = rows + .first() + .and_then(|r| r.get("id")) + .and_then(|v| v.parse::().ok()); + + let character_id = match character_id { + Some(id) => id, + None => { + eprintln!("[EventsWorker] Kein lebender Charakter für Spieler {} gefunden", user_id); + return Err(DbError::new("Kein Charakter gefunden".to_string())); + } + }; + + // Verwende die existierende Logik zum Löschen von Charakteren + // (ähnlich wie in CharacterCreationWorker) + Self::handle_character_death(pool, broker, character_id)?; + + Ok(character_id) + } + + fn apply_regional_character_health_change( + conn: &mut DbConnection, + region_id: i32, + min_change: i32, + max_change: i32, + rng: &mut impl Rng, + ) -> Result, DbError> { + // Hole alle lebenden Charaktere in der Region + conn.prepare("get_region_characters", QUERY_GET_REGION_CHARACTERS)?; + let rows = conn.execute("get_region_characters", &[®ion_id])?; + + let mut affected_characters = Vec::new(); + + for row in rows { + let character_id: Option = row + .get("id") + .and_then(|v| v.parse::().ok()); + + let character_id = match character_id { + Some(id) => id, + None => continue, + }; + + let current_health: i32 = row + .get("health") + .and_then(|v| v.parse::().ok()) + .unwrap_or(100); + + let health_change = rng.gen_range(min_change..=max_change); + let new_health = (current_health + health_change).clamp(0, 100); + + // Update Gesundheit + conn.prepare("update_health_regional", QUERY_UPDATE_HEALTH)?; + conn.execute("update_health_regional", &[&new_health, &character_id])?; + + affected_characters.push((character_id, health_change)); + } + + Ok(affected_characters) + } + + fn apply_regional_character_death( + conn: &mut DbConnection, + region_id: i32, + pool: &ConnectionPool, + broker: &MessageBroker, + ) -> Result, DbError> { + // Hole alle lebenden Charaktere in der Region + conn.prepare("get_region_characters_death", QUERY_GET_REGION_CHARACTERS)?; + let rows = conn.execute("get_region_characters_death", &[®ion_id])?; + + let mut dead_characters = Vec::new(); + + for row in rows { + let character_id: Option = row + .get("id") + .and_then(|v| v.parse::().ok()); + + let character_id = match character_id { + Some(id) => id, + None => continue, + }; + + // Verwende die existierende Logik zum Löschen von Charakteren + if Self::handle_character_death(pool, broker, character_id).is_ok() { + dead_characters.push(character_id); + } + } + + Ok(dead_characters) + } + + fn handle_character_death( + pool: &ConnectionPool, + broker: &MessageBroker, + character_id: i32, + ) -> Result<(), DbError> { + // Diese Funktion verwendet die gleiche Logik wie CharacterCreationWorker + // Wir müssen die Queries aus character_creation.rs verwenden + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + // 1) Director löschen und User benachrichtigen + conn.prepare("delete_director", QUERY_DELETE_DIRECTOR)?; + let dir_result = conn.execute("delete_director", &[&character_id])?; + for row in dir_result { + if let Some(user_id) = row + .get("employer_user_id") + .and_then(|v| v.parse::().ok()) + { + Self::notify_user(pool, broker, user_id, "director_death")?; + } + } + + // 2) Relationships löschen und betroffene User benachrichtigen + conn.prepare("delete_relationship", QUERY_DELETE_RELATIONSHIP)?; + let rel_result = conn.execute("delete_relationship", &[&character_id])?; + for row in rel_result { + if let Some(related_user_id) = row + .get("related_user_id") + .and_then(|v| v.parse::().ok()) + { + Self::notify_user(pool, broker, related_user_id, "relationship_death")?; + } + } + + // 3) Erben-Logik für Spieler-Charaktere (VOR dem Löschen der Child-Relations!) + // Prüfe, ob der Charakter ein Spieler-Charakter ist + conn.prepare("get_user_id", QUERY_GET_USER_ID)?; + let user_rows = conn.execute("get_user_id", &[&character_id])?; + + let user_id: Option = user_rows + .first() + .and_then(|r| r.get("user_id")) + .and_then(|v| v.parse::().ok()); + + if let Some(falukant_user_id) = user_id { + // Spieler-Charakter: Erben-Logik ausführen + // WICHTIG: Dies muss VOR dem Löschen der Child-Relations passieren! + Self::handle_inheritance(pool, broker, &mut conn, character_id, falukant_user_id)?; + } + + // 4) Child-Relations löschen und Eltern benachrichtigen + conn.prepare("delete_child_relation", QUERY_DELETE_CHILD_RELATION)?; + let child_result = conn.execute("delete_child_relation", &[&character_id])?; + for row in child_result { + if let Some(father_user_id) = row + .get("father_user_id") + .and_then(|v| v.parse::().ok()) + { + Self::notify_user(pool, broker, father_user_id, "child_death")?; + } + if let Some(mother_user_id) = row + .get("mother_user_id") + .and_then(|v| v.parse::().ok()) + { + Self::notify_user(pool, broker, mother_user_id, "child_death")?; + } + } + + // 5) Charakter löschen + conn.prepare("delete_character", QUERY_DELETE_CHARACTER)?; + conn.execute("delete_character", &[&character_id])?; + + Ok(()) + } + + fn handle_inheritance( + pool: &ConnectionPool, + broker: &MessageBroker, + conn: &mut DbConnection, + deceased_character_id: i32, + falukant_user_id: i32, + ) -> Result<(), DbError> { + // 1) Finde den Erben (bevorzugt is_heir = TRUE) + conn.prepare("get_heir", QUERY_GET_HEIR)?; + let heir_rows = conn.execute("get_heir", &[&deceased_character_id])?; + + let heir_id: Option = heir_rows + .first() + .and_then(|r| r.get("child_character_id")) + .and_then(|v| v.parse::().ok()); + + let heir_id = match heir_id { + Some(id) if id > 0 => id, + _ => { + // Kein Erbe gefunden - Vermögen geht verloren + eprintln!( + "[EventsWorker] Kein Erbe für Charakter {} gefunden, Vermögen geht verloren", + deceased_character_id + ); + return Ok(()); + } + }; + + // 2) Setze den Erben als neuen Spieler-Charakter + conn.prepare("set_character_user", QUERY_SET_CHARACTER_USER)?; + conn.execute("set_character_user", &[&falukant_user_id, &heir_id])?; + + // 3) Berechne das neue Vermögen basierend auf dem gesamten Vermögen + // Hole alle Vermögenswerte (analog zu UserCharacterWorker::calculate_new_money) + conn.prepare("get_current_money", QUERY_GET_CURRENT_MONEY)?; + conn.prepare("get_house_value", QUERY_GET_HOUSE_VALUE)?; + conn.prepare("get_settlement_value", QUERY_GET_SETTLEMENT_VALUE)?; + conn.prepare("get_inventory_value", QUERY_GET_INVENTORY_VALUE)?; + conn.prepare("get_credit_debt", QUERY_GET_CREDIT_DEBT)?; + conn.prepare("count_children", QUERY_COUNT_CHILDREN)?; + + let cash: f64 = conn + .execute("get_current_money", &[&falukant_user_id])? + .first() + .and_then(|r| r.get("money")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0); + + let houses: f64 = conn + .execute("get_house_value", &[&falukant_user_id])? + .first() + .and_then(|r| r.get("sum")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0); + + let settlements: f64 = conn + .execute("get_settlement_value", &[&falukant_user_id])? + .first() + .and_then(|r| r.get("sum")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0); + + let inventory: f64 = conn + .execute("get_inventory_value", &[&falukant_user_id])? + .first() + .and_then(|r| r.get("sum")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0); + + let debt: f64 = conn + .execute("get_credit_debt", &[&falukant_user_id])? + .first() + .and_then(|r| r.get("sum")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0); + + let child_count: i32 = conn + .execute("count_children", &[&deceased_character_id, &heir_id])? + .first() + .and_then(|r| r.get("cnt")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + // Berechne das neue Vermögen (analog zu UserCharacterWorker::calculate_new_money) + let total_assets = cash + houses + settlements + inventory - debt; + let single = child_count <= 0; // Nur der Erbe bleibt + + let heir_share = if single { + total_assets + } else { + total_assets * 0.8 + }; + + let new_money = heir_share - (houses + settlements + inventory + debt); + let final_money = if new_money <= 1000.0 { + 1000.0 + } else { + new_money + }; + + // 4) Aktualisiere das Vermögen über die update_money Funktion + // Verwende die BaseWorker-Funktion für konsistente Geld-Updates + use crate::worker::base::BaseWorker; + let base = BaseWorker::new("EventsWorker", pool.clone(), broker.clone()); + let money_change = final_money - cash; + base.change_falukant_user_money( + falukant_user_id, + money_change, + &format!("Erbe für Charakter {}", deceased_character_id), + )?; + + eprintln!( + "[EventsWorker] Erbe {} übernimmt Vermögen von Charakter {} (User {}): {:.2} (von {:.2} Gesamtvermögen, {} weitere Kinder)", + heir_id, deceased_character_id, falukant_user_id, final_money, total_assets, child_count + ); + + Ok(()) + } + + fn notify_user( + pool: &ConnectionPool, + broker: &MessageBroker, + user_id: i32, + event_type: &str, + ) -> Result<(), DbError> { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("insert_notification", QUERY_INSERT_NOTIFICATION)?; + conn.execute("insert_notification", &[&user_id, &event_type])?; + + // falukantUpdateStatus + let update_message = + format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id); + broker.publish(update_message); + + // ursprüngliche Benachrichtigung + let message = + format!(r#"{{"event":"{event_type}","user_id":{}}}"#, user_id); + broker.publish(message); + + Ok(()) + } + + fn apply_storage_damage( + conn: &mut DbConnection, + params: StorageDamageParams, + rng: &mut impl Rng, + ) -> Result { + // 1. Finde Stock-Typ-ID basierend auf Label + conn.prepare("get_stock_type_id", QUERY_GET_STOCK_TYPE_ID)?; + let stock_type_rows = conn.execute("get_stock_type_id", &[¶ms.stock_type_label])?; + + let stock_type_id: Option = stock_type_rows + .first() + .and_then(|r| r.get("id")) + .and_then(|v| v.parse::().ok()); + + let stock_type_id = match stock_type_id { + Some(id) => id, + None => { + eprintln!( + "[EventsWorker] Stock-Typ '{}' nicht gefunden", + params.stock_type_label + ); + return Err(DbError::new(format!( + "Stock-Typ '{}' nicht gefunden", + params.stock_type_label + ))); + } + }; + + // 2. Hole alle Stocks dieses Typs in der Region mit ihren Branches + conn.prepare("get_region_stocks", QUERY_GET_REGION_STOCKS)?; + let stock_rows = conn.execute("get_region_stocks", &[¶ms.region_id, &stock_type_id])?; + + if stock_rows.is_empty() { + eprintln!( + "[EventsWorker] Keine Stocks vom Typ '{}' in Region {} gefunden", + params.stock_type_label, params.region_id + ); + return Ok(StorageDamageInfo { + inventory_damage_percent: 0.0, + storage_destruction_percent: 0.0, + affected_stocks: 0, + destroyed_stocks: 0, + }); + } + + // 3. Berechne Schäden + let inventory_damage_percent = + rng.gen_range(params.inventory_damage_min_percent..=params.inventory_damage_max_percent); + let storage_destruction_percent = + rng.gen_range(params.storage_destruction_min_percent..=params.storage_destruction_max_percent); + + let total_stocks = stock_rows.len(); + let stocks_to_destroy = ((total_stocks as f64 * storage_destruction_percent / 100.0) + .round() as usize) + .min(total_stocks); + + // 4. Reduziere Lagerbestand in allen betroffenen Stocks + // Hole alle Inventar-Einträge für diese Stocks + conn.prepare("get_inventory_items", QUERY_GET_INVENTORY_ITEMS)?; + let inventory_rows = conn.execute("get_inventory_items", &[¶ms.region_id, &stock_type_id])?; + + let mut affected_stocks = 0; + let mut processed_stocks = std::collections::HashSet::new(); + + for row in &inventory_rows { + let inventory_id: Option = row + .get("inventory_id") + .and_then(|v| v.parse::().ok()); + + let inventory_id = match inventory_id { + Some(id) => id, + None => continue, + }; + + let stock_id: Option = row + .get("stock_id") + .and_then(|v| v.parse::().ok()); + + let stock_id = match stock_id { + Some(id) => id, + None => continue, + }; + + let inventory_quantity: i32 = row + .get("inventory_quantity") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + if inventory_quantity > 0 { + let damage = (inventory_quantity as f64 * inventory_damage_percent / 100.0).round() as i32; + let new_quantity = (inventory_quantity - damage).max(0); + + // Reduziere Lagerbestand pro Inventar-Eintrag + conn.prepare("reduce_inventory", QUERY_REDUCE_INVENTORY)?; + conn.execute("reduce_inventory", &[&new_quantity, &inventory_id])?; + + if !processed_stocks.contains(&stock_id) { + processed_stocks.insert(stock_id); + affected_stocks += 1; + } + } + } + + // 5. Zerstöre zufällig ausgewählte Stocks + let mut destroyed_stocks = 0; + if stocks_to_destroy > 0 { + // Wähle zufällige Stocks zum Zerstören + let mut stock_ids_to_destroy: Vec = stock_rows + .iter() + .filter_map(|row| row.get("stock_id").and_then(|v| v.parse::().ok())) + .collect(); + + // Mische die Liste zufällig + stock_ids_to_destroy.shuffle(rng); + stock_ids_to_destroy.truncate(stocks_to_destroy); + + for stock_id in &stock_ids_to_destroy { + // Lösche zuerst den Lagerbestand + conn.prepare("delete_inventory", QUERY_DELETE_INVENTORY)?; + conn.execute("delete_inventory", &[stock_id])?; + + // Lösche dann das Stock selbst + conn.prepare("delete_stock", QUERY_DELETE_STOCK)?; + conn.execute("delete_stock", &[stock_id])?; + + destroyed_stocks += 1; + } + } + + // 6. Sicherstelle, dass Lagerbestand <= Lageranzahl für alle verbleibenden Stocks + // Hole alle verbleibenden Stocks mit ihrem Lagerbestand + let remaining_stock_rows = conn.execute("get_region_stocks", &[¶ms.region_id, &stock_type_id])?; + + for row in remaining_stock_rows { + let stock_id: Option = row + .get("stock_id") + .and_then(|v| v.parse::().ok()); + + let stock_id = match stock_id { + Some(id) => id, + None => continue, + }; + + let stock_capacity: i32 = row + .get("stock_capacity") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + let total_inventory_quantity: i32 = row + .get("inventory_quantity") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + // Wenn Gesamt-Lagerbestand > Lageranzahl, reduziere proportional + if total_inventory_quantity > stock_capacity && stock_capacity > 0 { + // Hole alle Inventar-Einträge für diesen Stock + conn.prepare("get_stock_inventory", QUERY_GET_STOCK_INVENTORY)?; + let inventory_items = conn.execute("get_stock_inventory", &[&stock_id])?; + + // Reduziere proportional, sodass Gesamtmenge = stock_capacity + let reduction_factor = stock_capacity as f64 / total_inventory_quantity as f64; + + for item_row in inventory_items { + let inventory_id: Option = item_row + .get("id") + .and_then(|v| v.parse::().ok()); + + let inventory_id = match inventory_id { + Some(id) => id, + None => continue, + }; + + let item_quantity: i32 = item_row + .get("quantity") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + let new_quantity = (item_quantity as f64 * reduction_factor).round() as i32; + + conn.prepare("cap_inventory", QUERY_CAP_INVENTORY)?; + conn.execute("cap_inventory", &[&new_quantity, &inventory_id])?; + } + } + } + + Ok(StorageDamageInfo { + inventory_damage_percent, + storage_destruction_percent, + affected_stocks, + destroyed_stocks, + }) + } + + fn apply_personal_storage_damage( + conn: &mut DbConnection, + params: PersonalStorageDamageParams, + rng: &mut impl Rng, + ) -> Result { + // 1. Finde Stock-Typ-ID basierend auf Label + conn.prepare("get_stock_type_id_personal", QUERY_GET_STOCK_TYPE_ID)?; + let stock_type_rows = conn.execute("get_stock_type_id_personal", &[¶ms.stock_type_label])?; + + let stock_type_id: Option = stock_type_rows + .first() + .and_then(|r| r.get("id")) + .and_then(|v| v.parse::().ok()); + + let stock_type_id = match stock_type_id { + Some(id) => id, + None => { + eprintln!( + "[EventsWorker] Stock-Typ '{}' nicht gefunden", + params.stock_type_label + ); + return Err(DbError::new(format!( + "Stock-Typ '{}' nicht gefunden", + params.stock_type_label + ))); + } + }; + + // 2. Hole alle Stocks dieses Typs für alle Branches des Spielers + conn.prepare("get_user_stocks", QUERY_GET_USER_STOCKS)?; + let stock_rows = conn.execute("get_user_stocks", &[¶ms.user_id, &stock_type_id])?; + + if stock_rows.is_empty() { + eprintln!( + "[EventsWorker] Keine Stocks vom Typ '{}' für Spieler {} gefunden", + params.stock_type_label, params.user_id + ); + return Ok(StorageDamageInfo { + inventory_damage_percent: 0.0, + storage_destruction_percent: 0.0, + affected_stocks: 0, + destroyed_stocks: 0, + }); + } + + // 3. Berechne Schäden + let inventory_damage_percent = + rng.gen_range(params.inventory_damage_min_percent..=params.inventory_damage_max_percent); + let storage_destruction_percent = + rng.gen_range(params.storage_destruction_min_percent..=params.storage_destruction_max_percent); + + let total_stocks = stock_rows.len(); + let stocks_to_destroy = ((total_stocks as f64 * storage_destruction_percent / 100.0) + .round() as usize) + .min(total_stocks); + + // 4. Reduziere Lagerbestand in allen betroffenen Stocks + // Hole alle Inventar-Einträge für diese Stocks + conn.prepare("get_user_inventory_items", QUERY_GET_USER_INVENTORY_ITEMS)?; + let inventory_rows = conn.execute("get_user_inventory_items", &[¶ms.user_id, &stock_type_id])?; + + let mut affected_stocks = 0; + let mut processed_stocks = std::collections::HashSet::new(); + + for row in &inventory_rows { + let inventory_id: Option = row + .get("inventory_id") + .and_then(|v| v.parse::().ok()); + + let inventory_id = match inventory_id { + Some(id) => id, + None => continue, + }; + + let stock_id: Option = row + .get("stock_id") + .and_then(|v| v.parse::().ok()); + + let stock_id = match stock_id { + Some(id) => id, + None => continue, + }; + + let inventory_quantity: i32 = row + .get("inventory_quantity") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + if inventory_quantity > 0 { + let damage = (inventory_quantity as f64 * inventory_damage_percent / 100.0).round() as i32; + let new_quantity = (inventory_quantity - damage).max(0); + + // Reduziere Lagerbestand pro Inventar-Eintrag + conn.prepare("reduce_inventory_personal", QUERY_REDUCE_INVENTORY_PERSONAL)?; + conn.execute("reduce_inventory_personal", &[&new_quantity, &inventory_id])?; + + if !processed_stocks.contains(&stock_id) { + processed_stocks.insert(stock_id); + affected_stocks += 1; + } + } + } + + // 5. Zerstöre zufällig ausgewählte Stocks (nur für field und wood) + let mut destroyed_stocks = 0; + if stocks_to_destroy > 0 && (params.stock_type_label == "field" || params.stock_type_label == "wood") { + // Wähle zufällige Stocks zum Zerstören + let mut stock_ids_to_destroy: Vec = stock_rows + .iter() + .filter_map(|row| row.get("stock_id").and_then(|v| v.parse::().ok())) + .collect(); + + // Mische die Liste zufällig + stock_ids_to_destroy.shuffle(rng); + stock_ids_to_destroy.truncate(stocks_to_destroy); + + for stock_id in &stock_ids_to_destroy { + // Lösche zuerst den Lagerbestand + conn.prepare("delete_inventory_personal", QUERY_DELETE_INVENTORY_PERSONAL)?; + conn.execute("delete_inventory_personal", &[stock_id])?; + + // Lösche dann das Stock selbst + conn.prepare("delete_stock_personal", QUERY_DELETE_STOCK_PERSONAL)?; + conn.execute("delete_stock_personal", &[stock_id])?; + + destroyed_stocks += 1; + } + } + + // 6. Sicherstelle, dass Lagerbestand <= Lageranzahl für alle verbleibenden Stocks + let remaining_stock_rows = conn.execute("get_user_stocks", &[¶ms.user_id, &stock_type_id])?; + + for row in remaining_stock_rows { + let stock_id: Option = row + .get("stock_id") + .and_then(|v| v.parse::().ok()); + + let stock_id = match stock_id { + Some(id) => id, + None => continue, + }; + + let stock_capacity: i32 = row + .get("stock_capacity") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + let total_inventory_quantity: i32 = row + .get("inventory_quantity") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + // Wenn Gesamt-Lagerbestand > Lageranzahl, reduziere proportional + if total_inventory_quantity > stock_capacity && stock_capacity > 0 { + // Hole alle Inventar-Einträge für diesen Stock + conn.prepare("get_stock_inventory_personal", QUERY_GET_STOCK_INVENTORY_PERSONAL)?; + let inventory_items = conn.execute("get_stock_inventory_personal", &[&stock_id])?; + + // Reduziere proportional, sodass Gesamtmenge = stock_capacity + let reduction_factor = stock_capacity as f64 / total_inventory_quantity as f64; + + for item_row in inventory_items { + let inventory_id: Option = item_row + .get("id") + .and_then(|v| v.parse::().ok()); + + let inventory_id = match inventory_id { + Some(id) => id, + None => continue, + }; + + let item_quantity: i32 = item_row + .get("quantity") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + let new_quantity = (item_quantity as f64 * reduction_factor).round() as i32; + + conn.prepare("cap_inventory_personal", QUERY_CAP_INVENTORY_PERSONAL)?; + conn.execute("cap_inventory_personal", &[&new_quantity, &inventory_id])?; + } + } + } + + Ok(StorageDamageInfo { + inventory_damage_percent, + storage_destruction_percent, + affected_stocks, + destroyed_stocks, + }) + } +} + +impl Worker for EventsWorker { + fn start_worker_thread(&mut self) { + let pool = self.base.pool.clone(); + let broker = self.base.broker.clone(); + + self.base + .start_worker_with_loop(move |state: Arc| { + Self::run_loop(pool.clone(), broker.clone(), state); + }); + } + + fn stop_worker_thread(&mut self) { + self.base.stop_worker(); + } + + fn enable_watchdog(&mut self) { + self.base.start_watchdog(); + } +} + +// Hilfsfunktion zum Prüfen, ob ein Intervall abgelaufen ist +fn should_run_interval(last: Option, now: Instant, interval: Duration) -> bool { + match last { + None => true, + Some(last_time) => now.saturating_duration_since(last_time) >= interval, + } +} + diff --git a/YpDaemon/src/worker/user_character.rs b/YpDaemon/src/worker/user_character.rs new file mode 100644 index 0000000..c5c76f4 --- /dev/null +++ b/YpDaemon/src/worker/user_character.rs @@ -0,0 +1,881 @@ +use crate::db::{ConnectionPool, DbError, Rows}; +use crate::message_broker::MessageBroker; +use rand::distributions::{Distribution, Uniform}; +use rand::rngs::StdRng; +use rand::SeedableRng; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use super::base::{BaseWorker, Worker, WorkerState}; +use crate::worker::sql::{ + QUERY_GET_USERS_TO_UPDATE, + QUERY_UPDATE_CHARACTERS_HEALTH, + QUERY_UPDATE_MOOD, + QUERY_UPDATE_GET_ITEMS_TO_UPDATE, + QUERY_UPDATE_GET_CHARACTER_IDS, + QUERY_UPDATE_KNOWLEDGE, + QUERY_DELETE_LOG_ENTRY, + QUERY_GET_OPEN_CREDITS, + QUERY_UPDATE_CREDIT, + QUERY_CLEANUP_CREDITS, + QUERY_ADD_CHARACTER_TO_DEBTORS_PRISM, + QUERY_GET_CURRENT_MONEY, + QUERY_GET_HOUSE_VALUE, + QUERY_GET_SETTLEMENT_VALUE, + QUERY_GET_INVENTORY_VALUE, + QUERY_GET_CREDIT_DEBT, + QUERY_COUNT_CHILDREN, + QUERY_GET_HEIR, + QUERY_RANDOM_HEIR, + QUERY_SET_CHARACTER_USER, + QUERY_UPDATE_USER_MONEY, + QUERY_GET_FALUKANT_USER_ID, + QUERY_AUTOBATISM, + QUERY_GET_PREGNANCY_CANDIDATES, + QUERY_INSERT_CHILD, + QUERY_INSERT_CHILD_RELATION, + QUERY_DELETE_DIRECTOR, + QUERY_DELETE_RELATIONSHIP, + QUERY_DELETE_CHILD_RELATION, + QUERY_DELETE_KNOWLEDGE, + QUERY_DELETE_DEBTORS_PRISM, + QUERY_DELETE_POLITICAL_OFFICE, + QUERY_DELETE_ELECTION_CANDIDATE, +}; + +/// Vereinfachtes Abbild eines Characters aus `QUERY_GET_USERS_TO_UPDATE`. +#[derive(Debug, Clone)] +struct Character { + id: i32, + age: i32, + health: i32, +} + +pub struct UserCharacterWorker { + base: BaseWorker, + rng: StdRng, + dist: Uniform, + last_hourly_run: Option, + last_pregnancy_run: Option, + last_mood_run: Option, +} + +// SQL moved to `src/worker/sql.rs` + +impl UserCharacterWorker { + pub fn new(pool: ConnectionPool, broker: MessageBroker) -> Self { + let base = BaseWorker::new("UserCharacterWorker", pool, broker); + let rng = StdRng::from_entropy(); + let dist = Uniform::from(0.0..1.0); + + Self { + base, + rng, + dist, + last_hourly_run: None, + last_pregnancy_run: None, + last_mood_run: None, + } + } + + fn run_iteration(&mut self, state: &WorkerState) { + self.base.set_current_step("UserCharacterWorker iteration"); + + self.maybe_run_hourly_tasks(); + self.maybe_run_mood_updates(); + self.maybe_run_daily_pregnancies(); + + // Entspricht in etwa der 1-Sekunden-Schleife im C++-Code + std::thread::sleep(Duration::from_secs(1)); + + if let Err(err) = self.recalculate_knowledge() { + eprintln!("[UserCharacterWorker] Fehler in recalculateKnowledge: {err}"); + } + + if !state.running_worker.load(Ordering::Relaxed) { + // worker stopping + } + } + + fn maybe_run_hourly_tasks(&mut self) { + let now = Instant::now(); + let should_run = match self.last_hourly_run { + None => true, + Some(last) => now.saturating_duration_since(last) >= Duration::from_secs(3600), + }; + + if !should_run { + return; + } + + if let Err(err) = self.run_hourly_tasks() { + eprintln!("[UserCharacterWorker] Fehler in stündlichen Tasks: {err}"); + } + + self.last_hourly_run = Some(now); + } + + fn run_hourly_tasks(&mut self) -> Result<(), DbError> { + self.process_character_events()?; + self.handle_credits()?; + Ok(()) + } + + fn maybe_run_daily_pregnancies(&mut self) { + let now = Instant::now(); + let should_run = match self.last_pregnancy_run { + None => true, + Some(last) => now.saturating_duration_since(last) >= Duration::from_secs(24 * 3600), + }; + + if !should_run { + return; + } + + if let Err(err) = self.process_pregnancies() { + eprintln!("[UserCharacterWorker] Fehler in processPregnancies: {err}"); + } + self.last_pregnancy_run = Some(now); + } + + fn process_character_events(&mut self) -> Result<(), DbError> { + self.base.set_current_step("Get character data"); + + let rows = self.load_characters_to_update()?; + let mut characters: Vec = rows + .into_iter() + .filter_map(Self::map_row_to_character) + .collect(); + + for character in &mut characters { + self.update_character_health(character)?; + } + + Ok(()) + } + + fn load_characters_to_update(&mut self) -> Result { + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("get_users_to_update", QUERY_GET_USERS_TO_UPDATE)?; + conn.execute("get_users_to_update", &[]) + } + + fn map_row_to_character(row: crate::db::Row) -> Option { + Some(Character { + id: row.get("id")?.parse().ok()?, + age: row.get("age")?.parse().ok()?, + health: row.get("health")?.parse().ok()?, + }) + } + + fn update_character_health(&mut self, character: &mut Character) -> Result<(), DbError> { + let health_change = self.calculate_health_change(character.age); + if health_change == 0 { + return Ok(()); + } + + character.health = std::cmp::max(0, character.health + health_change); + + if character.health == 0 { + self.handle_character_death(character.id)?; + return Ok(()); + } + + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare( + "update_characters_health", + QUERY_UPDATE_CHARACTERS_HEALTH, + )?; + conn.execute( + "update_characters_health", + &[&character.health, &character.id], + )?; + + Ok(()) + } + + fn calculate_health_change(&mut self, age: i32) -> i32 { + if age < 30 { + return 0; + } + + if age >= 45 { + let probability = (0.1 + (age - 45) as f64 * 0.02).min(1.0); + if self.dist.sample(&mut self.rng) < probability { + let damage_dist = Uniform::from(1..=10); + return -damage_dist.sample(&mut self.rng); + } + return 0; + } + + let probability = (age - 30) as f64 / 30.0; + if self.dist.sample(&mut self.rng) < probability { + -1 + } else { + 0 + } + } + + fn maybe_run_mood_updates(&mut self) { + let now = Instant::now(); + let should_run = match self.last_mood_run { + None => true, + Some(last) => now.saturating_duration_since(last) >= Duration::from_secs(60), + }; + + if !should_run { + return; + } + + if let Err(err) = self.update_characters_mood_randomized() { + eprintln!("[UserCharacterWorker] Fehler in updateCharactersMood: {err}"); + } + + self.last_mood_run = Some(now); + } + + /// Setzt die Stimmung einzelner lebender Charaktere zufällig neu. + /// Jeder Charakter hat pro Minute eine kleine Chance auf einen Wechsel, + /// so dass sich über die Zeit ein individueller, zufälliger Rhythmus entsteht. + fn update_characters_mood_randomized(&mut self) -> Result<(), DbError> { + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("update_mood", QUERY_UPDATE_MOOD)?; + conn.execute("update_mood", &[])?; + Ok(()) + } + + fn recalculate_knowledge(&mut self) -> Result<(), DbError> { + self.base.set_current_step("recalculate knowledge"); + + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare( + "get_items_to_update", + QUERY_UPDATE_GET_ITEMS_TO_UPDATE, + )?; + let update_rows = conn.execute("get_items_to_update", &[])?; + + for update_item in update_rows { + let quantity: i32 = match update_item.get("quantity").and_then(|v| v.parse().ok()) { + Some(q) => q, + None => continue, + }; + + if quantity < 10 { + self.delete_production_log_entry(&mut conn, &update_item)?; + continue; + } + + self.update_knowledge_for_production(&mut conn, &update_item)?; + self.delete_production_log_entry(&mut conn, &update_item)?; + + if let Some(producer_id) = update_item + .get("producer_id") + .and_then(|v| v.parse::().ok()) + { + self.send_knowledge_update(producer_id); + } + } + + Ok(()) + } + + fn update_knowledge_for_production( + &mut self, + conn: &mut crate::db::DbConnection, + update_item: &crate::db::Row, + ) -> Result<(), DbError> { + let producer_id = match update_item.get("producer_id").and_then(|v| v.parse::().ok()) + { + Some(id) => id, + None => return Ok(()), + }; + + let product_id = match update_item.get("product_id").and_then(|v| v.parse::().ok()) { + Some(id) => id, + None => return Ok(()), + }; + + conn.prepare( + "get_character_ids", + QUERY_UPDATE_GET_CHARACTER_IDS, + )?; + let characters_data = + conn.execute("get_character_ids", &[&producer_id])?; + + conn.prepare("update_knowledge", QUERY_UPDATE_KNOWLEDGE)?; + + for character_row in characters_data { + let character_id = match character_row + .get("character_id") + .and_then(|v| v.parse::().ok()) + { + Some(id) => id, + None => continue, + }; + + let director_id = character_row + .get("director_id") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + if director_id == 0 { + conn.execute( + "update_knowledge", + &[&character_id, &product_id, &2_i32], + )?; + } else { + conn.execute( + "update_knowledge", + &[&character_id, &product_id, &1_i32], + )?; + conn.execute( + "update_knowledge", + &[&director_id, &product_id, &1_i32], + )?; + } + } + + Ok(()) + } + + fn delete_production_log_entry( + &mut self, + conn: &mut crate::db::DbConnection, + update_item: &crate::db::Row, + ) -> Result<(), DbError> { + let id = match update_item.get("id").and_then(|v| v.parse::().ok()) { + Some(id) => id, + None => return Ok(()), + }; + + conn.prepare("delete_log_entry", QUERY_DELETE_LOG_ENTRY)?; + conn.execute("delete_log_entry", &[&id])?; + Ok(()) + } + + fn send_knowledge_update(&self, producer_id: i32) { + let message = format!(r#"{{"event":"knowledge_update","user_id":{}}}"#, producer_id); + self.base.broker.publish(message); + } + + // Kredit-Logik (portiert aus handleCredits) + fn handle_credits(&mut self) -> Result<(), DbError> { + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("get_open_credits", QUERY_GET_OPEN_CREDITS)?; + conn.prepare("update_credit", QUERY_UPDATE_CREDIT)?; + conn.prepare("cleanup_credits", QUERY_CLEANUP_CREDITS)?; + conn.prepare( + "add_character_to_debtors_prism", + QUERY_ADD_CHARACTER_TO_DEBTORS_PRISM, + )?; + + let credits_rows = conn.execute("get_open_credits", &[])?; + for row in credits_rows { + if let Some(credit) = Self::map_row_to_credit(&row) { + self.process_single_credit(&mut conn, &credit)?; + } + } + + conn.execute("cleanup_credits", &[])?; + Ok(()) + } + + fn map_row_to_credit(row: &crate::db::Row) -> Option { + Some(Credit { + amount: row.get("amount")?.parse().ok()?, + remaining_amount: row.get("remaining_amount")?.parse().ok()?, + interest_rate: row.get("interest_rate")?.parse().ok()?, + user_id: row.get("user_id")?.parse().ok()?, + money: row.get("money")?.parse().ok()?, + character_id: row.get("character_id")?.parse().ok()?, + prism_started_previously: row + .get("prism_started_previously") + .map(|v| v == "t" || v == "true") + .unwrap_or(false), + }) + } + + fn process_single_credit( + &mut self, + conn: &mut crate::db::DbConnection, + credit: &Credit, + ) -> Result<(), DbError> { + let Credit { + amount, + mut remaining_amount, + interest_rate, + user_id, + money, + character_id, + prism_started_previously, + .. + } = *credit; + + let pay_rate = amount / 10.0 + amount * interest_rate as f64 / 100.0; + remaining_amount -= pay_rate; + + // Kann der User zahlen? + if pay_rate <= money - (pay_rate * 3.0) { + if let Err(err) = self + .base + .change_falukant_user_money(user_id, -pay_rate, "credit pay rate") + { + eprintln!( + "[UserCharacterWorker] Fehler bei change_falukant_user_money (credit pay rate): {err}" + ); + } + } else if prism_started_previously { + if let Err(err) = self + .base + .change_falukant_user_money(user_id, pay_rate, "debitor_prism") + { + eprintln!( + "[UserCharacterWorker] Fehler bei change_falukant_user_money (debitor_prism): {err}" + ); + } + } else { + conn.execute("add_character_to_debtors_prism", &[&character_id])?; + } + + conn.execute("update_credit", &[&remaining_amount, &user_id])?; + Ok(()) + } + + // Schwangerschafts-Logik (portiert aus processPregnancies) + fn process_pregnancies(&mut self) -> Result<(), DbError> { + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("autobatism", QUERY_AUTOBATISM)?; + conn.execute("autobatism", &[])?; + + conn.prepare("get_pregnancy_candidates", QUERY_GET_PREGNANCY_CANDIDATES)?; + let rows = conn.execute("get_pregnancy_candidates", &[])?; + + conn.prepare("insert_child", QUERY_INSERT_CHILD)?; + conn.prepare("insert_child_relation", QUERY_INSERT_CHILD_RELATION)?; + + for row in rows { + self.process_single_pregnancy_candidate(&mut conn, &row)?; + } + + Ok(()) + } + + fn process_single_pregnancy_candidate( + &mut self, + conn: &mut crate::db::DbConnection, + row: &crate::db::Row, + ) -> Result<(), DbError> { + let father_cid = parse_i32(row, "father_cid", -1); + let mother_cid = parse_i32(row, "mother_cid", -1); + if father_cid < 0 || mother_cid < 0 { + return Ok(()); + } + + let title_of_nobility = parse_i32(row, "title_of_nobility", 0); + let last_name = parse_i32(row, "last_name", 0); + let region_id = parse_i32(row, "region_id", 0); + + let father_uid = parse_opt_i32(row, "father_uid"); + let mother_uid = parse_opt_i32(row, "mother_uid"); + + let gender = if self.dist.sample(&mut self.rng) < 0.5 { + "male" + } else { + "female" + }; + + let inserted = + conn.execute("insert_child", &[®ion_id, &gender, &last_name, &title_of_nobility])?; + let child_cid = inserted + .first() + .and_then(|r| r.get("child_cid")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(-1); + if child_cid < 0 { + return Ok(()); + } + + conn.execute( + "insert_child_relation", + &[&father_cid, &mother_cid, &child_cid], + )?; + + if let Some(f_uid) = father_uid { + self.send_children_update_and_status(f_uid); + } + if let Some(m_uid) = mother_uid { + self.send_children_update_and_status(m_uid); + } + + Ok(()) + } + + fn send_children_update_and_status(&self, user_id: i32) { + let children_update = + format!(r#"{{"event":"children_update","user_id":{}}}"#, user_id); + self.base.broker.publish(children_update); + + let update_status = + format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id); + self.base.broker.publish(update_status); + } + + // Todes- und Erb-Logik + fn handle_character_death(&mut self, character_id: i32) -> Result<(), DbError> { + self.set_heir(character_id)?; + + let death_event = format!( + r#"{{"event":"CharacterDeath","character_id":{}}}"#, + character_id + ); + self.base.broker.publish(death_event); + + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("delete_director", QUERY_DELETE_DIRECTOR)?; + conn.prepare("delete_relationship", QUERY_DELETE_RELATIONSHIP)?; + conn.prepare("delete_child_relation", QUERY_DELETE_CHILD_RELATION)?; + conn.prepare("delete_knowledge", QUERY_DELETE_KNOWLEDGE)?; + conn.prepare("delete_debtors_prism", QUERY_DELETE_DEBTORS_PRISM)?; + conn.prepare("delete_political_office", QUERY_DELETE_POLITICAL_OFFICE)?; + conn.prepare("delete_election_candidate", QUERY_DELETE_ELECTION_CANDIDATE)?; + + conn.execute("delete_director", &[&character_id])?; + conn.execute("delete_relationship", &[&character_id])?; + conn.execute("delete_child_relation", &[&character_id])?; + conn.execute("delete_knowledge", &[&character_id])?; + conn.execute("delete_debtors_prism", &[&character_id])?; + conn.execute("delete_political_office", &[&character_id])?; + conn.execute("delete_election_candidate", &[&character_id])?; + + // Character selbst löschen + conn.prepare( + "delete_character", + r#"DELETE FROM falukant_data.character WHERE id = $1"#, + )?; + conn.execute("delete_character", &[&character_id])?; + + Ok(()) + } + + fn set_heir(&mut self, character_id: i32) -> Result<(), DbError> { + let falukant_user_id = self.get_falukant_user_id(character_id)?; + if falukant_user_id < 0 { + return Ok(()); + } + + let mut heir_id = self.get_heir_from_children(character_id)?; + let mut new_money = self.calculate_new_money(falukant_user_id, heir_id > 0)?; + + if heir_id < 1 { + heir_id = self.get_random_heir(character_id)?; + new_money = self.calculate_new_money(falukant_user_id, heir_id > 0)?; + } + + if heir_id > 0 { + self.set_new_character(falukant_user_id, heir_id)?; + } + self.set_new_money(falukant_user_id, new_money)?; + + Ok(()) + } + + fn get_falukant_user_id(&mut self, character_id: i32) -> Result { + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("get_falukant_user_id", QUERY_GET_FALUKANT_USER_ID)?; + let rows = conn.execute("get_falukant_user_id", &[&character_id])?; + + Ok(rows + .first() + .and_then(|r| r.get("user_id")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(-1)) + } + + fn get_heir_from_children(&mut self, deceased_character_id: i32) -> Result { + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("get_heir", QUERY_GET_HEIR)?; + let rows = conn.execute("get_heir", &[&deceased_character_id])?; + + Ok(rows + .first() + .and_then(|r| r.get("child_character_id")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(-1)) + } + + fn get_random_heir(&mut self, deceased_character_id: i32) -> Result { + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("random_heir", QUERY_RANDOM_HEIR)?; + let rows = conn.execute("random_heir", &[&deceased_character_id])?; + + Ok(rows + .first() + .and_then(|r| r.get("child_character_id")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(-1)) + } + + fn set_new_character( + &mut self, + falukant_user_id: i32, + heir_character_id: i32, + ) -> Result<(), DbError> { + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("set_character_user", QUERY_SET_CHARACTER_USER)?; + conn.execute( + "set_character_user", + &[&falukant_user_id, &heir_character_id], + )?; + Ok(()) + } + + fn set_new_money(&mut self, falukant_user_id: i32, new_amount: f64) -> Result<(), DbError> { + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("update_user_money", QUERY_UPDATE_USER_MONEY)?; + conn.execute("update_user_money", &[&new_amount, &falukant_user_id])?; + Ok(()) + } + + fn get_current_money(&mut self, falukant_user_id: i32) -> Result { + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("get_current_money", QUERY_GET_CURRENT_MONEY)?; + let rows = conn.execute("get_current_money", &[&falukant_user_id])?; + + Ok(rows + .first() + .and_then(|r| r.get("sum")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0)) + } + + fn get_house_value(&mut self, falukant_user_id: i32) -> Result { + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("house_value", QUERY_GET_HOUSE_VALUE)?; + let rows = conn.execute("house_value", &[&falukant_user_id])?; + + Ok(rows + .first() + .and_then(|r| r.get("sum")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0)) + } + + fn get_settlement_value(&mut self, falukant_user_id: i32) -> Result { + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("settlement_value", QUERY_GET_SETTLEMENT_VALUE)?; + let rows = conn.execute("settlement_value", &[&falukant_user_id])?; + + Ok(rows + .first() + .and_then(|r| r.get("sum")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0)) + } + + fn get_inventory_value(&mut self, falukant_user_id: i32) -> Result { + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("inventory_value", QUERY_GET_INVENTORY_VALUE)?; + let rows = conn.execute("inventory_value", &[&falukant_user_id])?; + + Ok(rows + .first() + .and_then(|r| r.get("sum")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0)) + } + + fn get_credit_debt(&mut self, falukant_user_id: i32) -> Result { + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("credit_debt", QUERY_GET_CREDIT_DEBT)?; + let rows = conn.execute("credit_debt", &[&falukant_user_id])?; + + Ok(rows + .first() + .and_then(|r| r.get("sum")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0)) + } + + fn get_child_count(&mut self, deceased_user_id: i32) -> Result { + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("count_children", QUERY_COUNT_CHILDREN)?; + let rows = conn.execute("count_children", &[&deceased_user_id])?; + + Ok(rows + .first() + .and_then(|r| r.get("cnt")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0)) + } + + fn calculate_new_money( + &mut self, + falukant_user_id: i32, + has_heir: bool, + ) -> Result { + if !has_heir { + return Ok(800.0); + } + + let cash = self.get_current_money(falukant_user_id)?; + let houses = self.get_house_value(falukant_user_id)?; + let settlements = self.get_settlement_value(falukant_user_id)?; + let inventory = self.get_inventory_value(falukant_user_id)?; + let debt = self.get_credit_debt(falukant_user_id)?; + + let total_assets = cash + houses + settlements + inventory - debt; + let child_count = self.get_child_count(falukant_user_id)?; + let single = child_count <= 1; + + let heir_share = if single { + total_assets + } else { + total_assets * 0.8 + }; + + let net = heir_share - (houses + settlements + inventory + debt); + if net <= 1000.0 { + Ok(1000.0) + } else { + Ok(net) + } + } +} + +/// Kleine Hilfsfunktionen für robustes Parsen aus `Row`. +fn parse_i32(row: &crate::db::Row, key: &str, default: i32) -> i32 { + row.get(key) + .and_then(|v| v.parse::().ok()) + .unwrap_or(default) +} + +fn parse_opt_i32(row: &crate::db::Row, key: &str) -> Option { + row.get(key).and_then(|v| v.parse::().ok()) +} + +#[derive(Debug, Clone)] +struct Credit { + amount: f64, + remaining_amount: f64, + interest_rate: i32, + user_id: i32, + money: f64, + character_id: i32, + prism_started_previously: bool, +} + +impl Worker for UserCharacterWorker { + fn start_worker_thread(&mut self) { + let pool = self.base.pool.clone(); + let broker = self.base.broker.clone(); + + self.base + .start_worker_with_loop(move |state: Arc| { + let mut worker = UserCharacterWorker::new(pool.clone(), broker.clone()); + while state.running_worker.load(Ordering::Relaxed) { + worker.run_iteration(&state); + } + }); + } + + fn stop_worker_thread(&mut self) { + self.base.stop_worker(); + } + + fn enable_watchdog(&mut self) { + self.base.start_watchdog(); + } +} + + diff --git a/src/worker/produce.rs b/src/worker/produce.rs index 16c8d24..4479967 100644 --- a/src/worker/produce.rs +++ b/src/worker/produce.rs @@ -14,6 +14,8 @@ use crate::worker::sql::{ QUERY_INSERT_INVENTORY, QUERY_INSERT_UPDATE_PRODUCTION_LOG, QUERY_ADD_OVERPRODUCTION_NOTIFICATION, + QUERY_FIND_OVERPRODUCTION_NOTIFICATION, + QUERY_UPDATE_OVERPRODUCTION_NOTIFICATION, }; /// Abbildet eine abgeschlossene Produktion aus der Datenbank. @@ -402,9 +404,15 @@ impl ProduceWorker { .get() .map_err(|e| crate::db::DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + // Prüfe, ob bereits eine unangezeigte Überproduktions-Benachrichtigung für diesen User/Branch existiert conn.prepare( - "add_overproduction_notification", - QUERY_ADD_OVERPRODUCTION_NOTIFICATION, + "find_overproduction_notification", + QUERY_FIND_OVERPRODUCTION_NOTIFICATION, + )?; + + let existing_rows = conn.execute( + "find_overproduction_notification", + &[&user_id, &branch_id], )?; // Zusätzlich zur Menge die Branch-ID in der Payload mitschicken, damit @@ -414,6 +422,35 @@ impl ProduceWorker { remaining_quantity, branch_id ); + if !existing_rows.is_empty() { + // Aktualisiere bestehende Benachrichtigung + // PostgreSQL aggregiert die Werte automatisch in der UPDATE-Query + conn.prepare( + "update_overproduction_notification", + QUERY_UPDATE_OVERPRODUCTION_NOTIFICATION, + )?; + // Versuche Update; wenn es fehlschlägt (z.B. ungültiges JSON), erstelle neue Benachrichtigung + match conn.execute( + "update_overproduction_notification", + &[&user_id, &branch_id, &remaining_quantity], + ) { + Ok(_) => return Ok(()), + Err(err) => { + eprintln!( + "[ProduceWorker] Fehler beim Aktualisieren der Overproduction-Notification für User {} Branch {}: {}. Erstelle neue Benachrichtigung.", + user_id, branch_id, err + ); + // Fallback: Erstelle neue Benachrichtigung + } + } + } + + // Erstelle neue Benachrichtigung, wenn keine existiert oder Parsing fehlschlug + conn.prepare( + "add_overproduction_notification", + QUERY_ADD_OVERPRODUCTION_NOTIFICATION, + )?; + conn.execute( "add_overproduction_notification", &[&user_id, ¬ification], diff --git a/src/worker/sql.rs b/src/worker/sql.rs index 76718f9..36cdfc1 100644 --- a/src/worker/sql.rs +++ b/src/worker/sql.rs @@ -1452,6 +1452,31 @@ pub const QUERY_ADD_OVERPRODUCTION_NOTIFICATION: &str = r#" ) VALUES ($1, $2, FALSE, NOW(), NOW()); "#; +pub const QUERY_UPDATE_OVERPRODUCTION_NOTIFICATION: &str = r#" + UPDATE falukant_log.notification + SET tr = jsonb_set( + tr::jsonb, + '{value}', + to_jsonb(COALESCE((tr::jsonb->>'value')::int, 0) + $3) + )::text, + updated_at = NOW() + WHERE user_id = $1 + AND shown = FALSE + AND tr::text LIKE '%"tr":"production.overproduction"%' + AND (tr::jsonb->>'branch_id')::int = $2; +"#; + +pub const QUERY_FIND_OVERPRODUCTION_NOTIFICATION: &str = r#" + SELECT id, tr + FROM falukant_log.notification + WHERE user_id = $1 + AND shown = FALSE + AND tr::text LIKE '%"tr":"production.overproduction"%' + AND (tr::jsonb->>'branch_id')::int = $2 + ORDER BY created_at DESC + LIMIT 1; +"#; + // Aliases for personal variants (keeps original prepared statement names used in events.worker) pub const QUERY_REDUCE_INVENTORY_PERSONAL: &str = QUERY_REDUCE_INVENTORY; pub const QUERY_DELETE_INVENTORY_PERSONAL: &str = QUERY_DELETE_INVENTORY;