From fb5c1eb8bedbf29a5445ee8805cb94c299394206 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 26 Aug 2026 10:01:21 +0200 Subject: [PATCH] =?UTF-8?q?Entferne=20automatische=20Annahme=20alter=20Bew?= =?UTF-8?q?erbungen=20und=20implementiere=20Entscheidung=20durch=20NPCs=20?= =?UTF-8?q?f=C3=BCr=20offene=20Stellen=20ohne=20Vorgesetzten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FALUKANT_CHURCH_DAEMON.md | 16 +- src/worker/politics.rs | 260 ++++++++++++++++----------------- src/worker/sql.rs | 76 +++++++++- 3 files changed, 207 insertions(+), 145 deletions(-) diff --git a/docs/FALUKANT_CHURCH_DAEMON.md b/docs/FALUKANT_CHURCH_DAEMON.md index 68066bb..09a6316 100755 --- a/docs/FALUKANT_CHURCH_DAEMON.md +++ b/docs/FALUKANT_CHURCH_DAEMON.md @@ -1,6 +1,6 @@ # 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`. +Umsetzung des Zielmodells: Laufbahn `highest_church_hierarchy_ever`, NPC-Bewerbungen, NPC-Vorgesetzte mit Score, Spieler-Vorgesetzte ohne Daemon-Entscheidung und Events `falukantUpdateChurch`. ## Migration @@ -13,16 +13,16 @@ Umsetzung des Zielmodells: Laufbahn `highest_church_hierarchy_ever`, NPC-Bewerbu | 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+ | +| NPC-Entscheidung | Nach mindestens **2 Tagen** offener Bewerbung, täglich | ## 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. +4. **NPC-Vorgesetzter**: Nach der zweitägigen Bewerbungsfrist bewertet er Bewerber mit `churchCandidateScore` und entscheidet über die freien Sitze. +5. **Kein Vorgesetzter**: Der Kirchen-NPC führt denselben Auswahlvorgang nach zwei Tagen durch. Das deckt insbesondere Einstiegsämter ab und verhindert dauerhaft offene Bewerbungen. +6. **NPC-Laufbahn**: Nur Charaktere mit `user_id IS NULL`; Einstieg ohne Kirchenamt, jede Beförderung ausschließlich aus einem aktuell gehaltenen Amt genau eine Hierarchiestufe darunter. ## Bewerbungsvoraussetzungen (Daemon-SQL) @@ -35,7 +35,7 @@ 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`). +- `QUERY_APPROVE_CHURCH_APPLICATION`: nach Eintrag `church_office` → Update `highest_church_hierarchy_ever`, dann `DELETE` niedrigerer konkurrierender `church_office` desselben Charakters (`remove_lower_ranked`). ## WebSocket @@ -44,7 +44,7 @@ Titelbedingungen unverändert (`min_title_level`). | `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). +**reason:** `applications` (Spieler-Vorgesetzter), `npc_decision` (Kirchen-NPC hat entschieden). Details: [`FALUKANT_UI_WEBSOCKET.md`](./FALUKANT_UI_WEBSOCKET.md). @@ -55,5 +55,5 @@ Details: [`FALUKANT_UI_WEBSOCKET.md`](./FALUKANT_UI_WEBSOCKET.md). ## 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/politics.rs` — `perform_church_office_task`, `process_church_supervisor_queue`, NPC-Entscheidungen mit und ohne Vorgesetzten, Hilfsfunktionen. - `src/worker/sql.rs` — Abschnitt „Church Office Queries“. diff --git a/src/worker/politics.rs b/src/worker/politics.rs index 3f51d49..78e276c 100755 --- a/src/worker/politics.rs +++ b/src/worker/politics.rs @@ -39,10 +39,7 @@ use crate::worker::sql::{ 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, + QUERY_GET_PENDING_CHURCH_APPLICATIONS_WITHOUT_SUPERVISOR, }; pub struct PoliticsWorker { @@ -81,7 +78,6 @@ struct Office { #[derive(Debug, Clone)] struct AvailableChurchOffice { office_type_id: i32, - hierarchy_level: i32, seats_per_region: i32, region_id: i32, occupied_seats: i32, @@ -92,9 +88,6 @@ struct ChurchSupervisor { supervisor_character_id: i32, } -/// Bis einschließlich dieser `hierarchy_level` (church_office_type): Interimsbesetzung ohne Vorgesetzten. -const INTERIM_MAX_CHURCH_HIERARCHY: i32 = 6; - struct ChurchAppScoreRow { application_id: i32, office_type_id: i32, @@ -116,7 +109,6 @@ impl PoliticsWorker { fn run_loop(pool: ConnectionPool, broker: MessageBroker, state: Arc) { let mut last_execution: Option = None; - let mut last_auto_approve_run: Option = None; while state.running_worker.load(Ordering::Relaxed) { let now = Instant::now(); @@ -132,21 +124,6 @@ impl PoliticsWorker { last_execution = Some(now); } - // Automatische Annahme alter Applications (stündlich) - let should_run_auto_approve = match last_auto_approve_run { - None => true, - Some(prev) => { - now.saturating_duration_since(prev) >= Duration::from_secs(3600) - } - }; - - if should_run_auto_approve { - if let Err(err) = Self::auto_approve_old_church_applications(&pool, &broker) { - eprintln!("[PoliticsWorker] Fehler bei auto_approve_old_church_applications: {err}"); - } - last_auto_approve_run = Some(now); - } - // Entspricht ungefähr der 5-Sekunden-Schleife im C++-Code for _ in 0..5 { if !state.running_worker.load(Ordering::Relaxed) { @@ -770,18 +747,37 @@ impl PoliticsWorker { pool, office.office_type_id, office.region_id, - supervisor.supervisor_character_id, + Some(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, Interim deaktiviert (hierarchy_level={}): office_type_id={}, region_id={}", - office.hierarchy_level, office.office_type_id, office.region_id - ); + // Einstiegsämter haben naturgemäß keinen Vorgesetzten. Offene + // Spielerbewerbungen dürfen deshalb nicht dauerhaft pending + // bleiben: Der Kirchen-NPC entscheidet sie für diese Stelle. + Self::npc_resolve_church_applications_without_supervisor(pool, broker, office)?; + + let occupied = Self::get_church_occupied_count( + pool, + office.office_type_id, + office.region_id, + )?; + let pending = Self::count_pending_church_apps( + pool, + office.office_type_id, + office.region_id, + )?; + let missing_applications = (office.seats_per_region - occupied - pending).max(0); + if missing_applications > 0 { + Self::create_church_application_jobs( + pool, + office.office_type_id, + office.region_id, + None, + missing_applications, + )?; + } } } @@ -854,7 +850,9 @@ impl PoliticsWorker { age_days: i32, wait_days: f64, ) -> f64 { - let age_years = age_days / 365; + // Ein Kalendertag ist ein Spieljahr; die Datenbankdifferenz ist somit + // bereits das Alter in Spieljahren. + let age_years = age_days; let age_bonus = if (25..=70).contains(&age_years) { 12.0 } else { @@ -950,100 +948,6 @@ impl PoliticsWorker { 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> { @@ -1071,7 +975,6 @@ 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); @@ -1079,7 +982,6 @@ 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, @@ -1336,11 +1238,102 @@ impl PoliticsWorker { Ok(()) } + /// Entscheidet Bewerbungen für Ämter, die noch keinen Vorgesetzten haben. + /// Das betrifft insbesondere `lay-preacher`: Ohne diesen Pfad bleiben die + /// vom Backend erzeugten Bewerbungen für immer auf `pending`. + fn npc_resolve_church_applications_without_supervisor( + 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( + "score_church_apps_no_supervisor", + QUERY_GET_PENDING_CHURCH_APPLICATIONS_WITHOUT_SUPERVISOR, + )?; + conn.prepare("reject_church_application", QUERY_REJECT_CHURCH_APPLICATION)?; + conn.prepare("approve_church_application", QUERY_APPROVE_CHURCH_APPLICATION)?; + + let rows = conn.execute( + "score_church_apps_no_supervisor", + &[&office.office_type_id, &office.region_id], + )?; + if rows.is_empty() { + return Ok(()); + } + + let mut candidates = Vec::new(); + for row in rows { + let application_id = parse_i32(&row, "application_id", -1); + let applicant_character_id = parse_i32(&row, "applicant_character_id", -1); + if application_id < 0 || applicant_character_id < 0 { + continue; + } + + if !Self::character_eligible_for_church_office( + pool, + applicant_character_id, + office.office_type_id, + )? { + conn.execute("reject_church_application", &[&application_id])?; + if let Some(uid) = Self::get_user_id_for_character(pool, applicant_character_id)? { + Self::publish_falukant_church_update(broker, uid, "npc_decision"); + } + continue; + } + + let score = Self::church_candidate_score( + row.get("supervisor_reputation") + .and_then(|v| v.parse::().ok()) + .unwrap_or(50.0), + row.get("applicant_reputation") + .and_then(|v| v.parse::().ok()) + .unwrap_or(50.0), + parse_i32(&row, "applicant_highest_ever", 0), + parse_i32(&row, "applicant_current_max_hierarchy", 0), + parse_i32(&row, "applicant_title_level", 0), + parse_i32(&row, "applicant_age_days", 0), + 0.0, + ); + candidates.push((application_id, applicant_character_id, score)); + } + + candidates.sort_by(|a, b| { + b.2.partial_cmp(&a.2) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let mut occupied = Self::get_church_occupied_count( + pool, + office.office_type_id, + office.region_id, + )?; + for (application_id, applicant_character_id, _) in candidates { + let approved = occupied < office.seats_per_region + && !conn + .execute("approve_church_application", &[&application_id])? + .is_empty(); + if approved { + occupied += 1; + } else { + conn.execute("reject_church_application", &[&application_id])?; + } + + if let Some(uid) = Self::get_user_id_for_character(pool, applicant_character_id)? { + Self::publish_falukant_church_update(broker, uid, "npc_decision"); + } + } + + Ok(()) + } + fn create_church_application_jobs( pool: &ConnectionPool, office_type_id: i32, region_id: i32, - supervisor_id: i32, + supervisor_id: Option, count: i32, ) -> Result<(), DbError> { let mut conn = pool @@ -1359,7 +1352,10 @@ impl PoliticsWorker { })?; let rows = conn - .execute("get_characters_for_church_office", &[®ion_id, &count]) + .execute( + "get_characters_for_church_office", + &[®ion_id, &office_type_id, &count], + ) .map_err(|e| { DbError::new(format!( "[PoliticsWorker] exec get_characters_for_church_office: {e}" @@ -1411,7 +1407,9 @@ impl PoliticsWorker { Ok(()) } - /// Automatische Annahme von Church Applications, die älter als 36 Stunden sind + /// Der frühere stündliche Auto-Approve ist bewusst nicht mehr im Scheduler + /// registriert; Entscheidungen laufen nach der zweitägigen Bewerbungsfrist. + #[allow(dead_code)] fn auto_approve_old_church_applications( pool: &ConnectionPool, broker: &MessageBroker, diff --git a/src/worker/sql.rs b/src/worker/sql.rs index ee4f272..2c835e2 100755 --- a/src/worker/sql.rs +++ b/src/worker/sql.rs @@ -3215,7 +3215,8 @@ pub const QUERY_CREATE_CHURCH_APPLICATION_JOB: &str = r#" RETURNING id; "#; -/// Nur NPCs: Spielerbewerbungen laufen über die UI. +/// Nur NPCs: Für den Einstieg ohne Amt, für eine Beförderung ausschließlich aus +/// dem unmittelbar niedrigeren derzeitigen Kirchenamt. pub const QUERY_GET_CHARACTERS_FOR_CHURCH_OFFICE: &str = r#" SELECT DISTINCT c.id AS character_id, @@ -3224,17 +3225,29 @@ pub const QUERY_GET_CHARACTERS_FOR_CHURCH_OFFICE: &str = r#" c.title_of_nobility, t.level AS title_level FROM falukant_data.character c + JOIN falukant_type.church_office_type target ON target.id = $2::int 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 - WHERE co.character_id = c.id + AND ( + (target.hierarchy_level <= 1 AND NOT EXISTS( + SELECT 1 + FROM falukant_data.church_office co + WHERE co.character_id = c.id + )) + OR + (target.hierarchy_level > 1 AND EXISTS( + SELECT 1 + FROM falukant_data.church_office co + JOIN falukant_type.church_office_type current + ON current.id = co.office_type_id + WHERE co.character_id = c.id + AND current.hierarchy_level = target.hierarchy_level - 1 + )) ) ORDER BY RANDOM() - LIMIT $2; + LIMIT $3; "#; pub const QUERY_COUNT_PENDING_CHURCH_APPS_BY_OFFICE_REGION: &str = r#" @@ -3285,9 +3298,45 @@ pub const QUERY_GET_PENDING_CHURCH_APPLICATIONS_FOR_SCORING: &str = r#" LEFT JOIN falukant_type.title t ON t.id = ac.title_of_nobility WHERE ca.status = 'pending' AND ca.supervisor_id = $1::int + AND ca.created_at <= NOW() - INTERVAL '2 days' ORDER BY ca.created_at ASC; "#; +/// Einstiegsämter haben noch keinen Amtsinhaber als Vorgesetzten. Diese offenen +/// Bewerbungen werden deshalb vom Kirchen-NPC für die konkrete Stelle entschieden. +pub const QUERY_GET_PENDING_CHURCH_APPLICATIONS_WITHOUT_SUPERVISOR: &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, + 50.0::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 cot2.id = co2.office_type_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_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 IS NULL + AND ca.office_type_id = $1::int + AND ca.region_id = $2::int + AND ca.created_at <= NOW() - INTERVAL '2 days' + ORDER BY ca.created_at ASC; +"#; + +#[allow(dead_code)] 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) @@ -3307,6 +3356,7 @@ pub const QUERY_INTERIM_APPOINT_CHURCH_OFFICE: &str = r#" RETURNING id, office_type_id, character_id, region_id; "#; +#[allow(dead_code)] pub const QUERY_UPDATE_CHARACTER_HIGHEST_CHURCH_FROM_OFFICE_TYPE: &str = r#" UPDATE falukant_data.character c SET highest_church_hierarchy_ever = GREATEST( @@ -3317,6 +3367,7 @@ pub const QUERY_UPDATE_CHARACTER_HIGHEST_CHURCH_FROM_OFFICE_TYPE: &str = r#" RETURNING c.id; "#; +#[allow(dead_code)] pub const QUERY_FIND_INTERIM_CHURCH_NPC_CANDIDATE: &str = r#" SELECT c.id AS character_id FROM falukant_data.character c @@ -3337,6 +3388,7 @@ pub const QUERY_FIND_INTERIM_CHURCH_NPC_CANDIDATE: &str = r#" LIMIT 1; "#; +#[allow(dead_code)] pub const QUERY_REMOVE_LOWER_CHURCH_OFFICES_FOR_CHARACTER: &str = r#" DELETE FROM falukant_data.church_office co WHERE co.character_id = $1::int @@ -4100,4 +4152,16 @@ mod tests { assert!(query.contains(">= 16"), "{query}"); } } + + #[test] + fn npc_church_promotions_wait_two_days_and_require_the_direct_lower_rank() { + assert!(QUERY_GET_CHARACTERS_FOR_CHURCH_OFFICE + .contains("current.hierarchy_level = target.hierarchy_level - 1")); + for query in [ + QUERY_GET_PENDING_CHURCH_APPLICATIONS_FOR_SCORING, + QUERY_GET_PENDING_CHURCH_APPLICATIONS_WITHOUT_SUPERVISOR, + ] { + assert!(query.contains("created_at <= NOW() - INTERVAL '2 days'")); + } + } }