diff --git a/docs/FALUKANT_DAEMON_HANDOFF.md b/docs/FALUKANT_DAEMON_HANDOFF.md index 22998e4..2a3b636 100644 --- a/docs/FALUKANT_DAEMON_HANDOFF.md +++ b/docs/FALUKANT_DAEMON_HANDOFF.md @@ -2,6 +2,8 @@ Technische Abstimmung mit dem Übergabedokument im Backend-Projekt (`FALUKANT_LOVERS_DAEMON_SPEC.md` / `FALUKANT_LOVERS_TECHNICAL_CONCEPT.md`). +**Ehe & Hausfrieden (Phase A):** [`FALUKANT_MARRIAGE_HOUSEPEACE_DAEMON_HANDOFF.md`](./FALUKANT_MARRIAGE_HOUSEPEACE_DAEMON_HANDOFF.md) + ## Abweichungen / Zuordnung | Handoff / Backend | YpDaemon | @@ -55,6 +57,7 @@ Ehe-Malus „≤ 15“ gilt pro Ehe, wenn **irgendeine** berührende Liebschaft 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). +5. `migrations/005_falukant_marriage_housepeace.sql` — `relationship.marriage_public_stability`, `user_house.household_tension_score`. Siehe [`FALUKANT_MARRIAGE_HOUSEPEACE_DAEMON_HANDOFF.md`](./FALUKANT_MARRIAGE_HOUSEPEACE_DAEMON_HANDOFF.md). ### Ehe-Buffs (Daemon) diff --git a/docs/FALUKANT_MARRIAGE_HOUSEPEACE_DAEMON_HANDOFF.md b/docs/FALUKANT_MARRIAGE_HOUSEPEACE_DAEMON_HANDOFF.md new file mode 100644 index 0000000..c548b5c --- /dev/null +++ b/docs/FALUKANT_MARRIAGE_HOUSEPEACE_DAEMON_HANDOFF.md @@ -0,0 +1,156 @@ +# Falukant: Daemon-Handoff für Ehe und Hausfrieden + +Dieses Dokument beschreibt den Stand nach Phase A und die Rolle des externen Daemons (Übergabe Backend ↔ YpDaemon). + +## 1. Was im Projekt jetzt vorhanden ist + +Backend-/API-seitig vorhanden: + +- `relationship_state.marriage_satisfaction` +- `relationship_state.marriage_public_stability` +- aktive Liebschaften mit: + - `visibility` + - `discretion` + - `maintenance_level` + - `status_fit` + - `months_underfunded` + - `acknowledged` +- `user_house` mit: + - `servant_count` + - `servant_quality` + - `servant_pay_level` + - `household_order` +- Family-API liefert jetzt zusätzlich: + - `householdTension` + - `householdTensionScore` + - `householdTensionReasons` + +Direkte Spieleraktionen vorhanden: + +- `POST /api/falukant/family/marriage/spend-time` +- `POST /api/falukant/family/marriage/gift` +- `POST /api/falukant/family/marriage/reconcile` +- `POST /api/falukant/houses/order` + +## 2. Daily-Input für den externen Daemon + +Pro betroffenem Falukant-User: + +- `falukant_user.id` +- `user.id` / `user.hashed_id` +- aktive Ehe-`relationship` mit `relationship_state` +- aktive Liebschaften mit `relationship_state` +- Kinder mit: + - `birth_context` + - `legitimacy` + - `public_known` +- Haus mit: + - `servant_count` + - `servant_quality` + - `servant_pay_level` + - `household_order` +- Charakter mit: + - `reputation` + - `title_of_nobility` + +## 3. Was der Daemon täglich berechnen soll + +### Ehe + +- Drift von `marriage_satisfaction` +- Drift von `marriage_public_stability` +- Einfluss aus: + - sichtbaren Liebschaften + - unterfinanzierten Liebschaften + - Standesunterschieden + - Dienerschaft / Haushaltsordnung + - zu jungen Liebschaften + +### Hausfrieden + +Der Daemon soll intern einen numerischen Spannungswert pflegen oder berechnen: + +- `householdTensionScore` `0..100` + +Einflussfaktoren: + +- sichtbare Liebschaften +- anerkannte Liebschaften +- unterfinanzierte Liebschaften +- Kinder aus Liebschaften +- Haushaltsordnung +- Dienerschaft +- schwache Ehe + +UI-Ableitung: + +- `0..24` => `low` +- `25..59` => `medium` +- `60..100` => `high` + +## 4. Was der Daemon zurückschreiben soll + +Pflicht: + +- `relationship_state.marriage_satisfaction` +- `relationship_state.marriage_public_stability` +- lover-state-Felder bei Änderungen: + - `visibility` + - `discretion` + - `months_underfunded` + - optional `notes_json` / `flags_json` +- falls eigener Persistenzwert eingeführt wird: + - `household_tension_score` + +Wenn kein eigener Persistenzwert eingeführt wird: + +- der Daemon darf den Spannungswert auch nur berechnen +- die API kann ihn weiterhin aus Ehe, Liebschaften, Kindern und Haus ableiten + +## 5. Socket-/Refresh-Verhalten + +Wenn Daily-/Monthly-Verarbeitung Ehe oder Hausfrieden betrifft: + +- `falukantUpdateFamily` mit `reason: "daily"` oder `reason: "monthly"` +- danach `falukantUpdateStatus` + +Wenn ein Sonderereignis entsteht: + +- `reason: "scandal"` zusätzlich + +## 6. Wichtige Phase-A-Regel + +Die neuen Direktaktionen geben nur Sofortimpulse: + +- `spend-time` +- `gift` +- `reconcile` +- `house/order` + +Der Daemon ist weiterhin verantwortlich für: + +- Rückdrift +- Gegenkräfte +- Langzeiteffekte +- Balancing + +Kurz: + +- UI/Backend setzen kleine direkte Impulse +- der Daemon bestimmt die dauerhafte Entwicklung + +--- + +## Anhang: Abgleich YpDaemon (dieses Repo) + +| Thema | Stand in YpDaemon | +|-------|---------------------| +| Ehe-Zufriedenheit, Buffs, Drift (`marriage_drift_*`) | `FalukantFamilyWorker` + Migrationen `001`/`003`, siehe [`FALUKANT_DAEMON_HANDOFF.md`](./FALUKANT_DAEMON_HANDOFF.md) | +| **`marriage_public_stability`** (Daily-Drift) | Migration **`005`**, `QUERY_GET_MARRIAGE_ROWS` / `QUERY_UPDATE_MARRIAGE_STATE_AND_BUFFS`, Logik in `falukant_family.rs` (Einfluss: sichtbare/unterfinanzierte/anerkannte Liebschaften, Stand, Alter, Haushalt/Diener, schwache Ehe) | +| **`household_tension_score`** (0..100) | Migration **`005`**, berechnet im Daily-Tick, persistiert in `user_house`; UI-Band **low/medium/high** weiterhin aus Score ableitbar (0–24 / 25–59 / 60–100) | +| Liebschaften (visibility, discretion, …, **`acknowledged`**, **`months_underfunded`**) | Daily-Query erweitert; Tension-Aggregation nutzt diese Felder | +| Dienerschaft / `household_order` | `falukant_servants.rs` + Migration `004`; zusätzlich in Ehe-Stabilität und Haus-Spannung | +| WebSocket | Bei Änderung von Ehe oder Spannung: `falukantUpdateFamily` mit `reason: "daily"` (wie bisher) | +| **HTTP-Routen** (`spend-time`, `gift`, …) | Liegen im **Backend** (nicht im Daemon-Repo) | + +Verwandte Doku: [`FALUKANT_UI_WEBSOCKET.md`](./FALUKANT_UI_WEBSOCKET.md), [`FALUKANT_SERVANTS_DAEMON.md`](./FALUKANT_SERVANTS_DAEMON.md). diff --git a/docs/FALUKANT_PRODUCTION_CERTIFICATE.md b/docs/FALUKANT_PRODUCTION_CERTIFICATE.md new file mode 100644 index 0000000..bb969f5 --- /dev/null +++ b/docs/FALUKANT_PRODUCTION_CERTIFICATE.md @@ -0,0 +1,41 @@ +# Falukant: Produktionszertifikate (Daemon) + +Der **`FalukantCertificateWorker`** berechnet einmal täglich die Zielstufe und schreibt `falukant_user.certificate` fort (max. **+1** pro Tag, keine normale Herabstufung). + +## SQL + +- `QUERY_GET_PRODUCTION_CERTIFICATE_INPUT_ROWS` – Eingangsdaten je Falukant-User (Spielercharakter, Wissen, Produktionen, Ämter, Haus …) +- `QUERY_UPDATE_FALUKANT_USER_CERTIFICATE` – Update der Stufe + +## Logik (Kurz) + +- `certificateScore` aus gewichteten Punktwerten (Wissen, Produktion, Amt, Adel, Ruf, Haus) +- `raw_target` aus Score-Schwellen (1.2 / 2.1 / 3.0 / 4.0) +- `effective_target` mit Mindestanforderungen je Stufe (Spec §4.5) +- Aufstieg nur wenn `effective_target > current` → **`current + 1`** (gegen `effective_target` begrenzt) +- **Bankrott** (`money <= -5000`): Zertifikat auf **1**, mit Event + +## Politische Ämter + +Rang aus **`political_office_type.name`** (Substring-Heuristik im Daemon, ohne DB-Änderung). Anpassung über `political_name_to_rank` in `falukant_certificate.rs`. + +## Kirchliche Ämter + +`officePoints` aus **`max(hierarchy_level)`** der aktiven `church_office`-Zeilen (gekappt 0–5). + +## Abgeschlossene Produktionen + +**`COUNT(*)`** aus `falukant_log.production` mit `producer_id = falukant_user.id` (Zeilen = aggregierte Log-Einträge). + +## Events (WebSocket) + +Bei Änderung der Stufe: + +1. `falukantUpdateProductionCertificate` mit `reason: "daily_recalculation"`, `old_certificate`, `new_certificate` +2. `falukantUpdateStatus` + +## Nicht umgesetzt (optional / später) + +- **Tod ohne Erben** / Zertifikats-Reset +- Feinere **Bankrott**-Definition +- **`political_office_history`** (nicht im Repo) diff --git a/docs/FALUKANT_UI_WEBSOCKET.md b/docs/FALUKANT_UI_WEBSOCKET.md index 94e9531..66ed872 100644 --- a/docs/FALUKANT_UI_WEBSOCKET.md +++ b/docs/FALUKANT_UI_WEBSOCKET.md @@ -12,6 +12,7 @@ Dieses Dokument beschreibt die **Nachrichten**, die der **YpDaemon** (`FalukantF |---------|----------------|----------------------| | `falukantUpdateFamily` | `user_id`, `reason` | Gezielter Refresh Familie/Liebe/Geld je nach `reason` | | `falukantUpdateStatus` | `user_id` | Allgemeiner Status-/Spielstand-Refresh (wie bisher) | +| `falukantUpdateProductionCertificate` | `user_id`, `reason`, `old_certificate`, `new_certificate` | Produkte / Produktions-UI / Zertifikat neu laden (nach Daily-Recalc oder Bankrott) | | `children_update` | `user_id` | Kinderliste / FamilyView aktualisieren | | `falukant_family_scandal_hint` | `relationship_id` | Optional: Toast, Log – **kein** `user_id` (siehe unten) | @@ -33,7 +34,7 @@ Dieses Dokument beschreibt die **Nachrichten**, die der **YpDaemon** (`FalukantF | `reason` | Bedeutung (Daemon) | Empfehlung UI | |----------|---------------------|---------------| -| `daily` | Daily-Tick: Liebschafts-/Ehe-/Ansehens-Logik für den Tag | Family-API + ggf. Charakter/Ansehen neu laden | +| `daily` | Daily-Tick: Liebschafts-/Ehe-/Ansehens-Logik; u. a. `marriage_public_stability`, `household_tension_score` | Family-API + ggf. Charakter/Ansehen/Haus neu laden | | `monthly` | Monthly-Tick: Kosten, Unterversorgung, Monatsstand | **Geld** (`falukant_user.money`) + Family-State neu laden | | `scandal` | Skandal-Ereignis (zusätzlich zu `daily` möglich) | Kurzer Hinweis / Eintrag „Skandal“; Family + Ruf | | `lover_birth` | Uneheliches Kind angelegt | Wie `children_update`, plus Eltern-Story | @@ -49,7 +50,22 @@ Dieses Dokument beschreibt die **Nachrichten**, die der **YpDaemon** (`FalukantF Kommt **typischerweise direkt nach** `falukantUpdateFamily` mit derselben `user_id` (gemeinsamer Refresh). -### 2.3 `children_update` +### 2.3 `falukantUpdateProductionCertificate` + +```json +{ + "event": "falukantUpdateProductionCertificate", + "user_id": 123, + "reason": "daily_recalculation", + "old_certificate": 2, + "new_certificate": 3 +} +``` + +- **`reason`:** in der ersten Version fest `daily_recalculation` (inkl. Bankrott-Herabstufung, falls so umgesetzt). +- Danach sendet der Daemon **`falukantUpdateStatus`** mit derselben `user_id` (wie bei anderen Falukant-Events). + +### 2.4 `children_update` ```json { @@ -60,7 +76,7 @@ Kommt **typischerweise direkt nach** `falukantUpdateFamily` mit derselben `user_ Tritt bei **Geburt aus Liebschaft** auf; oft zusammen mit `falukantUpdateFamily` (`reason: lover_birth`) und `falukantUpdateStatus`. -### 2.4 `falukant_family_scandal_hint` +### 2.5 `falukant_family_scandal_hint` ```json { @@ -84,6 +100,10 @@ onMessage(json): refreshPlayerStatus() // bestehend return + case "falukantUpdateProductionCertificate": + refreshProductsAndProductionUi() // Zertifikat / erlaubte Produkte + return + case "children_update": refreshChildrenAndFamilyView() return diff --git a/migrations/005_falukant_marriage_housepeace.sql b/migrations/005_falukant_marriage_housepeace.sql new file mode 100644 index 0000000..da4f8c2 --- /dev/null +++ b/migrations/005_falukant_marriage_housepeace.sql @@ -0,0 +1,16 @@ +-- Ehe: öffentliche Stabilität (Daemon-Drift). Hausfrieden: persistenter Spannungswert 0..100. +-- Siehe docs/FALUKANT_MARRIAGE_HOUSEPEACE_DAEMON_HANDOFF.md + +ALTER TABLE falukant_data.relationship + ADD COLUMN IF NOT EXISTS marriage_public_stability smallint NOT NULL DEFAULT 55 + CHECK (marriage_public_stability >= 0 AND marriage_public_stability <= 100); + +COMMENT ON COLUMN falukant_data.relationship.marriage_public_stability IS + 'Öffentliche Ehe-Stabilität 0..100; Daily-Drift durch Daemon (Liebschaften, Haus, Stand)'; + +ALTER TABLE falukant_data.user_house + ADD COLUMN IF NOT EXISTS household_tension_score smallint NOT NULL DEFAULT 0 + CHECK (household_tension_score >= 0 AND household_tension_score <= 100); + +COMMENT ON COLUMN falukant_data.user_house.household_tension_score IS + 'Hausfrieden-Spannung 0..100 (Daemon); UI: low 0–24, medium 25–59, high 60–100'; diff --git a/src/main.rs b/src/main.rs index d4a23ad..466a3f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,7 @@ use worker::{ CharacterCreationWorker, ConnectionPool, DirectorWorker, EventsWorker, HouseWorker, PoliticsWorker, ProduceWorker, StockageManager, TransportWorker, UndergroundWorker, UserCharacterWorker, ValueRecalculationWorker, WeatherWorker, Worker, - FalukantFamilyWorker, + FalukantFamilyWorker, FalukantCertificateWorker, }; static KEEP_RUNNING: AtomicBool = AtomicBool::new(true); @@ -142,6 +142,10 @@ fn create_workers(pool: ConnectionPool, broker: MessageBroker) -> Vec Self { + Self { + base: BaseWorker::new("FalukantCertificateWorker", pool, broker), + } + } + + fn run_loop(pool: ConnectionPool, broker: MessageBroker, state: Arc) { + let mut last: Option = None; + while state.running_worker.load(Ordering::Relaxed) { + let now = std::time::Instant::now(); + let run = match last { + None => true, + Some(t) => now.saturating_duration_since(t) >= DAILY_INTERVAL, + }; + if run { + if let Err(e) = Self::process_daily(&pool, &broker) { + eprintln!("[FalukantCertificateWorker] process_daily: {e}"); + } + last = Some(now); + } + for _ in 0..60 { + if !state.running_worker.load(Ordering::Relaxed) { + break; + } + std::thread::sleep(Duration::from_secs(1)); + } + } + } + + fn process_daily(pool: &ConnectionPool, broker: &MessageBroker) -> Result<(), DbError> { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("cert_rows", QUERY_GET_PRODUCTION_CERTIFICATE_INPUT_ROWS)?; + conn.prepare("cert_upd", QUERY_UPDATE_FALUKANT_USER_CERTIFICATE)?; + let rows = conn.execute("cert_rows", &[])?; + + for row in rows { + let fu_id = parse_i32(&row, "falukant_user_id", -1); + if fu_id < 0 { + continue; + } + let current = parse_i32(&row, "certificate", 1).clamp(1, 127); + let money = parse_f64(&row, "money", 0.0); + let avg_knowledge = parse_f64(&row, "avg_knowledge", 0.0); + let completed = parse_i64(&row, "completed_production_count", 0); + let max_church_hierarchy = parse_i32(&row, "max_church_hierarchy", 0); + let pol_names = row + .get("political_office_names") + .cloned() + .unwrap_or_default(); + let reputation = parse_f64(&row, "reputation", 50.0); + let title_level = parse_i32(&row, "title_level", 0); + let house_position = parse_i32(&row, "house_position", 0); + + // Bankrott: Herabsetzung erlaubt (Spec) + if money <= BANKRUPTCY_MONEY_THRESHOLD && current > 1 { + conn.execute("cert_upd", &[&1_i32, &fu_id])?; + publish_certificate_event(broker, fu_id, current, 1); + continue; + } + + let knowledge_points = knowledge_points_from_avg(avg_knowledge); + let production_points = production_points_from_count(completed); + let political_rank = max_political_rank_from_names(&pol_names); + let church_rank = church_rank_from_hierarchy(max_church_hierarchy); + let highest_office_rank = political_rank.max(church_rank).min(5); + let office_points = highest_office_rank.min(5); + let nobility_points = (title_level - 1).clamp(0, 5); + let reputation_points = reputation_points_from_rep(reputation); + let house_points = house_points_from_position(house_position); + + let certificate_score = knowledge_points as f64 * 0.35 + + production_points as f64 * 0.20 + + office_points as f64 * 0.15 + + nobility_points as f64 * 0.10 + + reputation_points as f64 * 0.10 + + house_points as f64 * 0.10; + + let raw_target = raw_target_from_score(certificate_score); + let effective_target = effective_certificate_target( + raw_target, + avg_knowledge, + completed, + office_points, + nobility_points, + reputation_points, + house_points, + ); + + let new_certificate = if effective_target > current { + (current + 1).min(effective_target) + } else { + current + }; + + if new_certificate != current { + conn.execute("cert_upd", &[&new_certificate, &fu_id])?; + publish_certificate_event(broker, fu_id, current, new_certificate); + } + } + + Ok(()) + } +} + +fn publish_certificate_event(broker: &MessageBroker, user_id: i32, old_c: i32, new_c: i32) { + let msg = format!( + r#"{{"event":"falukantUpdateProductionCertificate","user_id":{},"reason":"daily_recalculation","old_certificate":{},"new_certificate":{}}}"#, + user_id, old_c, new_c + ); + broker.publish(msg); + let status = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id); + broker.publish(status); +} + +fn parse_i32(row: &Row, key: &str, default: i32) -> i32 { + row.get(key) + .and_then(|v| v.parse::().ok()) + .unwrap_or(default) +} + +fn parse_i64(row: &Row, key: &str, default: i64) -> i64 { + row.get(key) + .and_then(|v| v.parse::().ok()) + .unwrap_or(default) +} + +fn parse_f64(row: &Row, key: &str, default: f64) -> f64 { + row.get(key) + .and_then(|v| v.parse::().ok()) + .unwrap_or(default) +} + +fn knowledge_points_from_avg(avg: f64) -> i32 { + if avg >= 80.0 { + 5 + } else if avg >= 65.0 { + 4 + } else if avg >= 50.0 { + 3 + } else if avg >= 35.0 { + 2 + } else if avg >= 20.0 { + 1 + } else { + 0 + } +} + +fn production_points_from_count(n: i64) -> i32 { + if n >= 200 { + 5 + } else if n >= 100 { + 4 + } else if n >= 50 { + 3 + } else if n >= 20 { + 2 + } else if n >= 5 { + 1 + } else { + 0 + } +} + +/// Kirchliche Ämter: `hierarchy_level` auf 0–5 begrenzen (Daemon). +fn church_rank_from_hierarchy(h: i32) -> i32 { + if h <= 0 { + 0 + } else { + h.min(5) + } +} + +/// Politische Amtsnamen → Rang 1–5 (konfigurierbar im Daemon). +fn political_name_to_rank(name: &str) -> i32 { + let n = name.to_lowercase(); + if n.contains("reich") + || n.contains("könig") + || n.contains("konig") + || n.contains("king") + || n.contains("kanzler") + || n.contains("chancellor") + { + return 5; + } + if n.contains("minister") + || n.contains("ministerpräsident") + || n.contains("herzog") + || n.contains("duke") + || n.contains("landeshaupt") + { + return 4; + } + if n.contains("regierungs") + || n.contains("oberbürger") + || n.contains("oberbuerg") + || n.contains("präsident") + || n.contains("president") + { + return 3; + } + if n.contains("bürgermeister") + || n.contains("buergermeister") + || n.contains("mayor") + || n.contains("landrat") + || n.contains("regional") + { + return 2; + } + if !n.is_empty() { + return 1; + } + 0 +} + +fn max_political_rank_from_names(agg: &str) -> i32 { + if agg.is_empty() { + return 0; + } + agg.split('|') + .map(|s| political_name_to_rank(s.trim())) + .max() + .unwrap_or(0) +} + +fn reputation_points_from_rep(rep: f64) -> i32 { + if rep >= 90.0 { + 5 + } else if rep >= 75.0 { + 4 + } else if rep >= 60.0 { + 3 + } else if rep >= 40.0 { + 2 + } else if rep >= 20.0 { + 1 + } else { + 0 + } +} + +fn house_points_from_position(position: i32) -> i32 { + if position >= 10 { + 5 + } else if position >= 8 { + 4 + } else if position >= 6 { + 3 + } else if position >= 4 { + 2 + } else if position >= 2 { + 1 + } else { + 0 + } +} + +fn raw_target_from_score(score: f64) -> i32 { + if score >= 4.0 { + 5 + } else if score >= 3.0 { + 4 + } else if score >= 2.1 { + 3 + } else if score >= 1.2 { + 2 + } else { + 1 + } +} + +fn status_one( + office_points: i32, + nobility_points: i32, + reputation_points: i32, + house_points: i32, +) -> bool { + office_points >= 1 + || nobility_points >= 1 + || reputation_points >= 2 + || house_points >= 1 +} + +fn status_count_cert4( + office_points: i32, + nobility_points: i32, + reputation_points: i32, + house_points: i32, +) -> i32 { + let mut c = 0; + if office_points >= 1 { + c += 1; + } + if nobility_points >= 1 { + c += 1; + } + if reputation_points >= 2 { + c += 1; + } + if house_points >= 1 { + c += 1; + } + c +} + +fn cert5_extra_two( + office_points: i32, + nobility_points: i32, + house_points: i32, +) -> i32 { + let mut c = 0; + if office_points >= 2 { + c += 1; + } + if nobility_points >= 2 { + c += 1; + } + if house_points >= 2 { + c += 1; + } + c +} + +fn meets_min_for_level( + level: i32, + avg_knowledge: f64, + completed: i64, + office_points: i32, + nobility_points: i32, + reputation_points: i32, + house_points: i32, +) -> bool { + match level { + 1 => true, + 2 => avg_knowledge >= 25.0 && completed >= 5, + 3 => { + avg_knowledge >= 40.0 + && completed >= 20 + && status_one( + office_points, + nobility_points, + reputation_points, + house_points, + ) + } + 4 => { + avg_knowledge >= 55.0 + && completed >= 60 + && status_count_cert4( + office_points, + nobility_points, + reputation_points, + house_points, + ) >= 2 + } + 5 => { + avg_knowledge >= 70.0 + && completed >= 150 + && reputation_points >= 3 + && cert5_extra_two(office_points, nobility_points, house_points) >= 2 + } + _ => false, + } +} + +/// Höchste Stufe ≤ `raw_target`, die alle Mindestanforderungen erfüllt. +fn effective_certificate_target( + raw_target: i32, + avg_knowledge: f64, + completed: i64, + office_points: i32, + nobility_points: i32, + reputation_points: i32, + house_points: i32, +) -> i32 { + let cap = raw_target.clamp(1, 5); + for lvl in (1..=cap).rev() { + if meets_min_for_level( + lvl, + avg_knowledge, + completed, + office_points, + nobility_points, + reputation_points, + house_points, + ) { + return lvl; + } + } + 1 +} + +impl Worker for FalukantCertificateWorker { + 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| { + FalukantCertificateWorker::run_loop(pool.clone(), broker.clone(), state); + }); + } + + fn stop_worker_thread(&mut self) { + self.base.stop_worker(); + } + + fn enable_watchdog(&mut self) { + self.base.start_watchdog(); + } +} diff --git a/src/worker/falukant_family.rs b/src/worker/falukant_family.rs index af6ed70..1b8c8e8 100644 --- a/src/worker/falukant_family.rs +++ b/src/worker/falukant_family.rs @@ -1,6 +1,7 @@ //! Liebhaber, Ehezufriedenheit, Ansehen, Monatskosten (Handoff: docs/FALUKANT_DAEMON_HANDOFF.md). //! Benötigt `migrations/001_falukant_family_lovers.sql` (ggf. `002` bei Altbestand), `003` für Ehe-Buffs, -//! optional `004` + Backend-Stammdaten für Dienerschaft (`falukant_servants`). +//! optional `004` + Backend-Stammdaten für Dienerschaft (`falukant_servants`), +//! `005` Ehe öffentliche Stabilität + Hausfrieden (`household_tension_score`). //! //! WebSocket: `falukantUpdateFamily` (reason) + `falukantUpdateStatus` für betroffene Nutzer. @@ -15,14 +16,16 @@ use rand::SeedableRng; use super::base::{BaseWorker, Worker, WorkerState}; use super::sql::{ - QUERY_FAMILY_SCHEMA_READY, QUERY_GET_ACTIVE_LOVER_ROWS_FOR_DAILY, - QUERY_GET_ACTIVE_LOVER_ROWS_FOR_MONTHLY, QUERY_GET_LOVER_PREGNANCY_CANDIDATES, - QUERY_GET_MARRIAGE_ROWS, QUERY_INSERT_CHILD, QUERY_INSERT_CHILD_RELATION_LOVER, + QUERY_COUNT_LOVER_CHILDREN_FOR_USER, QUERY_FAMILY_SCHEMA_READY, + QUERY_GET_ACTIVE_LOVER_ROWS_FOR_DAILY, QUERY_GET_ACTIVE_LOVER_ROWS_FOR_MONTHLY, + QUERY_GET_LOVER_PREGNANCY_CANDIDATES, QUERY_GET_MARRIAGE_ROWS, + QUERY_GET_USER_HOUSE_ROW_BY_USER, QUERY_INSERT_CHILD, QUERY_INSERT_CHILD_RELATION_LOVER, QUERY_LOVER_BIRTH_PENALTY_MARRIAGE, QUERY_LOVER_BIRTH_PENALTY_REPUTATION, QUERY_MARK_LOVER_DAILY_DONE, QUERY_MARK_LOVER_MONTHLY_DONE, QUERY_MARRIAGE_SUBTRACT_SATISFACTION, QUERY_RESET_LOVER_UNDERPAY_COUNTERS, QUERY_UPDATE_CHARACTER_REPUTATION, QUERY_UPDATE_LOVER_UNDERPAY_STATE, QUERY_UPDATE_LOVER_VISIBILITY_DISCRETION, QUERY_UPDATE_MARRIAGE_STATE_AND_BUFFS, + QUERY_UPDATE_USER_HOUSE_TENSION_BY_USER, }; use crate::db::{ConnectionPool, DbError}; use crate::message_broker::MessageBroker; @@ -137,6 +140,7 @@ impl FalukantFamilyWorker { m1: parse_i32(&r, "m1", -1), m2: parse_i32(&r, "m2", -1), satisfaction: parse_i32(&r, "marriage_satisfaction", 55), + public_stability: parse_i32(&r, "marriage_public_stability", 55), drift_high: parse_i32(&r, "marriage_drift_high", 0), drift_low: parse_i32(&r, "marriage_drift_low", 0), title1_tr: r.get("title1_tr").cloned().unwrap_or_default(), @@ -147,6 +151,10 @@ impl FalukantFamilyWorker { feast_pending: parse_i32(&r, "marriage_pending_feast_bonus", 0), house_supply: parse_i32(&r, "marriage_house_supply", 50), no_lover_counter: parse_i32(&r, "marriage_no_lover_bonus_counter", 0), + household_order_1: parse_i32(&r, "household_order_1", 55), + household_order_2: parse_i32(&r, "household_order_2", 55), + servant_quality_1: parse_i32(&r, "servant_quality_1", 50), + servant_quality_2: parse_i32(&r, "servant_quality_2", 50), }) }) .filter(|m| m.id > 0) @@ -166,6 +174,8 @@ impl FalukantFamilyWorker { maintenance_level: parse_i32(&r, "maintenance_level", 50), status_fit: parse_i32(&r, "status_fit", 0), scandal_extra: parse_i32(&r, "scandal_extra_daily_pct", 0), + months_underfunded: parse_i32(&r, "months_underfunded", 0), + acknowledged: parse_bool_row(&r, "acknowledged"), title1_tr: r.get("title1_tr").cloned().unwrap_or_default(), title2_tr: r.get("title2_tr").cloned().unwrap_or_default(), user1_id: parse_opt_i32(&r, "user1_id"), @@ -267,6 +277,7 @@ impl FalukantFamilyWorker { let touching_empty = touching.is_empty(); let sat0 = m.satisfaction; + let pst0 = m.public_stability; let dh0 = m.drift_high; let dl0 = m.drift_low; let g0 = m.gift_days; @@ -346,12 +357,58 @@ impl FalukantFamilyWorker { m.feast_pending = feast; m.no_lover_counter = nl_counter; + let mut pst = m.public_stability; + if !touching_empty { + let max_vis = touching.iter().map(|l| l.visibility).max().unwrap_or(0); + pst -= (max_vis / 25).min(4); + if touching.iter().any(|l| l.visibility >= 60) { + pst -= 2; + } + if touching.iter().any(|l| l.months_underfunded >= 1) { + pst -= 1; + } + if touching.iter().any(|l| l.months_underfunded >= 3) { + pst -= 1; + } + if touching.iter().any(|l| l.acknowledged) { + pst -= 2; + } + let g1 = title_group(&m.title1_tr); + let g2 = title_group(&m.title2_tr); + if (g1 as i32 - g2 as i32).abs() >= 2 { + pst -= 1; + } + if touching.iter().any(|l| l.min_age_years <= 15) { + pst -= 2; + } + let ho_avg = (m.household_order_1 + m.household_order_2) / 2; + if ho_avg < 35 { + pst -= 2; + } else if ho_avg < 50 { + pst -= 1; + } + let sq_avg = (m.servant_quality_1 + m.servant_quality_2) / 2; + if sq_avg < 40 { + pst -= 1; + } + if sat < 40 { + pst -= 1; + } + } else if pst < 55 { + pst += 1; + } else if pst > 55 { + pst -= 1; + } + pst = clamp_i32(pst, 0, 100); + m.public_stability = pst; + conn.execute( "upd_marriage_full", - &[&sat, &dh, &dl, &gift_days, &feast, &nl_counter, &m.id], + &[&sat, &dh, &dl, &gift_days, &feast, &nl_counter, &pst, &m.id], )?; if sat != sat0 + || pst != pst0 || dh != dh0 || dl != dl0 || gift_days != g0 @@ -363,6 +420,71 @@ impl FalukantFamilyWorker { } } + // Hausfrieden: Spannungswert 0..100 persistieren (Handoff Ehe & Hausfrieden) + let mut tension_users: HashSet = HashSet::new(); + for l in &lovers { + push_user_id(&mut tension_users, l.user1_id); + push_user_id(&mut tension_users, l.user2_id); + } + for m in &marriages { + push_user_id(&mut tension_users, m.user1_id); + push_user_id(&mut tension_users, m.user2_id); + } + + let mut tension_agg: HashMap = HashMap::new(); + for uid in &tension_users { + tension_agg.insert(*uid, UserTensionAgg::default()); + } + for l in &lovers { + for uid in [l.user1_id, l.user2_id].into_iter().flatten() { + if let Some(a) = tension_agg.get_mut(&uid) { + a.max_lover_visibility = a.max_lover_visibility.max(l.visibility); + if l.acknowledged { + a.n_acknowledged += 1; + } + a.max_months_underfunded = a.max_months_underfunded.max(l.months_underfunded); + } + } + } + for m in &marriages { + for uid in [m.user1_id, m.user2_id].into_iter().flatten() { + if let Some(a) = tension_agg.get_mut(&uid) { + a.min_marriage_sat = a.min_marriage_sat.min(m.satisfaction); + } + } + } + + conn.prepare("upd_tension", QUERY_UPDATE_USER_HOUSE_TENSION_BY_USER)?; + conn.prepare("child_cnt_lover", QUERY_COUNT_LOVER_CHILDREN_FOR_USER)?; + conn.prepare("uh_row_u", QUERY_GET_USER_HOUSE_ROW_BY_USER)?; + + let mut tension_socket_users: HashSet = HashSet::new(); + for (&uid, agg) in &tension_agg { + let child_rows = conn.execute("child_cnt_lover", &[&uid])?; + let lover_children = child_rows + .first() + .map(|r| parse_i32(r, "cnt", 0)) + .unwrap_or(0); + + let house_rows = conn.execute("uh_row_u", &[&uid])?; + let Some(hr) = house_rows.first() else { + continue; + }; + let ho = parse_i32(hr, "household_order", 55); + let sq = parse_i32(hr, "servant_quality", 50); + let pay = hr + .get("servant_pay_level") + .cloned() + .unwrap_or_else(|| "normal".into()); + let prev_ts = parse_i32(hr, "prev_tension_score", 0); + + let score = compute_household_tension_score(agg, lover_children, ho, sq, pay.as_str()); + if score != prev_ts { + conn.execute("upd_tension", &[&score, &uid])?; + tension_socket_users.insert(uid); + } + } + conn.prepare("upd_rep", QUERY_UPDATE_CHARACTER_REPUTATION)?; for l in &lovers { let g = pair_rank_group(&l.title1_tr, &l.title2_tr); @@ -497,6 +619,7 @@ impl FalukantFamilyWorker { push_user_id(&mut notify, char_user.get(cid).copied().flatten()); } notify.extend(marriage_socket_users); + notify.extend(tension_socket_users); self.publish_falukant_update_family_batch(¬ify, "daily"); Ok(()) @@ -766,6 +889,8 @@ struct MarriageData { m1: i32, m2: i32, satisfaction: i32, + /// Öffentliche Ehe-Stabilität (Daemon-Drift). + public_stability: i32, drift_high: i32, drift_low: i32, title1_tr: String, @@ -780,6 +905,10 @@ struct MarriageData { house_supply: i32, /// Tageszähler für Haus-Bonus (0..4). no_lover_counter: i32, + household_order_1: i32, + household_order_2: i32, + servant_quality_1: i32, + servant_quality_2: i32, } struct LoverData { @@ -793,6 +922,8 @@ struct LoverData { maintenance_level: i32, status_fit: i32, scandal_extra: i32, + months_underfunded: i32, + acknowledged: bool, title1_tr: String, title2_tr: String, user1_id: Option, @@ -804,6 +935,27 @@ struct LoverData { servant_disc_u2: i32, } +/// Aggregation für `household_tension_score` (0..100) pro Falukant-User. +#[derive(Clone)] +struct UserTensionAgg { + max_lover_visibility: i32, + n_acknowledged: i32, + max_months_underfunded: i32, + /// Mindest-Ehezufriedenheit aller Ehen dieses Users (100 = keine belastende Ehe). + min_marriage_sat: i32, +} + +impl Default for UserTensionAgg { + fn default() -> Self { + Self { + max_lover_visibility: 0, + n_acknowledged: 0, + max_months_underfunded: 0, + min_marriage_sat: 100, + } + } +} + fn push_user_id(set: &mut HashSet, uid: Option) { if let Some(id) = uid.filter(|x| *x > 0) { set.insert(id); @@ -820,10 +972,50 @@ fn parse_opt_i32(row: &crate::db::Row, key: &str) -> Option { row.get(key).and_then(|v| v.parse::().ok()) } +fn parse_bool_row(row: &crate::db::Row, key: &str) -> bool { + row.get(key) + .map(|v| { + matches!( + v.as_str(), + "t" | "true" | "True" | "1" | "TRUE" + ) + }) + .unwrap_or(false) +} + fn clamp_i32(v: i32, lo: i32, hi: i32) -> i32 { v.max(lo).min(hi) } +/// Hausfrieden 0..100 (persistiert als `user_house.household_tension_score`). +fn compute_household_tension_score( + agg: &UserTensionAgg, + lover_children: i32, + household_order: i32, + servant_quality: i32, + servant_pay: &str, +) -> i32 { + let mut s: f64 = 0.0; + s += agg.max_lover_visibility as f64 * 0.35; + s += agg.n_acknowledged as f64 * 12.0; + s += agg.max_months_underfunded as f64 * 8.0; + s += lover_children as f64 * 10.0; + s += (100 - household_order.clamp(0, 100)) as f64 * 0.22; + if servant_pay == "low" { + s += 6.0; + } + s += (50 - servant_quality.min(50)).max(0) as f64 * 0.35; + if agg.min_marriage_sat < 100 { + if agg.min_marriage_sat < 40 { + s += 18.0; + } else if agg.min_marriage_sat < 55 { + s += 8.0; + } + } + s = s.clamp(0.0, 100.0); + s.round() as i32 +} + fn title_group(tr: &str) -> u8 { match tr { "noncivil" | "civil" | "sir" => 0, diff --git a/src/worker/mod.rs b/src/worker/mod.rs index 7f8973b..3ac59b4 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_certificate; mod falukant_servants; mod sql; @@ -30,4 +31,5 @@ pub use transport::TransportWorker; pub use weather::WeatherWorker; pub use events::EventsWorker; pub use falukant_family::FalukantFamilyWorker; +pub use falukant_certificate::FalukantCertificateWorker; diff --git a/src/worker/sql.rs b/src/worker/sql.rs index 97414f7..a06f96e 100644 --- a/src/worker/sql.rs +++ b/src/worker/sql.rs @@ -2162,6 +2162,7 @@ pub const QUERY_GET_ACTIVE_LOVER_ROWS_FOR_DAILY: &str = r#" rs.monthly_base_cost, rs.scandal_extra_daily_pct, rs.months_underfunded, + COALESCE(rs.acknowledged, false) AS acknowledged, c1.gender AS g1, c2.gender AS g2, COALESCE(t1.tr, '') AS title1_tr, @@ -2267,6 +2268,7 @@ pub const QUERY_GET_MARRIAGE_ROWS: &str = r#" r.character1_id AS m1, r.character2_id AS m2, r.marriage_satisfaction, + COALESCE(r.marriage_public_stability, 55)::int AS marriage_public_stability, r.marriage_drift_high, r.marriage_drift_low, COALESCE(t1.tr, '') AS title1_tr, @@ -2276,7 +2278,23 @@ pub const QUERY_GET_MARRIAGE_ROWS: &str = r#" COALESCE(r.marriage_gift_buff_days_remaining, 0)::int AS marriage_gift_buff_days_remaining, COALESCE(r.marriage_pending_feast_bonus, 0)::int AS marriage_pending_feast_bonus, COALESCE(r.marriage_house_supply, 50)::int AS marriage_house_supply, - COALESCE(r.marriage_no_lover_bonus_counter, 0)::int AS marriage_no_lover_bonus_counter + COALESCE(r.marriage_no_lover_bonus_counter, 0)::int AS marriage_no_lover_bonus_counter, + COALESCE(( + SELECT uh.household_order FROM falukant_data.user_house uh + WHERE uh.user_id = c1.user_id ORDER BY uh.id LIMIT 1 + ), 55)::int AS household_order_1, + COALESCE(( + SELECT uh.household_order FROM falukant_data.user_house uh + WHERE uh.user_id = c2.user_id ORDER BY uh.id LIMIT 1 + ), 55)::int AS household_order_2, + COALESCE(( + SELECT uh.servant_quality FROM falukant_data.user_house uh + WHERE uh.user_id = c1.user_id ORDER BY uh.id LIMIT 1 + ), 50)::int AS servant_quality_1, + COALESCE(( + SELECT uh.servant_quality FROM falukant_data.user_house uh + WHERE uh.user_id = c2.user_id ORDER BY uh.id LIMIT 1 + ), 50)::int AS servant_quality_2 FROM falukant_data.relationship r JOIN falukant_type.relationship rt ON rt.id = r.relationship_type_id AND rt.tr IN ('married', 'engaged', 'wooing') @@ -2298,6 +2316,7 @@ pub const QUERY_UPDATE_MARRIAGE_STATE: &str = r#" "#; /// Inkl. Geschenk-/Fest-/Haus-Zähler (Migration `003_falukant_family_marriage_buffs.sql`). +/// `marriage_public_stability`: Migration `005_falukant_marriage_housepeace.sql`. pub const QUERY_UPDATE_MARRIAGE_STATE_AND_BUFFS: &str = r#" UPDATE falukant_data.relationship SET marriage_satisfaction = $1::smallint, @@ -2305,8 +2324,48 @@ pub const QUERY_UPDATE_MARRIAGE_STATE_AND_BUFFS: &str = r#" marriage_drift_low = $3::smallint, marriage_gift_buff_days_remaining = $4::smallint, marriage_pending_feast_bonus = $5::smallint, - marriage_no_lover_bonus_counter = $6::smallint - WHERE id = $7::int; + marriage_no_lover_bonus_counter = $6::smallint, + marriage_public_stability = $7::smallint + WHERE id = $8::int; +"#; + +/// Persistiert berechneten Hausfrieden (0..100), Migration `005_falukant_marriage_housepeace.sql`. +pub const QUERY_UPDATE_USER_HOUSE_TENSION_BY_USER: &str = r#" + UPDATE falukant_data.user_house + SET household_tension_score = $1::smallint + WHERE user_id = $2::int; +"#; + +/// Kinder aus Liebschaften, die einen Charakter dieses Falukant-Users betreffen. +pub const QUERY_COUNT_LOVER_CHILDREN_FOR_USER: &str = r#" + SELECT COUNT(*)::int AS cnt + FROM falukant_data.child_relation cr + WHERE cr.birth_context = 'lover' + AND ( + EXISTS ( + SELECT 1 FROM falukant_data.character c + WHERE c.id = cr.father_character_id AND c.user_id = $1::int + ) + OR EXISTS ( + SELECT 1 FROM falukant_data.character c + WHERE c.id = cr.mother_character_id AND c.user_id = $1::int + ) + ); +"#; + +/// Haushalt für Spannungsberechnung (ein Eintrag pro Zeile; typisch eine Zeile pro User). +pub const QUERY_GET_USER_HOUSE_ROW_BY_USER: &str = r#" + SELECT uh.id AS user_house_id, + uh.user_id AS falukant_user_id, + 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, + COALESCE(uh.household_tension_score, 0)::int AS prev_tension_score + FROM falukant_data.user_house uh + WHERE uh.user_id = $1::int + ORDER BY uh.id + LIMIT 1; "#; pub const QUERY_MARRIAGE_SUBTRACT_SATISFACTION: &str = r#" @@ -2438,3 +2497,57 @@ pub const QUERY_INSERT_CHILD_RELATION_LOVER: &str = r#" ); "#; +// --- Produktionszertifikat (Daemon Daily, Spec: Produktionszertifikate) --- + +/// Ein Spielercharakter pro Falukant-User (niedrigste character.id bei mehreren lebenden). +pub const QUERY_GET_PRODUCTION_CERTIFICATE_INPUT_ROWS: &str = r#" + SELECT DISTINCT ON (fu.id) + fu.id AS falukant_user_id, + COALESCE(fu.certificate, 1)::int AS certificate, + COALESCE(fu.money, 0)::float8 AS money, + c.id AS character_id, + COALESCE(c.reputation, 50)::float8 AS reputation, + COALESCE(t.level, 0)::int AS title_level, + COALESCE(( + SELECT AVG(k.knowledge)::float8 + FROM falukant_data.knowledge k + WHERE k.character_id = c.id + ), 0.0) AS avg_knowledge, + COALESCE(( + SELECT COUNT(*)::bigint + FROM falukant_log.production pl + WHERE pl.producer_id = fu.id + ), 0) AS completed_production_count, + COALESCE(( + SELECT MAX(cot.hierarchy_level)::int + FROM falukant_data.church_office co + JOIN falukant_type.church_office_type cot ON cot.id = co.office_type_id + WHERE co.character_id = c.id + ), 0) AS max_church_hierarchy, + COALESCE(( + SELECT STRING_AGG(DISTINCT pot.name, '|') + FROM falukant_data.political_office po + JOIN falukant_type.political_office_type pot ON pot.id = po.office_type_id + WHERE po.character_id = c.id + AND (po.created_at + (pot.term_length * INTERVAL '1 day')) > NOW() + ), '') AS political_office_names, + COALESCE(( + SELECT h.position::int + FROM falukant_data.user_house uh + JOIN falukant_type.house h ON h.id = uh.house_type_id + WHERE uh.user_id = fu.id + ORDER BY uh.id + LIMIT 1 + ), 0) AS house_position + FROM falukant_data.falukant_user fu + 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 + ORDER BY fu.id, c.id; +"#; + +pub const QUERY_UPDATE_FALUKANT_USER_CERTIFICATE: &str = r#" + UPDATE falukant_data.falukant_user + SET certificate = $1::int + WHERE id = $2::int; +"#; +