diff --git a/docs/FALUKANT_DAEMON_HANDOFF.md b/docs/FALUKANT_DAEMON_HANDOFF.md index 2a3b636..e82076c 100644 --- a/docs/FALUKANT_DAEMON_HANDOFF.md +++ b/docs/FALUKANT_DAEMON_HANDOFF.md @@ -11,13 +11,15 @@ Technische Abstimmung mit dem Übergabedokument im Backend-Projekt (`FALUKANT_LO | `relationship_state.marriage_satisfaction` (Ehe) | **`relationship.marriage_satisfaction`** für Zeilen mit `relationship_type` ∈ `married`, `engaged`, `wooing` | | `months_underfunded` | Spalte `months_underfunded` (Migration 001; Legacy: `002` benennt `consecutive_underpayment_months` um) | | Idempotenz `last_daily_processed_at` / `last_monthly_processed_at` | Gesetzt von `FalukantFamilyWorker` pro Liebschaft | +| Idempotenz `lover_last_installment_at` (Migration `006`) | Gesetzt alle **2 h** pro Liebschaft nach Unterhalts-Tick (1/12 des Monatsbetrags) | ## Ticks - **Daily:** nur Zeilen mit `(last_daily_processed_at IS NULL OR last_daily_processed_at::date < CURRENT_DATE)`; danach `last_daily_processed_at = NOW()`. -- **Monthly:** nur Zeilen mit `(last_monthly_processed_at IS NULL OR date_trunc('month', last_monthly_processed_at) < date_trunc('month', CURRENT_TIMESTAMP))`; nach Kosten/Unterversorgung `last_monthly_processed_at = NOW()`. +- **Monthly:** nur Zeilen mit `(last_monthly_processed_at IS NULL OR date_trunc('month', last_monthly_processed_at) < date_trunc('month', CURRENT_TIMESTAMP))`; danach `last_monthly_processed_at = NOW()` (**ohne** Liebschafts-Geld — nur Monatsstand/Schwangerschafts-Logik; ggf. **Dienerschaft** `run_monthly` mit eigenen Kosten). +- **Liebschafts-Unterhalt (Spielzeit):** alle **2 h** (`lover_last_installment_at`), Betrag = **1/12** des bisherigen Monatsunterhalts (12 „Spielmonate“ pro **Spieltag** = 1 Spieljahr). Action in `money_history`: weiterhin `lover maintenance`. -**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. +**Hinweis:** Der Worker nutzt weiterhin **Wandzeit** (24 h / 30 Tage / **2 h** Unterhalt) als Intervall; die Idempotenz über die Zeitstempel verhindert Doppelverarbeitung bei Neustarts am selben Tag/Monat bzw. im selben 2-h-Fenster. ## WebSocket-Events (UI) @@ -30,7 +32,7 @@ Pro betroffener `falukant_user.id` werden über den **MessageBroker** (Broadcast | `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`. +**`reason`** bei `falukantUpdateFamily`: `daily`, `monthly`, `lover_installment`, `scandal`, `lover_birth`. Die UI kann auf `falukantUpdateFamily` filtern und nach `reason` unterscheiden; `falukantUpdateStatus` wie bisher für allgemeinen Daten-Refresh nutzen. @@ -58,6 +60,7 @@ Ehe-Malus „≤ 15“ gilt pro Ehe, wenn **irgendeine** berührende Liebschaft 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). +6. `migrations/006_falukant_lover_installments.sql` — `relationship_state.lover_last_installment_at` (Unterhalt 12× pro Spieltag). ### Ehe-Buffs (Daemon) diff --git a/docs/FALUKANT_UI_WEBSOCKET.md b/docs/FALUKANT_UI_WEBSOCKET.md index 66ed872..834f4e4 100644 --- a/docs/FALUKANT_UI_WEBSOCKET.md +++ b/docs/FALUKANT_UI_WEBSOCKET.md @@ -35,7 +35,8 @@ Dieses Dokument beschreibt die **Nachrichten**, die der **YpDaemon** (`FalukantF | `reason` | Bedeutung (Daemon) | Empfehlung UI | |----------|---------------------|---------------| | `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 | +| `monthly` | Monthly-Tick: Monatsmarkierung Liebschaft (`last_monthly_processed_at`), ggf. Dienerschaft-Monatskosten; **kein** vollständiger Liebschafts-Monatsbetrag mehr | **Geld** (falls Dienerschaft zahlt) + Family-State neu laden | +| `lover_installment` | Alle **2 h**: **1/12** Liebschafts-Unterhalt bzw. Unterversorgung (`money_history`: `lover maintenance`) | **Geld** + 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 | @@ -118,6 +119,10 @@ onMessage(json): refreshMoney() refreshFamilyAndRelationships() break + case "lover_installment": + refreshMoney() + refreshFamilyAndRelationships() + break case "scandal": showScandalToastOptional() refreshFamilyAndRelationships() @@ -141,7 +146,8 @@ onMessage(json): | 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: monthly` | **Geld** (Dienerschaft o. ä.), Family-State | +| `reason: lover_installment` | **Geld** + Liebschafts-State (Unterhalt/Unterversorgung) | | `reason: daily` / `scandal` | Ansehen (`character.reputation`), Sichtbarkeit/Diskretion der Liebschaften | | `children_update` / `lover_birth` | `child_relation` inkl. `legitimacy`, `birth_context`, `public_known` | @@ -163,7 +169,7 @@ Konkrete Routen stehen im **YourPart3**-Backend; das Frontend sollte eine zentra - Worker: `src/worker/falukant_family.rs` - SQL-Konstanten: `src/worker/sql.rs` (Abschnitt Falukant Familie) -- Schema: `migrations/001_falukant_family_lovers.sql` +- Schema: `migrations/001_falukant_family_lovers.sql`, `006_falukant_lover_installments.sql` (Unterhalt 12×/Tag) - Daemon-Handoff (technisch): `docs/FALUKANT_DAEMON_HANDOFF.md` --- diff --git a/migrations/006_falukant_lover_installments.sql b/migrations/006_falukant_lover_installments.sql new file mode 100644 index 0000000..977cebe --- /dev/null +++ b/migrations/006_falukant_lover_installments.sql @@ -0,0 +1,6 @@ +-- Liebschafts-Unterhalt: 12 Teilzahlungen pro Spieltag (alle 2 h), 1 Spieltag = 1 Spieljahr. +ALTER TABLE falukant_data.relationship_state + ADD COLUMN IF NOT EXISTS lover_last_installment_at TIMESTAMPTZ; + +COMMENT ON COLUMN falukant_data.relationship_state.lover_last_installment_at IS + 'Letzte Abbuchung eines Zwölftels des Monatsunterhalts; Intervall 2 h im Daemon'; diff --git a/src/worker/falukant_family.rs b/src/worker/falukant_family.rs index 1b8c8e8..875a685 100644 --- a/src/worker/falukant_family.rs +++ b/src/worker/falukant_family.rs @@ -1,7 +1,8 @@ //! 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`), -//! `005` Ehe öffentliche Stabilität + Hausfrieden (`household_tension_score`). +//! `005` Ehe öffentliche Stabilität + Hausfrieden (`household_tension_score`), +//! `006` Liebschafts-Unterhalt 12× pro Spieltag (alle 2 h), 1 Spieltag = 1 Spieljahr. //! //! WebSocket: `falukantUpdateFamily` (reason) + `falukantUpdateStatus` für betroffene Nutzer. @@ -18,10 +19,11 @@ use super::base::{BaseWorker, Worker, WorkerState}; use super::sql::{ 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_GET_ACTIVE_LOVER_ROWS_FOR_INSTALLMENT, 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_LOVER_INSTALLMENT_SCHEMA_READY, + QUERY_MARK_LOVER_DAILY_DONE, QUERY_MARK_LOVER_INSTALLMENT_AT, 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, @@ -32,6 +34,8 @@ use crate::message_broker::MessageBroker; const DAILY_INTERVAL: Duration = Duration::from_secs(24 * 3600); const MONTHLY_INTERVAL: Duration = Duration::from_secs(30 * 24 * 3600); +/// 12 Teilzahlungen pro Spieltag (24 h = 1 Spieljahr); 2 h = 1 Spielmonat. +const LOVER_INSTALLMENT_INTERVAL: Duration = Duration::from_secs(2 * 3600); pub struct FalukantFamilyWorker { base: BaseWorker, @@ -39,9 +43,12 @@ pub struct FalukantFamilyWorker { dist: Uniform, last_daily: Option, last_monthly: Option, + last_lover_installment: Option, schema_ready: bool, /// Migration `004_falukant_servants_daemon.sql` (Dienerschaft-Ticks). servants_schema_ready: bool, + /// Migration `006_falukant_lover_installments.sql`. + lover_installment_schema_ready: bool, } impl FalukantFamilyWorker { @@ -52,8 +59,10 @@ impl FalukantFamilyWorker { dist: Uniform::from(0.0..1.0), last_daily: None, last_monthly: None, + last_lover_installment: None, schema_ready: false, servants_schema_ready: false, + lover_installment_schema_ready: false, } } @@ -84,6 +93,15 @@ impl FalukantFamilyWorker { self.last_monthly = Some(now); } + if self.lover_installment_schema_ready { + if Self::should_run(self.last_lover_installment, now, LOVER_INSTALLMENT_INTERVAL) { + if let Err(e) = self.process_lover_installments() { + eprintln!("[FalukantFamilyWorker] process_lover_installments: {e}"); + } + self.last_lover_installment = Some(now); + } + } + std::thread::sleep(Duration::from_secs(1)); if !state.running_worker.load(Ordering::Relaxed) { // stopping @@ -112,6 +130,13 @@ impl FalukantFamilyWorker { .unwrap_or(false); self.servants_schema_ready = super::falukant_servants::servants_schema_ready(&self.base.pool).unwrap_or(false); + conn.prepare("lover_inst_schema", QUERY_LOVER_INSTALLMENT_SCHEMA_READY)?; + let inst_rows = conn.execute("lover_inst_schema", &[])?; + self.lover_installment_schema_ready = inst_rows + .first() + .and_then(|r| r.get("ready")) + .map(|v| v == "true" || v == "t") + .unwrap_or(false); Ok(family_ok) } @@ -638,13 +663,46 @@ impl FalukantFamilyWorker { conn.prepare("get_lovers_m", QUERY_GET_ACTIVE_LOVER_ROWS_FOR_MONTHLY)?; let lover_rows = conn.execute("get_lovers_m", &[])?; + 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 { + continue; + } + let u1 = parse_opt_i32(&r, "user1_id"); + let u2 = parse_opt_i32(&r, "user2_id"); + 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(()) + } + + /// Ein Zwölftel des bisherigen Monatsunterhalts, alle 2 h (12 Spielmonate pro Spieljahr). + fn process_lover_installments(&mut self) -> Result<(), DbError> { + let mut conn = self + .base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("get_lovers_i", QUERY_GET_ACTIVE_LOVER_ROWS_FOR_INSTALLMENT)?; + let lover_rows = conn.execute("get_lovers_i", &[])?; conn.prepare("upd_under", QUERY_UPDATE_LOVER_UNDERPAY_STATE)?; conn.prepare("reset_under", QUERY_RESET_LOVER_UNDERPAY_COUNTERS)?; conn.prepare("mar_sub", QUERY_MARRIAGE_SUBTRACT_SATISFACTION)?; - conn.prepare("mark_monthly", QUERY_MARK_LOVER_MONTHLY_DONE)?; + conn.prepare("mark_inst", QUERY_MARK_LOVER_INSTALLMENT_AT)?; - let mut monthly_notify: HashSet = HashSet::new(); + let mut notify: HashSet = HashSet::new(); for r in lover_rows { let rel_id = parse_i32(&r, "rel_id", -1); @@ -674,6 +732,7 @@ impl FalukantFamilyWorker { _ => 1.0, }; let cost = ((base as f64) * rank_m * maint_f * sf_m).round() as i32; + let installment = ((cost as f64) / 12.0 * 100.0).round() / 100.0; let u1 = parse_opt_i32(&r, "user1_id"); let u2 = parse_opt_i32(&r, "user2_id"); @@ -686,66 +745,69 @@ impl FalukantFamilyWorker { let mut scandal_extra = parse_i32(&r, "scandal_extra_daily_pct", 0); if let Some(uid) = payer { - let money = self.get_user_money(uid)?; - if money >= cost as f64 { - self.base.change_falukant_user_money( - uid, - -(cost as f64), - "lover maintenance", - )?; - conn.execute("reset_under", &[&rel_id])?; + if installment <= 0.0 { + conn.execute("mark_inst", &[&rel_id])?; } else { - let new_aff = clamp_i32(affection - 8, 0, 100); - let new_disc = clamp_i32(discretion - 6, 0, 100); - let new_vis = clamp_i32(visibility + 8, 0, 100); - consec += 1; - if consec >= 2 { - scandal_extra = (scandal_extra + 2).min(100); - } - conn.execute( - "upd_under", - &[ - &new_aff, - &new_disc, - &new_vis, - &consec, - &scandal_extra, - &rel_id, - ], - )?; - - for cid in [parse_i32(&r, "c1", 0), parse_i32(&r, "c2", 0)] { - if cid <= 0 { - continue; + let money = self.get_user_money(uid)?; + if money >= installment { + self.base.change_falukant_user_money( + uid, + -installment, + "lover maintenance", + )?; + conn.execute("reset_under", &[&rel_id])?; + conn.execute("mark_inst", &[&rel_id])?; + } else { + let new_aff = clamp_i32(affection - 8, 0, 100); + let new_disc = clamp_i32(discretion - 6, 0, 100); + let new_vis = clamp_i32(visibility + 8, 0, 100); + consec += 1; + if consec >= 2 { + scandal_extra = (scandal_extra + 2).min(100); } - if let Ok(Some(mid)) = marriage_id_for_character(&mut conn, cid) { - conn.execute("mar_sub", &[&mid, &4])?; - } - } + conn.execute( + "upd_under", + &[ + &new_aff, + &new_disc, + &new_vis, + &consec, + &scandal_extra, + &rel_id, + ], + )?; - if visibility >= 40 { for cid in [parse_i32(&r, "c1", 0), parse_i32(&r, "c2", 0)] { - if cid > 0 { - let cur = fetch_reputation(&mut conn, cid)?; - let s = format!("{:.2}", (cur - 1.0).max(0.0)); - conn.prepare("upd_rep", QUERY_UPDATE_CHARACTER_REPUTATION)?; - conn.execute("upd_rep", &[&s, &cid])?; + if cid <= 0 { + continue; + } + if let Ok(Some(mid)) = marriage_id_for_character(&mut conn, cid) { + conn.execute("mar_sub", &[&mid, &4])?; } } + + if visibility >= 40 { + for cid in [parse_i32(&r, "c1", 0), parse_i32(&r, "c2", 0)] { + if cid > 0 { + let cur = fetch_reputation(&mut conn, cid)?; + let s = format!("{:.2}", (cur - 1.0).max(0.0)); + conn.prepare("upd_rep", QUERY_UPDATE_CHARACTER_REPUTATION)?; + conn.execute("upd_rep", &[&s, &cid])?; + } + } + } + conn.execute("mark_inst", &[&rel_id])?; } } + } else { + conn.execute("mark_inst", &[&rel_id])?; } - conn.execute("mark_monthly", &[&rel_id])?; - - push_user_id(&mut monthly_notify, u1); - push_user_id(&mut monthly_notify, u2); + push_user_id(&mut notify, u1); + push_user_id(&mut notify, u2); } - self.publish_falukant_update_family_batch(&monthly_notify, "monthly"); - - drop(conn); - self.process_lover_births()?; + self.publish_falukant_update_family_batch(¬ify, "lover_installment"); Ok(()) } diff --git a/src/worker/sql.rs b/src/worker/sql.rs index a06f96e..a772ea7 100644 --- a/src/worker/sql.rs +++ b/src/worker/sql.rs @@ -2395,6 +2395,66 @@ pub const QUERY_MARK_LOVER_MONTHLY_DONE: &str = r#" WHERE relationship_id = $1::int; "#; +/// Liebschaft: fällige Teilzahlung (alle 2 h), Migration `006_falukant_lover_installments.sql`. +pub const QUERY_GET_ACTIVE_LOVER_ROWS_FOR_INSTALLMENT: &str = r#" + SELECT + r.id AS rel_id, + r.character1_id AS c1, + r.character2_id AS c2, + rs.lover_role, + rs.affection, + rs.visibility, + rs.discretion, + rs.maintenance_level, + rs.status_fit, + rs.monthly_base_cost, + rs.scandal_extra_daily_pct, + rs.months_underfunded, + c1.gender AS g1, + c2.gender AS g2, + COALESCE(t1.tr, '') AS title1_tr, + COALESCE(t2.tr, '') AS title2_tr, + COALESCE(c1.reputation, 50)::float8 AS rep1, + COALESCE(c2.reputation, 50)::float8 AS rep2, + fu1.id AS user1_id, + fu2.id AS user2_id, + 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 + JOIN falukant_data.character c1 ON c1.id = r.character1_id + JOIN falukant_data.character c2 ON c2.id = r.character2_id + LEFT JOIN falukant_type.title t1 ON t1.id = c1.title_of_nobility + LEFT JOIN falukant_type.title t2 ON t2.id = c2.title_of_nobility + LEFT JOIN falukant_data.falukant_user fu1 ON fu1.id = c1.user_id + LEFT JOIN falukant_data.falukant_user fu2 ON fu2.id = c2.user_id + WHERE rs.active = true + AND ( + rs.lover_last_installment_at IS NULL + OR rs.lover_last_installment_at < NOW() - INTERVAL '2 hours' + ); +"#; + +pub const QUERY_MARK_LOVER_INSTALLMENT_AT: &str = r#" + UPDATE falukant_data.relationship_state + SET lover_last_installment_at = NOW() + WHERE relationship_id = $1::int; +"#; + +pub const QUERY_LOVER_INSTALLMENT_SCHEMA_READY: &str = r#" + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'falukant_data' + AND table_name = 'relationship_state' + AND column_name = 'lover_last_installment_at' + ) AS ready; +"#; + pub const QUERY_UPDATE_CHARACTER_REPUTATION: &str = r#" UPDATE falukant_data.character SET reputation = $1::numeric,