Implement lover installment logic in FalukantFamilyWorker: Added support for processing lover maintenance payments every 2 hours, including new SQL queries for installment tracking and updates to relationship states. Enhanced WebSocket events to notify UI of installment changes, improving family dynamics and financial interactions.

This commit is contained in:
Torsten Schulz (local)
2026-03-23 10:01:34 +01:00
parent fe0361971d
commit c82fbc0f7c
5 changed files with 198 additions and 61 deletions

View File

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

View File

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

View File

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

View File

@@ -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<f64>,
last_daily: Option<Instant>,
last_monthly: Option<Instant>,
last_lover_installment: Option<Instant>,
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<i32> = 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<i32> = HashSet::new();
let mut notify: HashSet<i32> = 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(&notify, "lover_installment");
Ok(())
}

View File

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