Refactor Falukant certificate management: Consolidated certificate logic into the FalukantFamilyWorker's daily processing, removing the separate FalukantCertificateWorker. Updated SQL queries to include app_user_id and enhanced documentation for clarity on certificate scoring and daily recalculation logic.

This commit is contained in:
Torsten Schulz (local)
2026-03-25 11:12:55 +01:00
parent 3b25f8c3a0
commit 083fa26297
7 changed files with 163 additions and 221 deletions

View File

@@ -0,0 +1,40 @@
# Director: Stückkosten Produktion (Balancing)
## Problem
Die DB wählt das „beste“ Produkt über `QUERY_GET_BEST_PRODUCTION` (Worth-Formel mit `- 6 * ftp.category`), während der Daemon früher **nur** `certificate * 6` pro Stück abgebucht hat **ohne** die Produktklasse. Dadurch konnten Stückkosten und Worth-Ranking auseinanderlaufen.
## Aktuelle Formel (Rust, `DirectorWorker::calc_one_piece_cost`)
```
raw = certificate × PRODUCTION_COST_PER_CERT_LEVEL
+ product_category × PRODUCTION_COST_PER_PRODUCT_CATEGORY
effektiv = raw × (1 headroom_discount)
```
- `product_category` kommt aus `QUERY_GET_BEST_PRODUCTION` (`ftp.category`).
- **Headroom** = `max(0, certificate product_category)`
Wenn du **nicht** am Klassenlimit produzierst (Zertifikat höher als nötig für das Produkt), gibt es einen kleinen Rabatt (Effizienz / Reserve).
Konstanten in `src/worker/director.rs` (anpassen zum Feintuning):
| Konstante | Standard | Bedeutung |
|-----------|----------|-----------|
| `PRODUCTION_COST_PER_CERT_LEVEL` | `6.0` | wie früher `×6` pro Zertifikatsstufe |
| `PRODUCTION_COST_PER_PRODUCT_CATEGORY` | `1.0` | Material pro Produktklasse |
| `PRODUCTION_HEADROOM_DISCOUNT_PER_STEP` | `0.035` | Rabatt pro Headroom-Stufe |
| `PRODUCTION_HEADROOM_DISCOUNT_CAP` | `0.14` | maximaler Gesamtrabatt |
## Spielerfortschritt
- **Höheres Zertifikat** allein erhöht die Basis-Stückkosten linear entspricht **besserer** Produktpalette (`ftp.category <= certificate` in SQL).
- **Höhere Produktklasse** erhöht `raw` über `product_category` (bessere Ware = mehr Material).
- **Zertifikat über der Produktklasse** (Headroom) senkt die effektiven Kosten Belohnung, wenn du nicht immer nur am Limit produzierst.
## Worth in SQL
Die Worth-Zeile in `QUERY_GET_BEST_PRODUCTION` sollte bei größeren Formeländerungen **mit** angepasst werden, damit der Director weiterhin sinnvoll sortiert.
## Parallelproduktionen
`MAX_PARALLEL_PRODUCTIONS` (aktuell 2) bestimmt, wie viele Linien pro Tick Geld binden unabhängig von der Stückkostenformel; bei Liquiditätsproblemen ggf. auf `1` setzen.

View File

