From 6a5ff4557e570bd6bc9021dec7063e85c4ce9a6e Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 20 Mar 2026 10:26:15 +0100 Subject: [PATCH] Add FalukantFamilyWorker and related SQL queries: Introduced the FalukantFamilyWorker to manage family-related logic, including marriage satisfaction and relationship states. Added new SQL queries for handling lover relationships and marriage updates, enhancing the overall functionality of family dynamics in the application. --- docs/FALUKANT_DAEMON_HANDOFF.md | 23 + migrations/001_falukant_family_lovers.sql | 62 ++ ..._falukant_family_rename_legacy_columns.sql | 18 + migrations/README.md | 21 + src/main.rs | 5 + src/worker/falukant_family.rs | 766 ++++++++++++++++++ src/worker/mod.rs | 2 + src/worker/sql.rs | 268 +++++- 8 files changed, 1164 insertions(+), 1 deletion(-) create mode 100644 docs/FALUKANT_DAEMON_HANDOFF.md create mode 100644 migrations/001_falukant_family_lovers.sql create mode 100644 migrations/002_falukant_family_rename_legacy_columns.sql create mode 100644 migrations/README.md create mode 100644 src/worker/falukant_family.rs diff --git a/docs/FALUKANT_DAEMON_HANDOFF.md b/docs/FALUKANT_DAEMON_HANDOFF.md new file mode 100644 index 0000000..a13fde9 --- /dev/null +++ b/docs/FALUKANT_DAEMON_HANDOFF.md @@ -0,0 +1,23 @@ +# Falukant: Daemon-Handoff (YpDaemon) + +Technische Abstimmung mit dem Übergabedokument im Backend-Projekt (`FALUKANT_LOVERS_DAEMON_SPEC.md` / `FALUKANT_LOVERS_TECHNICAL_CONCEPT.md`). + +## Abweichungen / Zuordnung + +| Handoff / Backend | YpDaemon | +|-------------------|----------| +| `relationship_state.marriage_satisfaction` (Ehe) | **`relationship.marriage_satisfaction`** für Zeilen mit `relationship_type` ∈ `married`, `engaged`, `wooing` | +| `months_underfunded` | Spalte `months_underfunded` (Migration 001; Legacy: `002` benennt `consecutive_underpayment_months` um) | +| Idempotenz `last_daily_processed_at` / `last_monthly_processed_at` | Gesetzt von `FalukantFamilyWorker` pro Liebschaft | + +## Ticks + +- **Daily:** nur Zeilen mit `(last_daily_processed_at IS NULL OR last_daily_processed_at::date < CURRENT_DATE)`; danach `last_daily_processed_at = NOW()`. +- **Monthly:** nur Zeilen mit `(last_monthly_processed_at IS NULL OR date_trunc('month', last_monthly_processed_at) < date_trunc('month', CURRENT_TIMESTAMP))`; nach Kosten/Unterversorgung `last_monthly_processed_at = NOW()`. + +**Hinweis:** Der Worker nutzt weiterhin **Wandzeit** (24 h / 30 Tage) als Intervall; die Idempotenz über die Zeitstempel verhindert Doppelverarbeitung bei Neustarts am selben Tag/Monat. + +## Migrationen + +1. `migrations/001_falukant_family_lovers.sql` +2. Optional: `migrations/002_falukant_family_rename_legacy_columns.sql` bei Altbestand diff --git a/migrations/001_falukant_family_lovers.sql b/migrations/001_falukant_family_lovers.sql new file mode 100644 index 0000000..7f6a5a2 --- /dev/null +++ b/migrations/001_falukant_family_lovers.sql @@ -0,0 +1,62 @@ +-- Falukant: Liebhaber, Ehezufriedenheit, uneheliche Kinder (Handoff: externer Daemon) +-- Siehe docs/FALUKANT_DAEMON_HANDOFF.md + +ALTER TABLE falukant_data.character + ADD COLUMN IF NOT EXISTS reputation numeric(6,2) NOT NULL DEFAULT 50.00; + +ALTER TABLE falukant_data.relationship + ADD COLUMN IF NOT EXISTS marriage_satisfaction smallint NOT NULL DEFAULT 55 + CHECK (marriage_satisfaction >= 0 AND marriage_satisfaction <= 100), + ADD COLUMN IF NOT EXISTS marriage_drift_high smallint NOT NULL DEFAULT 0 + CHECK (marriage_drift_high >= 0 AND marriage_drift_high < 3), + ADD COLUMN IF NOT EXISTS marriage_drift_low smallint NOT NULL DEFAULT 0 + CHECK (marriage_drift_low >= 0 AND marriage_drift_low < 5); + +COMMENT ON COLUMN falukant_data.relationship.marriage_satisfaction IS + 'Ehezufriedenheit 0..100 (married / engaged / wooing); Schreiben durch Daemon'; + +CREATE TABLE IF NOT EXISTS falukant_data.relationship_state ( + relationship_id integer PRIMARY KEY + REFERENCES falukant_data.relationship (id) ON DELETE CASCADE, + lover_role varchar(32) NOT NULL + CHECK (lover_role IN ('secret_affair', 'lover', 'mistress_or_favorite')), + affection smallint NOT NULL DEFAULT 50 + CHECK (affection >= 0 AND affection <= 100), + visibility smallint NOT NULL DEFAULT 20 + CHECK (visibility >= 0 AND visibility <= 100), + discretion smallint NOT NULL DEFAULT 50 + CHECK (discretion >= 0 AND discretion <= 100), + maintenance_level smallint NOT NULL DEFAULT 50 + CHECK (maintenance_level >= 0 AND maintenance_level <= 100), + status_fit smallint NOT NULL DEFAULT 0 + CHECK (status_fit >= -2 AND status_fit <= 2), + monthly_base_cost integer NOT NULL DEFAULT 30, + active boolean NOT NULL DEFAULT true, + acknowledged boolean NOT NULL DEFAULT false, + exclusive boolean, + months_underfunded smallint NOT NULL DEFAULT 0 + CHECK (months_underfunded >= 0 AND months_underfunded < 100), + scandal_extra_daily_pct smallint NOT NULL DEFAULT 0 + CHECK (scandal_extra_daily_pct >= 0 AND scandal_extra_daily_pct <= 100), + last_daily_processed_at timestamptz, + last_monthly_processed_at timestamptz +); + +CREATE INDEX IF NOT EXISTS idx_relationship_state_active + ON falukant_data.relationship_state (active) + WHERE active = true; + +COMMENT ON COLUMN falukant_data.relationship_state.last_daily_processed_at IS + 'Idempotenz: kein zweiter Daily-Tick am selben Kalendertag (Serverzeit)'; +COMMENT ON COLUMN falukant_data.relationship_state.last_monthly_processed_at IS + 'Idempotenz: kein zweiter Monthly-Tick im selben Kalendermonat (Serverzeit)'; + +ALTER TABLE falukant_data.child_relation + ADD COLUMN IF NOT EXISTS legitimacy varchar(32) NOT NULL DEFAULT 'legitimate' + CHECK (legitimacy IN ('legitimate', 'acknowledged_bastard', 'hidden_bastard')), + ADD COLUMN IF NOT EXISTS birth_context varchar(32) NOT NULL DEFAULT 'marriage' + CHECK (birth_context IN ('marriage', 'lover')), + ADD COLUMN IF NOT EXISTS public_known boolean NOT NULL DEFAULT true; + +COMMENT ON COLUMN falukant_data.child_relation.legitimacy IS 'legitimate | acknowledged_bastard | hidden_bastard'; +COMMENT ON COLUMN falukant_data.child_relation.birth_context IS 'marriage | lover'; diff --git a/migrations/002_falukant_family_rename_legacy_columns.sql b/migrations/002_falukant_family_rename_legacy_columns.sql new file mode 100644 index 0000000..ea3d2ab --- /dev/null +++ b/migrations/002_falukant_family_rename_legacy_columns.sql @@ -0,0 +1,18 @@ +-- Upgrade falls bereits 001 mit alten Spaltennamen eingespielt wurde. +-- Neuinstallationen nutzen 001 direkt und brauchen 002 nicht. + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'falukant_data' AND table_name = 'relationship_state' + AND column_name = 'consecutive_underpayment_months' + ) THEN + ALTER TABLE falukant_data.relationship_state + RENAME COLUMN consecutive_underpayment_months TO months_underfunded; + END IF; +END $$; + +ALTER TABLE falukant_data.relationship_state + ADD COLUMN IF NOT EXISTS last_daily_processed_at timestamptz, + ADD COLUMN IF NOT EXISTS last_monthly_processed_at timestamptz; diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..4dc71e5 --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,21 @@ +# Datenbank-Migrationen (Falukant / YpDaemon) + +Siehe auch **`docs/FALUKANT_DAEMON_HANDOFF.md`** (Abgleich mit Backend-Übergabe). + +## `001_falukant_family_lovers.sql` + +Voraussetzung für den **`FalukantFamilyWorker`** (Liebhaber, Ehezufriedenheit, Ansehen, Monatskosten, Kinder aus Liebschaften). + +**Manuell ausführen** auf der Ziel-DB (nach Backup): + +```bash +psql "$DATABASE_URL" -f migrations/001_falukant_family_lovers.sql +``` + +## `002_falukant_family_rename_legacy_columns.sql` + +Nur nötig, wenn **`001`** bereits mit den **alten** Spaltennamen (`consecutive_underpayment_months`) eingespielt wurde. + +**Backend (YourPart3):** Beim Anlegen einer `lover`-Beziehung `relationship_state` erzeugen; Ehezufriedenheit liegt auf **`relationship`** (married / engaged / wooing); Idempotenzfelder `last_daily_processed_at` / `last_monthly_processed_at` werden vom Daemon gesetzt. + +Ohne passende Spalten (`last_daily_processed_at`) bleibt der Family-Worker inaktiv. diff --git a/src/main.rs b/src/main.rs index 90b2708..d4a23ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ use worker::{ CharacterCreationWorker, ConnectionPool, DirectorWorker, EventsWorker, HouseWorker, PoliticsWorker, ProduceWorker, StockageManager, TransportWorker, UndergroundWorker, UserCharacterWorker, ValueRecalculationWorker, WeatherWorker, Worker, + FalukantFamilyWorker, }; static KEEP_RUNNING: AtomicBool = AtomicBool::new(true); @@ -137,6 +138,10 @@ fn create_workers(pool: ConnectionPool, broker: MessageBroker) -> Vec, + last_daily: Option, + last_monthly: Option, + 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, + 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; + } else { + std::thread::sleep(Duration::from_secs(1)); + return; + } + } + + if Self::should_run(self.last_daily, now, DAILY_INTERVAL) { + 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); + } + + 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", &[])?; + Ok(rows + .first() + .and_then(|r| r.get("ready")) + .map(|v| v == "true" || v == "t") + .unwrap_or(false)) + } + + fn process_daily(&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", 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 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), + 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(), + }) + }) + .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), + title1_tr: r.get("title1_tr").cloned().unwrap_or_default(), + title2_tr: r.get("title2_tr").cloned().unwrap_or_default(), + }) + }) + .filter(|l| l.rel_id > 0) + .collect(); + + let mut char_lover_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; + } + + 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 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])?; + } + + for m in &marriages { + 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(); + + if touching.is_empty() { + let mut sat = m.satisfaction; + let mut dh = m.drift_high; + let mut dl = m.drift_low; + 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; + } + } + conn.prepare("upd_marriage", QUERY_UPDATE_MARRIAGE_STATE)?; + conn.execute( + "upd_marriage", + &[&sat, &dh, &dl, &m.id], + )?; + continue; + } + + 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; + } + + let sat = clamp_i32(m.satisfaction + delta, 0, 100); + conn.prepare("upd_marriage", QUERY_UPDATE_MARRIAGE_STATE)?; + conn.execute( + "upd_marriage", + &[&sat, &m.drift_high, &m.drift_low, &m.id], + )?; + } + + 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 rm = rank_rep_modifier(g, scandal); + 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; + + let order_ok = l.maintenance_level >= 65 + && l.discretion >= 60 + && l.visibility <= 35 + && mistress_count_for_pair(&lovers, l.c1, l.c2) <= 1; + 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); + + for cid in [l.c1, l.c2] { + let cur = fetch_reputation(&mut conn, cid)?; + let new_rep = (cur + 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; + 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 + )); + } + } + + 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("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_monthly", QUERY_MARK_LOVER_MONTHLY_DONE)?; + + 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 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 { + let money = self.get_user_money(uid)?; + if money >= cost as f64 { + self.base.change_falukant_user_money( + uid, + -(cost as f64), + "lover maintenance", + )?; + conn.execute("reset_under", &[&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_monthly", &[&rel_id])?; + } + + drop(conn); + self.process_lover_births()?; + 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); + let update_status = + format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id); + self.base.broker.publish(update_status); + } +} + +struct MarriageData { + id: i32, + m1: i32, + m2: i32, + satisfaction: i32, + drift_high: i32, + drift_low: i32, + title1_tr: String, + title2_tr: String, +} + +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, + title1_tr: String, + title2_tr: String, +} + +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 clamp_i32(v: i32, lo: i32, hi: i32) -> i32 { + v.max(lo).min(hi) +} + +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, + } +} + +fn rank_rep_modifier(g: u8, scandal: bool) -> f64 { + if g == 3 && scandal { + return 1.5; + } + match g { + 0 => 1.8, + 1 => 1.3, + 2 => 1.0, + 3 => 0.7, + _ => 1.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(); + } +} diff --git a/src/worker/mod.rs b/src/worker/mod.rs index 317d6ae..01aacbe 100644 --- a/src/worker/mod.rs +++ b/src/worker/mod.rs @@ -11,6 +11,7 @@ mod user_character; mod transport; mod weather; mod events; +mod falukant_family; mod sql; pub use base::Worker; @@ -27,4 +28,5 @@ pub use user_character::UserCharacterWorker; pub use transport::TransportWorker; pub use weather::WeatherWorker; pub use events::EventsWorker; +pub use falukant_family::FalukantFamilyWorker; diff --git a/src/worker/sql.rs b/src/worker/sql.rs index 8421798..7259be3 100644 --- a/src/worker/sql.rs +++ b/src/worker/sql.rs @@ -1677,7 +1677,10 @@ pub const QUERY_SET_MARRIAGES_BY_PARTY: &str = r#" SELECT id FROM falukant_type.relationship AS rt WHERE rt.tr = 'married' - ) + ), + marriage_satisfaction = 55, + marriage_drift_high = 0, + marriage_drift_low = 0 WHERE rel.id IN ( SELECT rel2.id FROM falukant_data.party AS p @@ -2005,3 +2008,266 @@ pub const QUERY_GET_CHARACTERS_FOR_CHURCH_OFFICE: &str = r#" LIMIT $2; "#; +// --- Falukant: Familie / Liebhaber / Ehezufriedenheit (siehe migrations/001_falukant_family_lovers.sql) --- + +pub const QUERY_FAMILY_SCHEMA_READY: &str = r#" + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'falukant_data' + AND table_name = 'relationship_state' + AND column_name = 'last_daily_processed_at' + ) AS ready; +"#; + +pub const QUERY_GET_ACTIVE_LOVER_ROWS_FOR_DAILY: &str = r#" + SELECT + r.id AS rel_id, + r.character1_id AS c1, + r.character2_id AS c2, + rs.lover_role, + rs.affection, + rs.visibility, + rs.discretion, + rs.maintenance_level, + rs.status_fit, + rs.monthly_base_cost, + rs.scandal_extra_daily_pct, + rs.months_underfunded, + c1.gender AS g1, + c2.gender AS g2, + COALESCE(t1.tr, '') AS title1_tr, + COALESCE(t2.tr, '') AS title2_tr, + COALESCE(c1.reputation, 50)::float8 AS rep1, + COALESCE(c2.reputation, 50)::float8 AS rep2, + fu1.id AS user1_id, + fu2.id AS user2_id + FROM falukant_data.relationship r + JOIN falukant_type.relationship rt + ON rt.id = r.relationship_type_id AND rt.tr = 'lover' + JOIN falukant_data.relationship_state rs ON rs.relationship_id = r.id + JOIN falukant_data.character c1 ON c1.id = r.character1_id + JOIN falukant_data.character c2 ON c2.id = r.character2_id + LEFT JOIN falukant_type.title t1 ON t1.id = c1.title_of_nobility + LEFT JOIN falukant_type.title t2 ON t2.id = c2.title_of_nobility + LEFT JOIN falukant_data.falukant_user fu1 ON fu1.id = c1.user_id + LEFT JOIN falukant_data.falukant_user fu2 ON fu2.id = c2.user_id + WHERE rs.active = true + AND ( + rs.last_daily_processed_at IS NULL + OR (rs.last_daily_processed_at::date < CURRENT_DATE) + ); +"#; + +pub const QUERY_GET_ACTIVE_LOVER_ROWS_FOR_MONTHLY: &str = r#" + SELECT + r.id AS rel_id, + r.character1_id AS c1, + r.character2_id AS c2, + rs.lover_role, + rs.affection, + rs.visibility, + rs.discretion, + rs.maintenance_level, + rs.status_fit, + rs.monthly_base_cost, + rs.scandal_extra_daily_pct, + rs.months_underfunded, + c1.gender AS g1, + c2.gender AS g2, + COALESCE(t1.tr, '') AS title1_tr, + COALESCE(t2.tr, '') AS title2_tr, + COALESCE(c1.reputation, 50)::float8 AS rep1, + COALESCE(c2.reputation, 50)::float8 AS rep2, + fu1.id AS user1_id, + fu2.id AS user2_id + FROM falukant_data.relationship r + JOIN falukant_type.relationship rt + ON rt.id = r.relationship_type_id AND rt.tr = 'lover' + JOIN falukant_data.relationship_state rs ON rs.relationship_id = r.id + JOIN falukant_data.character c1 ON c1.id = r.character1_id + JOIN falukant_data.character c2 ON c2.id = r.character2_id + LEFT JOIN falukant_type.title t1 ON t1.id = c1.title_of_nobility + LEFT JOIN falukant_type.title t2 ON t2.id = c2.title_of_nobility + LEFT JOIN falukant_data.falukant_user fu1 ON fu1.id = c1.user_id + LEFT JOIN falukant_data.falukant_user fu2 ON fu2.id = c2.user_id + WHERE rs.active = true + AND ( + rs.last_monthly_processed_at IS NULL + OR date_trunc('month', rs.last_monthly_processed_at) < date_trunc('month', CURRENT_TIMESTAMP) + ); +"#; + +pub const QUERY_UPDATE_LOVER_VISIBILITY_DISCRETION: &str = r#" + UPDATE falukant_data.relationship_state + SET visibility = $1::smallint, + discretion = $2::smallint + WHERE relationship_id = $3::int; +"#; + +pub const QUERY_UPDATE_LOVER_UNDERPAY_STATE: &str = r#" + UPDATE falukant_data.relationship_state + SET affection = $1::smallint, + discretion = $2::smallint, + visibility = $3::smallint, + months_underfunded = $4::smallint, + scandal_extra_daily_pct = $5::smallint + WHERE relationship_id = $6::int; +"#; + +pub const QUERY_GET_MARRIAGE_ROWS: &str = r#" + SELECT + r.id AS marriage_id, + r.character1_id AS m1, + r.character2_id AS m2, + r.marriage_satisfaction, + r.marriage_drift_high, + r.marriage_drift_low, + COALESCE(t1.tr, '') AS title1_tr, + COALESCE(t2.tr, '') AS title2_tr + FROM falukant_data.relationship r + JOIN falukant_type.relationship rt ON rt.id = r.relationship_type_id + AND rt.tr IN ('married', 'engaged', 'wooing') + JOIN falukant_data.character c1 ON c1.id = r.character1_id + JOIN falukant_data.character c2 ON c2.id = r.character2_id + LEFT JOIN falukant_type.title t1 ON t1.id = c1.title_of_nobility + LEFT JOIN falukant_type.title t2 ON t2.id = c2.title_of_nobility; +"#; + +pub const QUERY_UPDATE_MARRIAGE_STATE: &str = r#" + UPDATE falukant_data.relationship + SET marriage_satisfaction = $1::smallint, + marriage_drift_high = $2::smallint, + marriage_drift_low = $3::smallint + WHERE id = $4::int; +"#; + +pub const QUERY_MARRIAGE_SUBTRACT_SATISFACTION: &str = r#" + UPDATE falukant_data.relationship r + SET marriage_satisfaction = GREATEST(0, r.marriage_satisfaction - $2::int) + FROM falukant_type.relationship rt + WHERE rt.id = r.relationship_type_id + AND rt.tr IN ('married', 'engaged', 'wooing') + AND r.id = $1::int; +"#; + +pub const QUERY_RESET_LOVER_UNDERPAY_COUNTERS: &str = r#" + UPDATE falukant_data.relationship_state + SET months_underfunded = 0 + WHERE relationship_id = $1::int; +"#; + +pub const QUERY_MARK_LOVER_DAILY_DONE: &str = r#" + UPDATE falukant_data.relationship_state + SET last_daily_processed_at = NOW() + WHERE relationship_id = $1::int; +"#; + +pub const QUERY_MARK_LOVER_MONTHLY_DONE: &str = r#" + UPDATE falukant_data.relationship_state + SET last_monthly_processed_at = NOW() + WHERE relationship_id = $1::int; +"#; + +pub const QUERY_UPDATE_CHARACTER_REPUTATION: &str = r#" + UPDATE falukant_data.character + SET reputation = $1::numeric, + updated_at = NOW() + WHERE id = $2::int; +"#; + +pub const QUERY_GET_LOVER_PREGNANCY_CANDIDATES: &str = r#" + SELECT + CASE WHEN c1.gender = 'male' THEN c1.id ELSE c2.id END AS father_cid, + CASE WHEN c1.gender = 'female' THEN c1.id ELSE c2.id END AS mother_cid, + CASE WHEN c1.gender = 'male' THEN c1.title_of_nobility ELSE c2.title_of_nobility END AS title_of_nobility, + CASE WHEN c1.gender = 'male' THEN c1.last_name ELSE c2.last_name END AS last_name, + CASE WHEN c1.gender = 'male' THEN c1.region_id ELSE c2.region_id END AS region_id, + CASE WHEN c1.gender = 'male' THEN fu1.id ELSE fu2.id END AS father_uid, + CASE WHEN c1.gender = 'female' THEN fu1.id ELSE fu2.id END AS mother_uid, + (CURRENT_DATE - c_female.birthdate::date)::int AS mother_age_days + FROM falukant_data.relationship r + JOIN falukant_type.relationship rt ON rt.id = r.relationship_type_id AND rt.tr = 'lover' + JOIN falukant_data.relationship_state rs ON rs.relationship_id = r.id AND rs.active = true + JOIN falukant_data.character c1 ON c1.id = r.character1_id + JOIN falukant_data.character c2 ON c2.id = r.character2_id + JOIN falukant_data.character c_female ON c_female.id = ( + CASE WHEN c1.gender = 'female' THEN c1.id ELSE c2.id END + ) + LEFT JOIN falukant_data.falukant_user fu1 ON fu1.id = c1.user_id + LEFT JOIN falukant_data.falukant_user fu2 ON fu2.id = c2.user_id + WHERE (c1.gender = 'female' AND c2.gender = 'male') + OR (c1.gender = 'male' AND c2.gender = 'female') + AND rs.affection >= 45 + AND rs.maintenance_level >= 30 + AND rs.last_monthly_processed_at IS NOT NULL + AND date_trunc('month', rs.last_monthly_processed_at) = date_trunc('month', CURRENT_TIMESTAMP) + AND NOT EXISTS ( + SELECT 1 + FROM falukant_data.child_relation cr + WHERE cr.father_character_id = (CASE WHEN c1.gender = 'male' THEN c1.id ELSE c2.id END) + AND cr.mother_character_id = (CASE WHEN c1.gender = 'female' THEN c1.id ELSE c2.id END) + AND cr.created_at >= date_trunc('month', CURRENT_TIMESTAMP) + ) + AND (CURRENT_DATE - c_female.birthdate::date) >= 4380 + AND (CURRENT_DATE - c_female.birthdate::date) < 18993 + AND random() * 100.0 < ( + LEAST(12.0, GREATEST(0.0, + CASE rs.lover_role + WHEN 'secret_affair' THEN 2.0 + WHEN 'lover' THEN 4.0 + WHEN 'mistress_or_favorite' THEN 6.0 + ELSE 0.0 + END + + CASE WHEN rs.affection >= 75 THEN 2.0 ELSE 0.0 END + + CASE WHEN rs.visibility >= 70 AND rs.affection < 50 THEN -2.0 ELSE 0.0 END + + CASE + WHEN (CURRENT_DATE - c_female.birthdate::date) > 14600 + THEN -3.0 + ELSE 0.0 + END + )) + ); +"#; + +pub const QUERY_LOVER_BIRTH_PENALTY_MARRIAGE: &str = r#" + UPDATE falukant_data.relationship r + SET marriage_satisfaction = GREATEST(0, r.marriage_satisfaction - 8) + FROM falukant_type.relationship rt + WHERE rt.id = r.relationship_type_id + AND rt.tr IN ('married', 'engaged', 'wooing') + AND (r.character1_id = $1::int OR r.character2_id = $1::int); +"#; + +pub const QUERY_LOVER_BIRTH_PENALTY_REPUTATION: &str = r#" + UPDATE falukant_data.character + SET reputation = GREATEST(0::numeric, COALESCE(reputation, 50::numeric) - 4::numeric), + updated_at = NOW() + WHERE id = $1::int; +"#; + +pub const QUERY_INSERT_CHILD_RELATION_LOVER: &str = r#" + INSERT INTO falukant_data.child_relation ( + father_character_id, + mother_character_id, + child_character_id, + name_set, + legitimacy, + birth_context, + public_known, + created_at, + updated_at + ) + VALUES ( + $1::int, + $2::int, + $3::int, + FALSE, + 'hidden_bastard', + 'lover', + FALSE, + NOW(), + NOW() + ); +"#; +