//! Liebhaber, Ehezufriedenheit, Ansehen, Monatskosten (Handoff: docs/FALUKANT_DAEMON_HANDOFF.md). //! Benötigt `migrations/001_falukant_family_lovers.sql` (ggf. `002` bei Altbestand), `003` für Ehe-Buffs, //! optional `004` + Backend-Stammdaten für Dienerschaft (`falukant_servants`), //! `005` Ehe öffentliche Stabilität + Hausfrieden (`household_tension_score`), //! `006` Liebschafts-Unterhalt 12× pro Spieltag (alle 2 h), 1 Spieltag = 1 Spieljahr. //! Schuldturm/Verzug/Pfändung: `falukant_debtors::run_daily` (siehe `docs/FALUKANT_DEBTORS_DAEMON.md`). //! //! WebSocket: `falukantUpdateFamily` (reason) + `falukantUpdateStatus` für betroffene Nutzer. use std::collections::{HashMap, HashSet}; use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::{Duration, Instant}; use rand::distributions::{Distribution, Uniform}; use rand::rngs::StdRng; use rand::SeedableRng; use super::base::{BaseWorker, Worker, WorkerState}; use super::sql::{ QUERY_COUNT_LOVER_CHILDREN_FOR_USER, QUERY_FAMILY_SCHEMA_READY, QUERY_GET_ACTIVE_LOVER_ROWS_FOR_DAILY, QUERY_GET_ACTIVE_LOVER_ROWS_FOR_MONTHLY, QUERY_GET_ACTIVE_LOVER_ROWS_FOR_INSTALLMENT, QUERY_GET_LOVER_PREGNANCY_CANDIDATES, QUERY_GET_MARRIAGE_ROWS, QUERY_GET_USER_HOUSE_ROW_BY_USER, QUERY_INSERT_CHILD, QUERY_INSERT_CHILD_RELATION_LOVER, QUERY_LOVER_BIRTH_PENALTY_MARRIAGE, QUERY_LOVER_BIRTH_PENALTY_REPUTATION, QUERY_LOVER_INSTALLMENT_SCHEMA_READY, QUERY_MARK_LOVER_DAILY_DONE, QUERY_MARK_LOVER_INSTALLMENT_AT, QUERY_MARK_LOVER_MONTHLY_DONE, QUERY_MARRIAGE_SUBTRACT_SATISFACTION, QUERY_RESET_LOVER_UNDERPAY_COUNTERS, QUERY_UPDATE_CHARACTER_REPUTATION, QUERY_UPDATE_LOVER_UNDERPAY_STATE, QUERY_UPDATE_LOVER_VISIBILITY_DISCRETION, QUERY_UPDATE_MARRIAGE_STATE_AND_BUFFS, QUERY_UPDATE_USER_HOUSE_TENSION_BY_USER, }; use crate::db::{ConnectionPool, DbError}; use crate::message_broker::MessageBroker; const DAILY_INTERVAL: Duration = Duration::from_secs(24 * 3600); const MONTHLY_INTERVAL: Duration = Duration::from_secs(30 * 24 * 3600); /// 12 Monatsticke pro Spieltag (24 h = 1 Spieljahr); 2 h = 1 Spielmonat (Liebschaft + Dienerschaft). const GAME_MONTH_SLICE_INTERVAL: Duration = Duration::from_secs(2 * 3600); pub struct FalukantFamilyWorker { base: BaseWorker, rng: StdRng, dist: Uniform, last_daily: Option, last_monthly: Option, /// Gemeinsamer Tick für Liebschafts-Raten + Dienerschaft (Monatstick ≈ 2 h). last_game_month_slice: Option, schema_ready: bool, /// Migration `004_falukant_servants_daemon.sql` (Dienerschaft-Ticks). servants_schema_ready: bool, /// Migration `006_falukant_lover_installments.sql`. lover_installment_schema_ready: bool, } impl FalukantFamilyWorker { pub fn new(pool: ConnectionPool, broker: MessageBroker) -> Self { Self { base: BaseWorker::new("FalukantFamilyWorker", pool, broker), rng: StdRng::from_entropy(), dist: Uniform::from(0.0..1.0), last_daily: None, last_monthly: None, last_game_month_slice: None, schema_ready: false, servants_schema_ready: false, lover_installment_schema_ready: false, } } fn run_iteration(&mut self, state: &WorkerState) { self.base.set_current_step("FalukantFamilyWorker iteration"); let now = Instant::now(); if !self.schema_ready { if let Ok(true) = self.check_schema() { self.schema_ready = true; } } if Self::should_run(self.last_daily, now, DAILY_INTERVAL) { if let Err(e) = super::falukant_debtors::run_daily(&self.base, &self.base.broker) { eprintln!("[FalukantFamilyWorker] falukant_debtors::run_daily: {e}"); } if let Err(e) = super::falukant_certificate::run_daily(&self.base, &self.base.broker) { eprintln!("[FalukantFamilyWorker] falukant_certificate::run_daily: {e}"); } if self.schema_ready { if let Err(e) = self.process_daily() { eprintln!("[FalukantFamilyWorker] process_daily: {e}"); } } self.last_daily = Some(now); } if Self::should_run(self.last_monthly, now, MONTHLY_INTERVAL) { if let Err(e) = self.process_monthly() { eprintln!("[FalukantFamilyWorker] process_monthly: {e}"); } self.last_monthly = Some(now); } if self.servants_schema_ready || self.lover_installment_schema_ready { if Self::should_run(self.last_game_month_slice, now, GAME_MONTH_SLICE_INTERVAL) { if self.servants_schema_ready { if let Err(e) = super::falukant_servants::run_monthly(&self.base, &self.base.broker) { eprintln!("[FalukantFamilyWorker] falukant_servants::run_monthly: {e}"); } } if self.lover_installment_schema_ready { if let Err(e) = self.process_lover_installments() { eprintln!("[FalukantFamilyWorker] process_lover_installments: {e}"); } } self.last_game_month_slice = Some(now); } } std::thread::sleep(Duration::from_secs(1)); if !state.running_worker.load(Ordering::Relaxed) { // stopping } } fn should_run(last: Option, now: Instant, interval: Duration) -> bool { match last { None => true, Some(t) => now.saturating_duration_since(t) >= interval, } } fn check_schema(&mut self) -> Result { let mut conn = self .base .pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("family_schema", QUERY_FAMILY_SCHEMA_READY)?; let rows = conn.execute("family_schema", &[])?; let family_ok = rows .first() .and_then(|r| r.get("ready")) .map(|v| v == "true" || v == "t") .unwrap_or(false); self.servants_schema_ready = super::falukant_servants::servants_schema_ready(&self.base.pool).unwrap_or(false); conn.prepare("lover_inst_schema", QUERY_LOVER_INSTALLMENT_SCHEMA_READY)?; let inst_rows = conn.execute("lover_inst_schema", &[])?; self.lover_installment_schema_ready = inst_rows .first() .and_then(|r| r.get("ready")) .map(|v| v == "true" || v == "t") .unwrap_or(false); Ok(family_ok) } fn process_daily(&mut self) -> Result<(), DbError> { if self.servants_schema_ready { super::falukant_servants::run_daily(&self.base, &self.base.broker)?; } let mut conn = self .base .pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("get_lovers", QUERY_GET_ACTIVE_LOVER_ROWS_FOR_DAILY)?; let lover_rows = conn.execute("get_lovers", &[])?; conn.prepare("get_marriages", QUERY_GET_MARRIAGE_ROWS)?; let marriage_rows = conn.execute("get_marriages", &[])?; let mut marriages: Vec = marriage_rows .into_iter() .filter_map(|r| { Some(MarriageData { id: parse_i32(&r, "marriage_id", -1), m1: parse_i32(&r, "m1", -1), m2: parse_i32(&r, "m2", -1), satisfaction: parse_i32(&r, "marriage_satisfaction", 55), public_stability: parse_i32(&r, "marriage_public_stability", 55), drift_high: parse_i32(&r, "marriage_drift_high", 0), drift_low: parse_i32(&r, "marriage_drift_low", 0), title1_tr: r.get("title1_tr").cloned().unwrap_or_default(), title2_tr: r.get("title2_tr").cloned().unwrap_or_default(), user1_id: parse_opt_i32(&r, "user1_id"), user2_id: parse_opt_i32(&r, "user2_id"), gift_days: parse_i32(&r, "marriage_gift_buff_days_remaining", 0), feast_pending: parse_i32(&r, "marriage_pending_feast_bonus", 0), house_supply: parse_i32(&r, "marriage_house_supply", 50), no_lover_counter: parse_i32(&r, "marriage_no_lover_bonus_counter", 0), household_order_1: parse_i32(&r, "household_order_1", 55), household_order_2: parse_i32(&r, "household_order_2", 55), servant_quality_1: parse_i32(&r, "servant_quality_1", 50), servant_quality_2: parse_i32(&r, "servant_quality_2", 50), }) }) .filter(|m| m.id > 0) .collect(); let mut lovers: Vec = lover_rows .into_iter() .filter_map(|r| { Some(LoverData { rel_id: parse_i32(&r, "rel_id", -1), c1: parse_i32(&r, "c1", -1), c2: parse_i32(&r, "c2", -1), lover_role: r.get("lover_role").cloned().unwrap_or_default(), affection: parse_i32(&r, "affection", 50), visibility: parse_i32(&r, "visibility", 0), discretion: parse_i32(&r, "discretion", 50), maintenance_level: parse_i32(&r, "maintenance_level", 50), status_fit: parse_i32(&r, "status_fit", 0), scandal_extra: parse_i32(&r, "scandal_extra_daily_pct", 0), months_underfunded: parse_i32(&r, "months_underfunded", 0), acknowledged: parse_bool_row(&r, "acknowledged"), title1_tr: r.get("title1_tr").cloned().unwrap_or_default(), title2_tr: r.get("title2_tr").cloned().unwrap_or_default(), user1_id: parse_opt_i32(&r, "user1_id"), user2_id: parse_opt_i32(&r, "user2_id"), min_age_years: parse_i32(&r, "min_age_years", 99), servant_disc_u1: parse_i32(&r, "servant_disc_u1", 0), servant_disc_u2: parse_i32(&r, "servant_disc_u2", 0), }) }) .filter(|l| l.rel_id > 0) .collect(); let mut char_user: HashMap> = HashMap::new(); for l in &lovers { char_user.insert(l.c1, l.user1_id); char_user.insert(l.c2, l.user2_id); } let mut char_lover_count: std::collections::HashMap = std::collections::HashMap::new(); let mut char_visible_lover_rel_count: std::collections::HashMap = std::collections::HashMap::new(); for l in &lovers { *char_lover_count.entry(l.c1).or_insert(0) += 1; *char_lover_count.entry(l.c2).or_insert(0) += 1; if l.visibility >= 60 { *char_visible_lover_rel_count.entry(l.c1).or_insert(0) += 1; *char_visible_lover_rel_count.entry(l.c2).or_insert(0) += 1; } } conn.prepare("upd_vd", QUERY_UPDATE_LOVER_VISIBILITY_DISCRETION)?; for l in &mut lovers { let ehe_conflict = marriages.iter().any(|m| { (m.m1 == l.c1 || m.m2 == l.c1 || m.m1 == l.c2 || m.m2 == l.c2) && m.satisfaction < 40 }); let mut v_net = 0i32; if l.maintenance_level < 35 { v_net += 1; } if l.affection < 30 { v_net += 1; } if l.status_fit == -2 { v_net += 2; } if ehe_conflict { v_net += 1; } if l.discretion >= 60 { v_net -= 1; } if l.maintenance_level >= 70 { v_net -= 1; } let comb = (l.servant_disc_u1 + l.servant_disc_u2) / 2; v_net += comb / 4; let new_vis = clamp_i32(l.visibility + v_net, 0, 100); let mut d_net = 0i32; if l.maintenance_level >= 70 { d_net += 1; } if l.maintenance_level < 35 { d_net -= 1; } if l.visibility > 60 { d_net -= 1; } let new_disc = clamp_i32(l.discretion + d_net, 0, 100); conn.execute( "upd_vd", &[&new_vis, &new_disc, &l.rel_id], )?; l.visibility = new_vis; l.discretion = new_disc; } conn.prepare("mark_daily", QUERY_MARK_LOVER_DAILY_DONE)?; for l in &lovers { conn.execute("mark_daily", &[&l.rel_id])?; } let mut marriage_socket_users: HashSet = HashSet::new(); conn.prepare("upd_marriage_full", QUERY_UPDATE_MARRIAGE_STATE_AND_BUFFS)?; for m in marriages.iter_mut() { let touching: Vec<&LoverData> = lovers .iter() .filter(|l| { let (a, b) = (l.c1, l.c2); ((a == m.m1 && b != m.m2) || (b == m.m1 && a != m.m2)) || ((a == m.m2 && b != m.m1) || (b == m.m2 && a != m.m1)) }) .collect(); let touching_empty = touching.is_empty(); let sat0 = m.satisfaction; let pst0 = m.public_stability; let dh0 = m.drift_high; let dl0 = m.drift_low; let g0 = m.gift_days; let f0 = m.feast_pending; let n0 = m.no_lover_counter; let mut sat = m.satisfaction; let mut dh = m.drift_high; let mut dl = m.drift_low; let mut gift_days = m.gift_days; let mut feast = m.feast_pending; let mut nl_counter = m.no_lover_counter; let house_supply = m.house_supply; if touching_empty { if sat > 55 { dh += 1; if dh >= 3 { sat = (sat - 1).max(0); dh = 0; } } else if sat < 55 { dl += 1; if dl >= 5 { sat = (sat + 1).min(100); dl = 0; } } } else { let mg = marriage_rank_group(&m.title1_tr, &m.title2_tr); let mistress_n = touching .iter() .filter(|l| l.lover_role == "mistress_or_favorite") .count(); let total_lovers_here = touching.len(); let mut delta = 0i32; for l in &touching { delta += lover_marriage_daily_delta( mg, l, m.satisfaction, mistress_n, total_lovers_here, ); } if touching.iter().any(|l| l.visibility >= 60) { delta -= 2; } if total_lovers_here >= 2 { delta -= 2; } if touching .iter() .any(|l| l.lover_role == "mistress_or_favorite" && l.maintenance_level < 35) { delta -= 1; } if touching.iter().any(|l| l.min_age_years <= 15) { delta -= 1; } sat = clamp_i32(sat + delta, 0, 100); } Self::apply_marriage_positive_buffs( &mut sat, &mut gift_days, &mut feast, &mut nl_counter, touching_empty, house_supply, ); m.satisfaction = sat; m.drift_high = dh; m.drift_low = dl; m.gift_days = gift_days; m.feast_pending = feast; m.no_lover_counter = nl_counter; let mut pst = m.public_stability; if !touching_empty { let max_vis = touching.iter().map(|l| l.visibility).max().unwrap_or(0); pst -= (max_vis / 25).min(4); if touching.iter().any(|l| l.visibility >= 60) { pst -= 2; } if touching.iter().any(|l| l.months_underfunded >= 1) { pst -= 1; } if touching.iter().any(|l| l.months_underfunded >= 3) { pst -= 1; } if touching.iter().any(|l| l.acknowledged) { pst -= 2; } let g1 = title_group(&m.title1_tr); let g2 = title_group(&m.title2_tr); if (g1 as i32 - g2 as i32).abs() >= 2 { pst -= 1; } if touching.iter().any(|l| l.min_age_years <= 15) { pst -= 2; } let ho_avg = (m.household_order_1 + m.household_order_2) / 2; if ho_avg < 35 { pst -= 2; } else if ho_avg < 50 { pst -= 1; } let sq_avg = (m.servant_quality_1 + m.servant_quality_2) / 2; if sq_avg < 40 { pst -= 1; } if sat < 40 { pst -= 1; } } else if pst < 55 { pst += 1; } else if pst > 55 { pst -= 1; } pst = clamp_i32(pst, 0, 100); m.public_stability = pst; conn.execute( "upd_marriage_full", &[&sat, &dh, &dl, &gift_days, &feast, &nl_counter, &pst, &m.id], )?; if sat != sat0 || pst != pst0 || dh != dh0 || dl != dl0 || gift_days != g0 || feast != f0 || nl_counter != n0 { push_user_id(&mut marriage_socket_users, m.user1_id); push_user_id(&mut marriage_socket_users, m.user2_id); } } // Hausfrieden: Spannungswert 0..100 persistieren (Handoff Ehe & Hausfrieden) let mut tension_users: HashSet = HashSet::new(); for l in &lovers { push_user_id(&mut tension_users, l.user1_id); push_user_id(&mut tension_users, l.user2_id); } for m in &marriages { push_user_id(&mut tension_users, m.user1_id); push_user_id(&mut tension_users, m.user2_id); } let mut tension_agg: HashMap = HashMap::new(); for uid in &tension_users { tension_agg.insert(*uid, UserTensionAgg::default()); } for l in &lovers { for uid in [l.user1_id, l.user2_id].into_iter().flatten() { if let Some(a) = tension_agg.get_mut(&uid) { a.max_lover_visibility = a.max_lover_visibility.max(l.visibility); if l.acknowledged { a.n_acknowledged += 1; } a.max_months_underfunded = a.max_months_underfunded.max(l.months_underfunded); } } } for m in &marriages { for uid in [m.user1_id, m.user2_id].into_iter().flatten() { if let Some(a) = tension_agg.get_mut(&uid) { a.min_marriage_sat = a.min_marriage_sat.min(m.satisfaction); } } } conn.prepare("upd_tension", QUERY_UPDATE_USER_HOUSE_TENSION_BY_USER)?; conn.prepare("child_cnt_lover", QUERY_COUNT_LOVER_CHILDREN_FOR_USER)?; conn.prepare("uh_row_u", QUERY_GET_USER_HOUSE_ROW_BY_USER)?; let mut tension_socket_users: HashSet = HashSet::new(); for (&uid, agg) in &tension_agg { let child_rows = conn.execute("child_cnt_lover", &[&uid])?; let lover_children = child_rows .first() .map(|r| parse_i32(r, "cnt", 0)) .unwrap_or(0); let house_rows = conn.execute("uh_row_u", &[&uid])?; let Some(hr) = house_rows.first() else { continue; }; let ho = parse_i32(hr, "household_order", 55); let sq = parse_i32(hr, "servant_quality", 50); let pay = hr .get("servant_pay_level") .cloned() .unwrap_or_else(|| "normal".into()); let prev_ts = parse_i32(hr, "prev_tension_score", 0); let score = compute_household_tension_score(agg, lover_children, ho, sq, pay.as_str()); if score != prev_ts { conn.execute("upd_tension", &[&score, &uid])?; tension_socket_users.insert(uid); } } conn.prepare("upd_rep", QUERY_UPDATE_CHARACTER_REPUTATION)?; for l in &lovers { let g = pair_rank_group(&l.title1_tr, &l.title2_tr); let scandal = l.visibility > 70; let order_ok = l.maintenance_level >= 65 && l.discretion >= 60 && l.visibility <= 35 && mistress_count_for_pair(&lovers, l.c1, l.c2) <= 1; let rm = rank_rep_modifier(g, scandal, order_ok); let vf = 0.4 + (l.visibility as f64 / 100.0) * 1.6; let base = match l.lover_role.as_str() { "secret_affair" => -0.2, "lover" => -0.4, "mistress_or_favorite" => -0.6, _ => -0.4, }; let mut delta = base * vf * rm; if l.lover_role == "mistress_or_favorite" && order_ok { if g == 2 { delta = 0.1; } else if g == 3 { delta = 0.2; } } delta = delta.clamp(-3.0, 1.0); let age_rep = age_reputation_delta(l.min_age_years); let vis_young = visibility_young_penalty(l.min_age_years, l.visibility); let final_delta = delta + age_rep + vis_young; for cid in [l.c1, l.c2] { let cur = fetch_reputation(&mut conn, cid)?; let new_rep = (cur + final_delta).clamp(0.0, 100.0); let s = format!("{:.2}", new_rep); conn.execute("upd_rep", &[&s, &cid])?; } let married_c1 = marriages .iter() .any(|m| m.m1 == l.c1 || m.m2 == l.c1); let married_c2 = marriages .iter() .any(|m| m.m1 == l.c2 || m.m2 == l.c2); let mut p = 1.0 + (l.visibility as f64) / 25.0 + if married_c1 || married_c2 { 2.0 } else { 0.0 } + if l.status_fit == -2 { 2.0 } else { 0.0 } + if l.maintenance_level < 25 { 3.0 } else { 0.0 } + if char_lover_count.get(&l.c1).copied().unwrap_or(0) >= 2 || char_lover_count.get(&l.c2).copied().unwrap_or(0) >= 2 { 3.0 } else { 0.0 } + l.scandal_extra as f64 + scandal_age_extra_pct(l.min_age_years); if l.discretion >= 75 { p -= 2.0; } if g == 3 && l.lover_role == "mistress_or_favorite" && l.maintenance_level >= 65 && l.visibility <= 40 { p -= 2.0; } p = p.clamp(0.0, 25.0); if self.dist.sample(&mut self.rng) * 100.0 < p { let _ = self.base.broker.publish(format!( r#"{{"event":"falukant_family_scandal_hint","relationship_id":{}}}"#, l.rel_id )); if let Some(uid) = l.user1_id.filter(|x| *x > 0) { self.publish_falukant_update_family_and_status(uid, "scandal"); } if let Some(uid) = l.user2_id.filter(|x| *x > 0) { self.publish_falukant_update_family_and_status(uid, "scandal"); } } } // Zwei sichtbare Liebschaften (visibility ≥ 60) pro Person: zusätzlich −4 Ansehen. for (&cid, &n_vis) in &char_visible_lover_rel_count { if n_vis < 2 { continue; } let cur = fetch_reputation(&mut conn, cid)?; let new_rep = (cur - 4.0).clamp(0.0, 100.0); let s = format!("{:.2}", new_rep); conn.execute("upd_rep", &[&s, &cid])?; } // Seltene Einmal-Malus (Gerücht / Tadel) für beteiligte Charaktere. let mut unique_cids: HashSet = HashSet::new(); for l in &lovers { unique_cids.insert(l.c1); unique_cids.insert(l.c2); } let mut rng_malus_cids: Vec = Vec::new(); for cid in unique_cids.iter().copied() { let r = self.dist.sample(&mut self.rng) * 100.0; let malus = if r < 1.0 { -5.0 } else if r < 3.0 { -3.0 } else { 0.0 }; if malus < 0.0 { let cur = fetch_reputation(&mut conn, cid)?; let new_rep = (cur + malus).clamp(0.0, 100.0); let s = format!("{:.2}", new_rep); conn.execute("upd_rep", &[&s, &cid])?; rng_malus_cids.push(cid); } } let mut notify: HashSet = HashSet::new(); for l in &lovers { push_user_id(&mut notify, l.user1_id); push_user_id(&mut notify, l.user2_id); } for (&cid, &n_vis) in &char_visible_lover_rel_count { if n_vis >= 2 { push_user_id(&mut notify, char_user.get(&cid).copied().flatten()); } } for cid in &rng_malus_cids { push_user_id(&mut notify, char_user.get(cid).copied().flatten()); } notify.extend(marriage_socket_users); notify.extend(tension_socket_users); self.publish_falukant_update_family_batch(¬ify, "daily"); drop(conn); // Liebschafts-Geburt: früher nur alle ~30 Tage in process_monthly — zu selten für kurze Testphasen. if let Err(e) = self.process_lover_births() { eprintln!("[FalukantFamilyWorker] process_lover_births: {e}"); } Ok(()) } fn process_monthly(&mut self) -> Result<(), DbError> { let mut conn = self .base .pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("get_lovers_m", QUERY_GET_ACTIVE_LOVER_ROWS_FOR_MONTHLY)?; let lover_rows = conn.execute("get_lovers_m", &[])?; conn.prepare("mark_monthly", QUERY_MARK_LOVER_MONTHLY_DONE)?; let mut monthly_notify: HashSet = HashSet::new(); for r in lover_rows { let rel_id = parse_i32(&r, "rel_id", -1); if rel_id < 0 { continue; } let u1 = parse_opt_i32(&r, "user1_id"); let u2 = parse_opt_i32(&r, "user2_id"); conn.execute("mark_monthly", &[&rel_id])?; push_user_id(&mut monthly_notify, u1); push_user_id(&mut monthly_notify, u2); } self.publish_falukant_update_family_batch(&monthly_notify, "monthly"); drop(conn); self.process_lover_births()?; Ok(()) } /// Ein Zwölftel des bisherigen Monatsunterhalts, alle 2 h (12 Spielmonate pro Spieljahr). fn process_lover_installments(&mut self) -> Result<(), DbError> { let mut conn = self .base .pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("get_lovers_i", QUERY_GET_ACTIVE_LOVER_ROWS_FOR_INSTALLMENT)?; let lover_rows = conn.execute("get_lovers_i", &[])?; conn.prepare("upd_under", QUERY_UPDATE_LOVER_UNDERPAY_STATE)?; conn.prepare("reset_under", QUERY_RESET_LOVER_UNDERPAY_COUNTERS)?; conn.prepare("mar_sub", QUERY_MARRIAGE_SUBTRACT_SATISFACTION)?; conn.prepare("mark_inst", QUERY_MARK_LOVER_INSTALLMENT_AT)?; let mut notify: HashSet = HashSet::new(); for r in lover_rows { let rel_id = parse_i32(&r, "rel_id", -1); if rel_id < 0 { continue; } let lover_role = r.get("lover_role").cloned().unwrap_or_default(); let maintenance_level = parse_i32(&r, "maintenance_level", 50); let status_fit = parse_i32(&r, "status_fit", 0); let mut base = parse_i32(&r, "monthly_base_cost", 0); if base <= 0 { base = match lover_role.as_str() { "secret_affair" => 10, "lover" => 30, "mistress_or_favorite" => 80, _ => 30, }; } let title1 = r.get("title1_tr").cloned().unwrap_or_default(); let title2 = r.get("title2_tr").cloned().unwrap_or_default(); let g = pair_rank_group(&title1, &title2); let rank_m = rank_cost_multiplier(g); let maint_f = 0.6 + (maintenance_level as f64 / 100.0) * 1.2; let sf_m = match status_fit { -2 => 1.35, -1 => 1.15, _ => 1.0, }; let cost = ((base as f64) * rank_m * maint_f * sf_m).round() as i32; let installment = ((cost as f64) / 12.0 * 100.0).round() / 100.0; let u1 = parse_opt_i32(&r, "user1_id"); let u2 = parse_opt_i32(&r, "user2_id"); let payer = u1.or(u2).filter(|x| *x > 0); let affection = parse_i32(&r, "affection", 50); let visibility = parse_i32(&r, "visibility", 0); let discretion = parse_i32(&r, "discretion", 50); let mut consec = parse_i32(&r, "months_underfunded", 0); let mut scandal_extra = parse_i32(&r, "scandal_extra_daily_pct", 0); if let Some(uid) = payer { if installment <= 0.0 { conn.execute("mark_inst", &[&rel_id])?; } else { let money = self.get_user_money(uid)?; if money >= installment { self.base.change_falukant_user_money( uid, -installment, "lover maintenance", )?; conn.execute("reset_under", &[&rel_id])?; conn.execute("mark_inst", &[&rel_id])?; } else { let new_aff = clamp_i32(affection - 8, 0, 100); let new_disc = clamp_i32(discretion - 6, 0, 100); let new_vis = clamp_i32(visibility + 8, 0, 100); consec += 1; if consec >= 2 { scandal_extra = (scandal_extra + 2).min(100); } conn.execute( "upd_under", &[ &new_aff, &new_disc, &new_vis, &consec, &scandal_extra, &rel_id, ], )?; for cid in [parse_i32(&r, "c1", 0), parse_i32(&r, "c2", 0)] { if cid <= 0 { continue; } if let Ok(Some(mid)) = marriage_id_for_character(&mut conn, cid) { conn.execute("mar_sub", &[&mid, &4])?; } } if visibility >= 40 { for cid in [parse_i32(&r, "c1", 0), parse_i32(&r, "c2", 0)] { if cid > 0 { let cur = fetch_reputation(&mut conn, cid)?; let s = format!("{:.2}", (cur - 1.0).max(0.0)); conn.prepare("upd_rep", QUERY_UPDATE_CHARACTER_REPUTATION)?; conn.execute("upd_rep", &[&s, &cid])?; } } } conn.execute("mark_inst", &[&rel_id])?; } } } else { conn.execute("mark_inst", &[&rel_id])?; } push_user_id(&mut notify, u1); push_user_id(&mut notify, u2); } self.publish_falukant_update_family_batch(¬ify, "lover_installment"); Ok(()) } fn get_user_money(&mut self, user_id: i32) -> Result { use super::sql::{QUERY_GET_CURRENT_MONEY}; let mut conn = self .base .pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("gcm", QUERY_GET_CURRENT_MONEY)?; let rows = conn.execute("gcm", &[&user_id])?; Ok(rows .first() .and_then(|r| r.get("sum")) .and_then(|v| v.parse::().ok()) .unwrap_or(0.0)) } fn process_lover_births(&mut self) -> Result<(), DbError> { let mut conn = self .base .pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("lover_preg", QUERY_GET_LOVER_PREGNANCY_CANDIDATES)?; let rows = conn.execute("lover_preg", &[])?; conn.prepare("insert_child", QUERY_INSERT_CHILD)?; conn.prepare("insert_child_rel_lover", QUERY_INSERT_CHILD_RELATION_LOVER)?; conn.prepare("pen_mar", QUERY_LOVER_BIRTH_PENALTY_MARRIAGE)?; conn.prepare("pen_rep", QUERY_LOVER_BIRTH_PENALTY_REPUTATION)?; for row in rows { 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 { continue; } 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 { continue; } conn.execute( "insert_child_rel_lover", &[&father_cid, &mother_cid, &child_cid], )?; conn.execute("pen_mar", &[&father_cid])?; conn.execute("pen_mar", &[&mother_cid])?; conn.execute("pen_rep", &[&father_cid])?; conn.execute("pen_rep", &[&mother_cid])?; if let Some(uid) = father_uid { self.publish_children(uid); } if let Some(uid) = mother_uid { self.publish_children(uid); } } Ok(()) } fn publish_children(&self, user_id: i32) { let children_update = format!(r#"{{"event":"children_update","user_id":{}}}"#, user_id); self.base.broker.publish(children_update); self.publish_falukant_update_family_and_status(user_id, "lover_birth"); } /// `falukantUpdateFamily` (mit `reason`) + `falukantUpdateStatus` — für UI-Refresh. fn publish_falukant_update_family_and_status(&self, user_id: i32, reason: &str) { let family = format!( r#"{{"event":"falukantUpdateFamily","user_id":{},"reason":"{}"}}"#, user_id, reason ); self.base.broker.publish(family); let status = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id); self.base.broker.publish(status); } fn publish_falukant_update_family_batch(&self, user_ids: &HashSet, reason: &str) { for &uid in user_ids { self.publish_falukant_update_family_and_status(uid, reason); } } /// Fest (einmal), Geschenk (−1 Tag, +1/Tag), Haus hoch & keine Liebschaft: alle 4 Tage +1. fn apply_marriage_positive_buffs( sat: &mut i32, gift_days: &mut i32, feast: &mut i32, nl_counter: &mut i32, touching_empty: bool, house_supply: i32, ) { if *feast > 0 { let bonus = (*feast).min(20).min(5); *sat = (*sat + bonus).min(100); *feast = 0; } if *gift_days > 0 { *sat = (*sat + 1).min(100); *gift_days -= 1; } if touching_empty && house_supply >= 65 { *nl_counter += 1; if *nl_counter >= 4 { *sat = (*sat + 1).min(100); *nl_counter = 0; } } else { *nl_counter = 0; } } } struct MarriageData { id: i32, m1: i32, m2: i32, satisfaction: i32, /// Öffentliche Ehe-Stabilität (Daemon-Drift). public_stability: i32, drift_high: i32, drift_low: i32, title1_tr: String, title2_tr: String, user1_id: Option, user2_id: Option, /// Tage mit +1 Zufriedenheit (Geschenk), runtergezählt. gift_days: i32, /// Einmal-Bonus (z. B. Fest), wird beim nächsten Daily verbraucht. feast_pending: i32, /// Hausversorgung 0–100; ab ~65: Bonus alle 4 Tage ohne Liebschaft. house_supply: i32, /// Tageszähler für Haus-Bonus (0..4). no_lover_counter: i32, household_order_1: i32, household_order_2: i32, servant_quality_1: i32, servant_quality_2: i32, } struct LoverData { rel_id: i32, c1: i32, c2: i32, lover_role: String, affection: i32, visibility: i32, discretion: i32, maintenance_level: i32, status_fit: i32, scandal_extra: i32, months_underfunded: i32, acknowledged: bool, title1_tr: String, title2_tr: String, user1_id: Option, user2_id: Option, /// Jüngeres Alter beider Partner (Jahre, ganzzahlig); für Altersmalus / Skandal / Ehe. min_age_years: i32, /// Dienerschaft: Diskretionsmodifikator je Haushalt (user_house), MAX pro User. servant_disc_u1: i32, servant_disc_u2: i32, } /// Aggregation für `household_tension_score` (0..100) pro Falukant-User. #[derive(Clone)] struct UserTensionAgg { max_lover_visibility: i32, n_acknowledged: i32, max_months_underfunded: i32, /// Mindest-Ehezufriedenheit aller Ehen dieses Users (100 = keine belastende Ehe). min_marriage_sat: i32, } impl Default for UserTensionAgg { fn default() -> Self { Self { max_lover_visibility: 0, n_acknowledged: 0, max_months_underfunded: 0, min_marriage_sat: 100, } } } fn push_user_id(set: &mut HashSet, uid: Option) { if let Some(id) = uid.filter(|x| *x > 0) { set.insert(id); } } 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()) } fn parse_bool_row(row: &crate::db::Row, key: &str) -> bool { row.get(key) .map(|v| { matches!( v.as_str(), "t" | "true" | "True" | "1" | "TRUE" ) }) .unwrap_or(false) } fn clamp_i32(v: i32, lo: i32, hi: i32) -> i32 { v.max(lo).min(hi) } /// Hausfrieden 0..100 (persistiert als `user_house.household_tension_score`). fn compute_household_tension_score( agg: &UserTensionAgg, lover_children: i32, household_order: i32, servant_quality: i32, servant_pay: &str, ) -> i32 { let mut s: f64 = 0.0; s += agg.max_lover_visibility as f64 * 0.35; s += agg.n_acknowledged as f64 * 12.0; s += agg.max_months_underfunded as f64 * 8.0; s += lover_children as f64 * 10.0; s += (100 - household_order.clamp(0, 100)) as f64 * 0.22; if servant_pay == "low" { s += 6.0; } s += (50 - servant_quality.min(50)).max(0) as f64 * 0.35; if agg.min_marriage_sat < 100 { if agg.min_marriage_sat < 40 { s += 18.0; } else if agg.min_marriage_sat < 55 { s += 8.0; } } s = s.clamp(0.0, 100.0); s.round() as i32 } fn title_group(tr: &str) -> u8 { match tr { "noncivil" | "civil" | "sir" => 0, "townlord" | "by" | "landlord" => 1, "knight" | "baron" | "count" | "palsgrave" | "margrave" | "landgrave" => 2, "ruler" | "elector" | "imperial-prince" | "duke" | "grand-duke" | "prince-regent" | "king" => 3, _ => 0, } } fn marriage_rank_group(t1: &str, t2: &str) -> u8 { title_group(t1).max(title_group(t2)) } fn pair_rank_group(t1: &str, t2: &str) -> u8 { title_group(t1).max(title_group(t2)) } fn rank_cost_multiplier(g: u8) -> f64 { match g { 0 => 1.0, 1 => 1.6, 2 => 2.6, 3 => 4.0, _ => 1.0, } } /// Gruppe 3: 0,7 nur bei „geordneter“ Liebschaft; sonst 1,0; Skandal 1,5. fn rank_rep_modifier(g: u8, scandal: bool, order_ok: bool) -> f64 { if g == 3 && scandal { return 1.5; } match g { 0 => 1.8, 1 => 1.3, 2 => 1.0, 3 => if order_ok { 0.7 } else { 1.0 }, _ => 1.0, } } /// Spec 5a: Altersmalus (jüngeres Alter der Beteiligten), zusätzlich zur Basis-Reputation. fn age_reputation_delta(min_age_years: i32) -> f64 { if min_age_years <= 13 { -1.5 } else if min_age_years <= 15 { -0.8 } else if min_age_years <= 17 { -0.3 } else { 0.0 } } /// Spec 5a: minAge <= 15 und hohe Sichtbarkeit. fn visibility_young_penalty(min_age_years: i32, visibility: i32) -> f64 { if min_age_years <= 15 && visibility >= 50 { -0.5 } else { 0.0 } } /// Spec Skandalrisiko: stufenweise Zusatz (exklusiv). fn scandal_age_extra_pct(min_age_years: i32) -> f64 { if min_age_years <= 13 { 6.0 } else if min_age_years <= 15 { 3.0 } else if min_age_years <= 17 { 1.0 } else { 0.0 } } fn lover_marriage_daily_delta( mg: u8, l: &LoverData, m_sat: i32, mistress_n: usize, total_lovers: usize, ) -> i32 { let role = l.lover_role.as_str(); match mg { 0 => match role { "secret_affair" => -1, "lover" => -2, "mistress_or_favorite" => -3, _ => -2, }, 1 => match role { "secret_affair" => -1, "lover" => -1, "mistress_or_favorite" => -2, _ => -1, }, 2 => match role { "secret_affair" => -1, "lover" => -1, "mistress_or_favorite" => -1, _ => -1, }, 3 => { if role == "mistress_or_favorite" { if l.visibility <= 35 && l.maintenance_level >= 65 && m_sat >= 45 && mistress_n <= 1 && total_lovers <= 1 { return 1; } if l.visibility > 50 || l.maintenance_level < 40 { return -1; } return 0; } if role == "secret_affair" { return 0; } if role == "lover" { return 0; } 0 } _ => -1, } } fn mistress_count_for_pair(lovers: &[LoverData], c1: i32, c2: i32) -> usize { lovers .iter() .filter(|l| { (l.c1 == c1 || l.c2 == c1 || l.c1 == c2 || l.c2 == c2) && l.lover_role == "mistress_or_favorite" }) .count() } fn fetch_reputation(conn: &mut crate::db::DbConnection, cid: i32) -> Result { conn.prepare("fr", "SELECT COALESCE(reputation, 50)::float8 AS r FROM falukant_data.character WHERE id = $1")?; let rows = conn.execute("fr", &[&cid])?; Ok(rows .first() .and_then(|r| r.get("r")) .and_then(|v| v.parse::().ok()) .unwrap_or(50.0)) } fn marriage_id_for_character( conn: &mut crate::db::DbConnection, cid: i32, ) -> Result, DbError> { conn.prepare( "mid", r#"SELECT r.id FROM falukant_data.relationship r JOIN falukant_type.relationship rt ON rt.id = r.relationship_type_id AND rt.tr IN ('married', 'engaged', 'wooing') WHERE r.character1_id = $1 OR r.character2_id = $1 LIMIT 1"#, )?; let rows = conn.execute("mid", &[&cid])?; Ok(rows .first() .and_then(|r| r.get("id")) .and_then(|v| v.parse::().ok())) } impl Worker for FalukantFamilyWorker { 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 = FalukantFamilyWorker::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(); } }