From ac5ec3a2453803f2a2e71b1b37267fd04b8b1b75 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 20 Mar 2026 11:02:28 +0100 Subject: [PATCH] Enhance Falukant family dynamics: Updated the FalukantFamilyWorker to incorporate marriage buffs and detailed age rules for relationships. Added new WebSocket events for real-time updates and expanded SQL queries to support marriage state and buff management, improving overall family interaction and satisfaction tracking. --- docs/FALUKANT_DAEMON_HANDOFF.md | 45 +++ docs/FALUKANT_UI_WEBSOCKET.md | 160 ++++++++ .../003_falukant_family_marriage_buffs.sql | 17 + src/worker/falukant_family.rs | 350 +++++++++++++++--- src/worker/sql.rs | 37 +- 5 files changed, 547 insertions(+), 62 deletions(-) create mode 100644 docs/FALUKANT_UI_WEBSOCKET.md create mode 100644 migrations/003_falukant_family_marriage_buffs.sql diff --git a/docs/FALUKANT_DAEMON_HANDOFF.md b/docs/FALUKANT_DAEMON_HANDOFF.md index a13fde9..6a422ff 100644 --- a/docs/FALUKANT_DAEMON_HANDOFF.md +++ b/docs/FALUKANT_DAEMON_HANDOFF.md @@ -17,7 +17,52 @@ Technische Abstimmung mit dem Übergabedokument im Backend-Projekt (`FALUKANT_LO **Hinweis:** Der Worker nutzt weiterhin **Wandzeit** (24 h / 30 Tage) als Intervall; die Idempotenz über die Zeitstempel verhindert Doppelverarbeitung bei Neustarts am selben Tag/Monat. +## WebSocket-Events (UI) + +Pro betroffener `falukant_user.id` werden über den **MessageBroker** (Broadcast an alle WS-Clients) gesendet: + +| Event | Payload (Beispiel) | Wann | +|-------|-------------------|------| +| `falukantUpdateFamily` | `{"event":"falukantUpdateFamily","user_id":N,"reason":"…"}` | Familie/Liebe relevant | +| `falukantUpdateStatus` | `{"event":"falukantUpdateStatus","user_id":N}` | Immer gleich mit `falukantUpdateFamily` (Refresh) | +| `children_update` | `{"event":"children_update","user_id":N}` | Kind aus Liebschaft | +| `falukant_family_scandal_hint` | `{"event":"falukant_family_scandal_hint","relationship_id":…}` | Skandal (ohne `user_id`) | + +**`reason`** bei `falukantUpdateFamily`: `daily`, `monthly`, `scandal`, `lover_birth`. + +Die UI kann auf `falukantUpdateFamily` filtern und nach `reason` unterscheiden; `falukantUpdateStatus` wie bisher für allgemeinen Daten-Refresh nutzen. + +**Detaillierte UI-Anleitung (Payloads, Handler, Checkliste):** [`FALUKANT_UI_WEBSOCKET.md`](./FALUKANT_UI_WEBSOCKET.md) + +## Altersregeln (Spec-Erweiterung, im Daemon umgesetzt) + +`min_age_years` = jüngeres Alter beider Partner in **ganzen Jahren** (`LEAST(…/365, …/365)` aus `birthdate`). + +| Bereich | Ansehen (zusätzl. zur Basisformel, Spec 5a) | Ehezufriedenheit (Spec §3 neg.) | Skandalrisiko (+ %, exkl. Stufen) | +|---------|---------------------------------------------|----------------------------------|-----------------------------------| +| ≤ 13 | −1,5 / Tag | −1 / Tag bei aktiver Berührung | +6 % | +| ≤ 15 | −0,8 / Tag | −1 / Tag (zusätzlich zu anderen Malus) | +3 % | +| ≤ 17 | −0,3 / Tag | — | +1 % | +| ≥ 18 | 0 | — | 0 | + +Zusätzlich: wenn `min_age_years ≤ 15` **und** `visibility ≥ 50`: weiterer Ansehens-Malus **−0,5** / Tag. + +Ehe-Malus „≤ 15“ gilt pro Ehe, wenn **irgendeine** berührende Liebschaft dieses Altersprofils hat. + ## Migrationen 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`. + +### Ehe-Buffs (Daemon) + +- **Geschenk:** Backend setzt `marriage_gift_buff_days_remaining` (z. B. 5); Daemon **+1** Zufriedenheit/Tag, Zähler −1. +- **Fest:** `marriage_pending_feast_bonus` einmalig (typ. +2…+5, max. 5 pro Tick) beim Daily verbrauchen. +- **Haus:** `marriage_house_supply ≥ 65` und **keine** berührende Liebschaft: alle **4** Tage **+1** Zufriedenheit (`marriage_no_lover_bonus_counter`). + +### Ansehen (Daily) + +- **Ranggruppe 3:** Reputations-Multiplikator **0,7** nur bei „geordneter“ Liebschaft (`order_ok`: u. a. Unterhalt ≥ 65, Diskretion ≥ 60, Sichtbarkeit ≤ 35, höchstens eine Mätresse im Umfeld des Paares); sonst **1,0**; bei Skandal **1,5**. +- **Zwei sichtbare Liebschaften** (`visibility ≥ 60`, mindestens zwei Beziehungen pro Charakter): zusätzlich **−4** Ansehen (einmal pro Person/Tag). +- **Zufall:** selten **Gerücht −3** oder **Tadel −5** (niedrige Wahrscheinlichkeit) für Charaktere mit Liebschaftsbezug. diff --git a/docs/FALUKANT_UI_WEBSOCKET.md b/docs/FALUKANT_UI_WEBSOCKET.md new file mode 100644 index 0000000..94e9531 --- /dev/null +++ b/docs/FALUKANT_UI_WEBSOCKET.md @@ -0,0 +1,160 @@ +# Falukant: UI-Anpassung – WebSocket & Familie / Liebschaften + +Dieses Dokument beschreibt die **Nachrichten**, die der **YpDaemon** (`FalukantFamilyWorker`) über den WebSocket-Broadcast sendet, damit die **UI gezielt** reagieren kann (Refresh, Toasts, Family-Ansicht). + +> **Transport:** Alle Clients erhalten denselben Broadcast. Die UI sollte Nachrichten **nach `user_id` filtern** (nur Events anzeigen/verarbeiten, die zur eingeloggten Session passen). + +--- + +## 1. Übersicht der Events + +| `event` | Pflichtfelder | Typische UI-Reaktion | +|---------|----------------|----------------------| +| `falukantUpdateFamily` | `user_id`, `reason` | Gezielter Refresh Familie/Liebe/Geld je nach `reason` | +| `falukantUpdateStatus` | `user_id` | Allgemeiner Status-/Spielstand-Refresh (wie bisher) | +| `children_update` | `user_id` | Kinderliste / FamilyView aktualisieren | +| `falukant_family_scandal_hint` | `relationship_id` | Optional: Toast, Log – **kein** `user_id` (siehe unten) | + +--- + +## 2. JSON-Payloads (exakt) + +### 2.1 `falukantUpdateFamily` + +```json +{ + "event": "falukantUpdateFamily", + "user_id": 123, + "reason": "daily" +} +``` + +**`reason`** ist immer einer der folgenden **festen** Strings: + +| `reason` | Bedeutung (Daemon) | Empfehlung UI | +|----------|---------------------|---------------| +| `daily` | Daily-Tick: Liebschafts-/Ehe-/Ansehens-Logik für den Tag | Family-API + ggf. Charakter/Ansehen 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 | + +### 2.2 `falukantUpdateStatus` + +```json +{ + "event": "falukantUpdateStatus", + "user_id": 123 +} +``` + +Kommt **typischerweise direkt nach** `falukantUpdateFamily` mit derselben `user_id` (gemeinsamer Refresh). + +### 2.3 `children_update` + +```json +{ + "event": "children_update", + "user_id": 123 +} +``` + +Tritt bei **Geburt aus Liebschaft** auf; oft zusammen mit `falukantUpdateFamily` (`reason: lover_birth`) und `falukantUpdateStatus`. + +### 2.4 `falukant_family_scandal_hint` + +```json +{ + "event": "falukant_family_scandal_hint", + "relationship_id": 456 +} +``` + +- **Kein** `user_id` – Betroffene erkennst du nur über die **Beziehung** (Backend: `relationship.id` → Charaktere laden) oder du ignorierst das Event und verlässt dich auf `falukantUpdateFamily` mit `reason: scandal` für deine Nutzer. + +--- + +## 3. Empfohlene Handler-Logik (Pseudo) + +```text +onMessage(json): + if json.user_id != currentUserId: return // Broadcast filtern + + switch json.event: + case "falukantUpdateStatus": + refreshPlayerStatus() // bestehend + return + + case "children_update": + refreshChildrenAndFamilyView() + return + + case "falukantUpdateFamily": + switch json.reason: + case "daily": + refreshFamilyAndRelationships() + refreshCharactersReputationIfNeeded() + break + case "monthly": + refreshMoney() + refreshFamilyAndRelationships() + break + case "scandal": + showScandalToastOptional() + refreshFamilyAndRelationships() + break + case "lover_birth": + refreshChildrenAndFamilyView() + break + return + + case "falukant_family_scandal_hint": + // optional: relationship_id → Detail-Modal / Log + return +``` + +**Hinweis:** Am selben Tag kann ein Nutzer **`scandal`** und danach **`daily`** erhalten – UI kann **deduplizieren** (z. B. nur ein voller Refresh) oder beide verarbeiten (idempotente API-Calls). + +--- + +## 4. Welche Backend-Daten neu laden? + +| Situation | Sinnvolle Endpunkte / Daten (konzeptionell) | +|-----------|---------------------------------------------| +| Jede `falukantUpdateFamily` | Family-/Relationship-API mit `relationship_state`, Ehe (`married`/`engaged`/`wooing`) | +| `reason: monthly` | **Geld** des Users, ggf. Kredit/Log | +| `reason: daily` / `scandal` | Ansehen (`character.reputation`), Sichtbarkeit/Diskretion der Liebschaften | +| `children_update` / `lover_birth` | `child_relation` inkl. `legitimacy`, `birth_context`, `public_known` | + +Konkrete Routen stehen im **YourPart3**-Backend; das Frontend sollte eine zentrale Funktion `refreshFamilyContext(userId)` kapseln. + +--- + +## 5. Sonderfälle + +| Fall | Verhalten | +|------|-----------| +| Charakter ohne `user_id` (NPC) | **Keine** Socket-Events für diesen Charakter – nur Spieler mit `falukant_user` erhalten `user_id`-Events. | +| Mehrere Events hintereinander | Normal; Requests sollten **idempotent** sein (mehrfaches Laden ok). | +| Nur `falukantUpdateStatus` ohne Family | Kann von **anderen** Workern kommen – nicht nur Familie. | + +--- + +## 6. Bezug zum Code (YpDaemon) + +- Worker: `src/worker/falukant_family.rs` +- SQL-Konstanten: `src/worker/sql.rs` (Abschnitt Falukant Familie) +- Schema: `migrations/001_falukant_family_lovers.sql` +- Daemon-Handoff (technisch): `docs/FALUKANT_DAEMON_HANDOFF.md` + +--- + +## 7. Checkliste UI-Integration + +- [ ] WebSocket-Handler: `user_id` mit Session abgleichen +- [ ] Auf `falukantUpdateFamily` reagieren und **`reason`** auswerten +- [ ] `falukantUpdateStatus` weiter nutzen (globaler Refresh) +- [ ] `children_update` + `lover_birth`: Kinder-Ansicht +- [ ] Optional: `falukant_family_scandal_hint` mit `relationship_id` +- [ ] Optional: Deduplizierung bei `scandal` + `daily` am selben Tag + +Damit kannst du die Oberfläche **gezielt** an die Daemon-Events anbinden, ohne jedes Mal den vollen Spielstand blind zu aktualisieren. diff --git a/migrations/003_falukant_family_marriage_buffs.sql b/migrations/003_falukant_family_marriage_buffs.sql new file mode 100644 index 0000000..a52ca3f --- /dev/null +++ b/migrations/003_falukant_family_marriage_buffs.sql @@ -0,0 +1,17 @@ +-- Ehe-Buffs (Spec: Geschenke, Fest, Hausversorgung ohne Liebschaft) + Backend-Steuerung +ALTER TABLE falukant_data.relationship + ADD COLUMN IF NOT EXISTS marriage_gift_buff_days_remaining smallint NOT NULL DEFAULT 0 + CHECK (marriage_gift_buff_days_remaining >= 0 AND marriage_gift_buff_days_remaining <= 30), + ADD COLUMN IF NOT EXISTS marriage_pending_feast_bonus smallint NOT NULL DEFAULT 0 + CHECK (marriage_pending_feast_bonus >= 0 AND marriage_pending_feast_bonus <= 20), + ADD COLUMN IF NOT EXISTS marriage_house_supply smallint NOT NULL DEFAULT 50 + CHECK (marriage_house_supply >= 0 AND marriage_house_supply <= 100), + ADD COLUMN IF NOT EXISTS marriage_no_lover_bonus_counter smallint NOT NULL DEFAULT 0 + CHECK (marriage_no_lover_bonus_counter >= 0 AND marriage_no_lover_bonus_counter < 4); + +COMMENT ON COLUMN falukant_data.relationship.marriage_gift_buff_days_remaining IS + 'Backend setzt z.B. 5 nach Geschenk: Daemon +1 Ehezufriedenheit/Tag, Zähler runter'; +COMMENT ON COLUMN falukant_data.relationship.marriage_pending_feast_bonus IS + 'Einmal +2..+5 beim nächsten Daily-Tick (Backend), danach 0'; +COMMENT ON COLUMN falukant_data.relationship.marriage_house_supply IS + 'Hausversorgung 0..100; ab ~65: +1 Ehezufriedenheit alle 4 Tage ohne aktive Liebschaft'; diff --git a/src/worker/falukant_family.rs b/src/worker/falukant_family.rs index cf31fc7..0a896ac 100644 --- a/src/worker/falukant_family.rs +++ b/src/worker/falukant_family.rs @@ -1,6 +1,9 @@ //! Liebhaber, Ehezufriedenheit, Ansehen, Monatskosten (Handoff: docs/FALUKANT_DAEMON_HANDOFF.md). -//! Benötigt `migrations/001_falukant_family_lovers.sql` (ggf. `002` bei Altbestand). +//! Benötigt `migrations/001_falukant_family_lovers.sql` (ggf. `002` bei Altbestand), `003` für Ehe-Buffs. +//! +//! WebSocket: `falukantUpdateFamily` (reason) + `falukantUpdateStatus` für betroffene Nutzer. +use std::collections::{HashMap, HashSet}; use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -18,7 +21,7 @@ use super::sql::{ 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, + QUERY_UPDATE_LOVER_VISIBILITY_DISCRETION, QUERY_UPDATE_MARRIAGE_STATE_AND_BUFFS, }; use crate::db::{ConnectionPool, DbError}; use crate::message_broker::MessageBroker; @@ -115,7 +118,7 @@ impl FalukantFamilyWorker { conn.prepare("get_marriages", QUERY_GET_MARRIAGE_ROWS)?; let marriage_rows = conn.execute("get_marriages", &[])?; - let marriages: Vec = marriage_rows + let mut marriages: Vec = marriage_rows .into_iter() .filter_map(|r| { Some(MarriageData { @@ -127,6 +130,12 @@ impl FalukantFamilyWorker { drift_low: parse_i32(&r, "marriage_drift_low", 0), title1_tr: r.get("title1_tr").cloned().unwrap_or_default(), title2_tr: r.get("title2_tr").cloned().unwrap_or_default(), + user1_id: parse_opt_i32(&r, "user1_id"), + user2_id: parse_opt_i32(&r, "user2_id"), + gift_days: parse_i32(&r, "marriage_gift_buff_days_remaining", 0), + feast_pending: parse_i32(&r, "marriage_pending_feast_bonus", 0), + house_supply: parse_i32(&r, "marriage_house_supply", 50), + no_lover_counter: parse_i32(&r, "marriage_no_lover_bonus_counter", 0), }) }) .filter(|m| m.id > 0) @@ -148,16 +157,31 @@ impl FalukantFamilyWorker { scandal_extra: parse_i32(&r, "scandal_extra_daily_pct", 0), title1_tr: r.get("title1_tr").cloned().unwrap_or_default(), title2_tr: r.get("title2_tr").cloned().unwrap_or_default(), + 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), }) }) .filter(|l| l.rel_id > 0) .collect(); + let mut char_user: HashMap> = HashMap::new(); + for l in &lovers { + char_user.insert(l.c1, l.user1_id); + char_user.insert(l.c2, l.user2_id); + } + let mut char_lover_count: std::collections::HashMap = std::collections::HashMap::new(); + let mut char_visible_lover_rel_count: std::collections::HashMap = + std::collections::HashMap::new(); for l in &lovers { *char_lover_count.entry(l.c1).or_insert(0) += 1; *char_lover_count.entry(l.c2).or_insert(0) += 1; + if l.visibility >= 60 { + *char_visible_lover_rel_count.entry(l.c1).or_insert(0) += 1; + *char_visible_lover_rel_count.entry(l.c2).or_insert(0) += 1; + } } conn.prepare("upd_vd", QUERY_UPDATE_LOVER_VISIBILITY_DISCRETION)?; @@ -212,7 +236,11 @@ impl FalukantFamilyWorker { conn.execute("mark_daily", &[&l.rel_id])?; } - for m in &marriages { + let mut marriage_socket_users: HashSet = HashSet::new(); + + conn.prepare("upd_marriage_full", QUERY_UPDATE_MARRIAGE_STATE_AND_BUFFS)?; + + for m in marriages.iter_mut() { let touching: Vec<&LoverData> = lovers .iter() .filter(|l| { @@ -222,10 +250,23 @@ impl FalukantFamilyWorker { }) .collect(); - if touching.is_empty() { - let mut sat = m.satisfaction; - let mut dh = m.drift_high; - let mut dl = m.drift_low; + let touching_empty = touching.is_empty(); + let sat0 = m.satisfaction; + let dh0 = m.drift_high; + let dl0 = m.drift_low; + let g0 = m.gift_days; + let f0 = m.feast_pending; + let n0 = m.no_lover_counter; + + let mut sat = m.satisfaction; + let mut dh = m.drift_high; + let mut dl = m.drift_low; + let mut gift_days = m.gift_days; + let mut feast = m.feast_pending; + let mut nl_counter = m.no_lover_counter; + let house_supply = m.house_supply; + + if touching_empty { if sat > 55 { dh += 1; if dh >= 3 { @@ -239,56 +280,83 @@ impl FalukantFamilyWorker { dl = 0; } } - conn.prepare("upd_marriage", QUERY_UPDATE_MARRIAGE_STATE)?; - conn.execute( - "upd_marriage", - &[&sat, &dh, &dl, &m.id], - )?; - continue; + } else { + let mg = marriage_rank_group(&m.title1_tr, &m.title2_tr); + let mistress_n = touching + .iter() + .filter(|l| l.lover_role == "mistress_or_favorite") + .count(); + let total_lovers_here = touching.len(); + let mut delta = 0i32; + for l in &touching { + delta += lover_marriage_daily_delta( + mg, + l, + m.satisfaction, + mistress_n, + total_lovers_here, + ); + } + if touching.iter().any(|l| l.visibility >= 60) { + delta -= 2; + } + if total_lovers_here >= 2 { + delta -= 2; + } + if touching + .iter() + .any(|l| l.lover_role == "mistress_or_favorite" && l.maintenance_level < 35) + { + delta -= 1; + } + if touching.iter().any(|l| l.min_age_years <= 15) { + delta -= 1; + } + sat = clamp_i32(sat + delta, 0, 100); } - let mg = marriage_rank_group(&m.title1_tr, &m.title2_tr); - let mistress_n = touching - .iter() - .filter(|l| l.lover_role == "mistress_or_favorite") - .count(); - let total_lovers_here = touching.len(); - let mut delta = 0i32; - for l in &touching { - delta += lover_marriage_daily_delta( - mg, - l, - m.satisfaction, - mistress_n, - total_lovers_here, - ); - } - if touching.iter().any(|l| l.visibility >= 60) { - delta -= 2; - } - if total_lovers_here >= 2 { - delta -= 2; - } - if touching - .iter() - .any(|l| l.lover_role == "mistress_or_favorite" && l.maintenance_level < 35) - { - delta -= 1; - } + Self::apply_marriage_positive_buffs( + &mut sat, + &mut gift_days, + &mut feast, + &mut nl_counter, + touching_empty, + house_supply, + ); + + m.satisfaction = sat; + m.drift_high = dh; + m.drift_low = dl; + m.gift_days = gift_days; + m.feast_pending = feast; + m.no_lover_counter = nl_counter; - let sat = clamp_i32(m.satisfaction + delta, 0, 100); - conn.prepare("upd_marriage", QUERY_UPDATE_MARRIAGE_STATE)?; conn.execute( - "upd_marriage", - &[&sat, &m.drift_high, &m.drift_low, &m.id], + "upd_marriage_full", + &[&sat, &dh, &dl, &gift_days, &feast, &nl_counter, &m.id], )?; + + if sat != sat0 + || dh != dh0 + || dl != dl0 + || gift_days != g0 + || feast != f0 + || nl_counter != n0 + { + push_user_id(&mut marriage_socket_users, m.user1_id); + push_user_id(&mut marriage_socket_users, m.user2_id); + } } conn.prepare("upd_rep", QUERY_UPDATE_CHARACTER_REPUTATION)?; for l in &lovers { let g = pair_rank_group(&l.title1_tr, &l.title2_tr); let scandal = l.visibility > 70; - let rm = rank_rep_modifier(g, scandal); + let order_ok = l.maintenance_level >= 65 + && l.discretion >= 60 + && l.visibility <= 35 + && mistress_count_for_pair(&lovers, l.c1, l.c2) <= 1; + let rm = rank_rep_modifier(g, scandal, order_ok); let vf = 0.4 + (l.visibility as f64 / 100.0) * 1.6; let base = match l.lover_role.as_str() { "secret_affair" => -0.2, @@ -298,10 +366,6 @@ impl FalukantFamilyWorker { }; let mut delta = base * vf * rm; - let order_ok = l.maintenance_level >= 65 - && l.discretion >= 60 - && l.visibility <= 35 - && mistress_count_for_pair(&lovers, l.c1, l.c2) <= 1; if l.lover_role == "mistress_or_favorite" && order_ok { if g == 2 { delta = 0.1; @@ -312,9 +376,13 @@ impl FalukantFamilyWorker { delta = delta.clamp(-3.0, 1.0); + let age_rep = age_reputation_delta(l.min_age_years); + let vis_young = visibility_young_penalty(l.min_age_years, l.visibility); + let final_delta = delta + age_rep + vis_young; + for cid in [l.c1, l.c2] { let cur = fetch_reputation(&mut conn, cid)?; - let new_rep = (cur + delta).clamp(0.0, 100.0); + let new_rep = (cur + final_delta).clamp(0.0, 100.0); let s = format!("{:.2}", new_rep); conn.execute("upd_rep", &[&s, &cid])?; } @@ -337,7 +405,8 @@ impl FalukantFamilyWorker { } else { 0.0 } - + l.scandal_extra as f64; + + l.scandal_extra as f64 + + scandal_age_extra_pct(l.min_age_years); if l.discretion >= 75 { p -= 2.0; } @@ -354,9 +423,67 @@ impl FalukantFamilyWorker { r#"{{"event":"falukant_family_scandal_hint","relationship_id":{}}}"#, l.rel_id )); + if let Some(uid) = l.user1_id.filter(|x| *x > 0) { + self.publish_falukant_update_family_and_status(uid, "scandal"); + } + if let Some(uid) = l.user2_id.filter(|x| *x > 0) { + self.publish_falukant_update_family_and_status(uid, "scandal"); + } } } + // Zwei sichtbare Liebschaften (visibility ≥ 60) pro Person: zusätzlich −4 Ansehen. + for (&cid, &n_vis) in &char_visible_lover_rel_count { + if n_vis < 2 { + continue; + } + let cur = fetch_reputation(&mut conn, cid)?; + let new_rep = (cur - 4.0).clamp(0.0, 100.0); + let s = format!("{:.2}", new_rep); + conn.execute("upd_rep", &[&s, &cid])?; + } + + // Seltene Einmal-Malus (Gerücht / Tadel) für beteiligte Charaktere. + let mut unique_cids: HashSet = HashSet::new(); + for l in &lovers { + unique_cids.insert(l.c1); + unique_cids.insert(l.c2); + } + let mut rng_malus_cids: Vec = Vec::new(); + for cid in unique_cids.iter().copied() { + let r = self.dist.sample(&mut self.rng) * 100.0; + let malus = if r < 1.0 { + -5.0 + } else if r < 3.0 { + -3.0 + } else { + 0.0 + }; + if malus < 0.0 { + let cur = fetch_reputation(&mut conn, cid)?; + let new_rep = (cur + malus).clamp(0.0, 100.0); + let s = format!("{:.2}", new_rep); + conn.execute("upd_rep", &[&s, &cid])?; + rng_malus_cids.push(cid); + } + } + + let mut notify: HashSet = HashSet::new(); + for l in &lovers { + push_user_id(&mut notify, l.user1_id); + push_user_id(&mut notify, l.user2_id); + } + for (&cid, &n_vis) in &char_visible_lover_rel_count { + if n_vis >= 2 { + push_user_id(&mut notify, char_user.get(&cid).copied().flatten()); + } + } + for cid in &rng_malus_cids { + push_user_id(&mut notify, char_user.get(cid).copied().flatten()); + } + notify.extend(marriage_socket_users); + self.publish_falukant_update_family_batch(¬ify, "daily"); + Ok(()) } @@ -375,6 +502,8 @@ impl FalukantFamilyWorker { conn.prepare("mar_sub", QUERY_MARRIAGE_SUBTRACT_SATISFACTION)?; conn.prepare("mark_monthly", QUERY_MARK_LOVER_MONTHLY_DONE)?; + let mut monthly_notify: HashSet = HashSet::new(); + for r in lover_rows { let rel_id = parse_i32(&r, "rel_id", -1); if rel_id < 0 { @@ -466,8 +595,13 @@ impl FalukantFamilyWorker { } conn.execute("mark_monthly", &[&rel_id])?; + + push_user_id(&mut monthly_notify, u1); + push_user_id(&mut monthly_notify, u2); } + self.publish_falukant_update_family_batch(&monthly_notify, "monthly"); + drop(conn); self.process_lover_births()?; Ok(()) @@ -558,9 +692,53 @@ impl FalukantFamilyWorker { let children_update = format!(r#"{{"event":"children_update","user_id":{}}}"#, user_id); self.base.broker.publish(children_update); - let update_status = - format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id); - self.base.broker.publish(update_status); + self.publish_falukant_update_family_and_status(user_id, "lover_birth"); + } + + /// `falukantUpdateFamily` (mit `reason`) + `falukantUpdateStatus` — für UI-Refresh. + fn publish_falukant_update_family_and_status(&self, user_id: i32, reason: &str) { + let family = format!( + r#"{{"event":"falukantUpdateFamily","user_id":{},"reason":"{}"}}"#, + user_id, reason + ); + self.base.broker.publish(family); + let status = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id); + self.base.broker.publish(status); + } + + fn publish_falukant_update_family_batch(&self, user_ids: &HashSet, reason: &str) { + for &uid in user_ids { + self.publish_falukant_update_family_and_status(uid, reason); + } + } + + /// Fest (einmal), Geschenk (−1 Tag, +1/Tag), Haus hoch & keine Liebschaft: alle 4 Tage +1. + fn apply_marriage_positive_buffs( + sat: &mut i32, + gift_days: &mut i32, + feast: &mut i32, + nl_counter: &mut i32, + touching_empty: bool, + house_supply: i32, + ) { + if *feast > 0 { + let bonus = (*feast).min(20).min(5); + *sat = (*sat + bonus).min(100); + *feast = 0; + } + if *gift_days > 0 { + *sat = (*sat + 1).min(100); + *gift_days -= 1; + } + if touching_empty && house_supply >= 65 { + *nl_counter += 1; + if *nl_counter >= 4 { + *sat = (*sat + 1).min(100); + *nl_counter = 0; + } + } else { + *nl_counter = 0; + } } } @@ -573,6 +751,16 @@ struct MarriageData { drift_low: i32, title1_tr: String, title2_tr: String, + user1_id: Option, + user2_id: Option, + /// Tage mit +1 Zufriedenheit (Geschenk), runtergezählt. + gift_days: i32, + /// Einmal-Bonus (z. B. Fest), wird beim nächsten Daily verbraucht. + feast_pending: i32, + /// Hausversorgung 0–100; ab ~65: Bonus alle 4 Tage ohne Liebschaft. + house_supply: i32, + /// Tageszähler für Haus-Bonus (0..4). + no_lover_counter: i32, } struct LoverData { @@ -588,6 +776,16 @@ struct LoverData { scandal_extra: i32, title1_tr: String, title2_tr: String, + user1_id: Option, + user2_id: Option, + /// Jüngeres Alter beider Partner (Jahre, ganzzahlig); für Altersmalus / Skandal / Ehe. + min_age_years: i32, +} + +fn push_user_id(set: &mut HashSet, uid: Option) { + if let Some(id) = uid.filter(|x| *x > 0) { + set.insert(id); + } } fn parse_i32(row: &crate::db::Row, key: &str, default: i32) -> i32 { @@ -637,7 +835,8 @@ fn rank_cost_multiplier(g: u8) -> f64 { } } -fn rank_rep_modifier(g: u8, scandal: bool) -> f64 { +/// Gruppe 3: 0,7 nur bei „geordneter“ Liebschaft; sonst 1,0; Skandal 1,5. +fn rank_rep_modifier(g: u8, scandal: bool, order_ok: bool) -> f64 { if g == 3 && scandal { return 1.5; } @@ -645,11 +844,46 @@ fn rank_rep_modifier(g: u8, scandal: bool) -> f64 { 0 => 1.8, 1 => 1.3, 2 => 1.0, - 3 => 0.7, + 3 => if order_ok { 0.7 } else { 1.0 }, _ => 1.0, } } +/// Spec 5a: Altersmalus (jüngeres Alter der Beteiligten), zusätzlich zur Basis-Reputation. +fn age_reputation_delta(min_age_years: i32) -> f64 { + if min_age_years <= 13 { + -1.5 + } else if min_age_years <= 15 { + -0.8 + } else if min_age_years <= 17 { + -0.3 + } else { + 0.0 + } +} + +/// Spec 5a: minAge <= 15 und hohe Sichtbarkeit. +fn visibility_young_penalty(min_age_years: i32, visibility: i32) -> f64 { + if min_age_years <= 15 && visibility >= 50 { + -0.5 + } else { + 0.0 + } +} + +/// Spec Skandalrisiko: stufenweise Zusatz (exklusiv). +fn scandal_age_extra_pct(min_age_years: i32) -> f64 { + if min_age_years <= 13 { + 6.0 + } else if min_age_years <= 15 { + 3.0 + } else if min_age_years <= 17 { + 1.0 + } else { + 0.0 + } +} + fn lover_marriage_daily_delta( mg: u8, l: &LoverData, diff --git a/src/worker/sql.rs b/src/worker/sql.rs index 7259be3..8040ef5 100644 --- a/src/worker/sql.rs +++ b/src/worker/sql.rs @@ -2041,7 +2041,11 @@ pub const QUERY_GET_ACTIVE_LOVER_ROWS_FOR_DAILY: &str = r#" COALESCE(c1.reputation, 50)::float8 AS rep1, COALESCE(c2.reputation, 50)::float8 AS rep2, fu1.id AS user1_id, - fu2.id AS user2_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' @@ -2080,7 +2084,11 @@ pub const QUERY_GET_ACTIVE_LOVER_ROWS_FOR_MONTHLY: &str = r#" COALESCE(c1.reputation, 50)::float8 AS rep1, COALESCE(c2.reputation, 50)::float8 AS rep2, fu1.id AS user1_id, - fu2.id AS user2_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' @@ -2124,16 +2132,25 @@ pub const QUERY_GET_MARRIAGE_ROWS: &str = r#" r.marriage_drift_high, r.marriage_drift_low, COALESCE(t1.tr, '') AS title1_tr, - COALESCE(t2.tr, '') AS title2_tr + COALESCE(t2.tr, '') AS title2_tr, + fu1.id AS user1_id, + fu2.id AS user2_id, + 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 FROM falukant_data.relationship r JOIN falukant_type.relationship rt ON rt.id = r.relationship_type_id AND rt.tr IN ('married', 'engaged', 'wooing') JOIN falukant_data.character c1 ON c1.id = r.character1_id JOIN falukant_data.character c2 ON c2.id = r.character2_id LEFT JOIN falukant_type.title t1 ON t1.id = c1.title_of_nobility - LEFT JOIN falukant_type.title t2 ON t2.id = c2.title_of_nobility; + 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; "#; +#[allow(dead_code)] // Einfaches Update ohne Buff-Spalten; Daemon nutzt QUERY_UPDATE_MARRIAGE_STATE_AND_BUFFS. pub const QUERY_UPDATE_MARRIAGE_STATE: &str = r#" UPDATE falukant_data.relationship SET marriage_satisfaction = $1::smallint, @@ -2142,6 +2159,18 @@ pub const QUERY_UPDATE_MARRIAGE_STATE: &str = r#" WHERE id = $4::int; "#; +/// Inkl. Geschenk-/Fest-/Haus-Zähler (Migration `003_falukant_family_marriage_buffs.sql`). +pub const QUERY_UPDATE_MARRIAGE_STATE_AND_BUFFS: &str = r#" + UPDATE falukant_data.relationship + SET marriage_satisfaction = $1::smallint, + marriage_drift_high = $2::smallint, + 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; +"#; + pub const QUERY_MARRIAGE_SUBTRACT_SATISFACTION: &str = r#" UPDATE falukant_data.relationship r SET marriage_satisfaction = GREATEST(0, r.marriage_satisfaction - $2::int)