Enhance church office management in Falukant daemon: Introduced falukantUpdateChurch event for church applications and appointments, updated SQL queries for church office processing, and refactored the PoliticsWorker to streamline daily tasks related to church offices. Improved handling of church application scoring and interim appointments, enhancing overall church dynamics and character interactions.

This commit is contained in:
Torsten Schulz (local)
2026-03-23 11:02:19 +01:00
parent 708ffc3eda
commit 9d7f61a329
5 changed files with 796 additions and 222 deletions

View File

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

View File

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

View File

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

View File

@@ -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<i32>,
min_title_level: Option<i32>,
}
/// 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<WorkerState>) {
let mut last_execution: Option<Instant> = None;
let mut last_church_office_run: Option<Instant> = None;
let mut last_auto_approve_run: Option<Instant> = 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 {
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,
remaining_seats,
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<i32, DbError> {
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, &region_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::<i32>().ok())
.unwrap_or(0))
}
fn get_church_occupied_count(
pool: &ConnectionPool,
office_type_id: i32,
region_id: i32,
) -> Result<i32, DbError> {
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, &region_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::<i32>().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::<f64>();
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::<bool>().unwrap_or(false),
None => false,
}
}
fn character_eligible_for_church_office(
pool: &ConnectionPool,
character_id: i32,
office_type_id: i32,
) -> Result<bool, DbError> {
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::<i32>().ok());
let min_title_level = req_row
.get("min_title_level")
.and_then(|v| v.parse::<i32>().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::<i32>().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<Vec<AvailableChurchOffice>, 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])
conn.prepare("approve_church_application", QUERY_APPROVE_CHURCH_APPLICATION)
.map_err(|e| {
DbError::new(format!(
"[PoliticsWorker] exec get_church_office_requirements: {e}"
"[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::<i32>().ok());
let min_title_level = req_row
.get("min_title_level")
.and_then(|v| v.parse::<i32>().ok());
let mut scored: Vec<ChurchAppScoreRow> = 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::<f64>().ok())
.unwrap_or(50.0);
let applicant_reputation = row
.get("applicant_reputation")
.and_then(|v| v.parse::<f64>().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::<bool>().ok())
.unwrap_or(false);
let meets_title_requirement: bool = elig_row
.get("meets_title_requirement")
.and_then(|v| v.parse::<bool>().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<ChurchAppScoreRow>> = 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");
}
}
}

View File

@@ -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
ch.character_id,
ch.title_level,
ch.has_office,
CASE
WHEN $2::int IS NULL THEN TRUE
ELSE EXISTS(
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
OR (
pr.prereq_hl IS NOT NULL
AND GREATEST(ch.highest_ever, ch.current_max_hl) >= pr.prereq_hl
)
SELECT
ci.character_id,
ci.title_level,
ci.has_office,
pc.has_prerequisite,
)
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#"