diff --git a/migrations/013_falukant_political_daily_salary.sql b/migrations/013_falukant_political_daily_salary.sql new file mode 100644 index 0000000..577eeff --- /dev/null +++ b/migrations/013_falukant_political_daily_salary.sql @@ -0,0 +1,6 @@ +-- Tägliches politisches Gehalt (Daemon): Idempotenz pro Kalendertag (UTC) +ALTER TABLE falukant_data.falukant_user + ADD COLUMN IF NOT EXISTS last_political_daily_salary_on DATE; + +COMMENT ON COLUMN falukant_data.falukant_user.last_political_daily_salary_on IS + 'Letzter Tag, an dem political daily salary gutgeschrieben wurde (YpDaemon political_benefits::run_daily_political_salary).'; diff --git a/migrations/README.md b/migrations/README.md index ab7e568..83f26ff 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -29,3 +29,7 @@ Tabellen **`political_benefit_last_tick`** und optional **`political_appointment - **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. + +## `013_falukant_political_daily_salary.sql` + +Spalte **`falukant_data.falukant_user.last_political_daily_salary_on`** (Datum): Idempotenz für **`political_benefits::run_daily_political_salary`** — einmal pro Tag Gutschrift; Beträge aus JSON-Feld **`daily_salary`** (`tr`/`benefitType` = `daily_salary`) oder gestufter Daemon-Fallback nach Amts-Rang. diff --git a/src/worker/falukant_certificate.rs b/src/worker/falukant_certificate.rs index 1d2cf8f..6d858c6 100644 --- a/src/worker/falukant_certificate.rs +++ b/src/worker/falukant_certificate.rs @@ -290,6 +290,11 @@ fn political_name_to_rank(name: &str) -> i32 { 0 } +/// Rang 1–5 für eine Amtsbezeichnung (Politik-Tagesgehalt, Zertifikat-Heuristik). Leer → 0. +pub fn political_office_name_rank(name: &str) -> i32 { + political_name_to_rank(name) +} + fn max_political_rank_from_names(agg: &str) -> i32 { if agg.is_empty() { return 0; diff --git a/src/worker/falukant_family.rs b/src/worker/falukant_family.rs index f4ebbbb..324eb8a 100644 --- a/src/worker/falukant_family.rs +++ b/src/worker/falukant_family.rs @@ -34,7 +34,8 @@ use crate::db::{ConnectionPool, DbError}; use crate::message_broker::MessageBroker; const DAILY_INTERVAL: Duration = Duration::from_secs(24 * 3600); -const MONTHLY_INTERVAL: Duration = Duration::from_secs(30 * 24 * 3600); +/// Wie `DAILY_INTERVAL`: 1 Spieljahr = 1 Kalendertag — Monats-Stempel/„monthly“-Batch pro Spieltag, nicht alle 30 echten Tage. +const MONTHLY_INTERVAL: Duration = DAILY_INTERVAL; /// 12 Monatsticke pro Spieltag (24 h = 1 Spieljahr); 2 h = 1 Spielmonat (Liebschaft + Dienerschaft). const GAME_MONTH_SLICE_INTERVAL: Duration = Duration::from_secs(2 * 3600); @@ -298,8 +299,11 @@ impl FalukantFamilyWorker { } conn.prepare("mark_daily", QUERY_MARK_LOVER_DAILY_DONE)?; + conn.prepare("mark_monthly", QUERY_MARK_LOVER_MONTHLY_DONE)?; for l in &lovers { conn.execute("mark_daily", &[&l.rel_id])?; + // Gleicher Spieltag wie Tageslauf: Geburts-/Schwangerschafts-Logik darf nicht 30 echte Tage warten. + conn.execute("mark_monthly", &[&l.rel_id])?; } let mut marriage_socket_users: HashSet = HashSet::new(); @@ -664,7 +668,7 @@ impl FalukantFamilyWorker { self.publish_falukant_update_family_batch(¬ify, "daily"); drop(conn); - // Liebschafts-Geburt: früher nur alle ~30 Tage in process_monthly — zu selten für kurze Testphasen. + // Liebschafts-Geburt: täglich (1 Spieljahr); Monats-Stempel erfolgt oben mit dem Tageslauf. if let Err(e) = self.process_lover_births() { eprintln!("[FalukantFamilyWorker] process_lover_births: {e}"); } diff --git a/src/worker/political_benefits.rs b/src/worker/political_benefits.rs index 3e952a3..86e2aeb 100644 --- a/src/worker/political_benefits.rs +++ b/src/worker/political_benefits.rs @@ -1,17 +1,24 @@ -//! Politische Amtsvorteile: `reputation_periodic` (täglich), Ablauf offener Ernennungen, +//! Politische Amtsvorteile: `reputation_periodic` (täglich), **tägliches Politik-Gehalt** (pro Amt, +//! gestaffelt nach Amts-Rang oder `daily_salary` im Benefit-JSON), Ablauf offener Ernennungen, //! Hilfsabfrage `free_lover_slots` für Liebschafts-Monatstick. //! -//! Voraussetzungen: Migration `012_falukant_political_benefits_daemon.sql`, Backend-Seeds +//! Voraussetzungen: Migrationen `012_falukant_political_benefits_daemon.sql`, +//! `013_falukant_political_daily_salary.sql`, Backend-Seeds //! `falukant_predefine.political_office_benefit` mit JSON (`tr` oder `benefitType`, `gain`, `intervalDays`, …). +use std::collections::HashMap; + use crate::db::{ConnectionPool, DbError, Row}; use crate::message_broker::MessageBroker; +use crate::worker::base::BaseWorker; +use crate::worker::falukant_certificate::political_office_name_rank; 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_BENEFIT_DAEMON_SCHEMA_READY, QUERY_POLITICAL_DAILY_SALARY_OFFICE_ROWS, + QUERY_POLITICAL_DAILY_SALARY_USER_COLUMN_READY, QUERY_POLITICAL_REPUTATION_APPLY_GAIN, QUERY_POLITICAL_REPUTATION_TICK_ROWS, QUERY_POLITICAL_REPUTATION_TICK_UPSERT, - QUERY_SUM_FREE_LOVER_SLOTS_FOR_CHARACTER, + QUERY_SUM_FREE_LOVER_SLOTS_FOR_CHARACTER, QUERY_UPDATE_LAST_POLITICAL_DAILY_SALARY_ON, }; fn parse_i32(row: &Row, key: &str, default: i32) -> i32 { @@ -47,6 +54,40 @@ pub fn daemon_schema_ready(pool: &ConnectionPool) -> bool { .unwrap_or(false) } +fn salary_user_column_ready(pool: &ConnectionPool) -> bool { + let mut conn = match pool.get() { + Ok(c) => c, + Err(_) => return false, + }; + if conn + .prepare("pds_col", QUERY_POLITICAL_DAILY_SALARY_USER_COLUMN_READY) + .is_err() + { + return false; + } + let rows = match conn.execute("pds_col", &[]) { + Ok(r) => r, + Err(_) => return false, + }; + rows.first() + .and_then(|r| r.get("ready")) + .map(|v| v == "true" || v == "t") + .unwrap_or(false) +} + +/// Fallback-Tageseinkommen (ohne konfiguriertes `daily_salary`), gestaffelt nach Rang 1–5 (niedrig → hoch). +fn fallback_daily_salary_for_political_rank(rank: i32) -> f64 { + let r = if rank <= 0 { 1 } else { rank.min(5) }; + match r { + 1 => 14.0, + 2 => 28.0, + 3 => 45.0, + 4 => 65.0, + 5 => 95.0, + _ => 14.0, + } +} + pub fn appointment_schema_ready(pool: &ConnectionPool) -> bool { let mut conn = match pool.get() { Ok(c) => c, @@ -130,6 +171,85 @@ pub fn run_reputation_periodic_ticks(pool: &ConnectionPool, broker: &MessageBrok Ok(()) } +/// Täglich: einmal pro Kalendertag Gutschrift für Spieler mit aktivem politischen Amt. +/// Betrag pro Amt: Summe `daily_salary` aus `political_office_benefit`, sonst Fallback nach Amtsbezeichnung (`political_office_name_rank`). +pub fn run_daily_political_salary(pool: &ConnectionPool, broker: &MessageBroker) -> Result<(), DbError> { + if !salary_user_column_ready(pool) { + return Ok(()); + } + + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + if conn + .prepare("pds_rows", QUERY_POLITICAL_DAILY_SALARY_OFFICE_ROWS) + .is_err() + { + return Ok(()); + } + + let rows = conn + .execute("pds_rows", &[]) + .map_err(|e| DbError::new(format!("[PoliticalBenefits] exec pds_rows: {e}")))?; + + let mut totals: HashMap = HashMap::new(); + + for row in rows { + let uid = parse_i32(&row, "falukant_user_id", -1); + if uid < 1 { + continue; + } + let configured = parse_f64_from_row(&row, "configured_daily_salary"); + let office_name = row.get("office_name").map(|s| s.as_str()).unwrap_or(""); + let piece = if configured > 0.0 { + configured + } else { + let r = political_office_name_rank(office_name); + fallback_daily_salary_for_political_rank(r) + }; + if piece <= 0.0 || !piece.is_finite() { + continue; + } + *totals.entry(uid).or_insert(0.0) += piece; + } + + if totals.is_empty() { + return Ok(()); + } + + conn.prepare("pds_upd_date", QUERY_UPDATE_LAST_POLITICAL_DAILY_SALARY_ON) + .map_err(|e| DbError::new(format!("[PoliticalBenefits] prepare pds_upd_date: {e}")))?; + + let base = BaseWorker::new("PoliticalBenefits", pool.clone(), broker.clone()); + + for (uid, total) in totals { + if total <= 0.0 || !total.is_finite() { + continue; + } + + base.change_falukant_user_money(uid, total, "political daily salary") + .map_err(|e| { + DbError::new(format!( + "[PoliticalBenefits] change_falukant_user_money uid={uid} total={total}: {e}" + )) + })?; + + conn.execute("pds_upd_date", &[&uid]) + .map_err(|e| DbError::new(format!("[PoliticalBenefits] pds_upd_date uid={uid}: {e}")))?; + + eprintln!( + "[PoliticalBenefits] falukantUserId={} action=political_daily_salary amount={:.2}", + uid, total + ); + + 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) { diff --git a/src/worker/politics.rs b/src/worker/politics.rs index ffd28bd..1a75c3f 100644 --- a/src/worker/politics.rs +++ b/src/worker/politics.rs @@ -228,6 +228,9 @@ impl PoliticsWorker { if let Err(e) = super::political_benefits::expire_political_appointments(pool) { eprintln!("[PoliticsWorker] expire_political_appointments: {e}"); } + if let Err(e) = super::political_benefits::run_daily_political_salary(pool, broker) { + eprintln!("[PoliticsWorker] run_daily_political_salary: {e}"); + } Ok(()) } diff --git a/src/worker/sql.rs b/src/worker/sql.rs index 76c1af3..2dcf473 100644 --- a/src/worker/sql.rs +++ b/src/worker/sql.rs @@ -1370,6 +1370,46 @@ pub const QUERY_POLITICAL_APPOINTMENT_EXPIRE_PENDING: &str = r#" AND expires_at < NOW(); "#; +/// Spalte `last_political_daily_salary_on` (Migration `013_falukant_political_daily_salary.sql`). +pub const QUERY_POLITICAL_DAILY_SALARY_USER_COLUMN_READY: &str = r#" + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'falukant_data' + AND table_name = 'falukant_user' + AND column_name = 'last_political_daily_salary_on' + ) AS ready; +"#; + +/// Aktive Ämter mit Spieler-Charakter, noch **kein** Gehalt heute (UTC). +/// `configured_daily_salary`: Summe aus `political_office_benefit` mit `daily_salary` im JSON; sonst 0 → Fallback im Daemon nach Amts-Rang. +pub const QUERY_POLITICAL_DAILY_SALARY_OFFICE_ROWS: &str = r#" + SELECT + ch.user_id AS falukant_user_id, + COALESCE(pot.name, '') AS office_name, + COALESCE(( + SELECT SUM(GREATEST(0, COALESCE(NULLIF(TRIM(sb.value::jsonb->>'daily_salary'), '')::numeric, 0))) + FROM falukant_predefine.political_office_benefit sb + WHERE sb.political_office_type_id = po.office_type_id + AND COALESCE(sb.value::jsonb->>'tr', sb.value::jsonb->>'benefitType') = 'daily_salary' + ), 0::numeric) AS configured_daily_salary + FROM falukant_data.political_office po + JOIN falukant_type.political_office_type pot ON pot.id = po.office_type_id + JOIN falukant_data.character ch ON ch.id = po.character_id + JOIN falukant_data.falukant_user fu ON fu.id = ch.user_id + WHERE ch.user_id IS NOT NULL + AND (po.created_at + (pot.term_length * INTERVAL '1 day')) > NOW() + AND (fu.last_political_daily_salary_on IS NULL OR fu.last_political_daily_salary_on < CURRENT_DATE); +"#; + +pub const QUERY_UPDATE_LAST_POLITICAL_DAILY_SALARY_ON: &str = r#" + UPDATE falukant_data.falukant_user + SET last_political_daily_salary_on = CURRENT_DATE, + updated_at = NOW() + WHERE id = $1::int + AND (last_political_daily_salary_on IS NULL OR last_political_daily_salary_on < CURRENT_DATE); +"#; + /// Summe `count` aus `free_lover_slots`-Benefits (JSON `tr`/`benefitType`), gedeckelt. pub const QUERY_SUM_FREE_LOVER_SLOTS_FOR_CHARACTER: &str = r#" SELECT LEAST( @@ -3330,7 +3370,7 @@ pub const QUERY_GET_ACTIVE_LOVER_ROWS_FOR_MONTHLY: &str = r#" WHERE rs.active = true AND ( rs.last_monthly_processed_at IS NULL - OR date_trunc('month', rs.last_monthly_processed_at) < date_trunc('month', CURRENT_TIMESTAMP) + OR rs.last_monthly_processed_at::date < CURRENT_DATE ); "#; @@ -3575,6 +3615,7 @@ pub const QUERY_GET_LOVER_PREGNANCY_CANDIDATES: &str = r#" OR (c1.gender = 'male' AND c2.gender = 'female') AND rs.affection >= 45 AND rs.maintenance_level >= 30 + -- `last_monthly_processed_at` wird im Familien-Tageslauf mitgeführt (1 Spieljahr = 1 Kalendertag). AND rs.last_monthly_processed_at IS NOT NULL AND rs.last_monthly_processed_at >= NOW() - INTERVAL '50 days' AND NOT EXISTS ( @@ -3582,7 +3623,7 @@ pub const QUERY_GET_LOVER_PREGNANCY_CANDIDATES: &str = r#" FROM falukant_data.child_relation cr WHERE cr.father_character_id = (CASE WHEN c1.gender = 'male' THEN c1.id ELSE c2.id END) AND cr.mother_character_id = (CASE WHEN c1.gender = 'female' THEN c1.id ELSE c2.id END) - AND cr.created_at >= date_trunc('month', CURRENT_TIMESTAMP) + AND cr.created_at::date >= CURRENT_DATE ) AND (CURRENT_DATE - c_female.birthdate::date) >= 4380 AND (CURRENT_DATE - c_female.birthdate::date) < 18993