diff --git a/docs/FALUKANT_DAEMON_HANDOFF.md b/docs/FALUKANT_DAEMON_HANDOFF.md index 6a422ff..22998e4 100644 --- a/docs/FALUKANT_DAEMON_HANDOFF.md +++ b/docs/FALUKANT_DAEMON_HANDOFF.md @@ -54,6 +54,7 @@ Ehe-Malus „≤ 15“ gilt pro Ehe, wenn **irgendeine** berührende Liebschaft 1. `migrations/001_falukant_family_lovers.sql` 2. Optional: `migrations/002_falukant_family_rename_legacy_columns.sql` bei Altbestand 3. `migrations/003_falukant_family_marriage_buffs.sql` — Ehe-Buffs (`marriage_gift_buff_days_remaining`, `marriage_pending_feast_bonus`, `marriage_house_supply`, `marriage_no_lover_bonus_counter`); Daily-Tick schreibt Zufriedenheit + Zähler via `QUERY_UPDATE_MARRIAGE_STATE_AND_BUFFS`. +4. `migrations/004_falukant_servants_daemon.sql` — Dienerschaft: Tick-Idempotenz + `servant_discretion_modifier` (Stammdaten-Dienerfelder kommen aus dem Backend). Siehe [`FALUKANT_SERVANTS_DAEMON.md`](./FALUKANT_SERVANTS_DAEMON.md). ### Ehe-Buffs (Daemon) diff --git a/docs/FALUKANT_SERVANTS_DAEMON.md b/docs/FALUKANT_SERVANTS_DAEMON.md new file mode 100644 index 0000000..cf9a94e --- /dev/null +++ b/docs/FALUKANT_SERVANTS_DAEMON.md @@ -0,0 +1,27 @@ +# Falukant: Dienerschaft im YpDaemon + +Umsetzung gemäß Projektspezifikation (Daily/Monthly, Handoff). + +## Voraussetzungen + +1. **Backend:** Stammdaten in `falukant_data.user_house` (`servant_count`, `servant_quality`, `servant_pay_level`, `household_order`) — z. B. Migration aus YourPart3. +2. **Daemon:** `migrations/004_falukant_servants_daemon.sql` ausführen (Tick-Spalten + `servant_discretion_modifier` + `servants_underfunded`). + +## Code + +- Logik: `src/worker/falukant_servants.rs` +- SQL: `src/worker/sql.rs` (Abschnitt Dienerschaft) +- Ausführung: **`FalukantFamilyWorker`** ruft bei gesetztem Servant-Schema **vor** Liebschafts-Daily/Monthly `run_daily` / `run_monthly` auf (keine Race mit Liebschafts-Ticks). + +## WebSocket + +Wie Spec: `falukantUpdateFamily` mit `reason`: `daily` oder `monthly`, danach `falukantUpdateStatus` — **kein** eigener Diener-`reason`. + +## Liebschaften (B7 Teil) + +`QUERY_GET_ACTIVE_LOVER_ROWS_FOR_DAILY` liefert `servant_disc_u1` / `servant_disc_u2` (MAX `servant_discretion_modifier` je `user_house` des Partners). Die Sichtbarkeits-Drift addiert `(u1+u2)/2 / 4` auf `v_net`. + +## Noch offen (Backlog) + +- **B8 Untergrund:** siehe [`FALUKANT_UNDERGROUND_INVESTIGATE_AFFAIR.md`](./FALUKANT_UNDERGROUND_INVESTIGATE_AFFAIR.md) (`investigate_affair` im `UndergroundWorker`). +- **Feinbalancing** (B9). diff --git a/docs/FALUKANT_UNDERGROUND_INVESTIGATE_AFFAIR.md b/docs/FALUKANT_UNDERGROUND_INVESTIGATE_AFFAIR.md new file mode 100644 index 0000000..a1dabe1 --- /dev/null +++ b/docs/FALUKANT_UNDERGROUND_INVESTIGATE_AFFAIR.md @@ -0,0 +1,49 @@ +# Untergrund: `investigate_affair` + +Wie die anderen Underground-Tasks: Zeile in `falukant_data.underground` mit `result IS NULL`, nach ≥1 Tag verarbeitet der `UndergroundWorker`. + +## Typ in der Datenbank + +In `falukant_type.underground` einen Eintrag mit **`tr = 'investigate_affair'`** und Backend-Logik, die den Auftrag anlegt. + +## Semantik (Spec: Liebschaften & Untergrund) + +| Feld | Bedeutung | +|------|-----------| +| `performer_id` | Charakter-ID des Ausspionierenden | +| `victim_id` | Charakter-ID des Opfers (mit Liebschaft) | +| `parameters` (JSON) | `goal`: `"expose"` (Standard) oder `"blackmail"`; optional `relationship_id` (int): feste Liebschaft — sonst **höchster `discoveryScore`** unter allen aktiven `lover`-Beziehungen | + +### `discoveryScore` (Auswahl der Ziel-Liebschaft) + +Gewichtung u. a.: `visibility`, `discretion`, `acknowledged`, Kinder (hidden/public, Deckel +20), Altersstufen, Haushalt/Diener (Leak-Bonus, Deckel +15), mehrere aktive Liebschaften, `status_fit`. + +### Erfolg + +- `successChance = clamp(20 + discoveryScore * 0.55, 5, 95)` (Prozent) +- Wurf 0–100: `roll ≤ successChance * 0.55` → voller Erfolg; `roll ≤ successChance` → Teilerfolg; sonst Fehlschlag + +### `goal = expose` + +Voller/Teilerfolg: Sichtbarkeit und Diskretion des Ziel-`relationship_state` werden angepasst, Ruf des **Opfers** (`victim_id`) sinkt. Ab `visibility ≥ 60` nach Anpassung: **Sofort-Skandalprüfung** mit WebSocket (`falukant_family_scandal_hint`, `falukantUpdateFamily` mit `reason: scandal`, `falukantUpdateStatus`). + +### `goal = blackmail` + +Voller/Teilerfolg: geringere Sichtbarkeits-/Diskretions-Effekte als bei `expose`; **`blackmailAmount`** aus Basisformel (Ruf, Sichtbarkeit, Stand, Kinder) × `outcomeFactor` (1.0 / 0.55). + +### Fehlschlag + +`status: failed`, `outcome: failure`, `discoveries: null`, Hinweis `no proof` — keine DB-Änderungen an Liebschaft/Ruf. + +## Ergebnis-JSON (`result`) + +Mindestens: `status`, `outcome`, `discoveries` (Pflichtfelder laut Spec), `visibilityDelta`, `reputationDelta`, optional `blackmailAmount`, `discoveryScore`, `successChance`, `roll`, `tier`, `notes`. + +## WebSocket + +- Nach jedem Job: **`underground_processed`** (wie bisher). +- Bei ausgelöstem **Skandal** (nur `expose` mit ausreichender Sichtbarkeit und bestandenem Wurf): wie oben. + +## Idempotenz + +Weiterhin nur Zeilen mit **`result IS NULL`**; nach Verarbeitung wird `result` gesetzt (Daemon fasst den Auftrag nicht erneut an). diff --git a/migrations/004_falukant_servants_daemon.sql b/migrations/004_falukant_servants_daemon.sql new file mode 100644 index 0000000..6649c49 --- /dev/null +++ b/migrations/004_falukant_servants_daemon.sql @@ -0,0 +1,14 @@ +-- Dienerschaft: Daemon-Spalten (Spec: Dienerschaft Daemon). +-- Stammdaten servant_count / servant_quality / servant_pay_level / household_order: +-- siehe YourPart3 (z. B. add_servants_to_user_house.sql) — müssen vorher existieren. + +ALTER TABLE falukant_data.user_house + ADD COLUMN IF NOT EXISTS servant_discretion_modifier smallint NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS servants_underfunded boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS servants_last_daily_at timestamptz, + ADD COLUMN IF NOT EXISTS servants_last_monthly_at timestamptz; + +COMMENT ON COLUMN falukant_data.user_house.servant_discretion_modifier IS + 'Daemon Daily: negativ = bessere Geheimhaltung, positiv = höheres Entdeckungsrisiko (Liebschaften)'; +COMMENT ON COLUMN falukant_data.user_house.servants_last_daily_at IS 'Idempotenz Daily'; +COMMENT ON COLUMN falukant_data.user_house.servants_last_monthly_at IS 'Idempotenz Monthly'; diff --git a/src/worker/falukant_family.rs b/src/worker/falukant_family.rs index 0a896ac..af6ed70 100644 --- a/src/worker/falukant_family.rs +++ b/src/worker/falukant_family.rs @@ -1,5 +1,6 @@ //! 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. +//! 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`). //! //! WebSocket: `falukantUpdateFamily` (reason) + `falukantUpdateStatus` für betroffene Nutzer. @@ -36,6 +37,8 @@ pub struct FalukantFamilyWorker { last_daily: Option, last_monthly: Option, schema_ready: bool, + /// Migration `004_falukant_servants_daemon.sql` (Dienerschaft-Ticks). + servants_schema_ready: bool, } impl FalukantFamilyWorker { @@ -47,6 +50,7 @@ impl FalukantFamilyWorker { last_daily: None, last_monthly: None, schema_ready: false, + servants_schema_ready: false, } } @@ -98,14 +102,21 @@ impl FalukantFamilyWorker { .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 + let family_ok = rows .first() .and_then(|r| r.get("ready")) .map(|v| v == "true" || v == "t") - .unwrap_or(false)) + .unwrap_or(false); + self.servants_schema_ready = + super::falukant_servants::servants_schema_ready(&self.base.pool).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 @@ -160,6 +171,8 @@ impl FalukantFamilyWorker { 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) @@ -209,6 +222,8 @@ impl FalukantFamilyWorker { 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; @@ -488,6 +503,10 @@ impl FalukantFamilyWorker { } fn process_monthly(&mut self) -> Result<(), DbError> { + if self.servants_schema_ready { + super::falukant_servants::run_monthly(&self.base, &self.base.broker)?; + } + let mut conn = self .base .pool @@ -780,6 +799,9 @@ struct LoverData { 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, } fn push_user_id(set: &mut HashSet, uid: Option) { diff --git a/src/worker/falukant_servants.rs b/src/worker/falukant_servants.rs new file mode 100644 index 0000000..bede3c0 --- /dev/null +++ b/src/worker/falukant_servants.rs @@ -0,0 +1,401 @@ +//! Dienerschaft: Daily/Monthly nach Spec (docs: Handoff Dienerschaft). +//! Voraussetzung: `migrations/004_falukant_servants_daemon.sql`. + +use crate::db::DbError; +use crate::message_broker::MessageBroker; +use crate::worker::base::BaseWorker; +use crate::worker::sql::{ + QUERY_COUNT_ACTIVE_LOVERS_FOR_CHARACTER, QUERY_GET_SERVANT_DAILY_ROWS, + QUERY_GET_SERVANT_MONTHLY_ROWS, QUERY_SERVANTS_SCHEMA_READY, + QUERY_UPDATE_CHARACTER_REPUTATION, QUERY_UPDATE_MARRIAGE_SATISFACTION_ADD_FOR_CHARACTER, + QUERY_UPDATE_USER_HOUSE_SERVANT_DAILY, QUERY_UPDATE_USER_HOUSE_SERVANT_MONTHLY_META, + QUERY_UPDATE_USER_HOUSE_SERVANT_UNDERFUNDED_PENALTY, +}; + +fn pay_shift(pay: &str) -> i32 { + match pay { + "low" => -6, + "high" => 6, + _ => 0, + } +} + +fn base_range(position: i32) -> (i32, i32) { + match position { + 3 => (1, 2), + 4 => (2, 4), + 5 => (3, 6), + p if p <= 2 => (0, 1), + p if p >= 6 => (4, 8), + _ => (0, 1), + } +} + +fn expected_range(house_position: i32, title_level: i32) -> (i32, i32) { + let (bmin, bmax) = base_range(house_position); + let bonus = (title_level / 3).max(0); + (bmin + bonus, bmax + bonus) +} + +fn clamp_i32(v: i32, lo: i32, hi: i32) -> i32 { + v.max(lo).min(hi) +} + +fn clamp_f64(v: f64, lo: f64, hi: f64) -> f64 { + v.max(lo).min(hi) +} + +/// True, wenn Migration 004 aktiv ist. +pub fn servants_schema_ready(pool: &crate::db::ConnectionPool) -> Result { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + conn.prepare("srv_schema", QUERY_SERVANTS_SCHEMA_READY)?; + let rows = conn.execute("srv_schema", &[])?; + Ok(rows + .first() + .and_then(|r| r.get("ready")) + .map(|v| v == "true" || v == "t") + .unwrap_or(false)) +} + +fn publish_falukant_family_and_status(broker: &MessageBroker, user_id: i32, reason: &str) { + let family = format!( + r#"{{"event":"falukantUpdateFamily","user_id":{},"reason":"{}"}}"#, + user_id, reason + ); + broker.publish(family); + let status = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id); + broker.publish(status); +} + +/// Daily-Tick: Haushaltsordnung, Qualität, Ruf, Diskretionsmodifikator, Ehe. +pub fn run_daily(base: &BaseWorker, broker: &MessageBroker) -> Result<(), DbError> { + let mut conn = base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("srv_daily", QUERY_GET_SERVANT_DAILY_ROWS)?; + conn.prepare("upd_uh_daily", QUERY_UPDATE_USER_HOUSE_SERVANT_DAILY)?; + conn.prepare("upd_rep", QUERY_UPDATE_CHARACTER_REPUTATION)?; + conn.prepare("upd_mar", QUERY_UPDATE_MARRIAGE_SATISFACTION_ADD_FOR_CHARACTER)?; + + let rows = conn.execute("srv_daily", &[])?; + + for row in rows { + let user_house_id = row + .get("user_house_id") + .and_then(|v| v.parse::().ok()) + .unwrap_or(-1); + let falukant_user_id = row + .get("falukant_user_id") + .and_then(|v| v.parse::().ok()) + .unwrap_or(-1); + let character_id = row + .get("character_id") + .and_then(|v| v.parse::().ok()) + .unwrap_or(-1); + if user_house_id < 0 || falukant_user_id < 0 || character_id < 0 { + continue; + } + + let title_level = row + .get("title_level") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let house_position = row + .get("house_position") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let servant_count = row + .get("servant_count") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let mut servant_quality = row + .get("servant_quality") + .and_then(|v| v.parse::().ok()) + .unwrap_or(50); + let pay = row + .get("servant_pay_level") + .cloned() + .unwrap_or_else(|| "normal".to_string()); + let pay = pay.trim(); + let pay_owned = if pay.is_empty() { + "normal".to_string() + } else { + pay.to_string() + }; + let pay = pay_owned.as_str(); + + let mut household_order = row + .get("household_order") + .and_then(|v| v.parse::().ok()) + .unwrap_or(55); + let mut reputation = row + .get("reputation") + .and_then(|v| v.parse::().ok()) + .unwrap_or(50.0); + + let (exp_min, exp_max) = expected_range(house_position, title_level); + let missing = (exp_min - servant_count).max(0); + let excessive = (servant_count - exp_max).max(0); + + let quality_part = ((servant_quality - 50) as f64 * 0.35).round() as i32; + let pay_part = pay_shift(pay); + let fit_penalty = missing * 10 + excessive * 4; + let mut target_ho = 55 + quality_part + pay_part - fit_penalty; + target_ho = clamp_i32(target_ho, 0, 100); + + let old_ho = household_order; + if household_order < target_ho { + household_order += (target_ho - household_order).min(2); + } else if household_order > target_ho { + household_order -= (household_order - target_ho).min(2); + } + if pay == "low" && servant_count < exp_min { + household_order -= 1; + } + if pay == "high" && servant_quality >= 65 { + household_order += 1; + } + household_order = clamp_i32(household_order, 0, 100); + + let mut qd = 0; + if pay == "low" { + qd -= 1; + } + if pay == "high" { + qd += 1; + } + if servant_count < exp_min { + qd -= 1; + } + if servant_count > exp_max + 2 { + qd -= 1; + } + if household_order >= 80 { + qd += 1; + } + if household_order <= 30 { + qd -= 1; + } + qd = clamp_i32(qd, -2, 2); + servant_quality = clamp_i32(servant_quality + qd, 0, 100); + + let mut rep_delta = 0.0_f64; + if title_level >= 4 && servant_count < exp_min { + rep_delta -= 0.15 * missing as f64; + } + if title_level <= 1 && servant_count > exp_max { + rep_delta -= 0.10 * excessive as f64; + } + if household_order >= 85 && servant_count >= exp_min && servant_count <= exp_max { + rep_delta += 0.05; + } + if household_order <= 25 { + rep_delta -= 0.20; + } + reputation = clamp_f64(reputation + rep_delta, 0.0, 100.0); + + let mut disc_mod = 0; + if servant_quality >= 70 && pay == "high" && servant_count <= exp_max { + disc_mod -= 8; + } + if pay == "low" { + disc_mod += 6; + } + if servant_count > exp_max + 1 { + disc_mod += 4; + } + if household_order <= 35 { + disc_mod += 5; + } + disc_mod = clamp_i32(disc_mod, -100, 100); + + let mut marriage_delta = 0.0_f64; + if household_order >= 75 { + marriage_delta += 0.10; + } + if household_order <= 35 { + marriage_delta -= 0.15; + } + if servant_count < exp_min { + marriage_delta -= 0.10; + } + let marriage_steps = if marriage_delta >= 0.05 { + 1 + } else if marriage_delta <= -0.05 { + -1 + } else { + 0 + }; + + let ho_changed = old_ho != household_order; + let sq_changed = qd != 0; + let rep_changed = rep_delta.abs() > f64::EPSILON; + + conn.execute( + "upd_uh_daily", + &[ + &household_order, + &servant_quality, + &disc_mod, + &user_house_id, + ], + )?; + + let rep_s = format!("{:.2}", reputation); + conn.execute("upd_rep", &[&rep_s, &character_id])?; + + if marriage_steps != 0 { + conn.execute("upd_mar", &[&marriage_steps, &character_id])?; + } + + if ho_changed || sq_changed || rep_changed || marriage_steps != 0 { + publish_falukant_family_and_status(broker, falukant_user_id, "daily"); + } + } + + Ok(()) +} + +/// Monthly: Kosten, Unterversorgung, Boni. +pub fn run_monthly(base: &BaseWorker, broker: &MessageBroker) -> Result<(), DbError> { + let mut conn = base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("srv_mon", QUERY_GET_SERVANT_MONTHLY_ROWS)?; + conn.prepare("upd_uh_mon", QUERY_UPDATE_USER_HOUSE_SERVANT_MONTHLY_META)?; + conn.prepare("upd_pen", QUERY_UPDATE_USER_HOUSE_SERVANT_UNDERFUNDED_PENALTY)?; + conn.prepare("upd_rep", QUERY_UPDATE_CHARACTER_REPUTATION)?; + conn.prepare("cnt_lov", QUERY_COUNT_ACTIVE_LOVERS_FOR_CHARACTER)?; + + let rows = conn.execute("srv_mon", &[])?; + + for row in rows { + let user_house_id = row + .get("user_house_id") + .and_then(|v| v.parse::().ok()) + .unwrap_or(-1); + let falukant_user_id = row + .get("falukant_user_id") + .and_then(|v| v.parse::().ok()) + .unwrap_or(-1); + let character_id = row + .get("character_id") + .and_then(|v| v.parse::().ok()) + .unwrap_or(-1); + if user_house_id < 0 || falukant_user_id < 0 || character_id < 0 { + continue; + } + + let title_level = row + .get("title_level") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let house_position = row + .get("house_position") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let house_cost = row + .get("house_cost") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let servant_count = row + .get("servant_count") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let servant_quality = row + .get("servant_quality") + .and_then(|v| v.parse::().ok()) + .unwrap_or(50); + let pay = row + .get("servant_pay_level") + .cloned() + .unwrap_or_else(|| "normal".to_string()); + let pay = pay.trim(); + let pay_owned = if pay.is_empty() { + "normal".to_string() + } else { + pay.to_string() + }; + let pay = pay_owned.as_str(); + + let household_order = row + .get("household_order") + .and_then(|v| v.parse::().ok()) + .unwrap_or(55); + let mut reputation = row + .get("reputation") + .and_then(|v| v.parse::().ok()) + .unwrap_or(50.0); + let user_money = row + .get("user_money") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0); + + let (exp_min, exp_max) = expected_range(house_position, title_level); + + let base_per_servant = ((house_cost as f64 / 1000.0) + 40.0).round().max(20.0); + let quality_factor = 1.0 + ((servant_quality - 50) as f64 / 200.0); + let pay_factor = match pay { + "low" => 0.8, + "high" => 1.3, + _ => 1.0, + }; + let monthly_cost = + (servant_count as f64 * base_per_servant * quality_factor * pay_factor * 100.0).round() + / 100.0; + + let lover_rows = conn.execute("cnt_lov", &[&character_id])?; + let lover_cnt = lover_rows + .first() + .and_then(|r| r.get("cnt")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + let notify = if servant_count <= 0 || monthly_cost <= 0.0 { + conn.execute("upd_uh_mon", &[&false, &user_house_id])?; + false + } else if user_money >= monthly_cost { + base.change_falukant_user_money( + falukant_user_id, + -monthly_cost, + "servants_monthly", + )?; + conn.execute("upd_uh_mon", &[&false, &user_house_id])?; + + if servant_count >= exp_min + && servant_count <= exp_max + && servant_quality >= 70 + && household_order >= 80 + && pay != "low" + && title_level >= 3 + { + reputation = clamp_f64(reputation + 1.0, 0.0, 100.0); + let rep_s = format!("{:.2}", reputation); + conn.execute("upd_rep", &[&rep_s, &character_id])?; + } + true + } else { + let disc_extra = if lover_cnt > 0 { 5 } else { 0 }; + conn.execute("upd_pen", &[&disc_extra, &user_house_id])?; + if title_level >= 4 { + reputation = clamp_f64(reputation - 1.0, 0.0, 100.0); + let rep_s = format!("{:.2}", reputation); + conn.execute("upd_rep", &[&rep_s, &character_id])?; + } + conn.execute("upd_uh_mon", &[&true, &user_house_id])?; + true + }; + + if notify { + publish_falukant_family_and_status(broker, falukant_user_id, "monthly"); + } + } + + Ok(()) +} diff --git a/src/worker/mod.rs b/src/worker/mod.rs index 01aacbe..7f8973b 100644 --- a/src/worker/mod.rs +++ b/src/worker/mod.rs @@ -12,6 +12,7 @@ mod transport; mod weather; mod events; mod falukant_family; +mod falukant_servants; mod sql; pub use base::Worker; diff --git a/src/worker/sql.rs b/src/worker/sql.rs index b3e2f76..97414f7 100644 --- a/src/worker/sql.rs +++ b/src/worker/sql.rs @@ -2027,6 +2027,115 @@ pub const QUERY_GET_CHARACTERS_FOR_CHURCH_OFFICE: &str = r#" LIMIT $2; "#; +// --- Falukant: Dienerschaft (siehe migrations/004_falukant_servants_daemon.sql) --- + +pub const QUERY_SERVANTS_SCHEMA_READY: &str = r#" + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'falukant_data' + AND table_name = 'user_house' + AND column_name = 'servants_last_daily_at' + ) AS ready; +"#; + +pub const QUERY_GET_SERVANT_DAILY_ROWS: &str = r#" + SELECT DISTINCT ON (uh.id) + uh.id AS user_house_id, + fu.id AS falukant_user_id, + c.id AS character_id, + COALESCE(c.reputation, 50)::float8 AS reputation, + COALESCE(t.level, 0)::int AS title_level, + COALESCE(ht.position, 0)::int AS house_position, + COALESCE(ht.cost, 0)::bigint AS house_cost, + uh.servant_count, + uh.servant_quality, + COALESCE(NULLIF(TRIM(uh.servant_pay_level), ''), 'normal') AS servant_pay_level, + uh.household_order + FROM falukant_data.user_house uh + JOIN falukant_data.falukant_user fu ON fu.id = uh.user_id + JOIN falukant_data.character c ON c.user_id = fu.id AND c.health > 0 + LEFT JOIN falukant_type.title t ON t.id = c.title_of_nobility + LEFT JOIN falukant_type.house ht ON ht.id = uh.house_type_id + WHERE (uh.servants_last_daily_at IS NULL OR (uh.servants_last_daily_at::date < CURRENT_DATE)) + AND NOT EXISTS ( + SELECT 1 FROM falukant_type.house h + WHERE h.id = uh.house_type_id AND h.label_tr = 'under_bridge' + ) + ORDER BY uh.id, c.id; +"#; + +pub const QUERY_UPDATE_USER_HOUSE_SERVANT_DAILY: &str = r#" + UPDATE falukant_data.user_house + SET household_order = $1::smallint, + servant_quality = $2::smallint, + servant_discretion_modifier = $3::smallint, + servants_last_daily_at = NOW() + WHERE id = $4::int; +"#; + +pub const QUERY_UPDATE_MARRIAGE_SATISFACTION_ADD_FOR_CHARACTER: &str = r#" + UPDATE falukant_data.relationship r + SET marriage_satisfaction = GREATEST(0, LEAST(100, marriage_satisfaction + $1::int)) + FROM falukant_type.relationship rt + WHERE rt.id = r.relationship_type_id + AND rt.tr IN ('married', 'engaged', 'wooing') + AND (r.character1_id = $2::int OR r.character2_id = $2::int); +"#; + +pub const QUERY_GET_SERVANT_MONTHLY_ROWS: &str = r#" + SELECT DISTINCT ON (uh.id) + uh.id AS user_house_id, + fu.id AS falukant_user_id, + c.id AS character_id, + COALESCE(c.reputation, 50)::float8 AS reputation, + COALESCE(t.level, 0)::int AS title_level, + COALESCE(ht.position, 0)::int AS house_position, + COALESCE(ht.cost, 0)::bigint AS house_cost, + uh.servant_count, + uh.servant_quality, + COALESCE(NULLIF(TRIM(uh.servant_pay_level), ''), 'normal') AS servant_pay_level, + uh.household_order, + COALESCE(fu.money, 0)::float8 AS user_money, + uh.servants_underfunded + FROM falukant_data.user_house uh + JOIN falukant_data.falukant_user fu ON fu.id = uh.user_id + JOIN falukant_data.character c ON c.user_id = fu.id AND c.health > 0 + LEFT JOIN falukant_type.title t ON t.id = c.title_of_nobility + LEFT JOIN falukant_type.house ht ON ht.id = uh.house_type_id + WHERE (uh.servants_last_monthly_at IS NULL + OR date_trunc('month', uh.servants_last_monthly_at) < date_trunc('month', CURRENT_TIMESTAMP)) + AND NOT EXISTS ( + SELECT 1 FROM falukant_type.house h + WHERE h.id = uh.house_type_id AND h.label_tr = 'under_bridge' + ) + ORDER BY uh.id, c.id; +"#; + +pub const QUERY_COUNT_ACTIVE_LOVERS_FOR_CHARACTER: &str = r#" + SELECT COUNT(*)::int AS cnt + 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 + WHERE r.character1_id = $1::int OR r.character2_id = $1::int; +"#; + +pub const QUERY_UPDATE_USER_HOUSE_SERVANT_MONTHLY_META: &str = r#" + UPDATE falukant_data.user_house + SET servants_underfunded = $1::boolean, + servants_last_monthly_at = NOW() + WHERE id = $2::int; +"#; + +pub const QUERY_UPDATE_USER_HOUSE_SERVANT_UNDERFUNDED_PENALTY: &str = r#" + UPDATE falukant_data.user_house + SET servant_quality = GREATEST(0, servant_quality - 4), + household_order = GREATEST(0, household_order - 6), + servant_discretion_modifier = GREATEST(-100, LEAST(100, + COALESCE(servant_discretion_modifier, 0) + $1::int)) + WHERE id = $2::int; +"#; + // --- Falukant: Familie / Liebhaber / Ehezufriedenheit (siehe migrations/001_falukant_family_lovers.sql) --- pub const QUERY_FAMILY_SCHEMA_READY: &str = r#" @@ -2064,7 +2173,17 @@ pub const QUERY_GET_ACTIVE_LOVER_ROWS_FOR_DAILY: &str = r#" LEAST( ((CURRENT_DATE - c1.birthdate::date) / 365), ((CURRENT_DATE - c2.birthdate::date) / 365) - )::int AS min_age_years + )::int AS min_age_years, + COALESCE(( + SELECT MAX(uh.servant_discretion_modifier)::int + FROM falukant_data.user_house uh + WHERE uh.user_id = fu1.id + ), 0) AS servant_disc_u1, + COALESCE(( + SELECT MAX(uh.servant_discretion_modifier)::int + FROM falukant_data.user_house uh + WHERE uh.user_id = fu2.id + ), 0) AS servant_disc_u2 FROM falukant_data.relationship r JOIN falukant_type.relationship rt ON rt.id = r.relationship_type_id AND rt.tr = 'lover' diff --git a/src/worker/underground.rs b/src/worker/underground.rs index 869fd3d..c4d3467 100644 --- a/src/worker/underground.rs +++ b/src/worker/underground.rs @@ -11,7 +11,9 @@ use std::sync::Arc; use std::time::Duration; use super::base::{BaseWorker, Worker, WorkerState}; -use crate::worker::sql::QUERY_UPDATE_MONEY; +use crate::worker::sql::{ + QUERY_COUNT_ACTIVE_LOVERS_FOR_CHARACTER, QUERY_UPDATE_CHARACTER_REPUTATION, QUERY_UPDATE_MONEY, +}; pub struct UndergroundWorker { base: BaseWorker, @@ -119,6 +121,90 @@ const Q_SELECT_FALUKANT_USER: &str = r#" LIMIT 1; "#; +/// Opfer-Charakter: Ruf, Stand, Haus/Diener (für discoveryScore / Erpressung / Leak). +const Q_AFFAIR_VICTIM_CONTEXT: &str = r#" + SELECT uh.id AS user_house_id, + COALESCE(c.reputation, 50)::float8 AS reputation, + COALESCE(t.level, 0)::int AS title_level, + COALESCE(ht.position, 0)::int AS house_position, + COALESCE(uh.household_order, 55)::int AS household_order, + COALESCE(uh.servant_count, 0)::int AS servant_count, + COALESCE(uh.servant_quality, 50)::int AS servant_quality, + COALESCE(NULLIF(TRIM(uh.servant_pay_level), ''), 'normal') AS servant_pay_level + FROM falukant_data.character c + LEFT JOIN falukant_data.user_house uh ON uh.user_id = c.user_id + LEFT JOIN falukant_type.house ht ON ht.id = uh.house_type_id + LEFT JOIN falukant_type.title t ON t.id = c.title_of_nobility + WHERE c.id = $1::int + LIMIT 1; +"#; + +const Q_AFFAIR_VICTIM_CHILDREN: &str = r#" + SELECT EXISTS ( + SELECT 1 FROM falukant_data.child_relation cr + WHERE (cr.father_character_id = $1::int OR cr.mother_character_id = $1::int) + AND cr.birth_context = 'lover' + AND cr.legitimacy = 'hidden_bastard' + AND COALESCE(cr.public_known, false) = false + ) AS has_hidden_bastard, + EXISTS ( + SELECT 1 FROM falukant_data.child_relation cr + WHERE (cr.father_character_id = $1::int OR cr.mother_character_id = $1::int) + AND cr.birth_context = 'lover' + AND cr.legitimacy IN ('hidden_bastard', 'acknowledged_bastard') + AND COALESCE(cr.public_known, false) = true + ) AS has_public_known_bastard; +"#; + +/// Alle aktiven Liebschaften des Opfers; optional feste `relationship_id` aus parameters. +const Q_AFFAIR_ALL_LOVERS: &str = r#" + SELECT r.id AS rel_id, + rs.lover_role, + rs.visibility::int AS visibility, + rs.discretion::int AS discretion, + rs.affection::int AS affection, + COALESCE(rs.acknowledged, false) AS acknowledged, + rs.status_fit::int AS status_fit, + rs.maintenance_level::int AS maintenance_level, + COALESCE(rs.scandal_extra_daily_pct, 0)::int AS scandal_extra, + COALESCE(t1.tr, '') AS title1_tr, + COALESCE(t2.tr, '') AS title2_tr, + fu1.id AS user1_id, + fu2.id AS user2_id, + LEAST( + ((CURRENT_DATE - c1.birthdate::date) / 365), + ((CURRENT_DATE - c2.birthdate::date) / 365) + )::int AS min_age_years + 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 + 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 (r.character1_id = $1::int OR r.character2_id = $1::int) + AND ($2::int IS NULL OR r.id = $2::int); +"#; + +const Q_UPDATE_AFFAIR_VISIBILITY_DISCRETION: &str = r#" + UPDATE falukant_data.relationship_state + SET visibility = LEAST(100, COALESCE(visibility, 0) + $2::smallint), + discretion = GREATEST(0, COALESCE(discretion, 0) - $3::smallint), + updated_at = NOW() + WHERE relationship_id = $1::int; +"#; + +const Q_UPDATE_AFFAIR_VISIBILITY_ONLY: &str = r#" + UPDATE falukant_data.relationship_state + SET visibility = LEAST(100, COALESCE(visibility, 0) + $2::smallint), + updated_at = NOW() + WHERE relationship_id = $1::int; +"#; + // Use centralized QUERY_UPDATE_MONEY from src/worker/sql.rs impl UndergroundWorker { @@ -153,7 +239,7 @@ impl UndergroundWorker { None => continue, }; - match Self::execute_row(pool, &row) { + match Self::execute_row(pool, broker, &row) { Ok(res) => { Self::update_result(pool, id, &res)?; let event = json!({ @@ -184,17 +270,18 @@ impl UndergroundWorker { conn.execute("ug_select_pending", &[]) } - fn execute_row(pool: &ConnectionPool, r: &Row) -> Result { + fn execute_row(pool: &ConnectionPool, broker: &MessageBroker, 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()); - Self::handle_task(pool, &task_type, performer_id, victim_id, ¶ms) + Self::handle_task(pool, broker, &task_type, performer_id, victim_id, ¶ms) } fn handle_task( pool: &ConnectionPool, + broker: &MessageBroker, task_type: &str, performer_id: i32, victim_id: i32, @@ -208,6 +295,7 @@ impl UndergroundWorker { "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), + "investigate_affair" => Self::investigate_affair(pool, broker, performer_id, victim_id, &p), _ => Ok(json!({ "status": "unknown_type", "type": task_type @@ -880,6 +968,348 @@ impl UndergroundWorker { } } + /// `investigate_affair` nach Spec „Liebschaften & Untergrund“: `discoveryScore`, Ziele + /// `expose` / `blackmail`, Teilerfolg, Erpressungssumme, optional Sofort-Skandal (WebSocket). + fn investigate_affair( + pool: &ConnectionPool, + broker: &MessageBroker, + performer_id: i32, + victim_id: i32, + p: &Json, + ) -> Result { + let goal = p + .get("goal") + .and_then(|v| v.as_str()) + .unwrap_or("expose"); + let expose_mode = goal == "expose"; + + let rel_id_param = p + .get("relationship_id") + .and_then(|v| v.as_i64()) + .map(|x| x as i32); + + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("aff_ctx", Q_AFFAIR_VICTIM_CONTEXT)?; + conn.prepare("aff_children", Q_AFFAIR_VICTIM_CHILDREN)?; + conn.prepare("aff_lovers_all", Q_AFFAIR_ALL_LOVERS)?; + conn.prepare("aff_cnt_lovers", QUERY_COUNT_ACTIVE_LOVERS_FOR_CHARACTER)?; + conn.prepare("aff_upd_vis_disc", Q_UPDATE_AFFAIR_VISIBILITY_DISCRETION)?; + conn.prepare("aff_upd_vis", Q_UPDATE_AFFAIR_VISIBILITY_ONLY)?; + conn.prepare("aff_upd_rep", QUERY_UPDATE_CHARACTER_REPUTATION)?; + + let ctx_row = conn.execute("aff_ctx", &[&victim_id])?; + let ctx = ctx_row.first(); + + let has_house_data = ctx + .and_then(|r| r.get("user_house_id")) + .map(|s| !s.is_empty()) + .unwrap_or(false); + + let victim_reputation = ctx + .map(|r| parse_f64(r, "reputation", 50.0)) + .unwrap_or(50.0); + let title_level = ctx.map(|r| parse_i32(r, "title_level", 0)).unwrap_or(0); + let house_position = ctx.map(|r| parse_i32(r, "house_position", 0)).unwrap_or(0); + let household_order = ctx.map(|r| parse_i32(r, "household_order", 55)).unwrap_or(55); + let servant_count = ctx.map(|r| parse_i32(r, "servant_count", 0)).unwrap_or(0); + let servant_quality = ctx.map(|r| parse_i32(r, "servant_quality", 50)).unwrap_or(50); + let servant_pay = ctx + .and_then(|r| r.get("servant_pay_level")) + .map(|s| s.to_string()) + .unwrap_or_else(|| "normal".into()); + + let child_row = conn.execute("aff_children", &[&victim_id])?; + let (has_hidden_bastard, has_public_known_bastard) = child_row + .first() + .map(|r| { + ( + parse_bool_cell(r, "has_hidden_bastard"), + parse_bool_cell(r, "has_public_known_bastard"), + ) + }) + .unwrap_or((false, false)); + + let cnt_row = conn.execute("aff_cnt_lovers", &[&victim_id])?; + let active_lover_count = cnt_row + .first() + .map(|r| parse_i32(r, "cnt", 0)) + .unwrap_or(0); + + let lover_rows = conn.execute("aff_lovers_all", &[&victim_id, &rel_id_param])?; + + if lover_rows.is_empty() { + return Ok(json!({ + "status": "failed", + "outcome": "failure", + "action": "investigate_affair", + "performer_id": performer_id, + "victim_id": victim_id, + "goal": goal, + "notes": "no active lover relationship", + "details": p + })); + } + + let (_exp_min, exp_max) = affair_expected_range(house_position, title_level); + + let household_leak_bonus = if has_house_data { + affair_household_leak_bonus( + household_order, + servant_pay.as_str(), + servant_count, + exp_max, + servant_quality, + ) + } else { + 0.0 + }; + + let child_bonus = affair_child_discovery_bonus(has_hidden_bastard, has_public_known_bastard); + let multiple_affair_bonus = affair_multiple_affair_bonus(active_lover_count); + + let mut scored: Vec<(f64, usize)> = Vec::new(); + for (i, row) in lover_rows.iter().enumerate() { + let visibility = parse_i32(row, "visibility", 0); + let discretion = parse_i32(row, "discretion", 50); + let acknowledged = parse_bool_cell(row, "acknowledged"); + let status_fit = parse_i32(row, "status_fit", 0); + let min_age = parse_i32(row, "min_age_years", 99); + + let score = affair_discovery_score( + visibility, + discretion, + acknowledged, + min_age, + status_fit, + child_bonus, + household_leak_bonus, + multiple_affair_bonus, + ); + scored.push((score, i)); + } + + scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + let best_score = scored[0].0; + let mut best_indices: Vec = scored + .iter() + .filter(|(s, _)| (*s - best_score).abs() < 1e-4) + .map(|(_, i)| *i) + .collect(); + if best_indices.is_empty() { + best_indices.push(scored[0].1); + } + let pick = *best_indices + .as_slice() + .choose(&mut rand::thread_rng()) + .unwrap_or(&scored[0].1); + let target = &lover_rows[pick]; + + let rel_id = parse_i32(target, "rel_id", -1); + let visibility_before = parse_i32(target, "visibility", 0); + let discretion_before = parse_i32(target, "discretion", 50); + let lover_role = target + .get("lover_role") + .cloned() + .unwrap_or_default(); + let min_age = parse_i32(target, "min_age_years", 99); + let status_fit = parse_i32(target, "status_fit", 0); + let maintenance_level = parse_i32(target, "maintenance_level", 50); + let scandal_extra = parse_i32(target, "scandal_extra", 0); + let title1_tr = target.get("title1_tr").cloned().unwrap_or_default(); + let title2_tr = target.get("title2_tr").cloned().unwrap_or_default(); + let user1_id = parse_opt_i32(target, "user1_id"); + let user2_id = parse_opt_i32(target, "user2_id"); + + let discovery_score = affair_discovery_score( + visibility_before, + discretion_before, + parse_bool_cell(target, "acknowledged"), + min_age, + status_fit, + child_bonus, + household_leak_bonus, + multiple_affair_bonus, + ); + + let success_chance = (20.0 + discovery_score * 0.55).clamp(5.0, 95.0); + let roll = random_double(0.0, 100.0); + + let tier = if roll <= success_chance * 0.55 { + "full" + } else if roll <= success_chance { + "partial" + } else { + "failure" + }; + + let mut blackmail_amount: Option = None; + let mut outcome_str = "failure"; + let mut status_str = "failed"; + let mut notes = String::new(); + + if tier == "failure" { + notes.push_str("no proof"); + return Ok(json!({ + "status": status_str, + "outcome": outcome_str, + "action": "investigate_affair", + "performer_id": performer_id, + "victim_id": victim_id, + "goal": goal, + "discoveries": null, + "relationshipId": rel_id, + "discoveryScore": (discovery_score * 100.0).round() / 100.0, + "successChance": (success_chance * 100.0).round() / 100.0, + "roll": (roll * 100.0).round() / 100.0, + "notes": notes, + "details": p + })); + } + + outcome_str = if tier == "full" { "success" } else { "partial" }; + status_str = "resolved"; + + let visibility_delta: i32; + let discretion_delta: i32; + let mut reputation_delta: i32; + + if expose_mode { + if tier == "full" { + visibility_delta = random_int(18, 30); + discretion_delta = random_int(8, 18); + reputation_delta = random_int(-6, -2); + } else { + visibility_delta = random_int(8, 15); + discretion_delta = random_int(3, 8); + reputation_delta = random_int(-3, -1); + } + if has_public_known_bastard { + reputation_delta -= 1; + } + + conn.execute( + "aff_upd_vis_disc", + &[&rel_id, &visibility_delta, &discretion_delta], + )?; + + let new_rep = (victim_reputation + reputation_delta as f64).clamp(0.0, 100.0); + let rep_s = format!("{:.2}", new_rep); + conn.execute("aff_upd_rep", &[&rep_s, &victim_id])?; + + let visibility_after = (visibility_before + visibility_delta).min(100); + + if visibility_after >= 60 { + let mut p_scandal = 1.0 + + (visibility_after as f64) / 25.0 + + if status_fit == -2 { 2.0 } else { 0.0 } + + if maintenance_level < 25 { 3.0 } else { 0.0 } + + if active_lover_count >= 2 { 3.0 } else { 0.0 } + + scandal_extra as f64 + + affair_scandal_age_extra_pct(min_age); + if discretion_before >= 75 { + p_scandal -= 2.0; + } + p_scandal = p_scandal.clamp(0.0, 25.0); + if min_age <= 15 { + p_scandal += 10.0; + } + if has_public_known_bastard { + p_scandal += 6.0; + } + if household_order <= 35 { + p_scandal += 5.0; + } + p_scandal = p_scandal.clamp(0.0, 100.0); + if random_double(0.0, 100.0) < p_scandal { + affair_publish_scandal_hint(broker, rel_id, user1_id, user2_id); + } + } + } else { + // blackmail + if tier == "full" { + visibility_delta = random_int(4, 9); + discretion_delta = random_int(2, 6); + reputation_delta = random_int(-1, 0); + } else { + visibility_delta = random_int(2, 5); + discretion_delta = 0; + reputation_delta = 0; + } + + if tier == "full" || tier == "partial" { + if discretion_delta > 0 { + conn.execute( + "aff_upd_vis_disc", + &[&rel_id, &visibility_delta, &discretion_delta], + )?; + } else { + conn.execute("aff_upd_vis", &[&rel_id, &visibility_delta])?; + } + if reputation_delta != 0 { + let new_rep = (victim_reputation + reputation_delta as f64).clamp(0.0, 100.0); + let rep_s = format!("{:.2}", new_rep); + conn.execute("aff_upd_rep", &[&rep_s, &victim_id])?; + } + } + + let outcome_factor = if tier == "full" { 1.0 } else { 0.55 }; + let tg = affair_title_group_bonus(&title1_tr, &title2_tr); + let child_bm = affair_child_blackmail_bonus(has_hidden_bastard, has_public_known_bastard); + let vis_for_bm = visibility_before; + let base = 500.0 + + (vis_for_bm as f64) * 12.0 + + victim_reputation.max(0.0) * 15.0 + + tg as f64 + + child_bm as f64; + blackmail_amount = Some((base * outcome_factor).round() as i64); + } + + let visibility_after = (visibility_before + visibility_delta).min(100); + let household_leak_flag = has_house_data && household_leak_bonus > 0.001; + + let min_age_bracket = if min_age <= 13 { + "<=13" + } else if min_age <= 15 { + "<=15" + } else if min_age <= 17 { + "<=17" + } else { + "adult" + }; + + Ok(json!({ + "status": status_str, + "outcome": outcome_str, + "action": "investigate_affair", + "performer_id": performer_id, + "victim_id": victim_id, + "goal": goal, + "discoveries": { + "relationshipId": rel_id, + "loverRole": lover_role, + "visibility": visibility_after, + "acknowledged": parse_bool_cell(target, "acknowledged"), + "publicKnownChild": has_public_known_bastard, + "hiddenChild": has_hidden_bastard, + "householdLeak": household_leak_flag, + "minAgeBracket": min_age_bracket, + "multipleAffairs": active_lover_count >= 2 + }, + "visibilityDelta": visibility_delta, + "reputationDelta": reputation_delta, + "blackmailAmount": blackmail_amount, + "discoveryScore": (discovery_score * 100.0).round() / 100.0, + "successChance": (success_chance * 100.0).round() / 100.0, + "roll": (roll * 100.0).round() / 100.0, + "tier": tier, + "notes": notes, + "details": p + })) + } + fn update_result(pool: &ConnectionPool, id: i32, result: &Json) -> Result<(), DbError> { let mut conn = pool .get() @@ -911,6 +1341,199 @@ impl Worker for UndergroundWorker { } } +// --- investigate_affair: Spec „Liebschaften & Untergrund“ (Hilfsfunktionen) --- + +fn affair_base_range(position: i32) -> (i32, i32) { + match position { + 3 => (1, 2), + 4 => (2, 4), + 5 => (3, 6), + p if p <= 2 => (0, 1), + p if p >= 6 => (4, 8), + _ => (0, 1), + } +} + +fn affair_expected_range(house_position: i32, title_level: i32) -> (i32, i32) { + let (bmin, bmax) = affair_base_range(house_position); + let bonus = (title_level / 3).max(0); + (bmin + bonus, bmax + bonus) +} + +fn affair_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 affair_title_group_bonus(t1: &str, t2: &str) -> i32 { + let g = affair_title_group(t1).max(affair_title_group(t2)); + match g { + 0 => 0, + 1 => 600, + 2 => 1800, + 3 => 4200, + _ => 0, + } +} + +fn affair_child_blackmail_bonus(hidden: bool, public: bool) -> i32 { + let mut b = 0; + if hidden { + b += 900; + } + if public { + b += 1600; + } + b +} + +fn age_malus_visibility_bonus(min_age: i32) -> f64 { + if min_age <= 13 { + 18.0 + } else if min_age <= 15 { + 12.0 + } else if min_age <= 17 { + 6.0 + } else { + 0.0 + } +} + +fn status_mismatch_bonus(status_fit: i32) -> f64 { + match status_fit { + -2 => 10.0, + -1 => 5.0, + _ => 0.0, + } +} + +fn affair_multiple_affair_bonus(cnt: i32) -> f64 { + if cnt >= 3 { + 14.0 + } else if cnt == 2 { + 8.0 + } else { + 0.0 + } +} + +fn affair_child_discovery_bonus(has_hidden: bool, has_public: bool) -> f64 { + let mut s: f64 = 0.0; + if has_hidden { + s += 8.0; + } + if has_public { + s += 18.0; + } + s.min(20.0) +} + +fn affair_household_leak_bonus( + household_order: i32, + pay: &str, + servant_count: i32, + exp_max: i32, + servant_quality: i32, +) -> f64 { + let mut s: f64 = 0.0; + if household_order <= 35 { + s += 8.0; + } + if pay == "low" { + s += 5.0; + } + if servant_count > exp_max + 1 { + s += 4.0; + } + if servant_quality <= 35 { + s += 6.0; + } + s.min(15.0) +} + +fn affair_discovery_score( + visibility: i32, + discretion: i32, + acknowledged: bool, + min_age: i32, + status_fit: i32, + child_bonus: f64, + household_leak_bonus: f64, + multiple_affair_bonus: f64, +) -> f64 { + visibility as f64 * 0.45 + + (100.0 - discretion as f64) * 0.30 + + if acknowledged { 10.0 } else { 0.0 } + + child_bonus + + age_malus_visibility_bonus(min_age) + + household_leak_bonus + + multiple_affair_bonus + + status_mismatch_bonus(status_fit) +} + +fn affair_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 affair_publish_scandal_hint( + broker: &MessageBroker, + relationship_id: i32, + user1_id: Option, + user2_id: Option, +) { + broker.publish(format!( + r#"{{"event":"falukant_family_scandal_hint","relationship_id":{}}}"#, + relationship_id + )); + for uid in [user1_id, user2_id] { + if let Some(id) = uid.filter(|x| *x > 0) { + let family = format!( + r#"{{"event":"falukantUpdateFamily","user_id":{},"reason":"scandal"}}"#, + id + ); + broker.publish(family); + let status = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, id); + broker.publish(status); + } + } +} + +fn parse_f64(row: &Row, key: &str, default: f64) -> f64 { + row.get(key) + .and_then(|v| v.parse::().ok()) + .unwrap_or(default) +} + +fn parse_bool_cell(row: &Row, key: &str) -> bool { + row.get(key) + .map(|v| matches!(v.as_str(), "t" | "true" | "True" | "1")) + .unwrap_or(false) +} + +fn parse_opt_i32(row: &Row, key: &str) -> Option { + row.get(key) + .and_then(|v| v.parse::().ok()) + .filter(|&x| x > 0) +} + // Hilfsfunktionen für Zufall und Parsing fn random_int(lo: i32, hi: i32) -> i32 {