use crate::db::{ConnectionPool, DbError, Row, Rows}; use crate::message_broker::MessageBroker; use rand::distributions::{Distribution, Uniform}; use rand::seq::SliceRandom; use rand::Rng; use serde_json::json; use serde_json::Value as Json; use std::cmp::{max, min}; use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::Duration; use super::base::{BaseWorker, Worker, WorkerState}; pub struct UndergroundWorker { base: BaseWorker, } #[derive(Debug, Clone)] struct HouseConditions { id: i32, roof: i32, floor: i32, wall: i32, windowc: i32, } // Query-Konstanten (1:1 aus der C++-Version übernommen) const Q_SELECT_BY_PERFORMER: &str = r#" SELECT u.id, t.tr AS underground_type, u.performer_id, u.victim_id, to_char(u.created_at,'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') AS created_at, COALESCE(u.parameters::text,'{}') AS parameters, COALESCE(u.result::text,'null') AS result_text FROM falukant_data.underground u JOIN falukant_type.underground t ON t.tr = u.underground_type_id WHERE u.performer_id = $1 ORDER BY u.created_at DESC; "#; const Q_SELECT_PENDING: &str = r#" SELECT u.id, t.tr AS underground_type, u.performer_id, u.victim_id, COALESCE(u.parameters::text,'{}') AS parameters FROM falukant_data.underground u JOIN falukant_type.underground t ON t.tr = u.underground_type_id WHERE u.result IS NULL AND u.created_at <= NOW() - INTERVAL '1 day' ORDER BY u.created_at ASC LIMIT 200; "#; const Q_UPDATE_RESULT: &str = r#" UPDATE falukant_data.underground SET result = $2::jsonb, updated_at = NOW() WHERE id = $1; "#; const Q_SELECT_CHAR_USER: &str = r#" SELECT user_id FROM falukant_data.character WHERE id = $1; "#; const Q_SELECT_HOUSE_BY_USER: &str = r#" SELECT id, roof_condition, floor_condition, wall_condition, window_condition FROM falukant_data.user_house WHERE user_id = $1 LIMIT 1; "#; const Q_UPDATE_HOUSE: &str = r#" UPDATE falukant_data.user_house SET roof_condition = $2, floor_condition = $3, wall_condition = $4, window_condition = $5 WHERE id = $1; "#; const Q_SELECT_STOCK_BY_BRANCH: &str = r#" SELECT id, stock_type_id, quantity FROM falukant_data.stock WHERE branch_id = $1 ORDER BY quantity DESC; "#; const Q_UPDATE_STOCK_QTY: &str = r#" UPDATE falukant_data.stock SET quantity = $2 WHERE id = $1; "#; const Q_SELECT_CHAR_HEALTH: &str = r#" SELECT health FROM falukant_data.character WHERE id = $1; "#; const Q_UPDATE_CHAR_HEALTH: &str = r#" UPDATE falukant_data.character SET health = $2, updated_at = NOW() WHERE id = $1; "#; const Q_SELECT_FALUKANT_USER: &str = r#" SELECT id, money, COALESCE(main_branch_region_id, 0) AS main_branch_region_id FROM falukant_data.falukant_user WHERE user_id = $1 LIMIT 1; "#; // Query für Geldänderungen (lokale Variante von BaseWorker::change_falukant_user_money) const QUERY_UPDATE_MONEY: &str = r#" SELECT falukant_data.update_money($1, $2, $3); "#; impl UndergroundWorker { pub fn new(pool: ConnectionPool, broker: MessageBroker) -> Self { Self { base: BaseWorker::new("UndergroundWorker", pool, broker), } } fn run_loop(pool: ConnectionPool, broker: MessageBroker, state: Arc) { while state.running_worker.load(Ordering::Relaxed) { if let Err(err) = Self::tick(&pool, &broker) { eprintln!("[UndergroundWorker] Fehler in tick: {err}"); } // Entspricht ~60-Sekunden-Loop mit 1-Sekunden-Schritten for _ in 0..60 { if !state.running_worker.load(Ordering::Relaxed) { break; } std::thread::sleep(Duration::from_secs(1)); } } } fn tick(pool: &ConnectionPool, broker: &MessageBroker) -> Result<(), DbError> { let rows = Self::fetch_pending(pool)?; for row in rows { let id = match row.get("id").and_then(|v| v.parse::().ok()) { Some(id) => id, None => continue, }; match Self::execute_row(pool, &row) { Ok(res) => { Self::update_result(pool, id, &res)?; let event = json!({ "event": "underground_processed", "id": id, "type": row.get("underground_type").cloned().unwrap_or_default() }); broker.publish(event.to_string()); } Err(err) => { let error_res = json!({ "status": "error", "message": err.to_string() }); let _ = Self::update_result(pool, id, &error_res); } } } Ok(()) } fn fetch_pending(pool: &ConnectionPool) -> Result { let mut conn = pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("ug_select_pending", Q_SELECT_PENDING)?; conn.execute("ug_select_pending", &[]) } fn execute_row(pool: &ConnectionPool, r: &Row) -> Result { let performer_id = parse_i32(r, "performer_id", -1); let victim_id = parse_i32(r, "victim_id", -1); let task_type = r.get("underground_type").cloned().unwrap_or_default(); let params = r.get("parameters").cloned().unwrap_or_else(|| "{}".into()); Ok(Self::handle_task(pool, &task_type, performer_id, victim_id, ¶ms)?) } fn handle_task( pool: &ConnectionPool, task_type: &str, performer_id: i32, victim_id: i32, params_json: &str, ) -> Result { let p: Json = serde_json::from_str(params_json).unwrap_or_else(|_| json!({})); match task_type { "spyin" => Self::spy_in(pool, performer_id, victim_id, &p), "assassin" => Self::assassin(pool, performer_id, victim_id, &p), "sabotage" => Self::sabotage(pool, performer_id, victim_id, &p), "corrupt_politician" => Ok(Self::corrupt_politician(performer_id, victim_id, &p)), "rob" => Self::rob(pool, performer_id, victim_id, &p), _ => Ok(json!({ "status": "unknown_type", "type": task_type })), } } fn spy_in( pool: &ConnectionPool, performer_id: i32, victim_id: i32, p: &Json, ) -> Result { let mut conn = pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("ug_select_by_performer", Q_SELECT_BY_PERFORMER)?; let rows = conn.execute("ug_select_by_performer", &[&victim_id])?; let mut activities = Vec::new(); for r in rows { let params: Json = r .get("parameters") .and_then(|s| serde_json::from_str(s).ok()) .unwrap_or_else(|| json!({})); let result_text = r.get("result_text").cloned().unwrap_or_else(|| "null".into()); let result: Json = serde_json::from_str(&result_text).unwrap_or(Json::Null); let mut status = "pending".to_string(); if let Json::Object(obj) = &result { if let Some(Json::String(s)) = obj.get("status") { status = s.clone(); } else { status = "done".to_string(); } } let activity = json!({ "id": parse_i32(&r, "id", -1), "type": r.get("underground_type").cloned().unwrap_or_default(), "performed_by": parse_i32(&r, "performer_id", -1), "victim_id": parse_i32(&r, "victim_id", -1), "created_at": r.get("created_at").cloned().unwrap_or_default(), "parameters": params, "result": result, "status": status }); activities.push(activity); } Ok(json!({ "status": "success", "action": "spyin", "performer_id": performer_id, "victim_id": victim_id, "details": p, "victim_illegal_activity_count": activities.len(), "victim_illegal_activities": activities })) } fn assassin( pool: &ConnectionPool, performer_id: i32, victim_id: i32, p: &Json, ) -> Result { let mut conn = pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("ug_select_char_health", Q_SELECT_CHAR_HEALTH)?; conn.prepare("ug_update_char_health", Q_UPDATE_CHAR_HEALTH)?; let rows = conn.execute("ug_select_char_health", &[&victim_id])?; if rows.is_empty() { return Ok(json!({ "status": "error", "action": "assassin", "performer_id": performer_id, "victim_id": victim_id, "message": "victim_not_found", "details": p })); } let current = parse_i32(&rows[0], "health", 0); let mut rng = rand::thread_rng(); let dist = Uniform::from(0..=current.max(0)); let new_health = dist.sample(&mut rng); conn.execute("ug_update_char_health", &[&victim_id, &new_health])?; Ok(json!({ "status": "success", "action": "assassin", "performer_id": performer_id, "victim_id": victim_id, "details": p, "previous_health": current, "new_health": new_health, "reduced_by": current - new_health })) } fn sabotage( pool: &ConnectionPool, performer_id: i32, victim_id: i32, p: &Json, ) -> Result { let target = p .get("target") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); match target.as_str() { "house" => Self::sabotage_house(pool, performer_id, victim_id, p), "storage" => Self::sabotage_storage(pool, performer_id, victim_id, p), _ => Ok(json!({ "status": "error", "action": "sabotage", "message": "unknown_target", "performer_id": performer_id, "victim_id": victim_id, "details": p })), } } fn get_user_id_for_character(pool: &ConnectionPool, character_id: i32) -> Result { let mut conn = pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("ug_select_char_user", Q_SELECT_CHAR_USER)?; let rows = conn.execute("ug_select_char_user", &[&character_id])?; Ok(rows .get(0) .and_then(|r| r.get("user_id")) .and_then(|v| v.parse::().ok()) .unwrap_or(-1)) } fn get_house_by_user(pool: &ConnectionPool, user_id: i32) -> Result, DbError> { let mut conn = pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("ug_select_house_by_user", Q_SELECT_HOUSE_BY_USER)?; let rows = conn.execute("ug_select_house_by_user", &[&user_id])?; if rows.is_empty() { return Ok(None); } let r = &rows[0]; Ok(Some(HouseConditions { id: parse_i32(r, "id", -1), roof: parse_i32(r, "roof_condition", 0), floor: parse_i32(r, "floor_condition", 0), wall: parse_i32(r, "wall_condition", 0), windowc: parse_i32(r, "window_condition", 0), })) } fn update_house(pool: &ConnectionPool, h: &HouseConditions) -> Result<(), DbError> { let mut conn = pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("ug_update_house", Q_UPDATE_HOUSE)?; let roof = h.roof.clamp(0, 100); let floor = h.floor.clamp(0, 100); let wall = h.wall.clamp(0, 100); let windowc = h.windowc.clamp(0, 100); conn.execute("ug_update_house", &[&h.id, &roof, &floor, &wall, &windowc])?; Ok(()) } fn sabotage_house( pool: &ConnectionPool, performer_id: i32, victim_id: i32, p: &Json, ) -> Result { let user_id = Self::get_user_id_for_character(pool, victim_id)?; if user_id < 0 { return Ok(json!({ "status": "error", "action": "sabotage", "target": "house", "message": "victim_not_found", "performer_id": performer_id, "victim_id": victim_id, "details": p })); } let mut house = match Self::get_house_by_user(pool, user_id)? { Some(h) => h, None => { return Ok(json!({ "status": "error", "action": "sabotage", "target": "house", "message": "house_not_found", "performer_id": performer_id, "victim_id": victim_id, "details": p })) } }; // Erlaubte Felder aus Params let mut allow: Vec = Vec::new(); if let Some(conds) = p.get("conditions").and_then(|v| v.as_array()) { for s in conds { if let Some(name) = s.as_str() { allow.push(name.to_string()); } } } // Statt Referenzen auf Felder zu speichern, arbeiten wir über Indizes, // um Borrowing-Probleme zu vermeiden. let all_fields = ["roof_condition", "floor_condition", "wall_condition", "window_condition"]; let candidate_indices: Vec = (0..all_fields.len()) .filter(|&idx| { allow.is_empty() || allow .iter() .any(|name| name == all_fields[idx]) }) .collect(); if candidate_indices.is_empty() { return Ok(json!({ "status": "error", "action": "sabotage", "target": "house", "message": "no_conditions_selected", "performer_id": performer_id, "victim_id": victim_id, "details": p })); } let k = random_int(1, candidate_indices.len() as i32) as usize; let picks = random_indices(candidate_indices.len(), k); let mut changed = Vec::new(); for i in picks { let idx = candidate_indices[i]; let (name, value_ref) = match idx { 0 => ("roof_condition", &mut house.roof), 1 => ("floor_condition", &mut house.floor), 2 => ("wall_condition", &mut house.wall), 3 => ("window_condition", &mut house.windowc), _ => continue, }; if *value_ref > 0 { let red = random_int(1, *value_ref); *value_ref = (*value_ref - red).clamp(0, 100); } changed.push(name.to_string()); } Self::update_house(pool, &house)?; Ok(json!({ "status": "success", "action": "sabotage", "target": "house", "performer_id": performer_id, "victim_id": victim_id, "details": p, "changed_conditions": changed, "new_conditions": { "roof_condition": house.roof, "floor_condition": house.floor, "wall_condition": house.wall, "window_condition": house.windowc } })) } fn select_stock_by_branch(pool: &ConnectionPool, branch_id: i32) -> Result { let mut conn = pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("ug_select_stock_by_branch", Q_SELECT_STOCK_BY_BRANCH)?; conn.execute("ug_select_stock_by_branch", &[&branch_id]) } fn filter_by_stock_types(rows: &Rows, allowed: &[i32]) -> Rows { if allowed.is_empty() { return rows.clone(); } let mut out = Vec::new(); for r in rows { if let Some(t) = r.get("stock_type_id").and_then(|v| v.parse::().ok()) { if allowed.contains(&t) { out.push(r.clone()); } } } out } fn update_stock_qty(pool: &ConnectionPool, id: i32, qty: i64) -> Result<(), DbError> { let mut conn = pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("ug_update_stock_qty", Q_UPDATE_STOCK_QTY)?; // beide Parameter explizit als ToSql-Traitobjekte typisieren, um Mischtypen zu erlauben use postgres::types::ToSql; let p1: &(dyn ToSql + Sync) = &id; let p2: &(dyn ToSql + Sync) = &qty; conn.execute("ug_update_stock_qty", &[p1, p2])?; Ok(()) } fn sabotage_storage( pool: &ConnectionPool, performer_id: i32, victim_id: i32, p: &Json, ) -> Result { let branch_id = match p.get("branch_id").and_then(|v| v.as_i64()) { Some(id) => id as i32, None => { return Ok(json!({ "status": "error", "action": "sabotage", "target": "storage", "message": "branch_id_required", "performer_id": performer_id, "victim_id": victim_id, "details": p })) } }; let mut allowed = Vec::new(); if let Some(arr) = p.get("stock_type_ids").and_then(|v| v.as_array()) { for v in arr { if let Some(id) = v.as_i64() { allowed.push(id as i32); } } } let rows_all = Self::select_stock_by_branch(pool, branch_id)?; let mut rows = Self::filter_by_stock_types(&rows_all, &allowed); if rows.is_empty() { return Ok(json!({ "status": "success", "action": "sabotage", "target": "storage", "performer_id": performer_id, "victim_id": victim_id, "details": p, "removed_total": 0, "affected_rows": [] })); } let mut total: i64 = 0; for r in &rows { if let Some(q) = r.get("quantity").and_then(|v| v.parse::().ok()) { total += q; } } if total <= 0 { return Ok(json!({ "status": "success", "action": "sabotage", "target": "storage", "performer_id": performer_id, "victim_id": victim_id, "details": p, "removed_total": 0, "affected_rows": [] })); } let cap = total / 4; if cap <= 0 { return Ok(json!({ "status": "success", "action": "sabotage", "target": "storage", "performer_id": performer_id, "victim_id": victim_id, "details": p, "removed_total": 0, "affected_rows": [] })); } let mut rng = rand::thread_rng(); let mut to_remove = random_ll(1, cap); rows.shuffle(&mut rng); let mut affected = Vec::new(); for r in rows { if to_remove == 0 { break; } let id = parse_i32(&r, "id", -1); let q = r .get("quantity") .and_then(|v| v.parse::().ok()) .unwrap_or(0); if q <= 0 { continue; } let take = random_ll(1, min(q, to_remove)); let newq = q - take; Self::update_stock_qty(pool, id, newq)?; to_remove -= take; let entry = json!({ "id": id, "stock_type_id": parse_i32(&r, "stock_type_id", -1), "previous_quantity": q, "new_quantity": newq, "removed": take }); affected.push(entry); } let removed_total: i64 = affected .iter() .filter_map(|a| a.get("removed").and_then(|v| v.as_i64())) .sum(); Ok(json!({ "status": "success", "action": "sabotage", "target": "storage", "performer_id": performer_id, "victim_id": victim_id, "details": p, "removed_total": removed_total, "affected_rows": affected })) } fn corrupt_politician( performer_id: i32, victim_id: i32, p: &Json, ) -> Json { json!({ "status": "success", "action": "corrupt_politician", "performer_id": performer_id, "victim_id": victim_id, "details": p }) } fn rob( pool: &ConnectionPool, performer_id: i32, victim_id: i32, p: &Json, ) -> Result { let user_id = Self::get_user_id_for_character(pool, victim_id)?; if user_id < 0 { return Ok(json!({ "status": "error", "action": "rob", "message": "victim_not_found", "performer_id": performer_id, "victim_id": victim_id, "details": p })); } let mut conn = pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("ug_select_falukant_user", Q_SELECT_FALUKANT_USER)?; let fu = conn.execute("ug_select_falukant_user", &[&user_id])?; if fu.is_empty() { return Ok(json!({ "status": "error", "action": "rob", "message": "falukant_user_not_found", "performer_id": performer_id, "victim_id": victim_id, "details": p })); } let falukant_user_id = parse_i32(&fu[0], "id", -1); let money = fu[0] .get("money") .and_then(|v| v.parse::().ok()) .unwrap_or(0.0); let default_branch = parse_i32(&fu[0], "main_branch_region_id", 0); let steal_goods = random_int(0, 1) == 1; if steal_goods { let branch_id = p .get("branch_id") .and_then(|v| v.as_i64()) .map(|v| v as i32) .unwrap_or(default_branch); if branch_id <= 0 { return Ok(json!({ "status": "success", "action": "rob", "mode": "goods", "performer_id": performer_id, "victim_id": victim_id, "details": p, "removed_total": 0, "affected_rows": [] })); } let rows_all = Self::select_stock_by_branch(pool, branch_id)?; let mut rows = rows_all; if rows.is_empty() { return Ok(json!({ "status": "success", "action": "rob", "mode": "goods", "performer_id": performer_id, "victim_id": victim_id, "details": p, "removed_total": 0, "affected_rows": [] })); } let mut total: i64 = 0; for r in &rows { if let Some(q) = r.get("quantity").and_then(|v| v.parse::().ok()) { total += q; } } if total <= 0 { return Ok(json!({ "status": "success", "action": "rob", "mode": "goods", "performer_id": performer_id, "victim_id": victim_id, "details": p, "removed_total": 0, "affected_rows": [] })); } let cap = max(1_i64, total / 2); let mut to_remove = random_ll(1, cap); let mut rng = rand::thread_rng(); rows.shuffle(&mut rng); let mut affected = Vec::new(); for r in rows { if to_remove == 0 { break; } let id = parse_i32(&r, "id", -1); let q = r .get("quantity") .and_then(|v| v.parse::().ok()) .unwrap_or(0); if q <= 0 { continue; } let take = random_ll(1, min(q, to_remove)); let newq = q - take; Self::update_stock_qty(pool, id, newq)?; to_remove -= take; affected.push(json!({ "id": id, "stock_type_id": parse_i32(&r, "stock_type_id", -1), "previous_quantity": q, "new_quantity": newq, "removed": take })); } let removed: i64 = affected .iter() .filter_map(|a| a.get("removed").and_then(|v| v.as_i64())) .sum(); Ok(json!({ "status": "success", "action": "rob", "mode": "goods", "performer_id": performer_id, "victim_id": victim_id, "details": p, "removed_total": removed, "affected_rows": affected })) } else { if money <= 0.0 { return Ok(json!({ "status": "success", "action": "rob", "mode": "money", "performer_id": performer_id, "victim_id": victim_id, "details": p, "stolen": 0.0, "balance_before": 0.0, "balance_after": 0.0 })); } let rate = random_double(0.0, 0.18); let mut amount = (money * rate * 100.0).round() / 100.0; if amount < 0.01 { amount = 0.01; } if amount > money { amount = money; } let _msg = json!({ "event": "money_changed", "reason": "robbery", "delta": -amount, "performer_id": performer_id, "victim_id": victim_id }); if let Err(err) = change_falukant_user_money(pool, falukant_user_id, -amount, "robbery") { eprintln!( "[UndergroundWorker] Fehler bei change_falukant_user_money: {err}" ); } // Event manuell publizieren // (BaseWorker kümmert sich aktuell nur um die DB-Änderung) // Hinweis: Wir haben keinen direkten Zugriff auf broker hier, daher wird das // Event nur im Rückgabe-JSON signalisiert. let after = ((money - amount) * 100.0).round() / 100.0; Ok(json!({ "status": "success", "action": "rob", "mode": "money", "performer_id": performer_id, "victim_id": victim_id, "details": p, "stolen": amount, "rate": rate, "balance_before": money, "balance_after": after })) } } fn update_result(pool: &ConnectionPool, id: i32, result: &Json) -> Result<(), DbError> { let mut conn = pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("ug_update_result", Q_UPDATE_RESULT)?; let result_text = result.to_string(); conn.execute("ug_update_result", &[&id, &result_text])?; Ok(()) } } impl Worker for UndergroundWorker { 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| { UndergroundWorker::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(); } } // Hilfsfunktionen für Zufall und Parsing fn random_int(lo: i32, hi: i32) -> i32 { let mut rng = rand::thread_rng(); rng.gen_range(lo..=hi) } fn random_ll(lo: i64, hi: i64) -> i64 { let mut rng = rand::thread_rng(); rng.gen_range(lo..=hi) } fn random_indices(n: usize, k: usize) -> Vec { let mut idx: Vec = (0..n).collect(); let mut rng = rand::thread_rng(); idx.shuffle(&mut rng); if k < idx.len() { idx.truncate(k); } idx } fn random_double(lo: f64, hi: f64) -> f64 { let mut rng = rand::thread_rng(); rng.gen_range(lo..hi) } fn parse_i32(row: &Row, key: &str, default: i32) -> i32 { row.get(key) .and_then(|v| v.parse::().ok()) .unwrap_or(default) } fn change_falukant_user_money( pool: &ConnectionPool, falukant_user_id: i32, money_change: f64, action: &str, ) -> Result<(), DbError> { use postgres::types::ToSql; let mut conn = pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("ug_update_money", QUERY_UPDATE_MONEY)?; let p1: &(dyn ToSql + Sync) = &falukant_user_id; let p2: &(dyn ToSql + Sync) = &money_change; let p3: &(dyn ToSql + Sync) = &action; conn.execute("ug_update_money", &[p1, p2, p3])?; // Best-effort insert into money_history for UI visibility let money_str = format!("{:.2}", money_change); fn escape_sql_literal(s: &str) -> String { s.replace('\'', "''") } let escaped_action = escape_sql_literal(action); let history_sql = format!( "INSERT INTO falukant_log.money_history (user_id, change, action, created_at) VALUES ({uid}, {money}::numeric, '{act}', NOW());", uid = falukant_user_id, money = money_str, act = escaped_action ); if let Err(err) = conn.query(&history_sql) { eprintln!( "[UndergroundWorker] Warning: inserting money_history failed for user {}: {}", falukant_user_id, err ); } Ok(()) }