Enhance production certificate and election result handling: Updated the logic in run_daily to improve clarity on level progression based on weighted scores, including detailed logging for cases where players meet minimum requirements but do not advance. Introduced a new function to notify players of election results, integrating SQL queries for efficient data retrieval and notification management. Enhanced documentation for the production certificate process to clarify UI vs. daemon discrepancies.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 2m54s

This commit is contained in:
Torsten Schulz (local)
2026-04-02 14:48:39 +02:00
parent 619e5e5123
commit 21525ec125
4 changed files with 230 additions and 0 deletions

View File

@@ -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 &lt; 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`.

View File

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

View File

@@ -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::<i64>().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::<i64>().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(

View File

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