Implement daily political salary management: Introduced a new function run_daily_political_salary to calculate and distribute daily salaries for players with active political offices, utilizing configured values or fallback based on office rank. Updated SQL queries to support this functionality, including checks for the readiness of the necessary database column. Enhanced the PoliticsWorker to trigger daily salary processing, ensuring timely updates for users. Improved documentation for clarity on the new salary management features and their integration into the existing political benefits system.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 2m51s

This commit is contained in:
Torsten Schulz (local)
2026-04-07 15:24:12 +02:00
parent ac024a8d14
commit 731c39dfa4
7 changed files with 191 additions and 8 deletions

View File

@@ -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).';

View File

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

View File

@@ -290,6 +290,11 @@ fn political_name_to_rank(name: &str) -> i32 {
0
}
/// Rang 15 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;

View File

@@ -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<i32> = HashSet::new();
@@ -664,7 +668,7 @@ impl FalukantFamilyWorker {
self.publish_falukant_update_family_batch(&notify, "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}");
}

View File

@@ -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 15 (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<i32, f64> = 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) {

View File

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

View File

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