diff --git a/docs/FALUKANT_PRODUCTION_CERTIFICATE.md b/docs/FALUKANT_PRODUCTION_CERTIFICATE.md index 065ddfb..7857168 100644 --- a/docs/FALUKANT_PRODUCTION_CERTIFICATE.md +++ b/docs/FALUKANT_PRODUCTION_CERTIFICATE.md @@ -17,6 +17,19 @@ Implementierung: `src/worker/falukant_certificate.rs` (`run_daily`). - Aufstieg nur wenn `effective_target > current` → **`current + 1`** (gegen `effective_target` und 5 begrenzt) - **Bankrott** (`money <= -5000`): Zertifikat auf **1**, mit Event +### Wichtig: UI vs. Daemon („48 h kein Aufstieg“) + +Die **Mindestanforderungen** (z. B. Wissen ≥ 28, Produktionen ≥ 15 für Stufe 3) sind **nur ein Teil**. Zusätzlich gilt eine **Obergrenze aus der gewichteten Wertung** (`certificateScore` → `raw_target`): Es wird die **höchste Stufe ≤ `raw_target`**, die **alle** Mindestanforderungen erfüllt (`effective_certificate_target` in `falukant_certificate.rs`). + +Typische Folge: Die UI zeigt **nur** zwei grüne Häkchen (Wissen/Produktionen), der Spieler bleibt aber auf Stufe 2, weil: + +1. **`raw_target` = 2** (Wertung **unter** 1,8) — dann ist Stufe 3 **fachlich ausgeschlossen**, auch wenn die Mindestzahlen für Stufe 3 erfüllt sind. Oft liegt die Wertung knapp unter 1,8, wenn z. B. **Produktionspunkte** im Daemon niedrig sind (Bucket < 20 abgeschlossene Produktionen in `falukant_log.production` trotz höherer Anzeige in der UI). +2. **Abweichende Eingangsdaten** gegenüber der UI: anderer gewählter Charakter (`DISTINCT ON … c.id DESC`), andere Zählung `falukant_log.production` (`producer_id`), Geld/Bankrott, etc. + +**Diagnose:** Daemon mit `YPDAEMON_CERT_VERBOSE=1` starten. Wenn jemand die Mindestanforderungen für die **nächste** Stufe erfüllt, aber **nicht** aufsteigt, erscheint eine Zeile `[falukant_certificate] fu_id=… bleibt auf Stufe …` mit `certificate_score`, `raw_target`, `effective_target` und Rohwerten. + +**SQL:** `QUERY_GET_PRODUCTION_CERTIFICATE_INPUT_ROWS` in `src/worker/sql.rs` — für einen Betroffenen `falukant_user.id` filtern und mit der UI abgleichen. + ## Politische Ämter Rang aus **`political_office_type.name`** (Substring-Heuristik im Daemon, ohne DB-Änderung). Anpassung über `political_name_to_rank` in `falukant_certificate.rs`. diff --git a/src/worker/falukant_certificate.rs b/src/worker/falukant_certificate.rs index 332798c..1d2cf8f 100644 --- a/src/worker/falukant_certificate.rs +++ b/src/worker/falukant_certificate.rs @@ -81,6 +81,41 @@ pub fn run_daily(base: &BaseWorker, broker: &MessageBroker) -> Result<(), DbErro house_points, ); + // Hilfe bei „UI sagt bereit, Stufe steigt nicht“: oft Mindestwerte erfüllt, aber + // `raw_target` (gewichtete Wertung) zu niedrig — siehe `docs/FALUKANT_PRODUCTION_CERTIFICATE.md`. + if current < 5 { + let next = current + 1; + if meets_min_for_level( + next, + avg_knowledge, + completed, + office_points, + nobility_points, + reputation_points, + house_points, + ) && effective_target < next + && cert_verbose() + { + eprintln!( + "[falukant_certificate] fu_id={} bleibt auf Stufe {}: Mindestanforderungen für Stufe {} sind erfüllt, aber die gewichtete Wertung {:.3} ergibt raw_target={} (effective_target={}). avg_knowledge={:.2} completed={} kp={} pp={} office={} nob={} rep_p={} house_p={}", + fu_id, + current, + next, + certificate_score, + raw_target, + effective_target, + avg_knowledge, + completed, + knowledge_points, + production_points, + office_points, + nobility_points, + reputation_points, + house_points, + ); + } + } + let new_certificate = if effective_target > current { (current + 1).min(effective_target).min(5) } else { @@ -102,6 +137,13 @@ pub fn run_daily(base: &BaseWorker, broker: &MessageBroker) -> Result<(), DbErro Ok(()) } +fn cert_verbose() -> bool { + matches!( + std::env::var("YPDAEMON_CERT_VERBOSE").map(|v| v == "1" || v.eq_ignore_ascii_case("true")), + Ok(true) + ) +} + /// Nach Tod ohne gültigen Erben: Zertifikat auf Stufe 1 (Spec: Erbfolge bricht ab). pub fn reset_certificate_on_succession_no_heir( base: &BaseWorker, diff --git a/src/worker/politics.rs b/src/worker/politics.rs index 5b3d4de..54eaf5e 100644 --- a/src/worker/politics.rs +++ b/src/worker/politics.rs @@ -1,5 +1,6 @@ use crate::db::{ConnectionPool, DbError, Row}; use crate::message_broker::MessageBroker; +use serde_json::json; use std::collections::{HashMap, HashSet}; use std::sync::atomic::Ordering; use std::sync::Arc; @@ -20,6 +21,8 @@ use crate::worker::sql::{ QUERY_GET_USERS_IN_REGIONS_WITH_ELECTIONS, QUERY_GET_USERS_WITH_FILLED_OFFICES, QUERY_PROCESS_ELECTIONS, + QUERY_PLAYER_ELECTION_RESULT_ROWS, + QUERY_INSERT_NOTIFICATION, QUERY_TRIM_EXCESS_OFFICES_GLOBAL, QUERY_FIND_AVAILABLE_CHURCH_OFFICES, QUERY_FIND_CHURCH_SUPERVISOR, @@ -206,6 +209,9 @@ impl PoliticsWorker { // 7) Wahlen auswerten und neu besetzte Ämter melden let new_offices_from_elections = Self::process_elections(pool)?; + if let Err(e) = Self::notify_player_election_results(pool, broker) { + eprintln!("[PoliticsWorker] notify_player_election_results: {e}"); + } if !new_offices_from_elections.is_empty() { Self::notify_office_filled(pool, broker, &new_offices_from_elections)?; } @@ -618,6 +624,94 @@ impl PoliticsWorker { .collect()) } + /// Benachrichtigt Spieler-Kandidaten mit Wahlergebnis (Stimmen, Rang, Gewinner, gewonnen?). + /// `tr` = JSON mit `event: "election_result"` (siehe `QUERY_PLAYER_ELECTION_RESULT_ROWS`). + fn notify_player_election_results( + pool: &ConnectionPool, + broker: &MessageBroker, + ) -> Result<(), DbError> { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare( + "player_election_results", + QUERY_PLAYER_ELECTION_RESULT_ROWS, + ) + .map_err(|e| DbError::new(format!("[PoliticsWorker] prepare player_election_results: {e}")))?; + let rows = conn + .execute("player_election_results", &[]) + .map_err(|e| DbError::new(format!("[PoliticsWorker] exec player_election_results: {e}")))?; + + conn.prepare("insert_notification_json", QUERY_INSERT_NOTIFICATION) + .map_err(|e| DbError::new(format!("[PoliticsWorker] prepare insert_notification_json: {e}")))?; + + for row in rows { + let user_id = parse_i32(&row, "user_id", -1); + let election_id = parse_i32(&row, "election_id", -1); + if user_id < 1 || election_id < 1 { + continue; + } + + let office_type_id = parse_i32(&row, "office_type_id", -1); + let region_id = parse_i32(&row, "region_id", -1); + let character_id = parse_i32(&row, "character_id", -1); + let candidate_id = parse_i32(&row, "candidate_id", -1); + let your_votes = row + .get("your_votes") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let rank_in_election = parse_i32(&row, "rank_in_election", 0); + let won = row + .get("won") + .map(|v| v == "t" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + let top_character_id = parse_i32(&row, "top_character_id", -1); + let top_votes = row + .get("top_votes") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let posts_to_fill = parse_i32(&row, "posts_to_fill", 0); + + let office_name = row.get("office_name").cloned().unwrap_or_default(); + let region_name = row.get("region_name").cloned().unwrap_or_default(); + + let payload = json!({ + "event": "election_result", + "user_id": user_id, + "election_id": election_id, + "office_type_id": office_type_id, + "office_name": office_name, + "region_id": region_id, + "region_name": region_name, + "character_id": character_id, + "candidate_id": candidate_id, + "your_votes": your_votes, + "rank": rank_in_election, + "won": won, + "leading_character_id": top_character_id, + "leading_votes": top_votes, + "posts_to_fill": posts_to_fill, + }) + .to_string(); + + conn.execute("insert_notification_json", &[&user_id, &payload]) + .map_err(|e| { + DbError::new(format!( + "[PoliticsWorker] insert election_result notification uid={} eid={}: {e}", + user_id, election_id + )) + })?; + + broker.publish(payload.clone()); + let status = + format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id); + broker.publish(status); + } + + Ok(()) + } + /// Täglich: freie Sitze, Bewerbungen (Spieler bleiben offen), NPC-Vorgesetzte entscheiden per Score, /// NPC-Bewerbungen erzeugen, Interimsbesetzung ohne Vorgesetzten (niedrige Hierarchie). fn perform_church_office_task( diff --git a/src/worker/sql.rs b/src/worker/sql.rs index 6482591..4d2a927 100644 --- a/src/worker/sql.rs +++ b/src/worker/sql.rs @@ -1208,6 +1208,87 @@ pub const QUERY_PROCESS_ELECTIONS: &str = r#" FROM falukant_data.process_elections(); "#; +/// Wahlergebnis für Spieler-Kandidaten (`falukant_data.candidate` + `character.user_id IS NOT NULL`). +/// Läuft nach `process_elections()`; Deduplizierung über bestehende `election_result`-Notifications (`tr` JSON). +pub const QUERY_PLAYER_ELECTION_RESULT_ROWS: &str = r#" +WITH vote_counts AS ( + SELECT c.id AS candidate_id, + c.election_id, + c.character_id, + COUNT(v.id)::bigint AS vote_count + FROM falukant_data.candidate c + LEFT JOIN falukant_data.vote v ON v.candidate_id = c.id + GROUP BY c.id, c.election_id, c.character_id +), +top_per_election AS ( + SELECT DISTINCT ON (vc.election_id) + vc.election_id, + vc.character_id AS top_character_id, + vc.vote_count AS top_votes + FROM vote_counts vc + ORDER BY vc.election_id, vc.vote_count DESC, vc.candidate_id +), +eligible AS ( + SELECT e.id AS election_id, + e.office_type_id, + e.region_id, + e.posts_to_fill, + vc.candidate_id, + vc.character_id, + vc.vote_count, + ch.user_id + FROM falukant_data.election e + JOIN vote_counts vc ON vc.election_id = e.id + JOIN falukant_data.character ch ON ch.id = vc.character_id + WHERE ch.user_id IS NOT NULL + AND e.date::date <= CURRENT_DATE + AND e.date::date >= CURRENT_DATE - INTERVAL '7 days' + AND NOT EXISTS ( + SELECT 1 + FROM falukant_log.notification n + WHERE n.user_id = ch.user_id + AND n.tr LIKE '{%' + AND (n.tr::jsonb->>'event') = 'election_result' + AND (n.tr::jsonb->>'election_id')::int = e.id + ) +), +ranked AS ( + SELECT vc.candidate_id, + ROW_NUMBER() OVER ( + PARTITION BY vc.election_id + ORDER BY vc.vote_count DESC, vc.candidate_id + ) AS rank_in_election + FROM vote_counts vc +) +SELECT elig.user_id, + elig.election_id, + elig.office_type_id, + COALESCE(pot.name::text, '') AS office_name, + elig.region_id, + COALESCE(NULLIF(TRIM(dr.name::text), ''), elig.region_id::text) AS region_name, + elig.character_id, + elig.candidate_id, + elig.vote_count AS your_votes, + COALESCE(rk.rank_in_election, 0)::int AS rank_in_election, + EXISTS ( + SELECT 1 + FROM falukant_data.political_office po + WHERE po.character_id = elig.character_id + AND po.office_type_id = elig.office_type_id + AND po.region_id = elig.region_id + AND po.created_at >= (CURRENT_TIMESTAMP - INTERVAL '3 days') + ) AS won, + tpe.top_character_id, + tpe.top_votes, + elig.posts_to_fill + FROM eligible elig + JOIN falukant_type.political_office_type pot ON pot.id = elig.office_type_id + LEFT JOIN falukant_data.region dr ON dr.id = elig.region_id + LEFT JOIN top_per_election tpe ON tpe.election_id = elig.election_id + LEFT JOIN ranked rk ON rk.candidate_id = elig.candidate_id + ORDER BY elig.election_id, elig.user_id; +"#; + pub const QUERY_TRIM_EXCESS_OFFICES_GLOBAL: &str = r#" WITH seats AS ( SELECT