diff --git a/migrations/009_falukant_region_display_name.sql b/migrations/009_falukant_region_display_name.sql new file mode 100644 index 0000000..522f0c5 --- /dev/null +++ b/migrations/009_falukant_region_display_name.sql @@ -0,0 +1,5 @@ +-- Optionaler Anzeigename für Regionen (Todes-/UI-Logs, Karten); ohne Spalte liefert der Daemon nur region_id. +ALTER TABLE falukant_data.region + ADD COLUMN IF NOT EXISTS name text; + +COMMENT ON COLUMN falukant_data.region.name IS 'Optional: Orts-/Regionsname für Logs und UI'; diff --git a/src/worker/character_creation.rs b/src/worker/character_creation.rs index ba636bc..4ec99e2 100644 --- a/src/worker/character_creation.rs +++ b/src/worker/character_creation.rs @@ -10,6 +10,7 @@ use std::thread; use std::time::Duration; use super::base::{BaseWorker, Worker, WorkerState}; +use super::death_log; use crate::worker::sql::{ QUERY_IS_PREVIOUS_DAY_CHARACTER_CREATED, QUERY_GET_TOWN_REGION_IDS, @@ -404,6 +405,14 @@ impl CharacterCreationWorker { .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + let death_ctx = match death_log::build_deceased_context(&mut conn, character_id) { + Ok(c) => Some(c), + Err(e) => { + eprintln!("[CharacterCreationWorker] Todes-Log Kontext: {e}"); + None + } + }; + // 1) Director löschen und User benachrichtigen conn.prepare("delete_director", QUERY_DELETE_DIRECTOR)?; let dir_result = conn.execute("delete_director", &[&character_id])?; @@ -412,7 +421,15 @@ impl CharacterCreationWorker { .get("employer_user_id") .and_then(|v| v.parse::().ok()) { - Self::notify_user(pool, broker, user_id, "director_death")?; + let tr = death_ctx.as_ref().map(|c| { + death_log::wrap_death_notification(c, "director_death", user_id) + }); + Self::notify_user( + pool, + broker, + user_id, + tr.as_deref().unwrap_or("director_death"), + )?; } // 2) Relationships löschen und betroffene User benachrichtigen @@ -423,7 +440,15 @@ impl CharacterCreationWorker { .get("related_user_id") .and_then(|v| v.parse::().ok()) { - Self::notify_user(pool, broker, related_user_id, "relationship_death")?; + let tr = death_ctx.as_ref().map(|c| { + death_log::wrap_death_notification(c, "relationship_death", related_user_id) + }); + Self::notify_user( + pool, + broker, + related_user_id, + tr.as_deref().unwrap_or("relationship_death"), + )?; } } @@ -437,13 +462,29 @@ impl CharacterCreationWorker { .get("father_user_id") .and_then(|v| v.parse::().ok()) { - Self::notify_user(pool, broker, father_user_id, "child_death")?; + let tr = death_ctx.as_ref().map(|c| { + death_log::wrap_death_notification(c, "child_death", father_user_id) + }); + Self::notify_user( + pool, + broker, + father_user_id, + tr.as_deref().unwrap_or("child_death"), + )?; } if let Some(mother_user_id) = row .get("mother_user_id") .and_then(|v| v.parse::().ok()) { - Self::notify_user(pool, broker, mother_user_id, "child_death")?; + let tr = death_ctx.as_ref().map(|c| { + death_log::wrap_death_notification(c, "child_death", mother_user_id) + }); + Self::notify_user( + pool, + broker, + mother_user_id, + tr.as_deref().unwrap_or("child_death"), + )?; } } @@ -457,24 +498,27 @@ impl CharacterCreationWorker { pool: &ConnectionPool, broker: &MessageBroker, user_id: i32, - event_type: &str, + tr_payload: &str, ) -> Result<(), DbError> { let mut conn = pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("insert_notification", QUERY_INSERT_NOTIFICATION)?; - conn.execute("insert_notification", &[&user_id, &event_type])?; + conn.execute("insert_notification", &[&user_id, &tr_payload])?; // falukantUpdateStatus let update_message = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id); broker.publish(update_message); - // ursprüngliche Benachrichtigung - let message = - format!(r#"{{"event":"{event_type}","user_id":{}}}"#, user_id); - broker.publish(message); + if tr_payload.trim_start().starts_with('{') { + broker.publish(tr_payload.to_string()); + } else { + let message = + format!(r#"{{"event":"{tr_payload}","user_id":{user_id}}}"#); + broker.publish(message); + } Ok(()) } diff --git a/src/worker/death_log.rs b/src/worker/death_log.rs new file mode 100644 index 0000000..ce0ea94 --- /dev/null +++ b/src/worker/death_log.rs @@ -0,0 +1,107 @@ +//! Kontext für `falukant_log.notification` bei Charaktertod (Direktor, Partner, Eltern). +//! `tr` enthält JSON mit Verstorbenem, Ansässigkeit, Alter, Ehe/Kind/Liebschaft. +//! +//! **Aufruf vor** `DELETE` auf `relationship` / `child_relation`, sonst sind Partner/Kinder/Geliebte leer. +//! +//! **Geliebte** sind keine eigene Tabelle: `falukant_data.relationship` + Typ `falukant_type.relationship.tr = 'lover'`, +//! Zusatzdaten in `relationship_state`. Zählt nur, wenn `relationship_state.active` nicht `false` ist (wie Backend-UI). +//! `lover_role` (`secret_affair` / `lover` / `mistress_or_favorite`) wird für die Liste **nicht** gefiltert. +//! +//! Kinder aus Liebschaft sind in `child_relation.birth_context = 'lover'` markiert; zusätzlich unter `linked.lover_birth_children`. + +use crate::db::{DbConnection, DbError, Row}; +use serde_json::{json, Value}; + +use crate::worker::sql::{ + QUERY_DEATH_LOG_CHARACTER_BASE, QUERY_DEATH_LOG_CHILD_DISPLAY_NAMES, + QUERY_DEATH_LOG_CHILD_LOVER_BIRTH_DISPLAY_NAMES, QUERY_DEATH_LOG_LOVER_DISPLAY_NAMES, + QUERY_DEATH_LOG_SPOUSE_DISPLAY_NAMES, +}; + +fn collect_display_names(rows: Vec, key: &str) -> Vec { + let mut out = Vec::new(); + for r in rows { + if let Some(s) = r.get(key) { + let t = s.trim(); + if !t.is_empty() { + out.push(t.to_string()); + } + } + } + out +} + +/// Lädt Verstorbenen + Ehepartner, Kinder, **aktive Geliebte** (`tr = 'lover'`, `active IS NOT FALSE`). +/// Muss ausgeführt werden, bevor Beziehungen aus der DB gelöscht werden. +pub fn build_deceased_context(conn: &mut DbConnection, deceased_character_id: i32) -> Result { + conn.prepare("death_base", QUERY_DEATH_LOG_CHARACTER_BASE)?; + let base_rows = conn.execute("death_base", &[&deceased_character_id])?; + let base = base_rows.first().ok_or_else(|| { + DbError::new(format!( + "Todes-Log: Charakter {deceased_character_id} nicht gefunden" + )) + })?; + + let character_id = base + .get("character_id") + .and_then(|v| v.parse::().ok()) + .unwrap_or(deceased_character_id); + let display_name = base + .get("display_name") + .cloned() + .unwrap_or_else(|| character_id.to_string()); + let region_id = base + .get("region_id") + .and_then(|v| v.parse::().ok()); + let region_label = base + .get("region_label") + .cloned() + .unwrap_or_default(); + let age_years = base + .get("age_years") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + conn.prepare("death_spouse", QUERY_DEATH_LOG_SPOUSE_DISPLAY_NAMES)?; + let spouse_rows = conn.execute("death_spouse", &[&deceased_character_id])?; + let spouses = collect_display_names(spouse_rows, "display_name"); + + conn.prepare("death_child", QUERY_DEATH_LOG_CHILD_DISPLAY_NAMES)?; + let child_rows = conn.execute("death_child", &[&deceased_character_id])?; + let children = collect_display_names(child_rows, "display_name"); + + conn.prepare("death_child_lover_birth", QUERY_DEATH_LOG_CHILD_LOVER_BIRTH_DISPLAY_NAMES)?; + let child_lover_rows = conn.execute("death_child_lover_birth", &[&deceased_character_id])?; + let lover_birth_children = collect_display_names(child_lover_rows, "display_name"); + + conn.prepare("death_lover", QUERY_DEATH_LOG_LOVER_DISPLAY_NAMES)?; + let lover_rows = conn.execute("death_lover", &[&deceased_character_id])?; + let lovers = collect_display_names(lover_rows, "display_name"); + + Ok(json!({ + "deceased": { + "character_id": character_id, + "display_name": display_name, + "region_id": region_id, + "region_label": region_label, + "age_years": age_years, + }, + "linked": { + "spouses": spouses, + "children": children, + "lover_birth_children": lover_birth_children, + "lovers": lovers, + }, + })) +} + +/// Vollständiges `tr`-/WebSocket-JSON mit `event` und `user_id`. +pub fn wrap_death_notification(context: &Value, event: &str, user_id: i32) -> String { + json!({ + "event": event, + "user_id": user_id, + "deceased": context.get("deceased"), + "linked": context.get("linked"), + }) + .to_string() +} diff --git a/src/worker/events.rs b/src/worker/events.rs index c022f78..e49bd42 100644 --- a/src/worker/events.rs +++ b/src/worker/events.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use super::base::{BaseWorker, Worker, WorkerState}; +use super::death_log; use crate::worker::sql::{ QUERY_GET_RANDOM_USER, QUERY_GET_RANDOM_INFANT, @@ -1751,6 +1752,15 @@ impl EventsWorker { .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + // Todes-Kontext (Namen, Ort, Alter, Ehe/Kind/Liebschaft) vor Löschen der Beziehungen + let death_ctx = match death_log::build_deceased_context(&mut conn, character_id) { + Ok(c) => Some(c), + Err(e) => { + eprintln!("[EventsWorker] Todes-Log Kontext: {e}"); + None + } + }; + // 1) Director löschen und User benachrichtigen conn.prepare("delete_director", QUERY_DELETE_DIRECTOR)?; let dir_result = conn.execute("delete_director", &[&character_id])?; @@ -1759,7 +1769,15 @@ impl EventsWorker { .get("employer_user_id") .and_then(|v| v.parse::().ok()) { - Self::notify_user(pool, broker, user_id, "director_death")?; + let tr = death_ctx.as_ref().map(|c| { + death_log::wrap_death_notification(c, "director_death", user_id) + }); + Self::notify_user( + pool, + broker, + user_id, + tr.as_deref().unwrap_or("director_death"), + )?; } } @@ -1771,7 +1789,15 @@ impl EventsWorker { .get("related_user_id") .and_then(|v| v.parse::().ok()) { - Self::notify_user(pool, broker, related_user_id, "relationship_death")?; + let tr = death_ctx.as_ref().map(|c| { + death_log::wrap_death_notification(c, "relationship_death", related_user_id) + }); + Self::notify_user( + pool, + broker, + related_user_id, + tr.as_deref().unwrap_or("relationship_death"), + )?; } } @@ -1801,13 +1827,29 @@ impl EventsWorker { .get("father_user_id") .and_then(|v| v.parse::().ok()) { - Self::notify_user(pool, broker, father_user_id, "child_death")?; + let tr = death_ctx.as_ref().map(|c| { + death_log::wrap_death_notification(c, "child_death", father_user_id) + }); + Self::notify_user( + pool, + broker, + father_user_id, + tr.as_deref().unwrap_or("child_death"), + )?; } if let Some(mother_user_id) = row .get("mother_user_id") .and_then(|v| v.parse::().ok()) { - Self::notify_user(pool, broker, mother_user_id, "child_death")?; + let tr = death_ctx.as_ref().map(|c| { + death_log::wrap_death_notification(c, "child_death", mother_user_id) + }); + Self::notify_user( + pool, + broker, + mother_user_id, + tr.as_deref().unwrap_or("child_death"), + )?; } } @@ -1937,28 +1979,32 @@ impl EventsWorker { Ok(()) } + /// `tr_payload`: kurzer `event`-Name oder JSON-Objekt (Todes-Logs mit `deceased`/`linked`). fn notify_user( pool: &ConnectionPool, broker: &MessageBroker, user_id: i32, - event_type: &str, + tr_payload: &str, ) -> Result<(), DbError> { let mut conn = pool .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; conn.prepare("insert_notification", QUERY_INSERT_NOTIFICATION)?; - conn.execute("insert_notification", &[&user_id, &event_type])?; + conn.execute("insert_notification", &[&user_id, &tr_payload])?; // falukantUpdateStatus let update_message = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id); broker.publish(update_message); - // ursprüngliche Benachrichtigung - let message = - format!(r#"{{"event":"{event_type}","user_id":{}}}"#, user_id); - broker.publish(message); + if tr_payload.trim_start().starts_with('{') { + broker.publish(tr_payload.to_string()); + } else { + let message = + format!(r#"{{"event":"{tr_payload}","user_id":{user_id}}}"#); + broker.publish(message); + } Ok(()) } diff --git a/src/worker/mod.rs b/src/worker/mod.rs index b526c1d..3daed3c 100644 --- a/src/worker/mod.rs +++ b/src/worker/mod.rs @@ -11,6 +11,7 @@ mod user_character; mod transport; mod weather; mod events; +mod death_log; mod falukant_family; mod falukant_certificate; mod falukant_servants; diff --git a/src/worker/sql.rs b/src/worker/sql.rs index c957cb7..aa8116c 100644 --- a/src/worker/sql.rs +++ b/src/worker/sql.rs @@ -641,6 +641,67 @@ pub const QUERY_GET_REGION_CHARACTERS: &str = r#" SELECT id, health, user_id FROM falukant_data."character" WHERE region_id = $1 AND health > 0; "#; +/// Anzeige für Todes-Logs (`falukant_log.notification.tr` als JSON): Name, Wohnort, Alter, Verknüpfungen. +/// `region_id` = Ansässigkeit; `region_label` aus `falukant_data.region.name`, falls vorhanden, sonst `region_id` als Text. +pub const QUERY_DEATH_LOG_CHARACTER_BASE: &str = r#" + SELECT + c.id AS character_id, + COALESCE(TRIM(BOTH FROM COALESCE(fn.label::text, '') || ' ' || COALESCE(ln.label::text, '')), c.id::text) AS display_name, + c.region_id, + COALESCE(NULLIF(TRIM(dr.name::text), ''), c.region_id::text) AS region_label, + GREATEST(0, FLOOR((CURRENT_DATE - c.birthdate::date) / 365.25))::int AS age_years + FROM falukant_data.character c + LEFT JOIN falukant_predefine.firstname fn ON fn.id = c.first_name + LEFT JOIN falukant_predefine.lastname ln ON ln.id = c.last_name + LEFT JOIN falukant_data.region dr ON dr.id = c.region_id + WHERE c.id = $1::int; +"#; + +pub const QUERY_DEATH_LOG_SPOUSE_DISPLAY_NAMES: &str = r#" + SELECT COALESCE(TRIM(BOTH FROM COALESCE(fn.label::text, '') || ' ' || COALESCE(ln.label::text, '')), o.id::text) AS display_name + 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 o ON o.id = CASE WHEN r.character1_id = $1::int THEN r.character2_id ELSE r.character1_id END + LEFT JOIN falukant_predefine.firstname fn ON fn.id = o.first_name + LEFT JOIN falukant_predefine.lastname ln ON ln.id = o.last_name + WHERE r.character1_id = $1::int OR r.character2_id = $1::int; +"#; + +pub const QUERY_DEATH_LOG_CHILD_DISPLAY_NAMES: &str = r#" + SELECT COALESCE(TRIM(BOTH FROM COALESCE(fn.label::text, '') || ' ' || COALESCE(ln.label::text, '')), ch.id::text) AS display_name + FROM falukant_data.child_relation cr + JOIN falukant_data.character ch ON ch.id = cr.child_character_id + LEFT JOIN falukant_predefine.firstname fn ON fn.id = ch.first_name + LEFT JOIN falukant_predefine.lastname ln ON ln.id = ch.last_name + WHERE cr.father_character_id = $1::int OR cr.mother_character_id = $1::int; +"#; + +/// Kinder mit `birth_context = 'lover'` (unehelich / aus Liebschaft); Teilmenge von `QUERY_DEATH_LOG_CHILD_DISPLAY_NAMES`. +pub const QUERY_DEATH_LOG_CHILD_LOVER_BIRTH_DISPLAY_NAMES: &str = r#" + SELECT COALESCE(TRIM(BOTH FROM COALESCE(fn.label::text, '') || ' ' || COALESCE(ln.label::text, '')), ch.id::text) AS display_name + FROM falukant_data.child_relation cr + JOIN falukant_data.character ch ON ch.id = cr.child_character_id + LEFT JOIN falukant_predefine.firstname fn ON fn.id = ch.first_name + LEFT JOIN falukant_predefine.lastname ln ON ln.id = ch.last_name + WHERE (cr.father_character_id = $1::int OR cr.mother_character_id = $1::int) + AND cr.birth_context = 'lover'; +"#; + +/// Geliebte: Zeilen in `falukant_data.relationship`, Typ über `falukant_type.relationship` mit `tr = 'lover'` +/// (kein Filter auf `relationship_state.lover_role` — `secret_affair` / `lover` / `mistress_or_favorite` sind nur Ausprägungen). +/// „Aktiv“ wie Backend (`falukantService`): `relationship_state.active` darf nicht `false` sein (`IS NOT FALSE`). +pub const QUERY_DEATH_LOG_LOVER_DISPLAY_NAMES: &str = r#" + SELECT COALESCE(TRIM(BOTH FROM COALESCE(fn.label::text, '') || ' ' || COALESCE(ln.label::text, '')), o.id::text) AS display_name + 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 AND (rs.active IS NOT FALSE) + JOIN falukant_data.character o ON o.id = CASE WHEN r.character1_id = $1::int THEN r.character2_id ELSE r.character1_id END + LEFT JOIN falukant_predefine.firstname fn ON fn.id = o.first_name + LEFT JOIN falukant_predefine.lastname ln ON ln.id = o.last_name + WHERE r.character1_id = $1::int OR r.character2_id = $1::int; +"#; + pub const QUERY_DELETE_DIRECTOR: &str = r#" DELETE FROM falukant_data.director WHERE director_character_id = $1 RETURNING employer_user_id; "#; diff --git a/src/worker/user_character.rs b/src/worker/user_character.rs index 8d3f760..8d09c0b 100644 --- a/src/worker/user_character.rs +++ b/src/worker/user_character.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use super::base::{BaseWorker, Worker, WorkerState}; +use super::death_log; use crate::worker::sql::{ QUERY_GET_CHARACTERS_ZERO_HEALTH, QUERY_GET_USERS_TO_UPDATE, @@ -646,23 +647,28 @@ impl UserCharacterWorker { self.base.broker.publish(update_status); } - /// Wie `EventsWorker::notify_user`: Eintrag in `falukant_log.notification` (`tr` = event_type) + WS. + /// Wie `EventsWorker::notify_user`: Eintrag in `falukant_log.notification` (`tr` = Payload) + WS. + /// `tr_payload` kann ein Kurz-String (`director_death`) oder vollständiges JSON sein. fn notify_user_death( &self, conn: &mut crate::db::DbConnection, user_id: i32, - event_type: &str, + tr_payload: &str, ) -> Result<(), DbError> { if user_id <= 0 { return Ok(()); } - conn.execute("insert_notification", &[&user_id, &event_type])?; + conn.execute("insert_notification", &[&user_id, &tr_payload])?; let update_message = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id); self.base.broker.publish(update_message); - let message = - format!(r#"{{"event":"{event_type}","user_id":{}}}"#, user_id); - self.base.broker.publish(message); + if tr_payload.trim_start().starts_with('{') { + self.base.broker.publish(tr_payload.to_string()); + } else { + let message = + format!(r#"{{"event":"{tr_payload}","user_id":{user_id}}}"#); + self.base.broker.publish(message); + } Ok(()) } @@ -692,13 +698,28 @@ impl UserCharacterWorker { conn.prepare("delete_election_candidate", QUERY_DELETE_ELECTION_CANDIDATE)?; conn.prepare("insert_notification", QUERY_INSERT_NOTIFICATION)?; + let death_ctx = match death_log::build_deceased_context(&mut conn, character_id) { + Ok(c) => Some(c), + Err(e) => { + eprintln!("[UserCharacterWorker] Todes-Log Kontext: {e}"); + None + } + }; + let dir_result = conn.execute("delete_director", &[&character_id])?; for row in dir_result { if let Some(user_id) = row .get("employer_user_id") .and_then(|v| v.parse::().ok()) { - self.notify_user_death(&mut conn, user_id, "director_death")?; + let tr = death_ctx.as_ref().map(|c| { + death_log::wrap_death_notification(c, "director_death", user_id) + }); + self.notify_user_death( + &mut conn, + user_id, + tr.as_deref().unwrap_or("director_death"), + )?; } } @@ -708,7 +729,14 @@ impl UserCharacterWorker { .get("related_user_id") .and_then(|v| v.parse::().ok()) { - self.notify_user_death(&mut conn, related_user_id, "relationship_death")?; + let tr = death_ctx.as_ref().map(|c| { + death_log::wrap_death_notification(c, "relationship_death", related_user_id) + }); + self.notify_user_death( + &mut conn, + related_user_id, + tr.as_deref().unwrap_or("relationship_death"), + )?; } } @@ -719,13 +747,27 @@ impl UserCharacterWorker { .get("father_user_id") .and_then(|v| v.parse::().ok()) { - self.notify_user_death(&mut conn, father_user_id, "child_death")?; + let tr = death_ctx.as_ref().map(|c| { + death_log::wrap_death_notification(c, "child_death", father_user_id) + }); + self.notify_user_death( + &mut conn, + father_user_id, + tr.as_deref().unwrap_or("child_death"), + )?; } if let Some(mother_user_id) = row .get("mother_user_id") .and_then(|v| v.parse::().ok()) { - self.notify_user_death(&mut conn, mother_user_id, "child_death")?; + let tr = death_ctx.as_ref().map(|c| { + death_log::wrap_death_notification(c, "child_death", mother_user_id) + }); + self.notify_user_death( + &mut conn, + mother_user_id, + tr.as_deref().unwrap_or("child_death"), + )?; } } conn.execute("delete_knowledge", &[&character_id])?;