From ac024a8d146448242df7c7cd6eb0ab59bb8cd228 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 2 Apr 2026 15:46:37 +0200 Subject: [PATCH] Implement political benefits management in FalukantFamilyWorker and SQL: Introduced a new structure for handling lover relationships, including political slots and reputation ticks. Updated SQL queries to support political benefits, ensuring proper handling of appointments and reputation gains. Enhanced the `FalukantFamilyWorker` logic to manage free political slots and maintain relationships effectively. Improved documentation for clarity on the new political benefits features and their integration into the existing system. --- ...012_falukant_political_benefits_daemon.sql | 43 +++++ migrations/README.md | 10 + src/worker/falukant_family.rs | 124 +++++++++++-- src/worker/mod.rs | 1 + src/worker/political_benefits.rs | 171 ++++++++++++++++++ src/worker/politics.rs | 7 + src/worker/sql.rs | 96 ++++++++++ 7 files changed, 441 insertions(+), 11 deletions(-) create mode 100644 migrations/012_falukant_political_benefits_daemon.sql create mode 100644 src/worker/political_benefits.rs diff --git a/migrations/012_falukant_political_benefits_daemon.sql b/migrations/012_falukant_political_benefits_daemon.sql new file mode 100644 index 0000000..b1be906 --- /dev/null +++ b/migrations/012_falukant_political_benefits_daemon.sql @@ -0,0 +1,43 @@ +-- Daemon: Amtsvorteile (reputation_periodic Ticks, optional Ernennungs-Ablauf) +-- Voraussetzung: Backend-Seeds `falukant_predefine.political_office_benefit` + ggf. `falukant_type.political_office_benefit_type` + +CREATE TABLE IF NOT EXISTS falukant_data.political_benefit_last_tick ( + id SERIAL PRIMARY KEY, + character_id INTEGER NOT NULL + REFERENCES falukant_data.character (id) ON DELETE CASCADE, + political_office_benefit_id INTEGER NOT NULL, + last_tick_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ticks_count INTEGER NOT NULL DEFAULT 0, + CONSTRAINT uq_political_benefit_last_tick UNIQUE (character_id, political_office_benefit_id) +); + +CREATE INDEX IF NOT EXISTS idx_political_benefit_last_tick_character + ON falukant_data.political_benefit_last_tick (character_id); + +COMMENT ON TABLE falukant_data.political_benefit_last_tick IS + 'Letzter reputation_periodic-Tick pro (Charakter × Benefit-Zeile aus political_office_benefit); Daemon: YpDaemon political_benefits.rs'; + +-- Optional: Spieler-Ernennungen (Backend legt Zeilen an; Daemon setzt nur abgelaufen) +CREATE TABLE IF NOT EXISTS falukant_data.political_appointment ( + id SERIAL PRIMARY KEY, + appointer_character_id INTEGER NOT NULL + REFERENCES falukant_data.character (id) ON DELETE CASCADE, + target_character_id INTEGER + REFERENCES falukant_data.character (id) ON DELETE SET NULL, + office_type_id INTEGER NOT NULL + REFERENCES falukant_type.political_office_type (id), + region_id INTEGER NOT NULL + REFERENCES falukant_data.region (id), + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ, + completed_office_id INTEGER + REFERENCES falukant_data.political_office (id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_political_appointment_status_expires + ON falukant_data.political_appointment (status, expires_at); + +COMMENT ON TABLE falukant_data.political_appointment IS + 'Ernennungen (Backend); Daemon markiert pending → expired wenn expires_at überschritten'; diff --git a/migrations/README.md b/migrations/README.md index 4dc71e5..ab7e568 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -19,3 +19,13 @@ Nur nötig, wenn **`001`** bereits mit den **alten** Spaltennamen (`consecutive_ **Backend (YourPart3):** Beim Anlegen einer `lover`-Beziehung `relationship_state` erzeugen; Ehezufriedenheit liegt auf **`relationship`** (married / engaged / wooing); Idempotenzfelder `last_daily_processed_at` / `last_monthly_processed_at` werden vom Daemon gesetzt. Ohne passende Spalten (`last_daily_processed_at`) bleibt der Family-Worker inaktiv. + +## `012_falukant_political_benefits_daemon.sql` + +Tabellen **`political_benefit_last_tick`** und optional **`political_appointment`** für den **`PoliticsWorker`** / Modul `political_benefits.rs`: + +- **`reputation_periodic`**: Ticks mit Persistenz (benötigt Backend-Seeds `falukant_predefine.political_office_benefit` mit JSON-Feldern `tr` oder `benefitType`, `gain`, `intervalDays`). +- **`free_lover_slots`**: Summe `count` im Liebschafts-Monatstick (Daemon), max. 5. +- **Ernennungen**: Daemon setzt nur `pending` → `expired`, wenn `expires_at` überschritten (Anlage durch Backend-API). + +Die Join-Spalte auf `political_office_benefit` heißt im Repo **`political_office_type_id`** — falls das Sequelize-Modell abweicht, SQL in `src/worker/sql.rs` anpassen. diff --git a/src/worker/falukant_family.rs b/src/worker/falukant_family.rs index 1c4c655..f4ebbbb 100644 --- a/src/worker/falukant_family.rs +++ b/src/worker/falukant_family.rs @@ -720,13 +720,35 @@ impl FalukantFamilyWorker { conn.prepare("mar_sub", QUERY_MARRIAGE_SUBTRACT_SATISFACTION)?; conn.prepare("mark_inst", QUERY_MARK_LOVER_INSTALLMENT_AT)?; - let mut notify: HashSet = HashSet::new(); + struct LoverInst { + rel_id: i32, + c1: i32, + c2: i32, + lover_role: String, + maintenance_level: i32, + status_fit: i32, + base: i32, + title1: String, + title2: String, + u1: Option, + u2: Option, + affection: i32, + visibility: i32, + discretion: i32, + consec: i32, + scandal_extra: i32, + payer_cid: Option, + free_political_slot: bool, + } + let mut items: Vec = Vec::new(); for r in lover_rows { let rel_id = parse_i32(&r, "rel_id", -1); if rel_id < 0 { continue; } + let c1 = parse_i32(&r, "c1", -1); + let c2 = parse_i32(&r, "c2", -1); let lover_role = r.get("lover_role").cloned().unwrap_or_default(); let maintenance_level = parse_i32(&r, "maintenance_level", 50); let status_fit = parse_i32(&r, "status_fit", 0); @@ -741,6 +763,83 @@ impl FalukantFamilyWorker { } let title1 = r.get("title1_tr").cloned().unwrap_or_default(); let title2 = r.get("title2_tr").cloned().unwrap_or_default(); + let u1 = parse_opt_i32(&r, "user1_id"); + let u2 = parse_opt_i32(&r, "user2_id"); + let payer = u1.or(u2).filter(|x| *x > 0); + let payer_cid = match (u1, u2, payer) { + (Some(a), _, Some(p)) if p == a => Some(c1), + (_, Some(b), Some(p)) if p == b => Some(c2), + _ => None, + }; + items.push(LoverInst { + rel_id, + c1, + c2, + lover_role, + maintenance_level, + status_fit, + base, + title1, + title2, + u1, + u2, + affection: parse_i32(&r, "affection", 50), + visibility: parse_i32(&r, "visibility", 0), + discretion: parse_i32(&r, "discretion", 50), + consec: parse_i32(&r, "months_underfunded", 0), + scandal_extra: parse_i32(&r, "scandal_extra_daily_pct", 0), + payer_cid, + free_political_slot: false, + }); + } + + items.sort_by_key(|x| (x.payer_cid.unwrap_or(-1), x.rel_id)); + + let mut free_slots_by_char: HashMap = HashMap::new(); + let mut seen_payer: HashSet = HashSet::new(); + for it in &items { + if let Some(cid) = it.payer_cid { + if seen_payer.insert(cid) { + if let Ok(n) = super::political_benefits::sum_free_lover_slots(&mut conn, cid) { + free_slots_by_char.insert(cid, n); + } + } + } + } + + let mut cur_group = -2i32; + let mut eligible_ix: usize = 0; + for it in &mut items { + let gk = it.payer_cid.unwrap_or(-1); + if gk != cur_group { + cur_group = gk; + eligible_ix = 0; + } + let free_n = it + .payer_cid + .and_then(|c| free_slots_by_char.get(&c).copied()) + .unwrap_or(0); + let role_ok = matches!( + it.lover_role.as_str(), + "lover" | "mistress_or_favorite" + ); + if role_ok && it.payer_cid.is_some() { + if eligible_ix < free_n as usize { + it.free_political_slot = true; + } + eligible_ix += 1; + } + } + + let mut notify: HashSet = HashSet::new(); + + for it in items { + let rel_id = it.rel_id; + let maintenance_level = it.maintenance_level; + let status_fit = it.status_fit; + let base = it.base; + let title1 = it.title1.clone(); + let title2 = it.title2.clone(); let g = pair_rank_group(&title1, &title2); let rank_m = rank_cost_multiplier(g); let maint_f = 0.6 + (maintenance_level as f64 / 100.0) * 1.2; @@ -750,17 +849,20 @@ 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 mut installment = ((cost as f64) / 12.0 * 100.0).round() / 100.0; + if it.free_political_slot { + installment = 0.0; + } - let u1 = parse_opt_i32(&r, "user1_id"); - let u2 = parse_opt_i32(&r, "user2_id"); + let u1 = it.u1; + let u2 = it.u2; let payer = u1.or(u2).filter(|x| *x > 0); - let affection = parse_i32(&r, "affection", 50); - let visibility = parse_i32(&r, "visibility", 0); - let discretion = parse_i32(&r, "discretion", 50); - let mut consec = parse_i32(&r, "months_underfunded", 0); - let mut scandal_extra = parse_i32(&r, "scandal_extra_daily_pct", 0); + let affection = it.affection; + let visibility = it.visibility; + let discretion = it.discretion; + let mut consec = it.consec; + let mut scandal_extra = it.scandal_extra; if let Some(uid) = payer { if installment <= 0.0 { @@ -795,7 +897,7 @@ impl FalukantFamilyWorker { ], )?; - for cid in [parse_i32(&r, "c1", 0), parse_i32(&r, "c2", 0)] { + for cid in [it.c1, it.c2] { if cid <= 0 { continue; } @@ -805,7 +907,7 @@ impl FalukantFamilyWorker { } if visibility >= 40 { - for cid in [parse_i32(&r, "c1", 0), parse_i32(&r, "c2", 0)] { + for cid in [it.c1, it.c2] { if cid > 0 { let cur = fetch_reputation(&mut conn, cid)?; let s = format!("{:.2}", (cur - 1.0).max(0.0)); diff --git a/src/worker/mod.rs b/src/worker/mod.rs index 3daed3c..d46842e 100644 --- a/src/worker/mod.rs +++ b/src/worker/mod.rs @@ -17,6 +17,7 @@ mod falukant_certificate; mod falukant_servants; mod falukant_debtors; mod falukant_transport_raid; +mod political_benefits; mod sql; pub use base::Worker; diff --git a/src/worker/political_benefits.rs b/src/worker/political_benefits.rs new file mode 100644 index 0000000..3e952a3 --- /dev/null +++ b/src/worker/political_benefits.rs @@ -0,0 +1,171 @@ +//! Politische Amtsvorteile: `reputation_periodic` (täglich), Ablauf offener Ernennungen, +//! Hilfsabfrage `free_lover_slots` für Liebschafts-Monatstick. +//! +//! Voraussetzungen: Migration `012_falukant_political_benefits_daemon.sql`, Backend-Seeds +//! `falukant_predefine.political_office_benefit` mit JSON (`tr` oder `benefitType`, `gain`, `intervalDays`, …). + +use crate::db::{ConnectionPool, DbError, Row}; +use crate::message_broker::MessageBroker; + +use crate::worker::sql::{ + QUERY_POLITICAL_APPOINTMENT_EXPIRE_PENDING, QUERY_POLITICAL_APPOINTMENT_SCHEMA_READY, + QUERY_POLITICAL_BENEFIT_DAEMON_SCHEMA_READY, QUERY_POLITICAL_REPUTATION_APPLY_GAIN, + QUERY_POLITICAL_REPUTATION_TICK_ROWS, QUERY_POLITICAL_REPUTATION_TICK_UPSERT, + QUERY_SUM_FREE_LOVER_SLOTS_FOR_CHARACTER, +}; + +fn parse_i32(row: &Row, key: &str, default: i32) -> i32 { + row.get(key) + .and_then(|v| v.parse::().ok()) + .unwrap_or(default) +} + +fn parse_f64_from_row(row: &Row, key: &str) -> f64 { + row.get(key) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0) +} + +pub fn daemon_schema_ready(pool: &ConnectionPool) -> bool { + let mut conn = match pool.get() { + Ok(c) => c, + Err(_) => return false, + }; + if conn + .prepare("pb_schema", QUERY_POLITICAL_BENEFIT_DAEMON_SCHEMA_READY) + .is_err() + { + return false; + } + let rows = match conn.execute("pb_schema", &[]) { + Ok(r) => r, + Err(_) => return false, + }; + rows.first() + .and_then(|r| r.get("ready")) + .map(|v| v == "true" || v == "t") + .unwrap_or(false) +} + +pub fn appointment_schema_ready(pool: &ConnectionPool) -> bool { + let mut conn = match pool.get() { + Ok(c) => c, + Err(_) => return false, + }; + if conn + .prepare("pa_schema", QUERY_POLITICAL_APPOINTMENT_SCHEMA_READY) + .is_err() + { + return false; + } + let rows = match conn.execute("pa_schema", &[]) { + Ok(r) => r, + Err(_) => return false, + }; + rows.first() + .and_then(|r| r.get("ready")) + .map(|v| v == "true" || v == "t") + .unwrap_or(false) +} + +/// Täglich: Ansehen gemäß `reputation_periodic` + Upsert `political_benefit_last_tick` + Status-Push. +pub fn run_reputation_periodic_ticks(pool: &ConnectionPool, broker: &MessageBroker) -> Result<(), DbError> { + if !daemon_schema_ready(pool) { + return Ok(()); + } + + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("rep_tick_rows", QUERY_POLITICAL_REPUTATION_TICK_ROWS) + .map_err(|e| DbError::new(format!("[PoliticalBenefits] prepare rep_tick_rows: {e}")))?; + conn.prepare("rep_tick_upsert", QUERY_POLITICAL_REPUTATION_TICK_UPSERT) + .map_err(|e| DbError::new(format!("[PoliticalBenefits] prepare rep_tick_upsert: {e}")))?; + conn.prepare("rep_apply", QUERY_POLITICAL_REPUTATION_APPLY_GAIN) + .map_err(|e| DbError::new(format!("[PoliticalBenefits] prepare rep_apply: {e}")))?; + + let rows = conn + .execute("rep_tick_rows", &[]) + .map_err(|e| DbError::new(format!("[PoliticalBenefits] exec rep_tick_rows: {e}")))?; + + let mut notified: std::collections::HashSet = std::collections::HashSet::new(); + + for row in rows { + let benefit_id = parse_i32(&row, "benefit_id", -1); + let character_id = parse_i32(&row, "character_id", -1); + let gain = parse_f64_from_row(&row, "gain"); + let user_id = parse_i32(&row, "falukant_user_id", -1); + + if benefit_id < 1 || character_id < 1 || gain <= 0.0 { + continue; + } + + let gain_str = format!("{:.2}", gain); + + conn.execute("rep_apply", &[&gain_str, &character_id]) + .map_err(|e| DbError::new(format!("[PoliticalBenefits] rep_apply cid={character_id}: {e}")))?; + + conn.execute( + "rep_tick_upsert", + &[&character_id, &benefit_id], + ) + .map_err(|e| DbError::new(format!("[PoliticalBenefits] rep_tick_upsert: {e}")))?; + + eprintln!( + "[PoliticalBenefits] characterId={} officeBenefitId={} action=reputation_tick gain={}", + character_id, benefit_id, gain_str + ); + + if user_id > 0 { + notified.insert(user_id); + } + } + + for uid in notified { + let msg = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, uid); + broker.publish(msg); + } + + Ok(()) +} + +/// Täglich: `pending` → `expired`, wenn `expires_at` überschritten. +pub fn expire_political_appointments(pool: &ConnectionPool) -> Result<(), DbError> { + if !appointment_schema_ready(pool) { + return Ok(()); + } + + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("pa_expire", QUERY_POLITICAL_APPOINTMENT_EXPIRE_PENDING) + .map_err(|e| DbError::new(format!("[PoliticalBenefits] prepare pa_expire: {e}")))?; + conn.execute("pa_expire", &[]) + .map_err(|e| DbError::new(format!("[PoliticalBenefits] exec pa_expire: {e}")))?; + Ok(()) +} + +/// Summe freier Liebschafts-Slots (max. 5) für einen Charakter mit aktivem politischen Amt. +pub fn sum_free_lover_slots( + conn: &mut crate::db::DbConnection, + character_id: i32, +) -> Result { + if character_id < 1 { + return Ok(0); + } + if conn + .prepare("free_lover_slots", QUERY_SUM_FREE_LOVER_SLOTS_FOR_CHARACTER) + .is_err() + { + return Ok(0); + } + let rows = conn.execute("free_lover_slots", &[&character_id])?; + Ok(rows + .first() + .and_then(|r| r.get("free_slots")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0) + .max(0)) +} diff --git a/src/worker/politics.rs b/src/worker/politics.rs index 54eaf5e..ffd28bd 100644 --- a/src/worker/politics.rs +++ b/src/worker/politics.rs @@ -222,6 +222,13 @@ impl PoliticsWorker { // Lösch- und Besetzungsvorgängen ausgeführt. Self::trim_excess_offices_global(pool)?; + if let Err(e) = super::political_benefits::run_reputation_periodic_ticks(pool, broker) { + eprintln!("[PoliticsWorker] run_reputation_periodic_ticks: {e}"); + } + if let Err(e) = super::political_benefits::expire_political_appointments(pool) { + eprintln!("[PoliticsWorker] expire_political_appointments: {e}"); + } + Ok(()) } diff --git a/src/worker/sql.rs b/src/worker/sql.rs index 4d2a927..76c1af3 100644 --- a/src/worker/sql.rs +++ b/src/worker/sql.rs @@ -1289,6 +1289,102 @@ SELECT elig.user_id, ORDER BY elig.election_id, elig.user_id; "#; +// --- Politische Amtsvorteile (reputation_periodic, free_lover_slots, Ernennungs-Ablauf) --- + +/// `political_benefit_last_tick` + `falukant_predefine.political_office_benefit` vorhanden. +pub const QUERY_POLITICAL_BENEFIT_DAEMON_SCHEMA_READY: &str = r#" + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'falukant_data' + AND table_name = 'political_benefit_last_tick' + ) AND EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'falukant_predefine' + AND table_name = 'political_office_benefit' + ) AS ready; +"#; + +/// Spieler mit aktivem Amt + `reputation_periodic` (JSON `tr` oder `benefitType`); Kalendertage seit `last_tick_at`. +pub const QUERY_POLITICAL_REPUTATION_TICK_ROWS: &str = r#" + SELECT + pob.id AS benefit_id, + po.character_id AS character_id, + GREATEST(1, COALESCE(NULLIF((pob.value::jsonb->>'intervalDays'), '')::int, 7)) AS interval_days, + COALESCE(NULLIF((pob.value::jsonb->>'gain'), '')::numeric, 0::numeric) AS gain, + ch.user_id AS falukant_user_id + FROM falukant_data.political_office po + JOIN falukant_predefine.political_office_benefit pob + ON pob.political_office_type_id = po.office_type_id + JOIN falukant_data.character ch ON ch.id = po.character_id + LEFT JOIN falukant_data.political_benefit_last_tick btl + ON btl.character_id = po.character_id + AND btl.political_office_benefit_id = pob.id + WHERE po.character_id IS NOT NULL + AND ch.user_id IS NOT NULL + AND COALESCE(pob.value::jsonb->>'tr', pob.value::jsonb->>'benefitType') = 'reputation_periodic' + AND COALESCE(NULLIF((pob.value::jsonb->>'gain'), '')::numeric, 0::numeric) > 0 + AND ( + btl.last_tick_at IS NULL + OR (CURRENT_DATE - (btl.last_tick_at::date)) + >= GREATEST(1, COALESCE(NULLIF((pob.value::jsonb->>'intervalDays'), '')::int, 7)) + ); +"#; + +pub const QUERY_POLITICAL_REPUTATION_TICK_UPSERT: &str = r#" + INSERT INTO falukant_data.political_benefit_last_tick + (character_id, political_office_benefit_id, last_tick_at, ticks_count) + VALUES ($1::int, $2::int, NOW(), 1) + ON CONFLICT (character_id, political_office_benefit_id) + DO UPDATE SET + last_tick_at = EXCLUDED.last_tick_at, + ticks_count = falukant_data.political_benefit_last_tick.ticks_count + 1; +"#; + +pub const QUERY_POLITICAL_REPUTATION_APPLY_GAIN: &str = r#" + UPDATE falukant_data.character c + SET reputation = LEAST( + 100::numeric, + GREATEST(0::numeric, COALESCE(c.reputation, 50::numeric) + $gain::numeric) + ), + updated_at = NOW() + WHERE c.id = $1::int; +"#; + +pub const QUERY_POLITICAL_APPOINTMENT_SCHEMA_READY: &str = r#" + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'falukant_data' + AND table_name = 'political_appointment' + ) AS ready; +"#; + +pub const QUERY_POLITICAL_APPOINTMENT_EXPIRE_PENDING: &str = r#" + UPDATE falukant_data.political_appointment + SET status = 'expired', + updated_at = NOW() + WHERE status = 'pending' + AND expires_at IS NOT NULL + AND expires_at < NOW(); +"#; + +/// Summe `count` aus `free_lover_slots`-Benefits (JSON `tr`/`benefitType`), gedeckelt. +pub const QUERY_SUM_FREE_LOVER_SLOTS_FOR_CHARACTER: &str = r#" + SELECT LEAST( + 5, + COALESCE(SUM( + GREATEST(0, COALESCE(NULLIF((pob.value::jsonb->>'count'), '')::int, 0)) + ), 0) + )::int AS free_slots + FROM falukant_data.political_office po + JOIN falukant_predefine.political_office_benefit pob + ON pob.political_office_type_id = po.office_type_id + WHERE po.character_id = $1::int + AND COALESCE(pob.value::jsonb->>'tr', pob.value::jsonb->>'benefitType') = 'free_lover_slots'; +"#; + pub const QUERY_TRIM_EXCESS_OFFICES_GLOBAL: &str = r#" WITH seats AS ( SELECT