diff --git a/docs/FALUKANT_DEBTORS_DAEMON.md b/docs/FALUKANT_DEBTORS_DAEMON.md new file mode 100644 index 0000000..2933b66 --- /dev/null +++ b/docs/FALUKANT_DEBTORS_DAEMON.md @@ -0,0 +1,29 @@ +# Falukant: Schuldturm & Pfändung (Daemon) + +Der externe Daemon (`YpDaemon`) pflegt Verzugstage, Schuldturm-Eintritt, Pfändung und Freilassung. **Keine zusätzliche Migration** im Daemon nötig – Spalten in `falukant_data.debtors_prism` kommen vom Projekt. + +## Wo im Code + +| Komponente | Datei | +|------------|--------| +| SQL | `src/worker/sql.rs` (`QUERY_DEBTORS_*`) | +| Tageslogik | `src/worker/falukant_debtors.rs` | +| Stündliche Kreditrate + Verzug-Reset bei Zahlung | `src/worker/user_character.rs` | +| Daily-Tick (24 h) | `src/worker/falukant_family.rs` → `falukant_debtors::run_daily` | + +## Ablauf + +1. **Stündlich**: `handle_credits` zieht Rate, wenn genug Geld. Bei Erfolg: `falukant_debtors::on_credit_payment_success` setzt `days_overdue` für `status = delinquent` zurück. +2. **Täglich** (gleicher Rhythmus wie Falukant-Family-Daily): Nutzer mit offenem Kredit → Verzug erhöhen / Warnstufen / ab Tag 3 Eintritt `imprisoned` → Geld- und Fahrzeugverwertung → soziale Haftfolgen. + +## Events (WebSocket) + +Primär: `falukantUpdateDebt` mit `reason` (siehe Projektspezifikation). Begleitend: `falukantUpdateStatus`, `falukantUpdateFamily`, `falukantHouseUpdate`, `falukantBranchUpdate` je nach Schritt. + +## Noch offen (größere Versionen) + +- Waren/Lager, Hauspfändung, Niederlassungsschließung +- `household_tension_reasons_json` um `debtorsPrison` ergänzen +- Beziehungsabbruch nach Spec (Schwellen / Zufall) + +Siehe die vollständige fachliche Spezifikation im Projektdokument „Schuldturm und Pfändung“. diff --git a/src/worker/falukant_debtors.rs b/src/worker/falukant_debtors.rs new file mode 100644 index 0000000..b5fb4d2 --- /dev/null +++ b/src/worker/falukant_debtors.rs @@ -0,0 +1,440 @@ +//! Schuldturm, Verzug, Pfändung (täglich). Siehe `docs/FALUKANT_DEBTORS_DAEMON.md`. +//! Voraussetzung: erweiterte Spalten in `falukant_data.debtors_prism` (projektseitig). + +use crate::db::DbError; +use crate::message_broker::MessageBroker; +use crate::worker::base::BaseWorker; +use crate::worker::sql::{ + QUERY_DEBTORS_CREDIT_USERS_FOR_DAILY, QUERY_DEBTORS_DELETE_VEHICLE, + QUERY_DEBTORS_ENTER_PRISON, QUERY_DEBTORS_GET_PRISM_BY_CHARACTER, + QUERY_DEBTORS_HOUSEHOLD_TENSION_ADD, QUERY_DEBTORS_IMPRISONED_LOVER_AFF_SUB2, + QUERY_DEBTORS_IMPRISONED_MARRIAGE_SUB1, QUERY_DEBTORS_IMPRISONED_PENALTY_PLUS1, + QUERY_DEBTORS_IMPRISONED_REP_MALUS, QUERY_DEBTORS_IMPRISONED_TENSION_PLUS2, + QUERY_DEBTORS_INSERT_DELINQUENT, QUERY_DEBTORS_INCREMENT_DELINQUENT, + QUERY_DEBTORS_REACTIVATE_DELINQUENT_FROM_RELEASED, + QUERY_DEBTORS_LOVER_AFFECTION_SUB, QUERY_DEBTORS_MARRIAGE_SATISFACTION_SUB, + QUERY_DEBTORS_PRISM_SCHEMA_READY, QUERY_DEBTORS_RELEASE_IF_PAID, + QUERY_DEBTORS_RESET_DELINQUENCY_SOLVENT, QUERY_DEBTORS_RESET_ON_PAYMENT_SUCCESS, + QUERY_DEBTORS_SUBTRACT_REPUTATION, QUERY_DEBTORS_UPDATE_PRISM_REMAINING, + QUERY_DEBTORS_UPDATE_PRISM_REMAINING_MONEY, QUERY_DEBTORS_VEHICLE_FOR_SEIZURE, +}; + +fn can_afford_installments(money: f64, total_pay_rate: f64) -> bool { + if total_pay_rate <= 0.0 { + return true; + } + // Wie UserCharacterWorker::process_single_credit: Reserve 3× Rate + total_pay_rate <= money - (total_pay_rate * 3.0) +} + +fn publish_debt(broker: &MessageBroker, user_id: i32, reason: &str) { + let d = format!( + r#"{{"event":"falukantUpdateDebt","user_id":{},"reason":"{}"}}"#, + user_id, reason + ); + broker.publish(d); + let s = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id); + broker.publish(s); +} + +fn publish_family_daily(broker: &MessageBroker, user_id: i32) { + let m = format!( + r#"{{"event":"falukantUpdateFamily","user_id":{},"reason":"daily"}}"#, + user_id + ); + broker.publish(m); +} + +fn publish_house(broker: &MessageBroker, user_id: i32) { + let m = format!(r#"{{"event":"falukantHouseUpdate","user_id":{}}}"#, user_id); + broker.publish(m); +} + +fn publish_branch(broker: &MessageBroker, user_id: i32) { + let m = format!(r#"{{"event":"falukantBranchUpdate","user_id":{}}}"#, user_id); + broker.publish(m); +} + +fn parse_i32_row(row: &crate::db::Row, key: &str, default: i32) -> i32 { + row.get(key) + .and_then(|v| v.parse::().ok()) + .unwrap_or(default) +} + +fn parse_f64_row(row: &crate::db::Row, key: &str, default: f64) -> f64 { + row.get(key) + .and_then(|v| v.parse::().ok()) + .unwrap_or(default) +} + +/// Nach erfolgreicher Kreditrate (hourly): Verzug zurücksetzen, solange nicht im Schuldturm. +pub fn on_credit_payment_success(base: &BaseWorker, character_id: i32) -> Result<(), DbError> { + let mut conn = base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + conn.prepare( + "dp_reset_pay", + QUERY_DEBTORS_RESET_ON_PAYMENT_SUCCESS, + ) + .map_err(|e| DbError::new(format!("prepare dp_reset_pay: {e}")))?; + conn.execute("dp_reset_pay", &[&character_id]) + .map_err(|e| DbError::new(format!("exec dp_reset_pay: {e}")))?; + Ok(()) +} + +pub fn schema_ready(pool: &crate::db::ConnectionPool) -> bool { + let Ok(mut conn) = pool.get() else { + return false; + }; + if conn + .prepare("dp_schema", QUERY_DEBTORS_PRISM_SCHEMA_READY) + .is_err() + { + return false; + } + let Ok(rows) = conn.execute("dp_schema", &[]) else { + return false; + }; + rows.first() + .and_then(|r| r.get("ready")) + .map(|v| v == "t" || v == "true") + .unwrap_or(false) +} + +pub fn run_daily(base: &BaseWorker, broker: &MessageBroker) -> Result<(), DbError> { + if !schema_ready(&base.pool) { + return Ok(()); + } + + let mut conn = base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("dp_users", QUERY_DEBTORS_CREDIT_USERS_FOR_DAILY) + .map_err(|e| DbError::new(format!("prepare dp_users: {e}")))?; + let user_rows = conn + .execute("dp_users", &[]) + .map_err(|e| DbError::new(format!("exec dp_users: {e}")))?; + + for row in user_rows { + let user_id = parse_i32_row(&row, "user_id", -1); + let character_id = parse_i32_row(&row, "character_id", -1); + let money = parse_f64_row(&row, "money", 0.0); + let total_pay_rate = parse_f64_row(&row, "total_pay_rate", 0.0); + let total_credit_remaining = parse_f64_row(&row, "total_credit_remaining", 0.0); + if user_id < 0 || character_id < 0 { + continue; + } + + drop(conn); + process_one_user( + base, + broker, + user_id, + character_id, + money, + total_pay_rate, + total_credit_remaining, + )?; + + conn = base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + conn.prepare("dp_users", QUERY_DEBTORS_CREDIT_USERS_FOR_DAILY) + .map_err(|e| DbError::new(format!("prepare dp_users: {e}")))?; + } + + Ok(()) +} + +fn process_one_user( + base: &BaseWorker, + broker: &MessageBroker, + user_id: i32, + character_id: i32, + money: f64, + total_pay_rate: f64, + total_credit_remaining: f64, +) -> Result<(), DbError> { + let mut conn = base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("dp_get", QUERY_DEBTORS_GET_PRISM_BY_CHARACTER) + .map_err(|e| DbError::new(format!("prepare dp_get: {e}")))?; + let prism_rows = conn + .execute("dp_get", &[&character_id]) + .map_err(|e| DbError::new(format!("exec dp_get: {e}")))?; + + let status = prism_rows + .first() + .and_then(|r| r.get("status")) + .cloned() + .unwrap_or_default(); + + let days_overdue = prism_rows + .first() + .map(|r| parse_i32_row(r, "days_overdue", 0)) + .unwrap_or(0); + + let solvent = can_afford_installments(money, total_pay_rate); + + // Freilassung wenn Kredit getilgt + conn.prepare("dp_rel", QUERY_DEBTORS_RELEASE_IF_PAID) + .map_err(|e| DbError::new(format!("prepare dp_rel: {e}")))?; + let released = conn + .execute("dp_rel", &[&user_id]) + .map_err(|e| DbError::new(format!("exec dp_rel: {e}")))?; + if !released.is_empty() { + publish_debt(broker, user_id, "debtors_prison_released"); + publish_family_daily(broker, user_id); + publish_house(broker, user_id); + publish_branch(broker, user_id); + return Ok(()); + } + + if status == "imprisoned" { + apply_imprisoned_daily_effects(base, broker, user_id, character_id)?; + seize_assets_step(base, broker, user_id, character_id, money)?; + return Ok(()); + } + + // Zahlungsfähig: Verzugstage nur bei delinquent zurücksetzen (nicht im Schuldturm). + if solvent && status == "delinquent" { + conn.prepare("dp_sol", QUERY_DEBTORS_RESET_DELINQUENCY_SOLVENT) + .map_err(|e| DbError::new(format!("prepare dp_sol: {e}")))?; + conn.execute("dp_sol", &[&character_id]) + .map_err(|e| DbError::new(format!("exec dp_sol: {e}")))?; + return Ok(()); + } + + if !solvent && total_credit_remaining > 0.01 { + if prism_rows.is_empty() || status.is_empty() { + conn.prepare("dp_ins", QUERY_DEBTORS_INSERT_DELINQUENT) + .map_err(|e| DbError::new(format!("prepare dp_ins: {e}")))?; + let ins = conn + .execute("dp_ins", &[&character_id, &total_credit_remaining]) + .map_err(|e| DbError::new(format!("exec dp_ins: {e}")))?; + if !ins.is_empty() { + publish_debt(broker, user_id, "delinquency"); + } + } else if status == "released" { + conn.prepare("dp_react", QUERY_DEBTORS_REACTIVATE_DELINQUENT_FROM_RELEASED) + .map_err(|e| DbError::new(format!("prepare dp_react: {e}")))?; + let r = conn + .execute("dp_react", &[&character_id, &total_credit_remaining]) + .map_err(|e| DbError::new(format!("exec dp_react: {e}")))?; + if !r.is_empty() { + publish_debt(broker, user_id, "delinquency"); + } + } else if status == "delinquent" { + conn.prepare("dp_inc", QUERY_DEBTORS_INCREMENT_DELINQUENT) + .map_err(|e| DbError::new(format!("prepare dp_inc: {e}")))?; + let inc = conn + .execute("dp_inc", &[&character_id, &total_credit_remaining]) + .map_err(|e| DbError::new(format!("exec dp_inc: {e}")))?; + if !inc.is_empty() { + publish_debt(broker, user_id, "delinquency"); + } + } + + drop(conn); + let mut conn2 = base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + conn2 + .prepare("dp_get2", QUERY_DEBTORS_GET_PRISM_BY_CHARACTER) + .map_err(|e| DbError::new(format!("prepare dp_get2: {e}")))?; + let pr2 = conn2 + .execute("dp_get2", &[&character_id]) + .map_err(|e| DbError::new(format!("exec dp_get2: {e}")))?; + let d = pr2 + .first() + .map(|r| parse_i32_row(r, "days_overdue", 0)) + .unwrap_or(days_overdue); + + if d >= 3 { + try_enter_prison(base, broker, user_id, character_id, total_credit_remaining)?; + } + } + + Ok(()) +} + +fn try_enter_prison( + base: &BaseWorker, + broker: &MessageBroker, + user_id: i32, + character_id: i32, + total_credit_remaining: f64, +) -> Result<(), DbError> { + let mut conn = base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("dp_ent", QUERY_DEBTORS_ENTER_PRISON) + .map_err(|e| DbError::new(format!("prepare dp_ent: {e}")))?; + let rows = conn + .execute("dp_ent", &[&character_id, &total_credit_remaining]) + .map_err(|e| DbError::new(format!("exec dp_ent: {e}")))?; + + if rows.is_empty() { + return Ok(()); + } + + conn.prepare("dp_rep12", QUERY_DEBTORS_SUBTRACT_REPUTATION) + .map_err(|e| DbError::new(format!("prepare dp_rep12: {e}")))?; + conn.execute("dp_rep12", &[&character_id, &12_i32]) + .map_err(|e| DbError::new(format!("exec dp_rep12: {e}")))?; + + conn.prepare("dp_mar10", QUERY_DEBTORS_MARRIAGE_SATISFACTION_SUB) + .map_err(|e| DbError::new(format!("prepare dp_mar10: {e}")))?; + conn.execute("dp_mar10", &[&character_id, &10_i32]) + .map_err(|e| DbError::new(format!("exec dp_mar10: {e}")))?; + + conn.prepare("dp_tens15", QUERY_DEBTORS_HOUSEHOLD_TENSION_ADD) + .map_err(|e| DbError::new(format!("prepare dp_tens15: {e}")))?; + conn.execute("dp_tens15", &[&user_id, &15_i32]) + .map_err(|e| DbError::new(format!("exec dp_tens15: {e}")))?; + + conn.prepare("dp_lov4", QUERY_DEBTORS_LOVER_AFFECTION_SUB) + .map_err(|e| DbError::new(format!("prepare dp_lov4: {e}")))?; + conn.execute("dp_lov4", &[&character_id, &4_i32]) + .map_err(|e| DbError::new(format!("exec dp_lov4: {e}")))?; + + publish_debt(broker, user_id, "debtors_prison_entered"); + publish_family_daily(broker, user_id); + publish_house(broker, user_id); + publish_branch(broker, user_id); + + Ok(()) +} + +fn apply_imprisoned_daily_effects( + base: &BaseWorker, + broker: &MessageBroker, + user_id: i32, + character_id: i32, +) -> Result<(), DbError> { + let mut conn = base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("dp_r2", QUERY_DEBTORS_IMPRISONED_REP_MALUS) + .map_err(|e| DbError::new(format!("prepare dp_r2: {e}")))?; + conn.execute("dp_r2", &[&character_id, &2_i32]) + .map_err(|e| DbError::new(format!("exec dp_r2: {e}")))?; + + conn.prepare("dp_p1", QUERY_DEBTORS_IMPRISONED_PENALTY_PLUS1) + .map_err(|e| DbError::new(format!("prepare dp_p1: {e}")))?; + conn.execute("dp_p1", &[&character_id]) + .map_err(|e| DbError::new(format!("exec dp_p1: {e}")))?; + + conn.prepare("dp_m1", QUERY_DEBTORS_IMPRISONED_MARRIAGE_SUB1) + .map_err(|e| DbError::new(format!("prepare dp_m1: {e}")))?; + conn.execute("dp_m1", &[&character_id]) + .map_err(|e| DbError::new(format!("exec dp_m1: {e}")))?; + + conn.prepare("dp_t2", QUERY_DEBTORS_IMPRISONED_TENSION_PLUS2) + .map_err(|e| DbError::new(format!("prepare dp_t2: {e}")))?; + conn.execute("dp_t2", &[&character_id]) + .map_err(|e| DbError::new(format!("exec dp_t2: {e}")))?; + + conn.prepare("dp_la2", QUERY_DEBTORS_IMPRISONED_LOVER_AFF_SUB2) + .map_err(|e| DbError::new(format!("prepare dp_la2: {e}")))?; + conn.execute("dp_la2", &[&character_id]) + .map_err(|e| DbError::new(format!("exec dp_la2: {e}")))?; + + publish_family_daily(broker, user_id); + let s = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id); + broker.publish(s); + + Ok(()) +} + +fn seize_assets_step( + base: &BaseWorker, + broker: &MessageBroker, + user_id: i32, + character_id: i32, + money: f64, +) -> Result<(), DbError> { + let mut conn = base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("dp_getr", QUERY_DEBTORS_GET_PRISM_BY_CHARACTER) + .map_err(|e| DbError::new(format!("prepare dp_getr: {e}")))?; + let pr = conn + .execute("dp_getr", &[&character_id]) + .map_err(|e| DbError::new(format!("exec dp_getr: {e}")))?; + let remaining = pr + .first() + .map(|r| parse_f64_row(r, "remaining_debt", 0.0)) + .unwrap_or(0.0); + if remaining <= 0.01 { + return Ok(()); + } + + drop(conn); + + if money > 0.01 { + let take = money.min(remaining); + base.change_falukant_user_money(user_id, -take, "debtors asset seizure")?; + + let mut conn = base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + conn.prepare("dp_rm", QUERY_DEBTORS_UPDATE_PRISM_REMAINING_MONEY) + .map_err(|e| DbError::new(format!("prepare dp_rm: {e}")))?; + conn.execute("dp_rm", &[&character_id, &take]) + .map_err(|e| DbError::new(format!("exec dp_rm: {e}")))?; + + publish_debt(broker, user_id, "asset_seizure"); + publish_branch(broker, user_id); + return Ok(()); + } + + let mut conn = base + .pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + conn.prepare("dp_v", QUERY_DEBTORS_VEHICLE_FOR_SEIZURE) + .map_err(|e| DbError::new(format!("prepare dp_v: {e}")))?; + let vr = conn + .execute("dp_v", &[&user_id]) + .map_err(|e| DbError::new(format!("exec dp_v: {e}")))?; + + if let Some(row) = vr.first() { + let vid = parse_i32_row(row, "vehicle_id", -1); + let cond = parse_i32_row(row, "veh_condition", 100).max(1) as f64; + let type_cost = parse_f64_row(row, "type_cost", 0.0); + if vid > 0 && type_cost > 0.0 { + let proceeds = (type_cost * (cond / 100.0) * 0.55).max(0.0); + conn.prepare("dp_dv", QUERY_DEBTORS_DELETE_VEHICLE) + .map_err(|e| DbError::new(format!("prepare dp_dv: {e}")))?; + conn.execute("dp_dv", &[&vid]) + .map_err(|e| DbError::new(format!("exec dp_dv: {e}")))?; + + conn.prepare("dp_rmv", QUERY_DEBTORS_UPDATE_PRISM_REMAINING) + .map_err(|e| DbError::new(format!("prepare dp_rmv: {e}")))?; + conn.execute("dp_rmv", &[&character_id, &proceeds, &vid]) + .map_err(|e| DbError::new(format!("exec dp_rmv: {e}")))?; + + publish_debt(broker, user_id, "vehicle_liquidation"); + let s = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id); + broker.publish(s); + } + } + + Ok(()) +} diff --git a/src/worker/falukant_family.rs b/src/worker/falukant_family.rs index b4d2a17..9068bf4 100644 --- a/src/worker/falukant_family.rs +++ b/src/worker/falukant_family.rs @@ -3,6 +3,7 @@ //! optional `004` + Backend-Stammdaten für Dienerschaft (`falukant_servants`), //! `005` Ehe öffentliche Stabilität + Hausfrieden (`household_tension_score`), //! `006` Liebschafts-Unterhalt 12× pro Spieltag (alle 2 h), 1 Spieltag = 1 Spieljahr. +//! Schuldturm/Verzug/Pfändung: `falukant_debtors::run_daily` (siehe `docs/FALUKANT_DEBTORS_DAEMON.md`). //! //! WebSocket: `falukantUpdateFamily` (reason) + `falukantUpdateStatus` für betroffene Nutzer. @@ -154,6 +155,9 @@ impl FalukantFamilyWorker { if self.servants_schema_ready { super::falukant_servants::run_daily(&self.base, &self.base.broker)?; } + if let Err(e) = super::falukant_debtors::run_daily(&self.base, &self.base.broker) { + eprintln!("[FalukantFamilyWorker] falukant_debtors::run_daily: {e}"); + } let mut conn = self .base diff --git a/src/worker/mod.rs b/src/worker/mod.rs index 3ac59b4..ca469af 100644 --- a/src/worker/mod.rs +++ b/src/worker/mod.rs @@ -14,6 +14,7 @@ mod events; mod falukant_family; mod falukant_certificate; mod falukant_servants; +mod falukant_debtors; mod sql; pub use base::Worker; diff --git a/src/worker/sql.rs b/src/worker/sql.rs index 8dc3182..652857d 100644 --- a/src/worker/sql.rs +++ b/src/worker/sql.rs @@ -1144,9 +1144,278 @@ pub const QUERY_CLEANUP_CREDITS: &str = r#" WHERE remaining_amount <= 0.01; "#; -pub const QUERY_ADD_CHARACTER_TO_DEBTORS_PRISM: &str = r#" - INSERT INTO falukant_data.debtors_prism (character_id) - VALUES ($1); +// --- Falukant: Schuldturm & Pfändung (docs/FALUKANT_DEBTORS_DAEMON.md) --- +pub const QUERY_DEBTORS_PRISM_SCHEMA_READY: &str = r#" + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'falukant_data' + AND table_name = 'debtors_prism' + AND column_name = 'days_overdue' + ) AS ready; +"#; + +pub const QUERY_DEBTORS_CREDIT_USERS_FOR_DAILY: &str = r#" + SELECT + c.falukant_user_id AS user_id, + MIN(ch.id) AS character_id, + COALESCE(fu.money, 0)::float8 AS money, + COALESCE(SUM( + c.amount::float8 / 10.0 + + c.amount::float8 * c.interest_rate::float8 / 100.0 + ), 0)::float8 AS total_pay_rate, + COALESCE(SUM(c.remaining_amount), 0)::float8 AS total_credit_remaining + FROM falukant_data.credit c + JOIN falukant_data.falukant_user fu ON fu.id = c.falukant_user_id + JOIN falukant_data.character ch ON ch.user_id = c.falukant_user_id AND ch.health > 0 + WHERE c.remaining_amount > 0.01 + GROUP BY c.falukant_user_id, fu.money; +"#; + +pub const QUERY_DEBTORS_GET_PRISM_BY_CHARACTER: &str = r#" + SELECT + dp.id, + dp.character_id, + COALESCE(dp.status, '') AS status, + COALESCE(dp.days_overdue, 0)::int AS days_overdue, + COALESCE(dp.remaining_debt, 0)::float8 AS remaining_debt, + dp.entered_at::text AS entered_at, + dp.released_at::text AS released_at + FROM falukant_data.debtors_prism dp + WHERE dp.character_id = $1::int + LIMIT 1; +"#; + +pub const QUERY_DEBTORS_INSERT_DELINQUENT: &str = r#" + INSERT INTO falukant_data.debtors_prism ( + character_id, + status, + days_overdue, + remaining_debt, + next_forced_action, + reason, + creditworthiness_penalty, + public_known, + assets_seized_json + ) + SELECT + $1::int, + 'delinquent', + 1, + $2::float8, + 'reminder', + 'delinquent', + 0, + false, + '{}'::jsonb + WHERE NOT EXISTS ( + SELECT 1 FROM falukant_data.debtors_prism d2 WHERE d2.character_id = $1::int + ) + RETURNING id; +"#; + +pub const QUERY_DEBTORS_INCREMENT_DELINQUENT: &str = r#" + UPDATE falukant_data.debtors_prism + SET days_overdue = COALESCE(days_overdue, 0) + 1, + remaining_debt = $2::float8, + next_forced_action = CASE + WHEN COALESCE(days_overdue, 0) + 1 >= 3 THEN 'asset_seizure' + WHEN COALESCE(days_overdue, 0) + 1 = 2 THEN 'final_warning' + ELSE 'reminder' + END, + updated_at = NOW() + WHERE character_id = $1::int + AND status = 'delinquent' + RETURNING id, COALESCE(days_overdue, 0) AS new_days; +"#; + +/// Nach abgeschlossenem Fall (`released`) neuer Verzug: Zeile wieder auf Delinquent setzen. +pub const QUERY_DEBTORS_REACTIVATE_DELINQUENT_FROM_RELEASED: &str = r#" + UPDATE falukant_data.debtors_prism + SET status = 'delinquent', + days_overdue = 1, + remaining_debt = $2::float8, + next_forced_action = 'reminder', + reason = 'delinquent', + updated_at = NOW() + WHERE character_id = $1::int + AND status = 'released' + RETURNING id; +"#; + +pub const QUERY_DEBTORS_RESET_DELINQUENCY_SOLVENT: &str = r#" + UPDATE falukant_data.debtors_prism + SET days_overdue = 0, + next_forced_action = 'reminder', + updated_at = NOW() + WHERE character_id = $1::int + AND status = 'delinquent'; +"#; + +pub const QUERY_DEBTORS_RESET_ON_PAYMENT_SUCCESS: &str = r#" + UPDATE falukant_data.debtors_prism + SET days_overdue = 0, + next_forced_action = 'reminder', + updated_at = NOW() + WHERE character_id = $1::int + AND status = 'delinquent'; +"#; + +pub const QUERY_DEBTORS_ENTER_PRISON: &str = r#" + UPDATE falukant_data.debtors_prism + SET status = 'imprisoned', + entered_at = COALESCE(entered_at, NOW()), + released_at = NULL, + debt_at_entry = $2::float8, + remaining_debt = $2::float8, + reason = 'credit_default', + creditworthiness_penalty = COALESCE(creditworthiness_penalty, 0) + 45, + next_forced_action = 'asset_seizure', + public_known = true, + updated_at = NOW() + WHERE character_id = $1::int + AND status = 'delinquent' + AND COALESCE(days_overdue, 0) >= 3 + RETURNING id; +"#; + +pub const QUERY_DEBTORS_RELEASE_IF_PAID: &str = r#" + UPDATE falukant_data.debtors_prism dp + SET status = 'released', + released_at = NOW(), + next_forced_action = NULL, + days_overdue = 0, + remaining_debt = 0, + updated_at = NOW() + FROM falukant_data.character ch + WHERE ch.id = dp.character_id + AND ch.user_id = $1::int + AND dp.status = 'imprisoned' + AND COALESCE(( + SELECT SUM(c.remaining_amount) + FROM falukant_data.credit c + WHERE c.falukant_user_id = $1::int + ), 0) <= 0.01 + RETURNING dp.character_id; +"#; + +pub const QUERY_DEBTORS_SUBTRACT_REPUTATION: &str = r#" + UPDATE falukant_data.character + SET reputation = GREATEST(0::numeric, COALESCE(reputation, 50::numeric) - $2::numeric), + updated_at = NOW() + WHERE id = $1::int AND user_id IS NOT NULL; +"#; + +pub const QUERY_DEBTORS_MARRIAGE_SATISFACTION_SUB: &str = r#" + UPDATE falukant_data.relationship r + SET marriage_satisfaction = GREATEST(0, COALESCE(r.marriage_satisfaction, 55) - $2::int), + updated_at = NOW() + FROM falukant_type.relationship rt + WHERE rt.id = r.relationship_type_id + AND rt.tr IN ('married', 'engaged', 'wooing') + AND (r.character1_id = $1::int OR r.character2_id = $1::int); +"#; + +pub const QUERY_DEBTORS_HOUSEHOLD_TENSION_ADD: &str = r#" + UPDATE falukant_data.user_house + SET household_tension_score = LEAST(100, COALESCE(household_tension_score, 0) + $2::int), + updated_at = NOW() + WHERE user_id = $1::int; +"#; + +pub const QUERY_DEBTORS_LOVER_AFFECTION_SUB: &str = r#" + UPDATE falukant_data.relationship_state rs + SET affection = GREATEST(0, COALESCE(rs.affection, 50) - $2::int), + updated_at = NOW() + FROM falukant_data.relationship r + JOIN falukant_type.relationship rt ON rt.id = r.relationship_type_id AND rt.tr = 'lover' + WHERE rs.relationship_id = r.id + AND rs.active = true + AND (r.character1_id = $1::int OR r.character2_id = $1::int); +"#; + +pub const QUERY_DEBTORS_IMPRISONED_REP_MALUS: &str = r#" + UPDATE falukant_data.character c + SET reputation = GREATEST(0::numeric, COALESCE(c.reputation, 50::numeric) - $2::numeric), + updated_at = NOW() + WHERE c.id = $1::int AND c.user_id IS NOT NULL; +"#; + +pub const QUERY_DEBTORS_IMPRISONED_PENALTY_PLUS1: &str = r#" + UPDATE falukant_data.debtors_prism + SET creditworthiness_penalty = COALESCE(creditworthiness_penalty, 0) + 1, + updated_at = NOW() + WHERE character_id = $1::int AND status = 'imprisoned'; +"#; + +pub const QUERY_DEBTORS_IMPRISONED_MARRIAGE_SUB1: &str = r#" + UPDATE falukant_data.relationship r + SET marriage_satisfaction = GREATEST(0, COALESCE(r.marriage_satisfaction, 55) - 1), + updated_at = NOW() + FROM falukant_type.relationship rt + WHERE rt.id = r.relationship_type_id + AND rt.tr IN ('married', 'engaged', 'wooing') + AND (r.character1_id = $1::int OR r.character2_id = $1::int); +"#; + +pub const QUERY_DEBTORS_IMPRISONED_TENSION_PLUS2: &str = r#" + UPDATE falukant_data.user_house uh + SET household_tension_score = LEAST(100, COALESCE(uh.household_tension_score, 0) + 2), + updated_at = NOW() + FROM falukant_data.character ch + WHERE ch.id = $1::int AND uh.user_id = ch.user_id; +"#; + +pub const QUERY_DEBTORS_IMPRISONED_LOVER_AFF_SUB2: &str = r#" + UPDATE falukant_data.relationship_state rs + SET affection = GREATEST(0, COALESCE(rs.affection, 50) - 2), + updated_at = NOW() + FROM falukant_data.relationship r + JOIN falukant_type.relationship rt ON rt.id = r.relationship_type_id AND rt.tr = 'lover' + WHERE rs.relationship_id = r.id AND rs.active = true + AND (r.character1_id = $1::int OR r.character2_id = $1::int); +"#; + +pub const QUERY_DEBTORS_VEHICLE_FOR_SEIZURE: &str = r#" + SELECT v.id AS vehicle_id, + COALESCE(v.condition, 100)::int AS veh_condition, + COALESCE(vt.cost, 0)::float8 AS type_cost + FROM falukant_data.vehicle v + JOIN falukant_type.vehicle vt ON vt.id = v.vehicle_type_id + WHERE v.falukant_user_id = $1::int + AND v.id NOT IN ( + SELECT DISTINCT t.vehicle_id FROM falukant_data.transport t + WHERE t.vehicle_id IS NOT NULL + ) + ORDER BY COALESCE(vt.cost, 0) ASC, v.id ASC + LIMIT 1; +"#; + +pub const QUERY_DEBTORS_DELETE_VEHICLE: &str = r#" + DELETE FROM falukant_data.vehicle WHERE id = $1::int RETURNING id; +"#; + +pub const QUERY_DEBTORS_UPDATE_PRISM_REMAINING: &str = r#" + UPDATE falukant_data.debtors_prism dp + SET remaining_debt = GREATEST(0, COALESCE(dp.remaining_debt, 0) - $2::float8), + assets_seized_json = COALESCE(assets_seized_json, '{}'::jsonb) + || jsonb_build_object( + 'last_vehicle', + jsonb_build_object('vehicle_id', $3::int, 'proceeds', $2::float8) + ), + updated_at = NOW() + WHERE dp.character_id = $1::int AND dp.status = 'imprisoned' + RETURNING dp.remaining_debt; +"#; + +pub const QUERY_DEBTORS_UPDATE_PRISM_REMAINING_MONEY: &str = r#" + UPDATE falukant_data.debtors_prism dp + SET remaining_debt = GREATEST(0, COALESCE(dp.remaining_debt, 0) - $2::float8), + assets_seized_json = COALESCE(assets_seized_json, '{}'::jsonb) + || jsonb_build_object('last_cash_seizure', $2::float8), + updated_at = NOW() + WHERE dp.character_id = $1::int AND dp.status = 'imprisoned' + RETURNING dp.remaining_debt; "#; pub const QUERY_RANDOM_HEIR: &str = r#" diff --git a/src/worker/user_character.rs b/src/worker/user_character.rs index 3ae2f16..2e1fae4 100644 --- a/src/worker/user_character.rs +++ b/src/worker/user_character.rs @@ -20,7 +20,6 @@ use crate::worker::sql::{ QUERY_GET_OPEN_CREDITS, QUERY_UPDATE_CREDIT, QUERY_CLEANUP_CREDITS, - QUERY_ADD_CHARACTER_TO_DEBTORS_PRISM, QUERY_GET_CURRENT_MONEY, QUERY_GET_HOUSE_VALUE, QUERY_GET_SETTLEMENT_VALUE, @@ -449,10 +448,6 @@ impl UserCharacterWorker { conn.prepare("get_open_credits", QUERY_GET_OPEN_CREDITS)?; conn.prepare("update_credit", QUERY_UPDATE_CREDIT)?; conn.prepare("cleanup_credits", QUERY_CLEANUP_CREDITS)?; - conn.prepare( - "add_character_to_debtors_prism", - QUERY_ADD_CHARACTER_TO_DEBTORS_PRISM, - )?; let credits_rows = conn.execute("get_open_credits", &[])?; for row in credits_rows { @@ -508,6 +503,12 @@ impl UserCharacterWorker { eprintln!( "[UserCharacterWorker] Fehler bei change_falukant_user_money (credit pay rate): {err}" ); + } else if let Err(err) = + super::falukant_debtors::on_credit_payment_success(&self.base, character_id) + { + eprintln!( + "[UserCharacterWorker] falukant_debtors::on_credit_payment_success: {err}" + ); } } else if prism_started_previously { if let Err(err) = self @@ -518,9 +519,8 @@ impl UserCharacterWorker { "[UserCharacterWorker] Fehler bei change_falukant_user_money (debitor_prism): {err}" ); } - } else { - conn.execute("add_character_to_debtors_prism", &[&character_id])?; } + // Verzug / Schuldturm: täglich `falukant_debtors::run_daily` (FalukantFamilyWorker) conn.execute("update_credit", &[&remaining_amount, &user_id])?; Ok(())