@@ -1,18 +1,20 @@
# Falukant: Produktionszertifikate (Daemon)
Der **`FalukantCertificateWorker`** berechnet einmal täglich die Zielstufe und schreibt `falukant_user.certificate` fort (max. **+1** pro Tag, keine normale Herabstufung).
Die Zertifikatslogik läuft **ausschließlich im Daily-Tick** von `FalukantFamilyWorker` (`process_daily`, 24h), nicht in einem eigenen Worker-Thread. Sie schreibt `falukant_user.certificate` fort (max. **+1** pro Tag, keine normale Herabstufung).
Implementierung: `src/worker/falukant_certificate.rs` (`run_daily`).
## SQL
- `QUERY_GET_PRODUCTION_CERTIFICATE_INPUT_ROWS` Eingangsdaten je Falukant-User (Spielercharakter, Wissen, Produktionen, Ämter, Haus …)
- `QUERY_UPDATE_FALUKANT_USER_CERTIFICATE` Update der Stufe
## Logik (Kurz)
## Logik (Kurz, Spec §4)
- `certificateScore` aus gewichteten Punktwerten (Wissen, Produktion, Amt, Adel, Ruf, Haus)
- `raw_target` aus Score-Schwellen (1.2 / 2.1 / 3.0 / 4.0)
- `effective_target` mit Mindestanforderungen je Stufe (Spec §4.5)
- Aufstieg nur wenn `effective_target > current`**`current + 1`** (gegen `effective_target` begrenzt)
- **certificateScore** (Gewichte): Wissen 0,45 · Produktion 0,30 · Amt 0,08 · Adel 0,05 · Ruf 0,07 · Haus 0,05
- **raw_target** aus Score-Schwellen: **&lt;0,9** → 1, **≥0,9** → 2, **≥1,8** → 3, **≥2,8** → 4, **≥3,8** → 5
- **effective_target** mit Mindestanforderungen je Stufe (Spec §4.5)
- Aufstieg nur wenn `effective_target > current`**`current + 1`** (gegen `effective_target` und 5 begrenzt)
- **Bankrott** (`money <= -5000`): Zertifikat auf **1**, mit Event
## Politische Ämter
@@ -25,7 +27,7 @@ Rang aus **`political_office_type.name`** (Substring-Heuristik im Daemon, ohne D
## Abgeschlossene Produktionen
**`COUNT(*)`** aus `falukant_log.production` mit `producer_id = falukant_user.id` (Zeilen = aggregierte Log-Einträge).
**`COUNT(*)`** aus `falukant_log.production` mit `producer_id = falukant_user.id`.
## Events (WebSocket)
@@ -34,6 +36,8 @@ Bei Änderung der Stufe:
1. `falukantUpdateProductionCertificate` mit `reason: "daily_recalculation"`, `old_certificate`, `new_certificate`
2. `falukantUpdateStatus`
`user_id` in den Events: **`app_user_id`** aus der Query (`COALESCE(fu.user_id, fu.id)`), sonst Fallback `falukant_user_id`.
## Nicht umgesetzt (optional / später)
- **Tod ohne Erben** / Zertifikats-Reset

View File

@@ -16,7 +16,7 @@ use worker::{
CharacterCreationWorker, ConnectionPool, DirectorWorker, EventsWorker, HouseWorker,
PoliticsWorker, ProduceWorker, StockageManager, TransportWorker, UndergroundWorker,
UserCharacterWorker, ValueRecalculationWorker, WeatherWorker, Worker,
FalukantFamilyWorker, FalukantCertificateWorker,
FalukantFamilyWorker,
};
static KEEP_RUNNING: AtomicBool = AtomicBool::new(true);
@@ -142,10 +142,6 @@ fn create_workers(pool: ConnectionPool, broker: MessageBroker) -> Vec<Box<dyn Wo
pool.clone(),
broker.clone(),
)),
Box::new(FalukantCertificateWorker::new(
pool.clone(),
broker.clone(),
)),
Box::new(HouseWorker::new(pool.clone(), broker.clone())),
Box::new(PoliticsWorker::new(pool.clone(), broker.clone())),
Box::new(TransportWorker::new(pool.clone(), broker.clone())),

View File

