Integrate servant management into Falukant family dynamics: Added support for servant-related logic in FalukantFamilyWorker, including daily and monthly processing of servant data. Updated SQL queries to handle servant attributes and integrated servant discretion modifiers into relationship calculations, enhancing family interaction and satisfaction tracking.

This commit is contained in:
Torsten Schulz (local)
2026-03-22 10:09:26 +01:00
parent c209c41b52
commit d921dc2f7e
9 changed files with 1265 additions and 8 deletions

View File

@@ -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)

View File

@@ -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).

View File

@@ -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 0100: `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).

View File

@@ -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';

View File

@@ -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<Instant>,
last_monthly: Option<Instant>,
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<i32>,
/// 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<i32>, uid: Option<i32>) {

View File

@@ -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<bool, DbError> {
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::<i32>().ok())
.unwrap_or(-1);
let falukant_user_id = row
.get("falukant_user_id")
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(-1);
let character_id = row
.get("character_id")
.and_then(|v| v.parse::<i32>().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::<i32>().ok())
.unwrap_or(0);
let house_position = row
.get("house_position")
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(0);
let servant_count = row
.get("servant_count")
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(0);
let mut servant_quality = row
.get("servant_quality")
.and_then(|v| v.parse::<i32>().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::<i32>().ok())
.unwrap_or(55);
let mut reputation = row
.get("reputation")
.and_then(|v| v.parse::<f64>().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::<i32>().ok())
.unwrap_or(-1);
let falukant_user_id = row
.get("falukant_user_id")
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(-1);
let character_id = row
.get("character_id")
.and_then(|v| v.parse::<i32>().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::<i32>().ok())
.unwrap_or(0);
let house_position = row
.get("house_position")
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(0);
let house_cost = row
.get("house_cost")
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(0);
let servant_count = row
.get("servant_count")
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(0);
let servant_quality = row
.get("servant_quality")
.and_then(|v| v.parse::<i32>().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::<i32>().ok())
.unwrap_or(55);
let mut reputation = row
.get("reputation")
.and_then(|v| v.parse::<f64>().ok())
.unwrap_or(50.0);
let user_money = row
.get("user_money")
.and_then(|v| v.parse::<f64>().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::<i32>().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(())
}

View File

@@ -12,6 +12,7 @@ mod transport;
mod weather;
mod events;
mod falukant_family;
mod falukant_servants;
mod sql;
pub use base::Worker;

View File

@@ -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'

View File

@@ -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<Json, DbError> {
fn execute_row(pool: &ConnectionPool, broker: &MessageBroker, r: &Row) -> Result<Json, DbError> {
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, &params)
Self::handle_task(pool, broker, &task_type, performer_id, victim_id, &params)
}
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<Json, DbError> {
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<usize> = 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<i64> = 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<i32>,
user2_id: Option<i32>,
) {
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::<f64>().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<i32> {
row.get(key)
.and_then(|v| v.parse::<i32>().ok())
.filter(|&x| x > 0)
}
// Hilfsfunktionen für Zufall und Parsing
fn random_int(lo: i32, hi: i32) -> i32 {