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.

This commit is contained in:
Torsten Schulz (local)
2026-03-20 11:02:28 +01:00
parent 6a5ff4557e
commit ac5ec3a245
5 changed files with 547 additions and 62 deletions

View File

@@ -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<MarriageData> = marriage_rows
let mut marriages: Vec<MarriageData> = 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<i32, Option<i32>> = 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<i32, usize> =
std::collections::HashMap::new();
let mut char_visible_lover_rel_count: std::collections::HashMap<i32, usize> =
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<i32> = 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<i32> = HashSet::new();
for l in &lovers {
unique_cids.insert(l.c1);
unique_cids.insert(l.c2);
}
let mut rng_malus_cids: Vec<i32> = 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<i32> = 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(&notify, "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<i32> = 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<i32>, 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<i32>,
user2_id: Option<i32>,
/// 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 0100; 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<i32>,
user2_id: Option<i32>,
/// Jüngeres Alter beider Partner (Jahre, ganzzahlig); für Altersmalus / Skandal / Ehe.
min_age_years: i32,
}
fn push_user_id(set: &mut HashSet<i32>, uid: Option<i32>) {
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,

View File

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