diff --git a/docs/FALUKANT_CHURCH_DAEMON.md b/docs/FALUKANT_CHURCH_DAEMON.md new file mode 100644 index 0000000..68066bb --- /dev/null +++ b/docs/FALUKANT_CHURCH_DAEMON.md @@ -0,0 +1,59 @@ +# Falukant: Kirchenämter (YpDaemon / PoliticsWorker) + +Umsetzung des Zielmodells: Laufbahn `highest_church_hierarchy_ever`, NPC-Bewerbungen, NPC-Vorgesetzte mit Score, Spieler-Vorgesetzte ohne Daemon-Entscheidung, Interimsbesetzung, Events `falukantUpdateChurch`. + +## Migration + +- `migrations/007_falukant_character_church_career.sql` — Spalte `falukant_data.character.highest_church_hierarchy_ever` + Backfill aus bestehenden `church_office`. + +**Ohne Migration** schlagen Bewerbungs-Checks fehl, die die Spalte referenzieren. + +## Ticks + +| Was | Wann | +|-----|------| +| Kirchen-Gesamtprozess | Täglich im **gleichen** Lauf wie `perform_daily_politics_task` (nicht mehr nur 13:00) | +| Auto-Approve alter Bewerbungen | Stündlich, **nur** wenn `supervisor.user_id IS NULL` (NPC-Vorgesetzter), 36 h+ | + +## Logik (Kurz) + +1. **Freie Sitze** je `church_office_type` / Region (`QUERY_FIND_AVAILABLE_CHURCH_OFFICES`, inkl. `hierarchy_level`). +2. **Vorgesetzter** = nächsthöhere besetzte Hierarchie in der Region (`QUERY_FIND_CHURCH_SUPERVISOR`). +3. **Spieler-Vorgesetzter** (`character.user_id IS NOT NULL`): `falukantUpdateChurch` mit `reason: applications` — **keine** automatische Annahme/Ablehnung durch den Daemon. +4. **NPC-Vorgesetzter**: Bewerber mit `churchCandidateScore` (Reputation, höchste Hierarchie, aktuelle Ämter, Titel, Alter, Zufallsanteil abhängig von `supervisor.reputation`). Pro `(office_type_id, region)` werden nur so viele Zusagen erteilt, wie Plätze frei sind. +5. **NPC-Bewerbungen**: Nur Charaktere mit `user_id IS NULL`; nur wenn `pending < freie Sitze` (nachzüglernde Jobs). +6. **Interimsbesetzung**: Kein Vorgesetzter, `hierarchy_level <= 6` (bis einschließlich „Bishop“-Ebene im Typ-Stammbaum — anpassbar über Konstante `INTERIM_MAX_CHURCH_HIERARCHY` in `politics.rs`): bester NPC-Kandidat + direktes `INSERT` in `church_office`, Update `highest_church_hierarchy_ever`, Entfernen niedrigerer kirchlicher Ämter. + +## Bewerbungsvoraussetzungen (Daemon-SQL) + +`QUERY_CHECK_CHARACTER_ELIGIBILITY`: Für ein gefordertes Voramt (`prerequisite_office_type_id`) gilt **Erfüllt**, wenn + +- das exakte Amt aktuell gehalten wird, **oder** +- `GREATEST(highest_church_hierarchy_ever, max. aktuelle Hierarchie aus church_office) >= hierarchy_level` des Voramts. + +Titelbedingungen unverändert (`min_title_level`). + +## Genehmigung / Amtsverlust + +- `QUERY_APPROVE_CHURCH_APPLICATION` und `QUERY_AUTO_APPROVE_CHURCH_APPLICATION`: nach Eintrag `church_office` → Update `highest_church_hierarchy_ever`, dann `DELETE` niedrigerer konkurrierender `church_office` desselben Charakters (`remove_lower_ranked`). + +## WebSocket + +| Event | Payload | +|-------|---------| +| `falukantUpdateChurch` | `{"event":"falukantUpdateChurch","user_id":N,"reason":"…"}` | +| `falukantUpdateStatus` | wie üblich, direkt danach | + +**reason:** `applications` (Spieler-Vorgesetzter), `npc_decision` (NPC hat zugesagt), `appointment` (Auto-Approve 36 h, nur NPC-Supervisor), `vacancy_fill` (Interimsbesetzung, nur wenn Bewerber ein Spielercharakter ist). + +Details: [`FALUKANT_UI_WEBSOCKET.md`](./FALUKANT_UI_WEBSOCKET.md). + +## Backend (außerhalb YpDaemon) + +- `getAvailableChurchPositions()` muss dieselbe Laufbahn-Logik nutzen (Spec Abschnitt 11). +- Spieleranträge und gleiche Bewertungsregeln wie im Daemon. + +## Code + +- `src/worker/politics.rs` — `perform_church_office_task`, `process_church_supervisor_queue`, `npc_resolve_church_applications_for_supervisor`, `try_interim_church_appointment`, Hilfsfunktionen. +- `src/worker/sql.rs` — Abschnitt „Church Office Queries“. diff --git a/docs/FALUKANT_UI_WEBSOCKET.md b/docs/FALUKANT_UI_WEBSOCKET.md index 6048eef..fa98188 100644 --- a/docs/FALUKANT_UI_WEBSOCKET.md +++ b/docs/FALUKANT_UI_WEBSOCKET.md @@ -15,6 +15,9 @@ Dieses Dokument beschreibt die **Nachrichten**, die der **YpDaemon** (`FalukantF | `falukantUpdateProductionCertificate` | `user_id`, `reason`, `old_certificate`, `new_certificate` | Produkte / Produktions-UI / Zertifikat neu laden (nach Daily-Recalc oder Bankrott) | | `children_update` | `user_id` | Kinderliste / FamilyView aktualisieren | | `falukant_family_scandal_hint` | `relationship_id` | Optional: Toast, Log – **kein** `user_id` (siehe unten) | +| `falukantUpdateChurch` | `user_id`, `reason` | Kirchenämter: Bewerbungen, Ernennungen (`PoliticsWorker`) | + +Siehe auch: [`FALUKANT_CHURCH_DAEMON.md`](./FALUKANT_CHURCH_DAEMON.md). --- @@ -40,6 +43,28 @@ Dieses Dokument beschreibt die **Nachrichten**, die der **YpDaemon** (`FalukantF | `scandal` | Skandal-Ereignis (zusätzlich zu `daily` möglich) | Kurzer Hinweis / Eintrag „Skandal“; Family + Ruf | | `lover_birth` | Uneheliches Kind angelegt | Wie `children_update`, plus Eltern-Story | +### 2.1a `falukantUpdateChurch` + +```json +{ + "event": "falukantUpdateChurch", + "user_id": 123, + "reason": "applications" +} +``` + +**`reason`:** + +| `reason` | Bedeutung | UI | +|----------|-----------|-----| +| `applications` | Spieler ist kirchlicher Vorgesetzter: offene Bewerbungen warten | Bewerbungslisten / supervised applications | +| `npc_decision` | NPC-Vorgesetzter hat zugesagt (Bewerber ist oft Spielercharakter) | Ämter + Bewerbungen | +| `appointment` | Auto-Annahme alter NPC-Supervisor-Bewerbung (36 h) | Ämter + Status | +| `vacancy_fill` | Interimsbesetzung (selten; Bewerber kann Spieler sein) | Ämter + freie Positionen | +| `promotion` | (reserviert / zukünftig) | — | + +Immer zusätzlich mit **`falukantUpdateStatus`** (gleiche `user_id`). + ### 2.2 `falukantUpdateStatus` ```json @@ -168,6 +193,7 @@ Konkrete Routen stehen im **YourPart3**-Backend; das Frontend sollte eine zentra ## 6. Bezug zum Code (YpDaemon) - Worker: `src/worker/falukant_family.rs` +- Kirche: `src/worker/politics.rs` (`falukantUpdateChurch`) - SQL-Konstanten: `src/worker/sql.rs` (Abschnitt Falukant Familie) - Schema: `migrations/001_falukant_family_lovers.sql`, `006_falukant_lover_installments.sql` (Unterhalt 12×/Tag) - Daemon-Handoff (technisch): `docs/FALUKANT_DAEMON_HANDOFF.md` diff --git a/migrations/007_falukant_character_church_career.sql b/migrations/007_falukant_character_church_career.sql new file mode 100644 index 0000000..9ec51e6 --- /dev/null +++ b/migrations/007_falukant_character_church_career.sql @@ -0,0 +1,20 @@ +-- Höchste erreichte kirchliche Hierarchiestufe (Laufbahn), nicht zurücksetzen bei Amtsverlust. +-- Siehe docs/FALUKANT_CHURCH_DAEMON.md + +ALTER TABLE falukant_data.character + ADD COLUMN IF NOT EXISTS highest_church_hierarchy_ever SMALLINT; + +COMMENT ON COLUMN falukant_data.character.highest_church_hierarchy_ever IS + 'Max. hierarchy_level (church_office_type) jemals erreicht; für Bewerbungsvoraussetzungen neben aktuellem Amt'; + +UPDATE falukant_data.character c +SET highest_church_hierarchy_ever = sub.mh::smallint +FROM ( + SELECT co.character_id AS cid, + MAX(cot.hierarchy_level)::int AS mh + FROM falukant_data.church_office co + JOIN falukant_type.church_office_type cot ON cot.id = co.office_type_id + GROUP BY co.character_id +) sub +WHERE c.id = sub.cid + AND (c.highest_church_hierarchy_ever IS NULL OR c.highest_church_hierarchy_ever < sub.mh); diff --git a/src/worker/politics.rs b/src/worker/politics.rs index c97b391..5b3d4de 100644 --- a/src/worker/politics.rs +++ b/src/worker/politics.rs @@ -1,11 +1,9 @@ use crate::db::{ConnectionPool, DbError, Row}; use crate::message_broker::MessageBroker; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::{Duration, Instant}; -use chrono::{Local, Timelike}; - use super::base::{BaseWorker, Worker, WorkerState}; use crate::worker::sql::{ QUERY_COUNT_OFFICES_PER_REGION, @@ -26,7 +24,6 @@ use crate::worker::sql::{ QUERY_FIND_AVAILABLE_CHURCH_OFFICES, QUERY_FIND_CHURCH_SUPERVISOR, QUERY_GET_CHURCH_OFFICE_REQUIREMENTS, - QUERY_GET_PENDING_CHURCH_APPLICATIONS, QUERY_CHECK_CHARACTER_ELIGIBILITY, QUERY_APPROVE_CHURCH_APPLICATION, QUERY_REJECT_CHURCH_APPLICATION, @@ -34,6 +31,14 @@ use crate::worker::sql::{ QUERY_GET_CHARACTERS_FOR_CHURCH_OFFICE, QUERY_GET_OLD_PENDING_CHURCH_APPLICATIONS, QUERY_AUTO_APPROVE_CHURCH_APPLICATION, + QUERY_COUNT_PENDING_CHURCH_APPS_BY_OFFICE_REGION, + QUERY_GET_CHURCH_OFFICE_OCCUPIED_COUNT, + QUERY_IS_CHARACTER_NPC, + QUERY_GET_PENDING_CHURCH_APPLICATIONS_FOR_SCORING, + QUERY_INTERIM_APPOINT_CHURCH_OFFICE, + QUERY_UPDATE_CHARACTER_HIGHEST_CHURCH_FROM_OFFICE_TYPE, + QUERY_FIND_INTERIM_CHURCH_NPC_CANDIDATE, + QUERY_REMOVE_LOWER_CHURCH_OFFICES_FOR_CHARACTER, }; pub struct PoliticsWorker { @@ -72,6 +77,7 @@ struct Office { #[derive(Debug, Clone)] struct AvailableChurchOffice { office_type_id: i32, + hierarchy_level: i32, seats_per_region: i32, region_id: i32, occupied_seats: i32, @@ -82,17 +88,16 @@ struct ChurchSupervisor { supervisor_character_id: i32, } -#[derive(Debug, Clone)] -struct ChurchOfficeRequirement { - prerequisite_office_type_id: Option, - min_title_level: Option, -} +/// Bis einschließlich dieser `hierarchy_level` (church_office_type): Interimsbesetzung ohne Vorgesetzten. +const INTERIM_MAX_CHURCH_HIERARCHY: i32 = 6; -#[derive(Debug, Clone)] -struct ChurchApplication { +struct ChurchAppScoreRow { application_id: i32, office_type_id: i32, applicant_character_id: i32, + region_id: i32, + seats_per_region: i32, + score: f64, } // --- SQL-Konstanten (1:1 aus politics_worker.h übernommen) ------------------ @@ -107,7 +112,6 @@ impl PoliticsWorker { fn run_loop(pool: ConnectionPool, broker: MessageBroker, state: Arc) { let mut last_execution: Option = None; - let mut last_church_office_run: Option = None; let mut last_auto_approve_run: Option = None; while state.running_worker.load(Ordering::Relaxed) { @@ -124,25 +128,6 @@ impl PoliticsWorker { last_execution = Some(now); } - // Church Office Job um 13 Uhr - if Self::is_time_13_00() { - let should_run_church = match last_church_office_run { - None => true, - Some(prev) => { - // Prüfe ob seit letztem Lauf mindestens 23 Stunden vergangen sind - // (um sicherzustellen, dass es nur einmal pro Tag läuft) - now.saturating_duration_since(prev) >= Duration::from_secs(23 * 3600) - } - }; - - if should_run_church { - if let Err(err) = Self::perform_church_office_task(&pool, &broker) { - eprintln!("[PoliticsWorker] Fehler bei performChurchOfficeTask: {err}"); - } - last_church_office_run = Some(now); - } - } - // Automatische Annahme alter Applications (stündlich) let should_run_auto_approve = match last_auto_approve_run { None => true, @@ -168,18 +153,15 @@ impl PoliticsWorker { } } - /// Prüft ob die aktuelle Uhrzeit 13:00 ist (mit Toleranz von ±1 Minute) - fn is_time_13_00() -> bool { - let now = Local::now(); - let hour = now.hour(); - let minute = now.minute(); - hour == 13 && minute <= 1 - } - fn perform_daily_politics_task( pool: &ConnectionPool, broker: &MessageBroker, ) -> Result<(), DbError> { + // 0) Täglich: Kirchenämter (NPC-Bewerbungen, NPC-Vorgesetzte, Interimsbesetzung) + if let Err(err) = Self::perform_church_office_task(pool, broker) { + eprintln!("[PoliticsWorker] Fehler bei perform_church_office_task: {err}"); + } + // 1) Optional: Positionen evaluieren (aktuell nur Logging/Struktur) Self::evaluate_political_positions(pool)?; @@ -636,45 +618,47 @@ impl PoliticsWorker { .collect()) } - /// Verarbeitet Church Office Jobs um 13 Uhr: - /// - Findet verfügbare Positionen - /// - Verarbeitet bestehende Bewerbungen - /// - Erstellt neue Bewerbungen falls nötig + /// 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( pool: &ConnectionPool, broker: &MessageBroker, ) -> Result<(), DbError> { - eprintln!("[PoliticsWorker] Starte Church Office Task um 13 Uhr"); + eprintln!("[PoliticsWorker] Starte Church Office Task (täglich)"); - // 1) Verfügbare Church Office Positionen finden let available_offices = Self::find_available_church_offices(pool)?; eprintln!( "[PoliticsWorker] Gefunden: {} verfügbare Church Office Positionen", available_offices.len() ); - // 2) Für jede verfügbare Position Bewerbungen verarbeiten for office in &available_offices { - // Supervisor finden - if let Some(supervisor) = Self::find_church_supervisor(pool, office.region_id, office.office_type_id)? { - // Bestehende Bewerbungen für diesen Supervisor verarbeiten - Self::process_church_applications(pool, broker, supervisor.supervisor_character_id)?; + if let Some(supervisor) = + Self::find_church_supervisor(pool, office.region_id, office.office_type_id)? + { + Self::process_church_supervisor_queue(pool, broker, supervisor.supervisor_character_id)?; - // Falls noch Plätze frei sind, neue Bewerbungen erstellen let remaining_seats = office.seats_per_region - office.occupied_seats; if remaining_seats > 0 { - Self::create_church_application_jobs( - pool, - office.office_type_id, - office.region_id, - supervisor.supervisor_character_id, - remaining_seats, - )?; + let pending_count = + Self::count_pending_church_apps(pool, office.office_type_id, office.region_id)?; + if pending_count < remaining_seats { + let need = remaining_seats - pending_count; + Self::create_church_application_jobs( + pool, + office.office_type_id, + office.region_id, + supervisor.supervisor_character_id, + need, + )?; + } } + } else if office.hierarchy_level <= INTERIM_MAX_CHURCH_HIERARCHY { + Self::try_interim_church_appointment(pool, broker, office)?; } else { eprintln!( - "[PoliticsWorker] Kein Supervisor gefunden für office_type_id={}, region_id={}", - office.office_type_id, office.region_id + "[PoliticsWorker] Kein Supervisor, Interim deaktiviert (hierarchy_level={}): office_type_id={}, region_id={}", + office.hierarchy_level, office.office_type_id, office.region_id ); } } @@ -683,6 +667,261 @@ impl PoliticsWorker { Ok(()) } + fn publish_falukant_church_update(broker: &MessageBroker, user_id: i32, reason: &str) { + let church = format!( + r#"{{"event":"falukantUpdateChurch","user_id":{},"reason":"{}"}}"#, + user_id, reason + ); + broker.publish(church); + let status = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id); + broker.publish(status); + } + + fn count_pending_church_apps( + pool: &ConnectionPool, + office_type_id: i32, + region_id: i32, + ) -> Result { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + conn.prepare( + "cnt_pending_church", + QUERY_COUNT_PENDING_CHURCH_APPS_BY_OFFICE_REGION, + ) + .map_err(|e| DbError::new(format!("[PoliticsWorker] prepare cnt_pending_church: {e}")))?; + let rows = conn + .execute("cnt_pending_church", &[&office_type_id, ®ion_id]) + .map_err(|e| DbError::new(format!("[PoliticsWorker] exec cnt_pending_church: {e}")))?; + Ok(rows + .first() + .and_then(|r| r.get("cnt")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0)) + } + + fn get_church_occupied_count( + pool: &ConnectionPool, + office_type_id: i32, + region_id: i32, + ) -> Result { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + conn.prepare( + "cnt_occ_church", + QUERY_GET_CHURCH_OFFICE_OCCUPIED_COUNT, + ) + .map_err(|e| DbError::new(format!("[PoliticsWorker] prepare cnt_occ_church: {e}")))?; + let rows = conn + .execute("cnt_occ_church", &[&office_type_id, ®ion_id]) + .map_err(|e| DbError::new(format!("[PoliticsWorker] exec cnt_occ_church: {e}")))?; + Ok(rows + .first() + .and_then(|r| r.get("cnt")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0)) + } + + fn church_candidate_score( + supervisor_rep: f64, + applicant_rep: f64, + highest_ever: i32, + current_max: i32, + title_level: i32, + age_days: i32, + wait_days: f64, + ) -> f64 { + let age_years = age_days / 365; + let age_bonus = if (25..=70).contains(&age_years) { + 12.0 + } else { + 0.0 + }; + let base = (highest_ever as f64) * 4.0 + + (current_max as f64) * 3.0 + + applicant_rep * 0.45 + + (title_level as f64) * 1.1 + + age_bonus + + wait_days * 0.15; + let inf = (supervisor_rep / 100.0).clamp(0.0, 1.0); + let noise = (1.0 - inf) * 28.0 * rand::random::(); + base + noise + } + + fn parse_pg_bool(v: Option<&str>) -> bool { + match v { + Some("t") | Some("true") => true, + Some("f") | Some("false") => false, + Some(s) => s.parse::().unwrap_or(false), + None => false, + } + } + + fn character_eligible_for_church_office( + pool: &ConnectionPool, + character_id: i32, + office_type_id: i32, + ) -> Result { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + conn.prepare( + "get_church_office_requirements", + QUERY_GET_CHURCH_OFFICE_REQUIREMENTS, + ) + .map_err(|e| { + DbError::new(format!( + "[PoliticsWorker] prepare get_church_office_requirements: {e}" + )) + })?; + conn.prepare( + "check_character_eligibility", + QUERY_CHECK_CHARACTER_ELIGIBILITY, + ) + .map_err(|e| { + DbError::new(format!( + "[PoliticsWorker] prepare check_character_eligibility: {e}" + )) + })?; + + let req_rows = conn + .execute("get_church_office_requirements", &[&office_type_id]) + .map_err(|e| { + DbError::new(format!( + "[PoliticsWorker] exec get_church_office_requirements: {e}" + )) + })?; + + for req_row in req_rows { + let prerequisite_office_type_id = req_row + .get("prerequisite_office_type_id") + .and_then(|v| v.parse::().ok()); + let min_title_level = req_row + .get("min_title_level") + .and_then(|v| v.parse::().ok()); + + let elig_rows = conn + .execute( + "check_character_eligibility", + &[ + &character_id, + &prerequisite_office_type_id, + &min_title_level, + ], + ) + .map_err(|e| { + DbError::new(format!( + "[PoliticsWorker] exec check_character_eligibility: {e}" + )) + })?; + + for elig_row in elig_rows { + let has_prerequisite = Self::parse_pg_bool(elig_row.get("has_prerequisite").map(|s| s.as_str())); + let meets_title_requirement = + Self::parse_pg_bool(elig_row.get("meets_title_requirement").map(|s| s.as_str())); + if !has_prerequisite || !meets_title_requirement { + return Ok(false); + } + } + } + Ok(true) + } + + fn remove_lower_ranked_church_offices_for_character( + pool: &ConnectionPool, + character_id: i32, + ) -> Result<(), DbError> { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + conn.prepare( + "rm_lower_church", + QUERY_REMOVE_LOWER_CHURCH_OFFICES_FOR_CHARACTER, + ) + .map_err(|e| DbError::new(format!("[PoliticsWorker] prepare rm_lower_church: {e}")))?; + conn.execute("rm_lower_church", &[&character_id]) + .map_err(|e| DbError::new(format!("[PoliticsWorker] exec rm_lower_church: {e}")))?; + Ok(()) + } + + fn try_interim_church_appointment( + pool: &ConnectionPool, + broker: &MessageBroker, + office: &AvailableChurchOffice, + ) -> Result<(), DbError> { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + conn.prepare( + "find_interim_npc", + QUERY_FIND_INTERIM_CHURCH_NPC_CANDIDATE, + ) + .map_err(|e| DbError::new(format!("[PoliticsWorker] prepare find_interim_npc: {e}")))?; + let rows = conn + .execute("find_interim_npc", &[&office.region_id, &office.office_type_id]) + .map_err(|e| DbError::new(format!("[PoliticsWorker] exec find_interim_npc: {e}")))?; + + let candidate_id = rows + .first() + .and_then(|r| r.get("character_id")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(-1); + if candidate_id < 0 { + return Ok(()); + } + + if !Self::character_eligible_for_church_office(pool, candidate_id, office.office_type_id)? { + return Ok(()); + } + + drop(conn); + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + conn.prepare( + "interim_ins", + QUERY_INTERIM_APPOINT_CHURCH_OFFICE, + ) + .map_err(|e| DbError::new(format!("[PoliticsWorker] prepare interim_ins: {e}")))?; + let ins = conn + .execute( + "interim_ins", + &[ + &office.office_type_id, + &candidate_id, + &office.region_id, + &office.seats_per_region, + ], + ) + .map_err(|e| DbError::new(format!("[PoliticsWorker] exec interim_ins: {e}")))?; + + if ins.is_empty() { + return Ok(()); + } + + conn.prepare( + "upd_hi_interim", + QUERY_UPDATE_CHARACTER_HIGHEST_CHURCH_FROM_OFFICE_TYPE, + ) + .map_err(|e| DbError::new(format!("[PoliticsWorker] prepare upd_hi_interim: {e}")))?; + conn.execute("upd_hi_interim", &[&candidate_id, &office.office_type_id]) + .map_err(|e| DbError::new(format!("[PoliticsWorker] exec upd_hi_interim: {e}")))?; + + Self::remove_lower_ranked_church_offices_for_character(pool, candidate_id)?; + + eprintln!( + "[PoliticsWorker] Interims-Kirchenamt: character_id={}, office_type_id={}, region_id={}", + candidate_id, office.office_type_id, office.region_id + ); + + if let Some(uid) = Self::get_user_id_for_character(pool, candidate_id)? { + Self::publish_falukant_church_update(broker, uid, "vacancy_fill"); + } + + Ok(()) + } + fn find_available_church_offices( pool: &ConnectionPool, ) -> Result, DbError> { @@ -710,6 +949,7 @@ impl PoliticsWorker { let mut offices = Vec::new(); for row in rows { let office_type_id = parse_i32(&row, "office_type_id", -1); + let hierarchy_level = parse_i32(&row, "hierarchy_level", 99); let region_id = parse_i32(&row, "region_id", -1); let seats_per_region = parse_i32(&row, "seats_per_region", 0); let occupied_seats = parse_i32(&row, "occupied_seats", 0); @@ -717,6 +957,7 @@ impl PoliticsWorker { if office_type_id >= 0 && region_id >= 0 { offices.push(AvailableChurchOffice { office_type_id, + hierarchy_level, seats_per_region, region_id, occupied_seats, @@ -763,7 +1004,37 @@ impl PoliticsWorker { Ok(None) } - fn process_church_applications( + /// Spieler-Vorgesetzte: nur Event (keine automatische Entscheidung). NPC: Score-basierte Auswahl. + fn process_church_supervisor_queue( + pool: &ConnectionPool, + broker: &MessageBroker, + supervisor_id: i32, + ) -> Result<(), DbError> { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + conn.prepare("is_npc_sup", QUERY_IS_CHARACTER_NPC) + .map_err(|e| DbError::new(format!("[PoliticsWorker] prepare is_npc_sup: {e}")))?; + let rows = conn + .execute("is_npc_sup", &[&supervisor_id]) + .map_err(|e| DbError::new(format!("[PoliticsWorker] exec is_npc_sup: {e}")))?; + let is_npc = rows + .first() + .and_then(|r| r.get("is_npc")) + .map(|v| v == "t" || v == "true") + .unwrap_or(false); + + if !is_npc { + if let Some(uid) = Self::get_user_id_for_character(pool, supervisor_id)? { + Self::publish_falukant_church_update(broker, uid, "applications"); + } + return Ok(()); + } + + Self::npc_resolve_church_applications_for_supervisor(pool, broker, supervisor_id) + } + + fn npc_resolve_church_applications_for_supervisor( pool: &ConnectionPool, broker: &MessageBroker, supervisor_id: i32, @@ -772,64 +1043,20 @@ impl PoliticsWorker { .get() .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; - // Bewerbungen für diesen Supervisor abrufen conn.prepare( - "get_pending_church_applications", - QUERY_GET_PENDING_CHURCH_APPLICATIONS, + "score_church_apps", + QUERY_GET_PENDING_CHURCH_APPLICATIONS_FOR_SCORING, ) .map_err(|e| { DbError::new(format!( - "[PoliticsWorker] prepare get_pending_church_applications: {e}" + "[PoliticsWorker] prepare score_church_apps: {e}" )) })?; let rows = conn - .execute("get_pending_church_applications", &[&supervisor_id]) + .execute("score_church_apps", &[&supervisor_id]) .map_err(|e| { DbError::new(format!( - "[PoliticsWorker] exec get_pending_church_applications: {e}" - )) - })?; - - let mut applications = Vec::new(); - for row in rows { - let application_id = parse_i32(&row, "application_id", -1); - let office_type_id = parse_i32(&row, "office_type_id", -1); - let applicant_character_id = parse_i32(&row, "applicant_character_id", -1); - - if application_id >= 0 { - applications.push(ChurchApplication { - application_id, - office_type_id, - applicant_character_id, - }); - } - } - - // Voraussetzungen prüfen und Bewerbungen verarbeiten - conn.prepare( - "get_church_office_requirements", - QUERY_GET_CHURCH_OFFICE_REQUIREMENTS, - ) - .map_err(|e| { - DbError::new(format!( - "[PoliticsWorker] prepare get_church_office_requirements: {e}" - )) - })?; - - conn.prepare( - "check_character_eligibility", - QUERY_CHECK_CHARACTER_ELIGIBILITY, - ) - .map_err(|e| { - DbError::new(format!( - "[PoliticsWorker] prepare check_character_eligibility: {e}" - )) - })?; - - conn.prepare("approve_church_application", QUERY_APPROVE_CHURCH_APPLICATION) - .map_err(|e| { - DbError::new(format!( - "[PoliticsWorker] prepare approve_church_application: {e}" + "[PoliticsWorker] exec score_church_apps: {e}" )) })?; @@ -840,101 +1067,146 @@ impl PoliticsWorker { )) })?; - for app in &applications { - // Voraussetzungen für dieses Amt abrufen - let req_rows = conn - .execute("get_church_office_requirements", &[&app.office_type_id]) - .map_err(|e| { - DbError::new(format!( - "[PoliticsWorker] exec get_church_office_requirements: {e}" - )) - })?; + conn.prepare("approve_church_application", QUERY_APPROVE_CHURCH_APPLICATION) + .map_err(|e| { + DbError::new(format!( + "[PoliticsWorker] prepare approve_church_application: {e}" + )) + })?; - let mut requirements = Vec::new(); - for req_row in req_rows { - let prerequisite_office_type_id = req_row - .get("prerequisite_office_type_id") - .and_then(|v| v.parse::().ok()); - let min_title_level = req_row - .get("min_title_level") - .and_then(|v| v.parse::().ok()); + let mut scored: Vec = Vec::new(); - requirements.push(ChurchOfficeRequirement { - prerequisite_office_type_id, - min_title_level, - }); + for row in rows { + let application_id = parse_i32(&row, "application_id", -1); + let office_type_id = parse_i32(&row, "office_type_id", -1); + let applicant_character_id = parse_i32(&row, "applicant_character_id", -1); + let region_id = parse_i32(&row, "region_id", -1); + let seats_per_region = parse_i32(&row, "seats_per_region", 1); + let supervisor_reputation = row + .get("supervisor_reputation") + .and_then(|v| v.parse::().ok()) + .unwrap_or(50.0); + let applicant_reputation = row + .get("applicant_reputation") + .and_then(|v| v.parse::().ok()) + .unwrap_or(50.0); + let applicant_highest_ever = parse_i32(&row, "applicant_highest_ever", 0); + let applicant_title_level = parse_i32(&row, "applicant_title_level", 0); + let applicant_current_max = parse_i32(&row, "applicant_current_max_hierarchy", 0); + let applicant_age_days = parse_i32(&row, "applicant_age_days", 0); + + if application_id < 0 || applicant_character_id < 0 { + continue; } - // Prüfe ob Character die Voraussetzungen erfüllt - let mut eligible = true; - for req in &requirements { - let elig_rows = conn - .execute( - "check_character_eligibility", - &[ - &app.applicant_character_id, - &req.prerequisite_office_type_id, - &req.min_title_level, - ], - ) - .map_err(|e| { - DbError::new(format!( - "[PoliticsWorker] exec check_character_eligibility: {e}" - )) - })?; - - for elig_row in elig_rows { - let has_prerequisite: bool = elig_row - .get("has_prerequisite") - .and_then(|v| v.parse::().ok()) - .unwrap_or(false); - let meets_title_requirement: bool = elig_row - .get("meets_title_requirement") - .and_then(|v| v.parse::().ok()) - .unwrap_or(false); - - if !has_prerequisite || !meets_title_requirement { - eligible = false; - break; - } - } - } - - // Bewerbung genehmigen oder ablehnen - if eligible { - let approve_rows = conn - .execute("approve_church_application", &[&app.application_id]) - .map_err(|e| { - DbError::new(format!( - "[PoliticsWorker] exec approve_church_application: {e}" - )) - })?; - - if !approve_rows.is_empty() { - eprintln!( - "[PoliticsWorker] Church Application {} genehmigt (office_type_id={}, character_id={})", - app.application_id, app.office_type_id, app.applicant_character_id - ); - - // Benachrichtigung senden - if let Some(user_id) = Self::get_user_id_for_character(pool, app.applicant_character_id)? { - let msg = format!( - r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, - user_id - ); - broker.publish(msg); - } - } - } else { - conn.execute("reject_church_application", &[&app.application_id]) + if !Self::character_eligible_for_church_office(pool, applicant_character_id, office_type_id)? { + conn.execute("reject_church_application", &[&application_id]) .map_err(|e| { DbError::new(format!( "[PoliticsWorker] exec reject_church_application: {e}" )) })?; eprintln!( - "[PoliticsWorker] Church Application {} abgelehnt (Voraussetzungen nicht erfüllt)", - app.application_id + "[PoliticsWorker] Church Application {} abgelehnt (NPC: nicht qualifiziert)", + application_id + ); + continue; + } + + let wait_days = 0.0_f64; + + let score = Self::church_candidate_score( + supervisor_reputation, + applicant_reputation, + applicant_highest_ever, + applicant_current_max, + applicant_title_level, + applicant_age_days, + wait_days, + ); + + scored.push(ChurchAppScoreRow { + application_id, + office_type_id, + applicant_character_id, + region_id, + seats_per_region, + score, + }); + } + + drop(conn); + + let mut groups: HashMap<(i32, i32), Vec> = HashMap::new(); + for s in scored { + groups + .entry((s.office_type_id, s.region_id)) + .or_default() + .push(s); + } + + for ((_ot, _reg), mut group) in groups { + group.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let seats = group.first().map(|g| g.seats_per_region).unwrap_or(1); + let office_type_id = group.first().map(|g| g.office_type_id).unwrap_or(-1); + let region_id = group.first().map(|g| g.region_id).unwrap_or(-1); + if office_type_id < 0 || region_id < 0 { + continue; + } + + let mut occupied = Self::get_church_occupied_count(pool, office_type_id, region_id)?; + let mut approved_here = 0usize; + + for app in group { + if occupied >= seats { + break; + } + let approve_rows = { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + conn.prepare( + "approve_church_application", + QUERY_APPROVE_CHURCH_APPLICATION, + ) + .map_err(|e| { + DbError::new(format!( + "[PoliticsWorker] prepare approve_church_application: {e}" + )) + })?; + conn.execute("approve_church_application", &[&app.application_id]) + .map_err(|e| { + DbError::new(format!( + "[PoliticsWorker] exec approve_church_application: {e}" + )) + })? + }; + + if !approve_rows.is_empty() { + approved_here += 1; + occupied += 1; + eprintln!( + "[PoliticsWorker] Church Application {} genehmigt (NPC-Score, office_type_id={}, character_id={})", + app.application_id, app.office_type_id, app.applicant_character_id + ); + + if let Some(uid) = + Self::get_user_id_for_character(pool, app.applicant_character_id)? + { + Self::publish_falukant_church_update(broker, uid, "npc_decision"); + } + } + } + + if approved_here > 0 { + eprintln!( + "[PoliticsWorker] NPC-Kirche: {} Zusagen für office_type_id={}, region_id={}", + approved_here, office_type_id, region_id ); } } @@ -1091,13 +1363,8 @@ impl PoliticsWorker { application_id, character_id ); - // Benachrichtigung senden if let Some(user_id) = Self::get_user_id_for_character(pool, *character_id)? { - let msg = format!( - r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, - user_id - ); - broker.publish(msg); + Self::publish_falukant_church_update(broker, user_id, "appointment"); } } } diff --git a/src/worker/sql.rs b/src/worker/sql.rs index 6373055..8dc3182 100644 --- a/src/worker/sql.rs +++ b/src/worker/sql.rs @@ -1772,11 +1772,12 @@ pub const QUERY_SET_LEARNING_DONE: &str = r#" WHERE id = $1; "#; -// Church Office Queries +// Church Office Queries (siehe docs/FALUKANT_CHURCH_DAEMON.md) pub const QUERY_FIND_AVAILABLE_CHURCH_OFFICES: &str = r#" SELECT cot.id AS office_type_id, cot.name AS office_type_name, + cot.hierarchy_level, cot.seats_per_region, cot.region_type, r.id AS region_id, @@ -1788,7 +1789,7 @@ pub const QUERY_FIND_AVAILABLE_CHURCH_OFFICES: &str = r#" ON cot.id = co.office_type_id AND co.region_id = r.id WHERE tr.label_tr = cot.region_type - GROUP BY cot.id, cot.name, cot.seats_per_region, cot.region_type, r.id + GROUP BY cot.id, cot.name, cot.hierarchy_level, cot.seats_per_region, cot.region_type, r.id HAVING COUNT(co.id) < cot.seats_per_region ORDER BY cot.hierarchy_level ASC, r.id; "#; @@ -1821,6 +1822,8 @@ pub const QUERY_GET_CHURCH_OFFICE_REQUIREMENTS: &str = r#" WHERE office_type_id = $1; "#; +/// Optional für Backend/API; der Daemon nutzt `QUERY_GET_PENDING_CHURCH_APPLICATIONS_FOR_SCORING`. +#[allow(dead_code)] pub const QUERY_GET_PENDING_CHURCH_APPLICATIONS: &str = r#" SELECT ca.id AS application_id, @@ -1837,8 +1840,15 @@ pub const QUERY_GET_PENDING_CHURCH_APPLICATIONS: &str = r#" ORDER BY cot.hierarchy_level ASC, ca.created_at ASC; "#; +/// Voraussetzung: Migration `007_falukant_character_church_career.sql` (highest_church_hierarchy_ever). pub const QUERY_CHECK_CHARACTER_ELIGIBILITY: &str = r#" - WITH character_info AS ( + WITH prereq AS ( + SELECT $2::int AS prereq_type_id, + CASE WHEN $2::int IS NULL THEN NULL ELSE ( + SELECT hierarchy_level FROM falukant_type.church_office_type WHERE id = $2::int + ) END AS prereq_hl + ), + char_h AS ( SELECT c.id AS character_id, c.title_of_nobility, @@ -1847,34 +1857,43 @@ pub const QUERY_CHECK_CHARACTER_ELIGIBILITY: &str = r#" SELECT 1 FROM falukant_data.church_office co2 WHERE co2.character_id = c.id - ) AS has_office + ) AS has_office, + COALESCE(c.highest_church_hierarchy_ever, 0)::int AS highest_ever, + COALESCE(( + SELECT MAX(cot2.hierarchy_level) + FROM falukant_data.church_office co2 + JOIN falukant_type.church_office_type cot2 ON co2.office_type_id = cot2.id + WHERE co2.character_id = c.id + ), 0) AS current_max_hl FROM falukant_data.character c LEFT JOIN falukant_type.title t ON c.title_of_nobility = t.id WHERE c.id = $1 - ), - prerequisite_check AS ( - SELECT - CASE - WHEN $2::int IS NULL THEN TRUE - ELSE EXISTS( + ) + SELECT + ch.character_id, + ch.title_level, + ch.has_office, + CASE + WHEN pr.prereq_type_id IS NULL THEN TRUE + ELSE ( + EXISTS( SELECT 1 FROM falukant_data.church_office co WHERE co.character_id = $1 - AND co.office_type_id = $2::int + AND co.office_type_id = pr.prereq_type_id ) - END AS has_prerequisite - ) - SELECT - ci.character_id, - ci.title_level, - ci.has_office, - pc.has_prerequisite, + OR ( + pr.prereq_hl IS NOT NULL + AND GREATEST(ch.highest_ever, ch.current_max_hl) >= pr.prereq_hl + ) + ) + END AS has_prerequisite, CASE WHEN $3::int IS NULL THEN TRUE - ELSE COALESCE(ci.title_level, 0) >= $3::int + ELSE COALESCE(ch.title_level, 0) >= $3::int END AS meets_title_requirement - FROM character_info ci - CROSS JOIN prerequisite_check pc; + FROM char_h ch + CROSS JOIN prereq pr; "#; pub const QUERY_APPROVE_CHURCH_APPLICATION: &str = r#" @@ -1910,6 +1929,36 @@ pub const QUERY_APPROVE_CHURCH_APPLICATION: &str = r#" AND co.character_id = updated_application.character_id ) RETURNING id, office_type_id, character_id, region_id + ), + upd_highest AS ( + UPDATE falukant_data.character c + SET highest_church_hierarchy_ever = GREATEST( + COALESCE(c.highest_church_hierarchy_ever, 0), + io.hl + )::smallint + FROM ( + SELECT io2.character_id, cot.hierarchy_level AS hl + FROM inserted_office io2 + JOIN falukant_type.church_office_type cot ON cot.id = io2.office_type_id + ) io + WHERE c.id = io.character_id + RETURNING c.id + ), + remove_lower_ranked AS ( + DELETE FROM falukant_data.church_office co + WHERE co.id IN ( + SELECT co3.id + FROM falukant_data.church_office co3 + JOIN falukant_type.church_office_type cot ON co3.office_type_id = cot.id + WHERE co3.character_id IN (SELECT character_id FROM inserted_office) + AND EXISTS ( + SELECT 1 + FROM falukant_data.church_office co2 + JOIN falukant_type.church_office_type cot2 ON co2.office_type_id = cot2.id + WHERE co2.character_id = co3.character_id + AND cot2.hierarchy_level > cot.hierarchy_level + ) + ) ) SELECT id AS office_id, @@ -1929,6 +1978,7 @@ pub const QUERY_REJECT_CHURCH_APPLICATION: &str = r#" RETURNING id; "#; +/// Nur NPC-Vorgesetzte: Spieler-Entscheidungen nicht per Timeout überschreiben. pub const QUERY_GET_OLD_PENDING_CHURCH_APPLICATIONS: &str = r#" SELECT ca.id AS application_id, @@ -1937,8 +1987,10 @@ pub const QUERY_GET_OLD_PENDING_CHURCH_APPLICATIONS: &str = r#" ca.region_id, ca.supervisor_id FROM falukant_data.church_application ca + JOIN falukant_data.character sup ON sup.id = ca.supervisor_id WHERE ca.status = 'pending' AND ca.created_at <= NOW() - INTERVAL '36 hours' + AND sup.user_id IS NULL ORDER BY ca.created_at ASC; "#; @@ -1976,6 +2028,36 @@ pub const QUERY_AUTO_APPROVE_CHURCH_APPLICATION: &str = r#" AND co.character_id = updated_application.character_id ) RETURNING id, office_type_id, character_id, region_id + ), + upd_highest AS ( + UPDATE falukant_data.character c + SET highest_church_hierarchy_ever = GREATEST( + COALESCE(c.highest_church_hierarchy_ever, 0), + io.hl + )::smallint + FROM ( + SELECT io2.character_id, cot.hierarchy_level AS hl + FROM inserted_office io2 + JOIN falukant_type.church_office_type cot ON cot.id = io2.office_type_id + ) io + WHERE c.id = io.character_id + RETURNING c.id + ), + remove_lower_ranked AS ( + DELETE FROM falukant_data.church_office co + WHERE co.id IN ( + SELECT co3.id + FROM falukant_data.church_office co3 + JOIN falukant_type.church_office_type cot ON co3.office_type_id = cot.id + WHERE co3.character_id IN (SELECT character_id FROM inserted_office) + AND EXISTS ( + SELECT 1 + FROM falukant_data.church_office co2 + JOIN falukant_type.church_office_type cot2 ON co2.office_type_id = cot2.id + WHERE co2.character_id = co3.character_id + AND cot2.hierarchy_level > cot.hierarchy_level + ) + ) ) SELECT id AS office_id, @@ -2007,6 +2089,7 @@ pub const QUERY_CREATE_CHURCH_APPLICATION_JOB: &str = r#" RETURNING id; "#; +/// Nur NPCs: Spielerbewerbungen laufen über die UI. pub const QUERY_GET_CHARACTERS_FOR_CHURCH_OFFICE: &str = r#" SELECT DISTINCT c.id AS character_id, @@ -2018,6 +2101,7 @@ pub const QUERY_GET_CHARACTERS_FOR_CHURCH_OFFICE: &str = r#" LEFT JOIN falukant_type.title t ON c.title_of_nobility = t.id WHERE c.region_id = $1 AND c.health > 0 + AND c.user_id IS NULL AND NOT EXISTS( SELECT 1 FROM falukant_data.church_office co @@ -2027,6 +2111,124 @@ pub const QUERY_GET_CHARACTERS_FOR_CHURCH_OFFICE: &str = r#" LIMIT $2; "#; +pub const QUERY_COUNT_PENDING_CHURCH_APPS_BY_OFFICE_REGION: &str = r#" + SELECT COUNT(*)::int AS cnt + FROM falukant_data.church_application ca + WHERE ca.office_type_id = $1::int + AND ca.region_id = $2::int + AND ca.status = 'pending'; +"#; + +pub const QUERY_GET_CHURCH_OFFICE_OCCUPIED_COUNT: &str = r#" + SELECT COUNT(*)::int AS cnt + FROM falukant_data.church_office co + WHERE co.office_type_id = $1::int + AND co.region_id = $2::int; +"#; + +pub const QUERY_IS_CHARACTER_NPC: &str = r#" + SELECT (c.user_id IS NULL) AS is_npc + FROM falukant_data.character c + WHERE c.id = $1::int; +"#; + +pub const QUERY_GET_PENDING_CHURCH_APPLICATIONS_FOR_SCORING: &str = r#" + SELECT + ca.id AS application_id, + ca.office_type_id, + ca.character_id AS applicant_character_id, + ca.region_id, + ca.created_at, + cot.hierarchy_level AS office_hierarchy_level, + cot.seats_per_region, + COALESCE(sc.reputation, 50)::float8 AS supervisor_reputation, + COALESCE(ac.reputation, 50)::float8 AS applicant_reputation, + COALESCE(ac.highest_church_hierarchy_ever, 0)::int AS applicant_highest_ever, + COALESCE(t.level, 0)::int AS applicant_title_level, + COALESCE(( + SELECT MAX(cot2.hierarchy_level) + FROM falukant_data.church_office co2 + JOIN falukant_type.church_office_type cot2 ON co2.office_type_id = cot2.id + WHERE co2.character_id = ac.id + ), 0)::int AS applicant_current_max_hierarchy, + (CURRENT_DATE - ac.birthdate::date)::int AS applicant_age_days + FROM falukant_data.church_application ca + JOIN falukant_data.character ac ON ac.id = ca.character_id + JOIN falukant_data.character sc ON sc.id = ca.supervisor_id + JOIN falukant_type.church_office_type cot ON cot.id = ca.office_type_id + LEFT JOIN falukant_type.title t ON t.id = ac.title_of_nobility + WHERE ca.status = 'pending' + AND ca.supervisor_id = $1::int + ORDER BY ca.created_at ASC; +"#; + +pub const QUERY_INTERIM_APPOINT_CHURCH_OFFICE: &str = r#" + INSERT INTO falukant_data.church_office + (office_type_id, character_id, region_id, supervisor_id, created_at, updated_at) + SELECT $1::int, $2::int, $3::int, NULL, NOW(), NOW() + WHERE ( + SELECT COUNT(*)::int + FROM falukant_data.church_office co + WHERE co.office_type_id = $1::int + AND co.region_id = $3::int + ) < $4::int + AND NOT EXISTS ( + SELECT 1 FROM falukant_data.church_office co + WHERE co.character_id = $2::int + AND co.office_type_id = $1::int + AND co.region_id = $3::int + ) + RETURNING id, office_type_id, character_id, region_id; +"#; + +pub const QUERY_UPDATE_CHARACTER_HIGHEST_CHURCH_FROM_OFFICE_TYPE: &str = r#" + UPDATE falukant_data.character c + SET highest_church_hierarchy_ever = GREATEST( + COALESCE(c.highest_church_hierarchy_ever, 0), + (SELECT cot.hierarchy_level FROM falukant_type.church_office_type cot WHERE cot.id = $2::int) + )::smallint + WHERE c.id = $1::int + RETURNING c.id; +"#; + +pub const QUERY_FIND_INTERIM_CHURCH_NPC_CANDIDATE: &str = r#" + SELECT c.id AS character_id + FROM falukant_data.character c + WHERE c.region_id = $1::int + AND c.user_id IS NULL + AND c.health > 0 + AND NOT EXISTS ( + SELECT 1 + FROM falukant_data.church_office co + JOIN falukant_type.church_office_type cot ON cot.id = co.office_type_id + WHERE co.character_id = c.id + AND cot.hierarchy_level >= ( + SELECT hierarchy_level FROM falukant_type.church_office_type WHERE id = $2::int + ) + ) + ORDER BY COALESCE(c.reputation, 50) DESC, + COALESCE(c.highest_church_hierarchy_ever, 0) DESC + LIMIT 1; +"#; + +pub const QUERY_REMOVE_LOWER_CHURCH_OFFICES_FOR_CHARACTER: &str = r#" + DELETE FROM falukant_data.church_office co + WHERE co.character_id = $1::int + AND co.id IN ( + SELECT co3.id + FROM falukant_data.church_office co3 + JOIN falukant_type.church_office_type cot ON co3.office_type_id = cot.id + WHERE co3.character_id = $1::int + AND EXISTS ( + SELECT 1 + FROM falukant_data.church_office co2 + JOIN falukant_type.church_office_type cot2 ON co2.office_type_id = cot2.id + WHERE co2.character_id = co3.character_id + AND cot2.hierarchy_level > cot.hierarchy_level + ) + ); +"#; + // --- Falukant: Dienerschaft (siehe migrations/004_falukant_servants_daemon.sql) --- pub const QUERY_SERVANTS_SCHEMA_READY: &str = r#"