@@ -1,132 +1,98 @@
//! Produktionszertifikat: tägliche Neuberechnung von `falukant_user.certificate`.
//! Spec: docs/FALUKANT_PRODUCTION_CERTIFICATE.md
//! Produktionszertifikat: tägliche Neuberechnung von `falukant_user.certificate` im **FalukantFamilyWorker-Daily-Tick**
//! (nicht in einem eigenen Worker-Thread). Spec: `docs/FALUKANT_PRODUCTION_CERTIFICATE.md` und
//! „Falukant: Produktionszertifikate Fach- und Integrationsspezifikation“.
use crate::db::{ConnectionPool, DbError, Row};
use crate::db::{DbError, Row};
use crate::message_broker::MessageBroker;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use super::base::{BaseWorker, Worker, WorkerState};
use super::base::BaseWorker;
use crate::worker::sql::{
QUERY_GET_PRODUCTION_CERTIFICATE_INPUT_ROWS, QUERY_UPDATE_FALUKANT_USER_CERTIFICATE,
};
const DAILY_INTERVAL: Duration = Duration::from_secs(24 * 3600);
/// Wenn `money` darunter liegt, gilt der Spieler als bankrott → Zertifikat auf Stufe 1.
/// Wenn `money` darunter liegt, gilt der Spieler als bankrott → Zertifikat auf Stufe 1 (Spec §4.7).
const BANKRUPTCY_MONEY_THRESHOLD: f64 = -5000.0;
pub struct FalukantCertificateWorker {
base: BaseWorker,
}
/// Einmal pro Daily-Tick (`FalukantFamilyWorker::process_daily`).
pub fn run_daily(base: &BaseWorker, broker: &MessageBroker) -> Result<(), DbError> {
let pool = &base.pool;
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
impl FalukantCertificateWorker {
pub fn new(pool: ConnectionPool, broker: MessageBroker) -> Self {
Self {
base: BaseWorker::new("FalukantCertificateWorker", pool, broker),
conn.prepare("cert_rows", QUERY_GET_PRODUCTION_CERTIFICATE_INPUT_ROWS)?;
conn.prepare("cert_upd", QUERY_UPDATE_FALUKANT_USER_CERTIFICATE)?;
let rows = conn.execute("cert_rows", &[])?;
for row in rows {
let fu_id = parse_i32(&row, "falukant_user_id", -1);
if fu_id < 0 {
continue;
}
let app_uid = parse_i32(&row, "app_user_id", -1);
let event_uid = if app_uid > 0 { app_uid } else { fu_id };
let current = parse_i32(&row, "certificate", 1).clamp(1, 5);
let money = parse_f64(&row, "money", 0.0);
let avg_knowledge = parse_f64(&row, "avg_knowledge", 0.0);
let completed = parse_i64(&row, "completed_production_count", 0);
let max_church_hierarchy = parse_i32(&row, "max_church_hierarchy", 0);
let pol_names = row
.get("political_office_names")
.cloned()
.unwrap_or_default();
let reputation = parse_f64(&row, "reputation", 50.0);
let title_level = parse_i32(&row, "title_level", 0);
let house_position = parse_i32(&row, "house_position", 0);
// Bankrott: Herabsetzung (Spec §4.7)
if money <= BANKRUPTCY_MONEY_THRESHOLD && current > 1 {
conn.execute("cert_upd", &[&1_i32, &fu_id])?;
publish_certificate_event(broker, event_uid, current, 1);
continue;
}
let knowledge_points = knowledge_points_from_avg(avg_knowledge);
let production_points = production_points_from_count(completed);
let political_rank = max_political_rank_from_names(&pol_names);
let church_rank = church_rank_from_hierarchy(max_church_hierarchy);
let highest_office_rank = political_rank.max(church_rank).min(5);
let office_points = highest_office_rank.min(5);
let nobility_points = (title_level - 1).clamp(0, 5);
let reputation_points = reputation_points_from_rep(reputation);
let house_points = house_points_from_position(house_position);
let certificate_score = knowledge_points as f64 * 0.45
+ production_points as f64 * 0.30
+ office_points as f64 * 0.08
+ nobility_points as f64 * 0.05
+ reputation_points as f64 * 0.07
+ house_points as f64 * 0.05;
let raw_target = raw_target_from_score(certificate_score);
let effective_target = effective_certificate_target(
raw_target,
avg_knowledge,
completed,
office_points,
nobility_points,
reputation_points,
house_points,
);
let new_certificate = if effective_target > current {
(current + 1).min(effective_target).min(5)
} else {
current
};
if new_certificate != current {
conn.execute("cert_upd", &[&new_certificate, &fu_id])?;
publish_certificate_event(broker, event_uid, current, new_certificate);
}
}
fn run_loop(pool: ConnectionPool, broker: MessageBroker, state: Arc<WorkerState>) {
let mut last: Option<std::time::Instant> = None;
while state.running_worker.load(Ordering::Relaxed) {
let now = std::time::Instant::now();
let run = match last {
None => true,
Some(t) => now.saturating_duration_since(t) >= DAILY_INTERVAL,
};
if run {
if let Err(e) = Self::process_daily(&pool, &broker) {
eprintln!("[FalukantCertificateWorker] process_daily: {e}");
}
last = Some(now);
}
for _ in 0..60 {
if !state.running_worker.load(Ordering::Relaxed) {
break;
}
std::thread::sleep(Duration::from_secs(1));
}
}
}
fn process_daily(pool: &ConnectionPool, broker: &MessageBroker) -> Result<(), DbError> {
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
conn.prepare("cert_rows", QUERY_GET_PRODUCTION_CERTIFICATE_INPUT_ROWS)?;
conn.prepare("cert_upd", QUERY_UPDATE_FALUKANT_USER_CERTIFICATE)?;
let rows = conn.execute("cert_rows", &[])?;
for row in rows {
let fu_id = parse_i32(&row, "falukant_user_id", -1);
if fu_id < 0 {
continue;
}
let current = parse_i32(&row, "certificate", 1).clamp(1, 127);
let money = parse_f64(&row, "money", 0.0);
let avg_knowledge = parse_f64(&row, "avg_knowledge", 0.0);
let completed = parse_i64(&row, "completed_production_count", 0);
let max_church_hierarchy = parse_i32(&row, "max_church_hierarchy", 0);
let pol_names = row
.get("political_office_names")
.cloned()
.unwrap_or_default();
let reputation = parse_f64(&row, "reputation", 50.0);
let title_level = parse_i32(&row, "title_level", 0);
let house_position = parse_i32(&row, "house_position", 0);
// Bankrott: Herabsetzung erlaubt (Spec)
if money <= BANKRUPTCY_MONEY_THRESHOLD && current > 1 {
conn.execute("cert_upd", &[&1_i32, &fu_id])?;
publish_certificate_event(broker, fu_id, current, 1);
continue;
}
let knowledge_points = knowledge_points_from_avg(avg_knowledge);
let production_points = production_points_from_count(completed);
let political_rank = max_political_rank_from_names(&pol_names);
let church_rank = church_rank_from_hierarchy(max_church_hierarchy);
let highest_office_rank = political_rank.max(church_rank).min(5);
let office_points = highest_office_rank.min(5);
let nobility_points = (title_level - 1).clamp(0, 5);
let reputation_points = reputation_points_from_rep(reputation);
let house_points = house_points_from_position(house_position);
let certificate_score = knowledge_points as f64 * 0.35
+ production_points as f64 * 0.20
+ office_points as f64 * 0.15
+ nobility_points as f64 * 0.10
+ reputation_points as f64 * 0.10
+ house_points as f64 * 0.10;
let raw_target = raw_target_from_score(certificate_score);
let effective_target = effective_certificate_target(
raw_target,
avg_knowledge,
completed,
office_points,
nobility_points,
reputation_points,
house_points,
);
let new_certificate = if effective_target > current {
(current + 1).min(effective_target)
} else {
current
};
if new_certificate != current {
conn.execute("cert_upd", &[&new_certificate, &fu_id])?;
publish_certificate_event(broker, fu_id, current, new_certificate);
}
}
Ok(())
}
Ok(())
}
fn publish_certificate_event(broker: &MessageBroker, user_id: i32, old_c: i32, new_c: i32) {
@@ -189,7 +155,6 @@ fn production_points_from_count(n: i64) -> i32 {
}
}
/// Kirchliche Ämter: `hierarchy_level` auf 05 begrenzen (Daemon).
fn church_rank_from_hierarchy(h: i32) -> i32 {
if h <= 0 {
0
@@ -198,7 +163,7 @@ fn church_rank_from_hierarchy(h: i32) -> i32 {
}
}
/// Politische Amtsnamen → Rang 15 (konfigurierbar im Daemon).
/// Politische Amtsnamen → Rang 15 (Heuristik im Daemon, ohne DB-Änderung).
fn political_name_to_rank(name: &str) -> i32 {
let n = name.to_lowercase();
if n.contains("reich")
@@ -282,72 +247,36 @@ fn house_points_from_position(position: i32) -> i32 {
}
}
/// Spec §4.6 Schwellen für die Zielstufe (vor Mindestanforderungen).
fn raw_target_from_score(score: f64) -> i32 {
if score >= 4.0 {
if score >= 3.8 {
5
} else if score >= 3.0 {
} else if score >= 2.8 {
4
} else if score >= 2.1 {
} else if score >= 1.8 {
3
} else if score >= 1.2 {
} else if score >= 0.9 {
2
} else {
1
}
}
fn status_one(
office_points: i32,
nobility_points: i32,
reputation_points: i32,
house_points: i32,
) -> bool {
office_points >= 1
|| nobility_points >= 1
|| reputation_points >= 2
|| house_points >= 1
}
fn status_count_cert4(
office_points: i32,
nobility_points: i32,
reputation_points: i32,
house_points: i32,
) -> i32 {
fn cert5_two_of_social(office_points: i32, nobility_points: i32, house_points: i32) -> bool {
let mut c = 0;
if office_points >= 1 {
if office_points >= 2 {
c += 1;
}
if nobility_points >= 1 {
c += 1;
}
if reputation_points >= 2 {
c += 1;
}
if house_points >= 1 {
c += 1;
}
c
}
fn cert5_extra_two(
office_points: i32,
nobility_points: i32,
house_points: i32,
) -> i32 {
let mut c = 0;
if office_points >= 2 {
c += 1;
}
if nobility_points >= 2 {
c += 1;
}
if house_points >= 2 {
c += 1;
}
c
c >= 2
}
/// Mindestanforderungen je Stufe (Spec §4.5).
fn meets_min_for_level(
level: i32,
avg_knowledge: f64,
@@ -359,38 +288,27 @@ fn meets_min_for_level(
) -> bool {
match level {
1 => true,
2 => avg_knowledge >= 25.0 && completed >= 5,
3 => {
avg_knowledge >= 40.0
&& completed >= 20
&& status_one(
office_points,
nobility_points,
reputation_points,
house_points,
)
}
2 => avg_knowledge >= 15.0 && completed >= 4,
3 => avg_knowledge >= 28.0 && completed >= 15,
4 => {
avg_knowledge >= 55.0
&& completed >= 60
&& status_count_cert4(
office_points,
nobility_points,
reputation_points,
house_points,
) >= 2
avg_knowledge >= 45.0
&& completed >= 45
&& (office_points >= 1
|| nobility_points >= 1
|| reputation_points >= 2
|| house_points >= 2)
}
5 => {
avg_knowledge >= 70.0
&& completed >= 150
&& reputation_points >= 3
&& cert5_extra_two(office_points, nobility_points, house_points) >= 2
avg_knowledge >= 60.0
&& completed >= 110
&& reputation_points >= 2
&& cert5_two_of_social(office_points, nobility_points, house_points)
}
_ => false,
}
}
/// Höchste Stufe ≤ `raw_target`, die alle Mindestanforderungen erfüllt.
/// Höchste Stufe ≤ `raw_target`, die alle Mindestanforderungen erfüllt (Spec §4.6).
fn effective_certificate_target(
raw_target: i32,
avg_knowledge: f64,
@@ -416,23 +334,3 @@ fn effective_certificate_target(
}
1
}
impl Worker for FalukantCertificateWorker {
fn start_worker_thread(&mut self) {
let pool = self.base.pool.clone();
let broker = self.base.broker.clone();
self.base
.start_worker_with_loop(move |state: Arc<WorkerState>| {
FalukantCertificateWorker::run_loop(pool.clone(), broker.clone(), state);
});
}
fn stop_worker_thread(&mut self) {
self.base.stop_worker();
}
fn enable_watchdog(&mut self) {
self.base.start_watchdog();
}
}

View File

@@ -158,6 +158,9 @@ impl FalukantFamilyWorker {
if let Err(e) = super::falukant_debtors::run_daily(&self.base, &self.base.broker) {
eprintln!("[FalukantFamilyWorker] falukant_debtors::run_daily: {e}");
}
if let Err(e) = super::falukant_certificate::run_daily(&self.base, &self.base.broker) {
eprintln!("[FalukantFamilyWorker] falukant_certificate::run_daily: {e}");
}
let mut conn = self
.base

View File

@@ -33,5 +33,4 @@ pub use transport::TransportWorker;
pub use weather::WeatherWorker;
pub use events::EventsWorker;
pub use falukant_family::FalukantFamilyWorker;
pub use falukant_certificate::FalukantCertificateWorker;

View File

@@ -3135,6 +3135,7 @@ pub const QUERY_INSERT_CHILD_RELATION_LOVER: &str = r#"
pub const QUERY_GET_PRODUCTION_CERTIFICATE_INPUT_ROWS: &str = r#"
SELECT DISTINCT ON (fu.id)
fu.id AS falukant_user_id,
COALESCE(fu.user_id, fu.id)::int AS app_user_id,
COALESCE(fu.certificate, 1)::int AS certificate,
COALESCE(fu.money, 0)::float8 AS money,
c.id AS character_id,
@@ -3179,7 +3180,8 @@ pub const QUERY_GET_PRODUCTION_CERTIFICATE_INPUT_ROWS: &str = r#"
pub const QUERY_UPDATE_FALUKANT_USER_CERTIFICATE: &str = r#"
UPDATE falukant_data.falukant_user
SET certificate = $1::int
SET certificate = $1::int,
updated_at = NOW()
WHERE id = $2::int;
"#;