Compare commits
1 Commits
e5e17c2c5e
...
renovate/r
| Author | SHA1 | Date | |
|---|---|---|---|
| 5eb98f33c9 |
@@ -1,52 +0,0 @@
|
||||
name: Deploy yourpart (blue-green)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
SSH_HOST: ${{ vars.PROD_HOST }}
|
||||
SSH_PORT: ${{ vars.PROD_PORT }}
|
||||
SSH_USER: ${{ vars.PROD_USER }}
|
||||
|
||||
steps:
|
||||
- name: Show resolved non-secret config
|
||||
run: |
|
||||
echo "SSH_HOST=$SSH_HOST"
|
||||
echo "SSH_PORT=$SSH_PORT"
|
||||
echo "SSH_USER=$SSH_USER"
|
||||
|
||||
- name: Prepare SSH
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p ~/.ssh
|
||||
printf "%s" "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -p "$SSH_PORT" "$SSH_HOST" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Test SSH connection
|
||||
run: |
|
||||
set -e
|
||||
ssh -i ~/.ssh/id_ed25519 \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o BatchMode=yes \
|
||||
-o ConnectTimeout=10 \
|
||||
-p "$SSH_PORT" \
|
||||
"$SSH_USER@$SSH_HOST" \
|
||||
"echo SSH OK"
|
||||
|
||||
- name: Run blue-green deployment
|
||||
run: |
|
||||
set -e
|
||||
ssh -i ~/.ssh/id_ed25519 \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o BatchMode=yes \
|
||||
-o ConnectTimeout=10 \
|
||||
-p "$SSH_PORT" \
|
||||
"$SSH_USER@$SSH_HOST" \
|
||||
"/home/tsschulz/actualize-daemon.sh"
|
||||
29
AGENTS.md
29
AGENTS.md
@@ -1,29 +0,0 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- **Daemon-Worker Architecture**: A standalone Rust daemon for the Falukant game backend, managing game mechanics through background workers.
|
||||
- **Workers (`./src/worker/`)**: Modular task handlers (e.g., `PoliticsWorker`, `FalukantFamilyWorker`, `EventsWorker`) that execute periodic game logic ("ticks"). Most workers extend the `BaseWorker` logic.
|
||||
- **Database Layer**: Uses PostgreSQL for persistence. Core SQL queries are centralized in `./src/worker/sql.rs`, while connection management resides in `./src/db/`.
|
||||
- **Real-time Communication**: `./src/websocket_server.rs` manages client connections, broadcasting game state updates via `./src/message_broker.rs`.
|
||||
- **Migrations**: Database schema changes are managed manually via SQL scripts in `./migrations/`.
|
||||
- **Documentation**: Detailed technical specs and handoff notes are located in `./docs/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `cargo build`: Compile the project.
|
||||
- `cargo clippy`: Run the linter to ensure code quality.
|
||||
- `cargo run`: Start the daemon.
|
||||
- `cargo fmt`: Format the codebase according to Rust standards.
|
||||
- `cargo check`: Rapidly check for compilation errors.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- **Rust Standards**: Follows idiomatic Rust conventions (snake_case for variables/functions, PascalCase for types).
|
||||
- **Linter**: Enforced via `clippy`. Use `cargo clippy` before committing.
|
||||
- **Database Logic**: Prefer centralizing complex SQL queries in `./src/worker/sql.rs` rather than inlining them in worker logic.
|
||||
|
||||
## Testing Guidelines
|
||||
- **Manual Verification**: No automated test suite exists (`cargo test` returns no results). Verify changes by running the daemon and inspecting database state or WebSocket output.
|
||||
- **Smoke Tests**: Follow instructions in `./docs/` for feature-specific manual testing (e.g., `./docs/FALUKANT_DEATH_SUCCESSION_SMOKE_TEST.md`).
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- **Commit Messages**: Primarily written in German, though English is acceptable. Messages typically start with an imperative verb (e.g., "Füge", "Verbessere", "Behebe", "Refactor", "Enhance").
|
||||
- **Context**: Reference specific features or workers in the commit subject to maintain clarity in the history.
|
||||
117
Cargo.lock
generated
117
Cargo.lock
generated
@@ -4,9 +4,8 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "YpDaemon"
|
||||
version = "0.5.0"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"ctrlc",
|
||||
"futures-util",
|
||||
"libsystemd",
|
||||
@@ -20,15 +19,6 @@ dependencies = [
|
||||
"tokio-tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -116,25 +106,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -325,30 +296,6 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.15"
|
||||
@@ -485,15 +432,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.3"
|
||||
@@ -1231,65 +1169,12 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "YpDaemon"
|
||||
version = "0.5.0"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
rand = "0.8"
|
||||
rand = "0.9"
|
||||
postgres = "0.19"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
@@ -15,4 +15,3 @@ ctrlc = "3"
|
||||
tokio-rustls = "0.25"
|
||||
rustls-pemfile = "2"
|
||||
libsystemd = "0.7"
|
||||
chrono = "0.4"
|
||||
@@ -1,18 +0,0 @@
|
||||
[package]
|
||||
name = "YpDaemon"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
rand = "0.8"
|
||||
postgres = "0.19"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1.48", features = ["rt-multi-thread", "macros", "net", "sync", "time"] }
|
||||
tokio-tungstenite = "0.23"
|
||||
futures-util = "0.3"
|
||||
ctrlc = "3"
|
||||
tokio-rustls = "0.25"
|
||||
rustls-pemfile = "2"
|
||||
libsystemd = "0.7"
|
||||
chrono = "0.4"
|
||||
@@ -1,250 +0,0 @@
|
||||
use crate::db::{ConnectionPool, DbError};
|
||||
use crate::worker::sql::{QUERY_UPDATE_MONEY, QUERY_GET_MONEY};
|
||||
use crate::message_broker::MessageBroker;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
pub trait Worker: Send {
|
||||
fn start_worker_thread(&mut self);
|
||||
fn stop_worker_thread(&mut self);
|
||||
fn enable_watchdog(&mut self);
|
||||
}
|
||||
|
||||
pub(crate) struct WorkerState {
|
||||
pub(crate) running_worker: AtomicBool,
|
||||
pub(crate) running_watchdog: AtomicBool,
|
||||
pub(crate) current_step: Mutex<String>,
|
||||
}
|
||||
|
||||
// Default tax percent and treasury user id used if no external config is available.
|
||||
// Percent, e.g. 10.0 => 10%.
|
||||
pub const DEFAULT_TAX_PERCENT: f64 = 10.0;
|
||||
pub const DEFAULT_TREASURY_USER_ID: i32 = 1;
|
||||
|
||||
impl WorkerState {
|
||||
pub(crate) fn new(name: &str) -> Self {
|
||||
Self {
|
||||
running_worker: AtomicBool::new(false),
|
||||
running_watchdog: AtomicBool::new(false),
|
||||
current_step: Mutex::new(format!("{name}: idle")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BaseWorker {
|
||||
pub name: String,
|
||||
pub pool: ConnectionPool,
|
||||
pub broker: MessageBroker,
|
||||
pub(crate) state: Arc<WorkerState>,
|
||||
worker_thread: Option<thread::JoinHandle<()>>,
|
||||
watchdog_thread: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl BaseWorker {
|
||||
pub fn new(name: &str, pool: ConnectionPool, broker: MessageBroker) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
pool,
|
||||
broker,
|
||||
state: Arc::new(WorkerState::new(name)),
|
||||
worker_thread: None,
|
||||
watchdog_thread: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_current_step<S: Into<String>>(&self, step: S) {
|
||||
if let Ok(mut guard) = self.state.current_step.lock() {
|
||||
*guard = step.into();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start_worker_with_loop<F>(&mut self, loop_fn: F)
|
||||
where
|
||||
F: Fn(Arc<WorkerState>) + Send + 'static,
|
||||
{
|
||||
if self.state.running_worker.swap(true, Ordering::SeqCst) {
|
||||
eprintln!("[{}] Worker thread already running, skipping start.", self.name);
|
||||
return;
|
||||
}
|
||||
|
||||
let state = Arc::clone(&self.state);
|
||||
|
||||
self.worker_thread = Some(thread::spawn(move || {
|
||||
loop_fn(state);
|
||||
}));
|
||||
}
|
||||
|
||||
pub(crate) fn stop_worker(&mut self) {
|
||||
// Erst den Worker stoppen, dann auch den Watchdog beenden, damit keine
|
||||
// Hintergrund-Threads weiterlaufen.
|
||||
self.state.running_worker.store(false, Ordering::Relaxed);
|
||||
self.stop_watchdog();
|
||||
if let Some(handle) = self.worker_thread.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start_watchdog(&mut self) {
|
||||
if self
|
||||
.state
|
||||
.running_watchdog
|
||||
.swap(true, Ordering::SeqCst)
|
||||
{
|
||||
eprintln!("[{}] Watchdog already enabled, skipping.", self.name);
|
||||
return;
|
||||
}
|
||||
|
||||
let state = Arc::clone(&self.state);
|
||||
|
||||
self.watchdog_thread = Some(thread::spawn(move || {
|
||||
while state.running_watchdog.load(Ordering::Relaxed) {
|
||||
// Nicht in einem großen 10s-Sleep blockieren, damit der
|
||||
// Shutdown (stop_watchdog) zügig reagieren kann. Stattdessen
|
||||
// in 1s-Scheiben schlafen und dazwischen das Flag prüfen.
|
||||
for _ in 0..10 {
|
||||
if !state.running_watchdog.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
|
||||
let step = state.current_step.lock().unwrap().clone();
|
||||
|
||||
// "idle"-Meldungen sind im Dauerbetrieb eher Spam und helfen
|
||||
// beim Debuggen selten. Deshalb nur loggen, wenn der Worker
|
||||
// sich nicht im Idle-Zustand befindet.
|
||||
if !step.ends_with(" idle") {
|
||||
// keine Info-Logs im Watchdog
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
pub(crate) fn stop_watchdog(&mut self) {
|
||||
self.state.running_watchdog.store(false, Ordering::Relaxed);
|
||||
if let Some(handle) = self.watchdog_thread.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
|
||||
// Bei Bedarf kann hier später wieder ein expliziter Statuszugriff ergänzt werden.
|
||||
}
|
||||
|
||||
impl BaseWorker {
|
||||
/// Aktualisiert das Geld eines Falukant-Users über die DB-Funktion `falukant_data.update_money`.
|
||||
/// `action` entspricht dem Log-/Aktions-Tag (z.B. "credit pay rate", "debitor_prism").
|
||||
pub fn change_falukant_user_money(
|
||||
&self,
|
||||
falukant_user_id: i32,
|
||||
money_change: f64,
|
||||
action: &str,
|
||||
) -> Result<(), DbError> {
|
||||
let mut conn = self
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
// Verwende parametrisierte Queries für Sicherheit gegen SQL-Injection
|
||||
conn.prepare("update_money", QUERY_UPDATE_MONEY)?;
|
||||
|
||||
// Validate float to avoid passing NaN/inf which the postgres client
|
||||
// may fail to serialize with an unclear error message.
|
||||
if !money_change.is_finite() {
|
||||
return Err(DbError::new(format!(
|
||||
"Ungültiger money_change: {} (not finite)",
|
||||
money_change
|
||||
)));
|
||||
}
|
||||
|
||||
// We must ensure the resulting money fits in numeric(14,2).
|
||||
// numeric(14,2) max absolute value is < 10^12 before rounding.
|
||||
// Fetch current money for the user and clamp the delta if needed.
|
||||
conn.prepare("get_money_for_clamp", QUERY_GET_MONEY)?;
|
||||
let rows = conn.execute("get_money_for_clamp", &[&falukant_user_id])?;
|
||||
|
||||
let current_money: f64 = rows
|
||||
.first()
|
||||
.and_then(|r| r.get("money"))
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
// compute tentative result
|
||||
let tentative = current_money + money_change;
|
||||
|
||||
// numeric(14,2) allows values with absolute < 10^12
|
||||
const MAX_ABS: f64 = 1_000_000_000_000.0 - 0.01; // leave room for scale
|
||||
|
||||
let adjusted_money_change = if tentative >= MAX_ABS {
|
||||
let clipped = MAX_ABS - current_money;
|
||||
eprintln!(
|
||||
"[BaseWorker] Clamping money_change: tentative {} exceeds numeric(14,2) max, clipping to {}",
|
||||
tentative, clipped
|
||||
);
|
||||
clipped
|
||||
} else if tentative <= -MAX_ABS {
|
||||
let clipped = -MAX_ABS - current_money;
|
||||
eprintln!(
|
||||
"[BaseWorker] Clamping money_change: tentative {} below min, clipping to {}",
|
||||
tentative, clipped
|
||||
);
|
||||
clipped
|
||||
} else {
|
||||
money_change
|
||||
};
|
||||
|
||||
// Send exact types matching the DB function signature:
|
||||
let uid_i32: i32 = falukant_user_id;
|
||||
let money_str = format!("{:.2}", adjusted_money_change);
|
||||
|
||||
// Note: we intentionally avoid parameterized call due to serialization
|
||||
// issues in this environment and instead execute a literal SQL below.
|
||||
fn escape_sql_literal(s: &str) -> String {
|
||||
s.replace('\'', "''")
|
||||
}
|
||||
let escaped_action = escape_sql_literal(action);
|
||||
let sql = format!(
|
||||
"SELECT falukant_data.update_money({uid}, {money}::numeric, '{act}');",
|
||||
uid = uid_i32,
|
||||
money = money_str,
|
||||
act = escaped_action
|
||||
);
|
||||
|
||||
let _ = conn.query(&sql)?;
|
||||
|
||||
// Best-effort: insert a money history entry so the UI/history views
|
||||
// can show the change even if the DB-function doesn't write it.
|
||||
// We don't want to fail the whole operation if this insert fails,
|
||||
// so log errors and continue.
|
||||
// Ensure money_history table exists (best-effort). If this fails,
|
||||
// we still don't want to abort the money update.
|
||||
let create_sql = r#"
|
||||
CREATE TABLE IF NOT EXISTS falukant_log.money_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
change NUMERIC(10,2) NOT NULL,
|
||||
action TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
"#;
|
||||
let _ = conn.query(create_sql);
|
||||
|
||||
let history_sql = format!(
|
||||
"INSERT INTO falukant_log.money_history (user_id, change, action, created_at) VALUES ({uid}, {money}::numeric, '{act}', NOW());",
|
||||
uid = uid_i32,
|
||||
money = money_str,
|
||||
act = escaped_action
|
||||
);
|
||||
if let Err(err) = conn.query(&history_sql) {
|
||||
eprintln!(
|
||||
"[BaseWorker] Warning: inserting money_history failed for user {}: {}",
|
||||
uid_i32, err
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,496 +0,0 @@
|
||||
use crate::db::{ConnectionPool, DbError, Rows};
|
||||
use crate::message_broker::MessageBroker;
|
||||
use rand::distributions::{Distribution, Uniform};
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{thread_rng, Rng, SeedableRng};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::base::{BaseWorker, Worker, WorkerState};
|
||||
use crate::worker::sql::{
|
||||
QUERY_IS_PREVIOUS_DAY_CHARACTER_CREATED,
|
||||
QUERY_GET_TOWN_REGION_IDS,
|
||||
QUERY_LOAD_FIRST_NAMES,
|
||||
QUERY_LOAD_LAST_NAMES,
|
||||
QUERY_INSERT_CHARACTER,
|
||||
QUERY_GET_ELIGIBLE_NPC_FOR_DEATH,
|
||||
QUERY_DELETE_DIRECTOR,
|
||||
QUERY_DELETE_RELATIONSHIP,
|
||||
QUERY_DELETE_CHILD_RELATION,
|
||||
QUERY_DELETE_CHILD_RELATION_BY_PARENT,
|
||||
QUERY_INSERT_NOTIFICATION,
|
||||
QUERY_MARK_CHARACTER_DECEASED,
|
||||
};
|
||||
|
||||
pub struct CharacterCreationWorker {
|
||||
pub(crate) base: BaseWorker,
|
||||
rng: StdRng,
|
||||
dist: Uniform<i32>,
|
||||
first_name_cache: HashMap<String, HashSet<i32>>,
|
||||
last_name_cache: HashSet<i32>,
|
||||
death_check_running: Arc<AtomicBool>,
|
||||
death_thread: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
|
||||
|
||||
impl CharacterCreationWorker {
|
||||
pub fn new(pool: ConnectionPool, broker: MessageBroker) -> Self {
|
||||
Self::new_internal(pool, broker, true)
|
||||
}
|
||||
|
||||
/// Interner Konstruktor, der optional den NPC-Todes-Monitor startet.
|
||||
fn new_internal(pool: ConnectionPool, broker: MessageBroker, start_death_thread: bool) -> Self {
|
||||
let base = BaseWorker::new("CharacterCreationWorker", pool.clone(), broker.clone());
|
||||
let rng = StdRng::from_entropy();
|
||||
let dist = Uniform::from(2..=3);
|
||||
let death_check_running = Arc::new(AtomicBool::new(start_death_thread));
|
||||
|
||||
let death_thread = if start_death_thread {
|
||||
let death_flag = Arc::clone(&death_check_running);
|
||||
let pool_clone = pool;
|
||||
let broker_clone = broker;
|
||||
Some(thread::spawn(move || {
|
||||
while death_flag.load(Ordering::Relaxed) {
|
||||
if let Err(err) =
|
||||
CharacterCreationWorker::monitor_character_deaths(&pool_clone, &broker_clone)
|
||||
{
|
||||
eprintln!(
|
||||
"[CharacterCreationWorker] Fehler beim Überprüfen von NPC-Todesfällen: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// Warte 1 Stunde, aber mit frühem Abbruch, wenn death_flag false wird
|
||||
for _ in 0..3600 {
|
||||
if !death_flag.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Self {
|
||||
base,
|
||||
rng,
|
||||
dist,
|
||||
first_name_cache: HashMap::new(),
|
||||
last_name_cache: HashSet::new(),
|
||||
death_check_running,
|
||||
death_thread,
|
||||
}
|
||||
}
|
||||
|
||||
/// Variante ohne separaten Todes-Monitor-Thread – wird nur in der Worker-Loop benutzt.
|
||||
fn new_for_loop(pool: ConnectionPool, broker: MessageBroker) -> Self {
|
||||
Self::new_internal(pool, broker, false)
|
||||
}
|
||||
|
||||
fn is_today_character_created(&self) -> bool {
|
||||
match self.fetch_today_characters() {
|
||||
Ok(rows) => !rows.is_empty(),
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"[CharacterCreationWorker] Fehler in is_today_character_created: {err}"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_today_characters(&self) -> Result<Rows, crate::db::DbError> {
|
||||
const STMT_NAME: &str = "is_previous_day_character_created";
|
||||
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| crate::db::DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare(STMT_NAME, QUERY_IS_PREVIOUS_DAY_CHARACTER_CREATED)?;
|
||||
conn.execute(STMT_NAME, &[])
|
||||
}
|
||||
|
||||
fn create_characters_for_today(&mut self) {
|
||||
self.load_names();
|
||||
if self.first_name_cache.is_empty() || self.last_name_cache.is_empty() {
|
||||
eprintln!(
|
||||
"[CharacterCreationWorker] Fehler: Namen konnten nicht geladen werden (Stub-Implementierung)."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let town_ids = self.get_town_region_ids();
|
||||
for region_id in town_ids {
|
||||
self.create_characters_for_region(region_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_characters_for_region(&mut self, region_id: i32) {
|
||||
let nobility_stands = [1, 2, 3];
|
||||
let genders = ["male", "female"];
|
||||
|
||||
for &nobility in &nobility_stands {
|
||||
for &gender in &genders {
|
||||
let num_chars = self.rng.sample(self.dist);
|
||||
for _ in 0..num_chars {
|
||||
self.create_character(region_id, gender, nobility);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_character(&mut self, region_id: i32, gender: &str, title_of_nobility: i32) {
|
||||
let first_set = self
|
||||
.first_name_cache
|
||||
.get(gender)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let first_name_id = Self::get_random_from_set(&first_set);
|
||||
if first_name_id == -1 {
|
||||
eprintln!("[CharacterCreationWorker] Fehler: Kein passender Vorname gefunden.");
|
||||
return;
|
||||
}
|
||||
|
||||
let last_name_id = Self::get_random_from_set(&self.last_name_cache);
|
||||
if last_name_id == -1 {
|
||||
eprintln!("[CharacterCreationWorker] Fehler: Kein passender Nachname gefunden.");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = Self::insert_character(
|
||||
&self.base.pool,
|
||||
region_id,
|
||||
first_name_id,
|
||||
last_name_id,
|
||||
gender,
|
||||
title_of_nobility,
|
||||
) {
|
||||
eprintln!("[CharacterCreationWorker] Fehler in createCharacter: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn get_town_region_ids(&self) -> Vec<i32> {
|
||||
match self.load_town_region_ids() {
|
||||
Ok(rows) => rows
|
||||
.into_iter()
|
||||
.filter_map(|row| row.get("id")?.parse::<i32>().ok())
|
||||
.collect(),
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"[CharacterCreationWorker] Fehler in getTownRegionIds: {err}"
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_town_region_ids(&self) -> Result<Rows, crate::db::DbError> {
|
||||
const STMT_NAME: &str = "get_town_region_ids";
|
||||
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| crate::db::DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare(STMT_NAME, QUERY_GET_TOWN_REGION_IDS)?;
|
||||
conn.execute(STMT_NAME, &[])
|
||||
}
|
||||
|
||||
fn load_names(&mut self) {
|
||||
if (self.first_name_cache.is_empty() || self.last_name_cache.is_empty())
|
||||
&& let Err(err) = self.load_first_and_last_names()
|
||||
{
|
||||
eprintln!("[CharacterCreationWorker] Fehler in loadNames: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn load_first_and_last_names(&mut self) -> Result<(), crate::db::DbError> {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| crate::db::DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
// Vornamen
|
||||
conn.prepare("load_first_names", QUERY_LOAD_FIRST_NAMES)?;
|
||||
let first_rows = conn.execute("load_first_names", &[])?;
|
||||
for row in first_rows {
|
||||
let id = match row.get("id").and_then(|v| v.parse::<i32>().ok()) {
|
||||
Some(id) => id,
|
||||
None => continue,
|
||||
};
|
||||
let gender = row.get("gender").cloned().unwrap_or_default();
|
||||
self.first_name_cache.entry(gender).or_default().insert(id);
|
||||
}
|
||||
|
||||
// Nachnamen
|
||||
conn.prepare("load_last_names", QUERY_LOAD_LAST_NAMES)?;
|
||||
let last_rows = conn.execute("load_last_names", &[])?;
|
||||
for row in last_rows {
|
||||
if let Some(id) = row.get("id").and_then(|v| v.parse::<i32>().ok()) {
|
||||
self.last_name_cache.insert(id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_random_from_set(set: &HashSet<i32>) -> i32 {
|
||||
if set.is_empty() {
|
||||
return -1;
|
||||
}
|
||||
let mut rng = thread_rng();
|
||||
let idx = rng.gen_range(0..set.len());
|
||||
*set.iter().nth(idx).unwrap_or(&-1)
|
||||
}
|
||||
|
||||
fn run_iteration(&mut self, state: &WorkerState) {
|
||||
self.base
|
||||
.set_current_step("Check if previous day character was created");
|
||||
|
||||
if !self.is_today_character_created() {
|
||||
self.base
|
||||
.set_current_step("Create characters for today");
|
||||
self.create_characters_for_today();
|
||||
}
|
||||
|
||||
self.sleep_one_minute(state);
|
||||
}
|
||||
|
||||
fn sleep_one_minute(&self, state: &WorkerState) {
|
||||
self.base
|
||||
.set_current_step("Sleep for 60 seconds");
|
||||
|
||||
for _ in 0..60 {
|
||||
if !state.running_worker.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
|
||||
self.base.set_current_step("Loop done");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl Worker for CharacterCreationWorker {
|
||||
fn start_worker_thread(&mut self) {
|
||||
let pool = self.base.pool.clone();
|
||||
let broker = self.base.broker.clone();
|
||||
|
||||
self.base
|
||||
.start_worker_with_loop(move |state: Arc<WorkerState>| {
|
||||
let mut worker = CharacterCreationWorker::new_for_loop(pool.clone(), broker.clone());
|
||||
while state.running_worker.load(Ordering::Relaxed) {
|
||||
worker.run_iteration(&state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn stop_worker_thread(&mut self) {
|
||||
self.base.stop_worker();
|
||||
}
|
||||
|
||||
fn enable_watchdog(&mut self) {
|
||||
self.base.start_watchdog();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CharacterCreationWorker {
|
||||
fn drop(&mut self) {
|
||||
self.death_check_running
|
||||
.store(false, Ordering::Relaxed);
|
||||
if let Some(handle) = self.death_thread.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zusätzliche Logik: NPC-Todesfälle überwachen und verarbeiten
|
||||
|
||||
impl CharacterCreationWorker {
|
||||
fn insert_character(
|
||||
pool: &ConnectionPool,
|
||||
region_id: i32,
|
||||
first_name_id: i32,
|
||||
last_name_id: i32,
|
||||
gender: &str,
|
||||
title_of_nobility: i32,
|
||||
) -> Result<(), DbError> {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("insert_character", QUERY_INSERT_CHARACTER)?;
|
||||
conn.execute(
|
||||
"insert_character",
|
||||
&[
|
||||
®ion_id,
|
||||
&first_name_id,
|
||||
&last_name_id,
|
||||
&gender,
|
||||
&title_of_nobility,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn monitor_character_deaths(
|
||||
pool: &ConnectionPool,
|
||||
broker: &MessageBroker,
|
||||
) -> Result<(), DbError> {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare(
|
||||
"get_eligible_npc_for_death",
|
||||
QUERY_GET_ELIGIBLE_NPC_FOR_DEATH,
|
||||
)?;
|
||||
let rows = conn.execute("get_eligible_npc_for_death", &[])?;
|
||||
|
||||
for row in rows {
|
||||
let character_id = row
|
||||
.get("id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(-1);
|
||||
let age = row
|
||||
.get("age")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
if character_id > 0 && Self::calculate_death_probability(age)
|
||||
&& let Err(err) = Self::handle_character_death(pool, broker, character_id)
|
||||
{
|
||||
eprintln!(
|
||||
"[CharacterCreationWorker] Fehler beim Bearbeiten des NPC-Todes (id={character_id}): {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn calculate_death_probability(age: i32) -> bool {
|
||||
if age < 60 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let base_probability = 0.01_f64;
|
||||
let increase_per_year = 0.01_f64;
|
||||
let death_probability =
|
||||
base_probability + increase_per_year * (age.saturating_sub(60) as f64);
|
||||
|
||||
let mut rng = thread_rng();
|
||||
let dist = Uniform::from(0.0..1.0);
|
||||
let roll: f64 = dist.sample(&mut rng);
|
||||
roll < death_probability
|
||||
}
|
||||
|
||||
fn handle_character_death(
|
||||
pool: &ConnectionPool,
|
||||
broker: &MessageBroker,
|
||||
character_id: i32,
|
||||
) -> Result<(), DbError> {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
// 1) Director löschen und User benachrichtigen
|
||||
conn.prepare("delete_director", QUERY_DELETE_DIRECTOR)?;
|
||||
let dir_result = conn.execute("delete_director", &[&character_id])?;
|
||||
if let Some(row) = dir_result.first()
|
||||
&& let Some(user_id) = row
|
||||
.get("employer_user_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
Self::notify_user(pool, broker, user_id, "director_death")?;
|
||||
}
|
||||
|
||||
// 2) Relationships löschen und betroffene User benachrichtigen
|
||||
conn.prepare("delete_relationship", QUERY_DELETE_RELATIONSHIP)?;
|
||||
let rel_result = conn.execute("delete_relationship", &[&character_id])?;
|
||||
for row in rel_result {
|
||||
if let Some(related_user_id) = row
|
||||
.get("related_user_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
Self::notify_user(pool, broker, related_user_id, "relationship_death")?;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Child-Relations löschen (als Elter zuerst, dann als Kind) und Eltern benachrichtigen
|
||||
conn.prepare("delete_child_relation_by_parent", QUERY_DELETE_CHILD_RELATION_BY_PARENT)?;
|
||||
conn.execute("delete_child_relation_by_parent", &[&character_id])?;
|
||||
conn.prepare("delete_child_relation", QUERY_DELETE_CHILD_RELATION)?;
|
||||
let child_result = conn.execute("delete_child_relation", &[&character_id])?;
|
||||
for row in child_result {
|
||||
if let Some(father_user_id) = row
|
||||
.get("father_user_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
Self::notify_user(pool, broker, father_user_id, "child_death")?;
|
||||
}
|
||||
if let Some(mother_user_id) = row
|
||||
.get("mother_user_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
Self::notify_user(pool, broker, mother_user_id, "child_death")?;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Charakter als verstorben markieren
|
||||
Self::mark_character_as_deceased(pool, character_id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn notify_user(
|
||||
pool: &ConnectionPool,
|
||||
broker: &MessageBroker,
|
||||
user_id: i32,
|
||||
event_type: &str,
|
||||
) -> Result<(), DbError> {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("insert_notification", QUERY_INSERT_NOTIFICATION)?;
|
||||
conn.execute("insert_notification", &[&user_id])?;
|
||||
|
||||
// falukantUpdateStatus
|
||||
let update_message =
|
||||
format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id);
|
||||
broker.publish(update_message);
|
||||
|
||||
// ursprüngliche Benachrichtigung
|
||||
let message =
|
||||
format!(r#"{{"event":"{event_type}","user_id":{}}}"#, user_id);
|
||||
broker.publish(message);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mark_character_as_deceased(
|
||||
pool: &ConnectionPool,
|
||||
character_id: i32,
|
||||
) -> Result<(), DbError> {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("mark_character_deceased", QUERY_MARK_CHARACTER_DECEASED)?;
|
||||
conn.execute("mark_character_deceased", &[&character_id])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,900 +0,0 @@
|
||||
use crate::db::{ConnectionPool, DbError, Rows};
|
||||
use crate::message_broker::MessageBroker;
|
||||
use rand::distributions::{Distribution, Uniform};
|
||||
use rand::rngs::StdRng;
|
||||
use rand::SeedableRng;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::base::{BaseWorker, Worker, WorkerState};
|
||||
use crate::worker::sql::{
|
||||
QUERY_GET_USERS_TO_UPDATE,
|
||||
QUERY_UPDATE_CHARACTERS_HEALTH,
|
||||
QUERY_UPDATE_MOOD,
|
||||
QUERY_UPDATE_GET_ITEMS_TO_UPDATE,
|
||||
QUERY_UPDATE_GET_CHARACTER_IDS,
|
||||
QUERY_UPDATE_KNOWLEDGE,
|
||||
QUERY_DELETE_LOG_ENTRY,
|
||||
QUERY_GET_OPEN_CREDITS,
|
||||
QUERY_UPDATE_CREDIT,
|
||||
QUERY_CLEANUP_CREDITS,
|
||||
QUERY_ADD_CHARACTER_TO_DEBTORS_PRISM,
|
||||
QUERY_GET_CURRENT_MONEY,
|
||||
QUERY_GET_HOUSE_VALUE,
|
||||
QUERY_GET_SETTLEMENT_VALUE,
|
||||
QUERY_GET_INVENTORY_VALUE,
|
||||
QUERY_GET_CREDIT_DEBT,
|
||||
QUERY_COUNT_CHILDREN_BY_USER,
|
||||
QUERY_GET_HEIR,
|
||||
QUERY_RANDOM_HEIR,
|
||||
QUERY_SET_CHARACTER_USER,
|
||||
QUERY_UPDATE_USER_MONEY,
|
||||
QUERY_GET_FALUKANT_USER_ID,
|
||||
QUERY_AUTOBATISM,
|
||||
QUERY_GET_PREGNANCY_CANDIDATES,
|
||||
QUERY_INSERT_CHILD,
|
||||
QUERY_INSERT_CHILD_RELATION,
|
||||
QUERY_DELETE_DIRECTOR,
|
||||
QUERY_DELETE_RELATIONSHIP,
|
||||
QUERY_DELETE_CHILD_RELATION,
|
||||
QUERY_DELETE_CHILD_RELATION_BY_PARENT,
|
||||
QUERY_DELETE_KNOWLEDGE,
|
||||
QUERY_DELETE_DEBTORS_PRISM,
|
||||
QUERY_DELETE_POLITICAL_OFFICE,
|
||||
QUERY_DELETE_ELECTION_CANDIDATE,
|
||||
};
|
||||
|
||||
/// Vereinfachtes Abbild eines Characters aus `QUERY_GET_USERS_TO_UPDATE`.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Character {
|
||||
id: i32,
|
||||
age: i32,
|
||||
health: i32,
|
||||
}
|
||||
|
||||
pub struct UserCharacterWorker {
|
||||
base: BaseWorker,
|
||||
rng: StdRng,
|
||||
dist: Uniform<f64>,
|
||||
last_hourly_run: Option<Instant>,
|
||||
last_pregnancy_run: Option<Instant>,
|
||||
last_mood_run: Option<Instant>,
|
||||
}
|
||||
|
||||
// SQL moved to `src/worker/sql.rs`
|
||||
|
||||
impl UserCharacterWorker {
|
||||
pub fn new(pool: ConnectionPool, broker: MessageBroker) -> Self {
|
||||
let base = BaseWorker::new("UserCharacterWorker", pool, broker);
|
||||
let rng = StdRng::from_entropy();
|
||||
let dist = Uniform::from(0.0..1.0);
|
||||
|
||||
Self {
|
||||
base,
|
||||
rng,
|
||||
dist,
|
||||
last_hourly_run: None,
|
||||
last_pregnancy_run: None,
|
||||
last_mood_run: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_iteration(&mut self, state: &WorkerState) {
|
||||
self.base.set_current_step("UserCharacterWorker iteration");
|
||||
|
||||
self.maybe_run_hourly_tasks();
|
||||
self.maybe_run_mood_updates();
|
||||
self.maybe_run_hourly_pregnancies();
|
||||
|
||||
// Entspricht in etwa der 1-Sekunden-Schleife im C++-Code
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
|
||||
if let Err(err) = self.recalculate_knowledge() {
|
||||
eprintln!("[UserCharacterWorker] Fehler in recalculateKnowledge: {err}");
|
||||
}
|
||||
|
||||
if !state.running_worker.load(Ordering::Relaxed) {
|
||||
// worker stopping
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_run_hourly_tasks(&mut self) {
|
||||
let now = Instant::now();
|
||||
let should_run = match self.last_hourly_run {
|
||||
None => true,
|
||||
Some(last) => now.saturating_duration_since(last) >= Duration::from_secs(3600),
|
||||
};
|
||||
|
||||
if !should_run {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = self.run_hourly_tasks() {
|
||||
eprintln!("[UserCharacterWorker] Fehler in stündlichen Tasks: {err}");
|
||||
}
|
||||
|
||||
self.last_hourly_run = Some(now);
|
||||
}
|
||||
|
||||
fn run_hourly_tasks(&mut self) -> Result<(), DbError> {
|
||||
self.process_character_events()?;
|
||||
self.handle_credits()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ehe-Schwangerschaft: höchstens einmal pro Stunde (Daemon-Schleife ~1 s).
|
||||
/// SQL nutzt stündliche Zerlegung der Jahres-Wahrscheinlichkeit (`1/8760`), siehe `QUERY_GET_PREGNANCY_CANDIDATES`.
|
||||
fn maybe_run_hourly_pregnancies(&mut self) {
|
||||
let now = Instant::now();
|
||||
let should_run = match self.last_pregnancy_run {
|
||||
None => true,
|
||||
Some(last) => now.saturating_duration_since(last) >= Duration::from_secs(3600),
|
||||
};
|
||||
|
||||
if !should_run {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = self.process_pregnancies() {
|
||||
eprintln!("[UserCharacterWorker] Fehler in processPregnancies: {err}");
|
||||
}
|
||||
self.last_pregnancy_run = Some(now);
|
||||
}
|
||||
|
||||
fn process_character_events(&mut self) -> Result<(), DbError> {
|
||||
self.base.set_current_step("Get character data");
|
||||
|
||||
let rows = self.load_characters_to_update()?;
|
||||
let mut characters: Vec<Character> = rows
|
||||
.into_iter()
|
||||
.filter_map(Self::map_row_to_character)
|
||||
.collect();
|
||||
|
||||
for character in &mut characters {
|
||||
self.update_character_health(character)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_characters_to_update(&mut self) -> Result<Rows, DbError> {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("get_users_to_update", QUERY_GET_USERS_TO_UPDATE)?;
|
||||
conn.execute("get_users_to_update", &[])
|
||||
}
|
||||
|
||||
fn map_row_to_character(row: crate::db::Row) -> Option<Character> {
|
||||
Some(Character {
|
||||
id: row.get("id")?.parse().ok()?,
|
||||
age: row.get("age")?.parse().ok()?,
|
||||
health: row.get("health")?.parse().ok()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn update_character_health(&mut self, character: &mut Character) -> Result<(), DbError> {
|
||||
// Bereits verstorbene Charaktere (health <= 0) sofort behandeln
|
||||
if character.health <= 0 {
|
||||
self.handle_character_death(character.id)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let health_change = self.calculate_health_change(character.age);
|
||||
if health_change == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
character.health = std::cmp::max(0, character.health + health_change);
|
||||
|
||||
if character.health == 0 {
|
||||
self.handle_character_death(character.id)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare(
|
||||
"update_characters_health",
|
||||
QUERY_UPDATE_CHARACTERS_HEALTH,
|
||||
)?;
|
||||
conn.execute(
|
||||
"update_characters_health",
|
||||
&[&character.health, &character.id],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn calculate_health_change(&mut self, age: i32) -> i32 {
|
||||
if age < 30 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if age >= 45 {
|
||||
let probability = (0.1 + (age - 45) as f64 * 0.02).min(1.0);
|
||||
if self.dist.sample(&mut self.rng) < probability {
|
||||
let damage_dist = Uniform::from(1..=10);
|
||||
return -damage_dist.sample(&mut self.rng);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
let probability = (age - 30) as f64 / 30.0;
|
||||
if self.dist.sample(&mut self.rng) < probability {
|
||||
-1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_run_mood_updates(&mut self) {
|
||||
let now = Instant::now();
|
||||
let should_run = match self.last_mood_run {
|
||||
None => true,
|
||||
Some(last) => now.saturating_duration_since(last) >= Duration::from_secs(60),
|
||||
};
|
||||
|
||||
if !should_run {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = self.update_characters_mood_randomized() {
|
||||
eprintln!("[UserCharacterWorker] Fehler in updateCharactersMood: {err}");
|
||||
}
|
||||
|
||||
self.last_mood_run = Some(now);
|
||||
}
|
||||
|
||||
/// Setzt die Stimmung einzelner lebender Charaktere zufällig neu.
|
||||
/// Jeder Charakter hat pro Minute eine kleine Chance auf einen Wechsel,
|
||||
/// so dass sich über die Zeit ein individueller, zufälliger Rhythmus entsteht.
|
||||
fn update_characters_mood_randomized(&mut self) -> Result<(), DbError> {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("update_mood", QUERY_UPDATE_MOOD)?;
|
||||
conn.execute("update_mood", &[])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn recalculate_knowledge(&mut self) -> Result<(), DbError> {
|
||||
self.base.set_current_step("recalculate knowledge");
|
||||
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare(
|
||||
"get_items_to_update",
|
||||
QUERY_UPDATE_GET_ITEMS_TO_UPDATE,
|
||||
)?;
|
||||
let update_rows = conn.execute("get_items_to_update", &[])?;
|
||||
|
||||
for update_item in update_rows {
|
||||
let quantity: i32 = match update_item.get("quantity").and_then(|v| v.parse().ok()) {
|
||||
Some(q) => q,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if quantity < 10 {
|
||||
self.delete_production_log_entry(&mut conn, &update_item)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
self.update_knowledge_for_production(&mut conn, &update_item)?;
|
||||
self.delete_production_log_entry(&mut conn, &update_item)?;
|
||||
|
||||
if let Some(producer_id) = update_item
|
||||
.get("producer_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
self.send_knowledge_update(producer_id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_knowledge_for_production(
|
||||
&mut self,
|
||||
conn: &mut crate::db::DbConnection,
|
||||
update_item: &crate::db::Row,
|
||||
) -> Result<(), DbError> {
|
||||
let producer_id = match update_item.get("producer_id").and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let product_id = match update_item.get("product_id").and_then(|v| v.parse::<i32>().ok()) {
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
conn.prepare(
|
||||
"get_character_ids",
|
||||
QUERY_UPDATE_GET_CHARACTER_IDS,
|
||||
)?;
|
||||
let characters_data =
|
||||
conn.execute("get_character_ids", &[&producer_id])?;
|
||||
|
||||
conn.prepare("update_knowledge", QUERY_UPDATE_KNOWLEDGE)?;
|
||||
|
||||
for character_row in characters_data {
|
||||
let character_id = match character_row
|
||||
.get("character_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
Some(id) => id,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let director_id = character_row
|
||||
.get("director_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
if director_id == 0 {
|
||||
conn.execute(
|
||||
"update_knowledge",
|
||||
&[&character_id, &product_id, &2_i32],
|
||||
)?;
|
||||
} else {
|
||||
conn.execute(
|
||||
"update_knowledge",
|
||||
&[&character_id, &product_id, &1_i32],
|
||||
)?;
|
||||
conn.execute(
|
||||
"update_knowledge",
|
||||
&[&director_id, &product_id, &1_i32],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_production_log_entry(
|
||||
&mut self,
|
||||
conn: &mut crate::db::DbConnection,
|
||||
update_item: &crate::db::Row,
|
||||
) -> Result<(), DbError> {
|
||||
let id = match update_item.get("id").and_then(|v| v.parse::<i32>().ok()) {
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
conn.prepare("delete_log_entry", QUERY_DELETE_LOG_ENTRY)?;
|
||||
conn.execute("delete_log_entry", &[&id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_knowledge_update(&self, producer_id: i32) {
|
||||
let message = format!(r#"{{"event":"knowledge_update","user_id":{}}}"#, producer_id);
|
||||
self.base.broker.publish(message);
|
||||
}
|
||||
|
||||
// Kredit-Logik (portiert aus handleCredits)
|
||||
fn handle_credits(&mut self) -> Result<(), DbError> {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("get_open_credits", QUERY_GET_OPEN_CREDITS)?;
|
||||
conn.prepare("update_credit", QUERY_UPDATE_CREDIT)?;
|
||||
conn.prepare("cleanup_credits", QUERY_CLEANUP_CREDITS)?;
|
||||
conn.prepare(
|
||||
"add_character_to_debtors_prism",
|
||||
QUERY_ADD_CHARACTER_TO_DEBTORS_PRISM,
|
||||
)?;
|
||||
|
||||
let credits_rows = conn.execute("get_open_credits", &[])?;
|
||||
for row in credits_rows {
|
||||
if let Some(credit) = Self::map_row_to_credit(&row) {
|
||||
self.process_single_credit(&mut conn, &credit)?;
|
||||
}
|
||||
}
|
||||
|
||||
conn.execute("cleanup_credits", &[])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_row_to_credit(row: &crate::db::Row) -> Option<Credit> {
|
||||
Some(Credit {
|
||||
amount: row.get("amount")?.parse().ok()?,
|
||||
remaining_amount: row.get("remaining_amount")?.parse().ok()?,
|
||||
interest_rate: row.get("interest_rate")?.parse().ok()?,
|
||||
user_id: row.get("user_id")?.parse().ok()?,
|
||||
money: row.get("money")?.parse().ok()?,
|
||||
character_id: row.get("character_id")?.parse().ok()?,
|
||||
prism_started_previously: row
|
||||
.get("prism_started_previously")
|
||||
.map(|v| v == "t" || v == "true")
|
||||
.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
fn process_single_credit(
|
||||
&mut self,
|
||||
conn: &mut crate::db::DbConnection,
|
||||
credit: &Credit,
|
||||
) -> Result<(), DbError> {
|
||||
let Credit {
|
||||
amount,
|
||||
mut remaining_amount,
|
||||
interest_rate,
|
||||
user_id,
|
||||
money,
|
||||
character_id,
|
||||
prism_started_previously,
|
||||
..
|
||||
} = *credit;
|
||||
|
||||
let pay_rate = amount / 10.0 + amount * interest_rate as f64 / 100.0;
|
||||
remaining_amount -= pay_rate;
|
||||
|
||||
// Kann der User zahlen?
|
||||
if pay_rate <= money - (pay_rate * 3.0) {
|
||||
if let Err(err) = self
|
||||
.base
|
||||
.change_falukant_user_money(user_id, -pay_rate, "credit pay rate")
|
||||
{
|
||||
eprintln!(
|
||||
"[UserCharacterWorker] Fehler bei change_falukant_user_money (credit pay rate): {err}"
|
||||
);
|
||||
}
|
||||
} else if prism_started_previously {
|
||||
if let Err(err) = self
|
||||
.base
|
||||
.change_falukant_user_money(user_id, pay_rate, "debitor_prism")
|
||||
{
|
||||
eprintln!(
|
||||
"[UserCharacterWorker] Fehler bei change_falukant_user_money (debitor_prism): {err}"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
conn.execute("add_character_to_debtors_prism", &[&character_id])?;
|
||||
}
|
||||
|
||||
conn.execute("update_credit", &[&remaining_amount, &user_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Schwangerschafts-Logik (portiert aus processPregnancies)
|
||||
fn process_pregnancies(&mut self) -> Result<(), DbError> {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("autobatism", QUERY_AUTOBATISM)?;
|
||||
conn.execute("autobatism", &[])?;
|
||||
|
||||
conn.prepare("get_pregnancy_candidates", QUERY_GET_PREGNANCY_CANDIDATES)?;
|
||||
let rows = conn.execute("get_pregnancy_candidates", &[])?;
|
||||
|
||||
conn.prepare("insert_child", QUERY_INSERT_CHILD)?;
|
||||
conn.prepare("insert_child_relation", QUERY_INSERT_CHILD_RELATION)?;
|
||||
|
||||
for row in rows {
|
||||
self.process_single_pregnancy_candidate(&mut conn, &row)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_single_pregnancy_candidate(
|
||||
&mut self,
|
||||
conn: &mut crate::db::DbConnection,
|
||||
row: &crate::db::Row,
|
||||
) -> Result<(), DbError> {
|
||||
let father_cid = parse_i32(row, "father_cid", -1);
|
||||
let mother_cid = parse_i32(row, "mother_cid", -1);
|
||||
if father_cid < 0 || mother_cid < 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let title_of_nobility = parse_i32(row, "title_of_nobility", 0);
|
||||
let last_name = parse_i32(row, "last_name", 0);
|
||||
let region_id = parse_i32(row, "region_id", 0);
|
||||
|
||||
let father_uid = parse_opt_i32(row, "father_uid");
|
||||
let mother_uid = parse_opt_i32(row, "mother_uid");
|
||||
|
||||
let gender = if self.dist.sample(&mut self.rng) < 0.5 {
|
||||
"male"
|
||||
} else {
|
||||
"female"
|
||||
};
|
||||
|
||||
let inserted =
|
||||
conn.execute("insert_child", &[®ion_id, &gender, &last_name, &title_of_nobility])?;
|
||||
let child_cid = inserted
|
||||
.first()
|
||||
.and_then(|r| r.get("child_cid"))
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(-1);
|
||||
if child_cid < 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"insert_child_relation",
|
||||
&[&father_cid, &mother_cid, &child_cid],
|
||||
)?;
|
||||
|
||||
if let Some(f_uid) = father_uid {
|
||||
self.send_children_update_and_status(f_uid);
|
||||
}
|
||||
if let Some(m_uid) = mother_uid {
|
||||
self.send_children_update_and_status(m_uid);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_children_update_and_status(&self, user_id: i32) {
|
||||
let children_update =
|
||||
format!(r#"{{"event":"children_update","user_id":{}}}"#, user_id);
|
||||
self.base.broker.publish(children_update);
|
||||
|
||||
let update_status =
|
||||
format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id);
|
||||
self.base.broker.publish(update_status);
|
||||
}
|
||||
|
||||
// Todes- und Erb-Logik
|
||||
fn handle_character_death(&mut self, character_id: i32) -> Result<(), DbError> {
|
||||
self.set_heir(character_id)?;
|
||||
|
||||
let death_event = format!(
|
||||
r#"{{"event":"CharacterDeath","character_id":{}}}"#,
|
||||
character_id
|
||||
);
|
||||
self.base.broker.publish(death_event);
|
||||
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("delete_director", QUERY_DELETE_DIRECTOR)?;
|
||||
conn.prepare("delete_relationship", QUERY_DELETE_RELATIONSHIP)?;
|
||||
conn.prepare("delete_child_relation", QUERY_DELETE_CHILD_RELATION)?;
|
||||
conn.prepare("delete_child_relation_by_parent", QUERY_DELETE_CHILD_RELATION_BY_PARENT)?;
|
||||
conn.prepare("delete_knowledge", QUERY_DELETE_KNOWLEDGE)?;
|
||||
conn.prepare("delete_debtors_prism", QUERY_DELETE_DEBTORS_PRISM)?;
|
||||
conn.prepare("delete_political_office", QUERY_DELETE_POLITICAL_OFFICE)?;
|
||||
conn.prepare("delete_election_candidate", QUERY_DELETE_ELECTION_CANDIDATE)?;
|
||||
|
||||
conn.execute("delete_director", &[&character_id])?;
|
||||
conn.execute("delete_relationship", &[&character_id])?;
|
||||
conn.execute("delete_child_relation_by_parent", &[&character_id])?;
|
||||
conn.execute("delete_child_relation", &[&character_id])?;
|
||||
conn.execute("delete_knowledge", &[&character_id])?;
|
||||
conn.execute("delete_debtors_prism", &[&character_id])?;
|
||||
conn.execute("delete_political_office", &[&character_id])?;
|
||||
conn.execute("delete_election_candidate", &[&character_id])?;
|
||||
|
||||
// Character selbst löschen
|
||||
conn.prepare(
|
||||
"delete_character",
|
||||
r#"DELETE FROM falukant_data.character WHERE id = $1"#,
|
||||
)?;
|
||||
conn.execute("delete_character", &[&character_id])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_heir(&mut self, character_id: i32) -> Result<(), DbError> {
|
||||
let falukant_user_id = self.get_falukant_user_id(character_id)?;
|
||||
if falukant_user_id < 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut heir_id = self.get_heir_from_children(character_id)?;
|
||||
let mut new_money = self.calculate_new_money(falukant_user_id, heir_id > 0)?;
|
||||
|
||||
if heir_id < 1 {
|
||||
heir_id = self.get_random_heir(character_id)?;
|
||||
new_money = self.calculate_new_money(falukant_user_id, heir_id > 0)?;
|
||||
}
|
||||
|
||||
if heir_id > 0 {
|
||||
self.set_new_character(falukant_user_id, heir_id)?;
|
||||
}
|
||||
self.set_new_money(falukant_user_id, new_money)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_falukant_user_id(&mut self, character_id: i32) -> Result<i32, DbError> {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("get_falukant_user_id", QUERY_GET_FALUKANT_USER_ID)?;
|
||||
let rows = conn.execute("get_falukant_user_id", &[&character_id])?;
|
||||
|
||||
Ok(rows
|
||||
.first()
|
||||
.and_then(|r| r.get("user_id"))
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(-1))
|
||||
}
|
||||
|
||||
fn get_heir_from_children(&mut self, deceased_character_id: i32) -> Result<i32, DbError> {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("get_heir", QUERY_GET_HEIR)?;
|
||||
let rows = conn.execute("get_heir", &[&deceased_character_id])?;
|
||||
|
||||
Ok(rows
|
||||
.first()
|
||||
.and_then(|r| r.get("child_character_id"))
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(-1))
|
||||
}
|
||||
|
||||
fn get_random_heir(&mut self, deceased_character_id: i32) -> Result<i32, DbError> {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("random_heir", QUERY_RANDOM_HEIR)?;
|
||||
let rows = conn.execute("random_heir", &[&deceased_character_id])?;
|
||||
|
||||
Ok(rows
|
||||
.first()
|
||||
.and_then(|r| r.get("child_character_id"))
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(-1))
|
||||
}
|
||||
|
||||
fn set_new_character(
|
||||
&mut self,
|
||||
falukant_user_id: i32,
|
||||
heir_character_id: i32,
|
||||
) -> Result<(), DbError> {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("set_character_user", QUERY_SET_CHARACTER_USER)?;
|
||||
conn.execute(
|
||||
"set_character_user",
|
||||
&[&falukant_user_id, &heir_character_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_new_money(&mut self, falukant_user_id: i32, new_amount: f64) -> Result<(), DbError> {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
let clamped = if !new_amount.is_finite() {
|
||||
1000.0
|
||||
} else {
|
||||
const MAX_ABS: f64 = 999_999_999_999.99;
|
||||
new_amount.clamp(-MAX_ABS, MAX_ABS)
|
||||
};
|
||||
let money_str = format!("{:.2}", clamped);
|
||||
|
||||
conn.prepare("update_user_money", QUERY_UPDATE_USER_MONEY)?;
|
||||
conn.execute("update_user_money", &[&money_str, &falukant_user_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_current_money(&mut self, falukant_user_id: i32) -> Result<f64, DbError> {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("get_current_money", QUERY_GET_CURRENT_MONEY)?;
|
||||
let rows = conn.execute("get_current_money", &[&falukant_user_id])?;
|
||||
|
||||
Ok(rows
|
||||
.first()
|
||||
.and_then(|r| r.get("sum"))
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
fn get_house_value(&mut self, falukant_user_id: i32) -> Result<f64, DbError> {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("house_value", QUERY_GET_HOUSE_VALUE)?;
|
||||
let rows = conn.execute("house_value", &[&falukant_user_id])?;
|
||||
|
||||
Ok(rows
|
||||
.first()
|
||||
.and_then(|r| r.get("sum"))
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
fn get_settlement_value(&mut self, falukant_user_id: i32) -> Result<f64, DbError> {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("settlement_value", QUERY_GET_SETTLEMENT_VALUE)?;
|
||||
let rows = conn.execute("settlement_value", &[&falukant_user_id])?;
|
||||
|
||||
Ok(rows
|
||||
.first()
|
||||
.and_then(|r| r.get("sum"))
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
fn get_inventory_value(&mut self, falukant_user_id: i32) -> Result<f64, DbError> {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("inventory_value", QUERY_GET_INVENTORY_VALUE)?;
|
||||
let rows = conn.execute("inventory_value", &[&falukant_user_id])?;
|
||||
|
||||
Ok(rows
|
||||
.first()
|
||||
.and_then(|r| r.get("sum"))
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
fn get_credit_debt(&mut self, falukant_user_id: i32) -> Result<f64, DbError> {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("credit_debt", QUERY_GET_CREDIT_DEBT)?;
|
||||
let rows = conn.execute("credit_debt", &[&falukant_user_id])?;
|
||||
|
||||
Ok(rows
|
||||
.first()
|
||||
.and_then(|r| r.get("sum"))
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
fn get_child_count(&mut self, deceased_user_id: i32) -> Result<i32, DbError> {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("count_children_by_user", QUERY_COUNT_CHILDREN_BY_USER)?;
|
||||
let rows = conn.execute("count_children_by_user", &[&deceased_user_id])?;
|
||||
|
||||
Ok(rows
|
||||
.first()
|
||||
.and_then(|r| r.get("cnt"))
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
fn calculate_new_money(
|
||||
&mut self,
|
||||
falukant_user_id: i32,
|
||||
has_heir: bool,
|
||||
) -> Result<f64, DbError> {
|
||||
if !has_heir {
|
||||
return Ok(800.0);
|
||||
}
|
||||
|
||||
let cash = self.get_current_money(falukant_user_id)?;
|
||||
let houses = self.get_house_value(falukant_user_id)?;
|
||||
let settlements = self.get_settlement_value(falukant_user_id)?;
|
||||
let inventory = self.get_inventory_value(falukant_user_id)?;
|
||||
let debt = self.get_credit_debt(falukant_user_id)?;
|
||||
|
||||
let total_assets = cash + houses + settlements + inventory - debt;
|
||||
let child_count = self.get_child_count(falukant_user_id)?;
|
||||
let single = child_count <= 1;
|
||||
|
||||
let heir_share = if single {
|
||||
total_assets
|
||||
} else {
|
||||
total_assets * 0.8
|
||||
};
|
||||
|
||||
let net = heir_share - (houses + settlements + inventory + debt);
|
||||
if net <= 1000.0 {
|
||||
Ok(1000.0)
|
||||
} else {
|
||||
Ok(net)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kleine Hilfsfunktionen für robustes Parsen aus `Row`.
|
||||
fn parse_i32(row: &crate::db::Row, key: &str, default: i32) -> i32 {
|
||||
row.get(key)
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn parse_opt_i32(row: &crate::db::Row, key: &str) -> Option<i32> {
|
||||
row.get(key).and_then(|v| v.parse::<i32>().ok())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Credit {
|
||||
amount: f64,
|
||||
remaining_amount: f64,
|
||||
interest_rate: i32,
|
||||
user_id: i32,
|
||||
money: f64,
|
||||
character_id: i32,
|
||||
prism_started_previously: bool,
|
||||
}
|
||||
|
||||
impl Worker for UserCharacterWorker {
|
||||
fn start_worker_thread(&mut self) {
|
||||
let pool = self.base.pool.clone();
|
||||
let broker = self.base.broker.clone();
|
||||
|
||||
self.base
|
||||
.start_worker_with_loop(move |state: Arc<WorkerState>| {
|
||||
let mut worker = UserCharacterWorker::new(pool.clone(), broker.clone());
|
||||
while state.running_worker.load(Ordering::Relaxed) {
|
||||
worker.run_iteration(&state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn stop_worker_thread(&mut self) {
|
||||
self.base.stop_worker();
|
||||
}
|
||||
|
||||
fn enable_watchdog(&mut self) {
|
||||
self.base.start_watchdog();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
Compiling YpDaemon v0.5.0 (/mnt/share/torsten/Programs/ypdaemon)
|
||||
warning: unused logical operation that must be used
|
||||
--> src/worker/director.rs:294:13
|
||||
|
|
||||
294 | / director.may_start_transport
|
||||
295 | | && self.start_transports_stub(&director).is_err();
|
||||
| |_________________________________________________________________^ the logical operation produces a value
|
||||
|
|
||||
= note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
|
||||
help: use `let _ = ...` to ignore the resulting value
|
||||
|
|
||||
294 | let _ = director.may_start_transport
|
||||
| +++++++
|
||||
|
||||
warning: `YpDaemon` (bin "YpDaemon") generated 1 warning
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.01s
|
||||
@@ -1,270 +0,0 @@
|
||||
-- Debug-Queries: Tod -> Erbwechsel -> Cleanup
|
||||
-- Nutzung:
|
||||
-- psql ... -f docs/DEBUG_DEATH_SUCCESSION_QUERIES.sql
|
||||
-- Ersetze zuerst:
|
||||
-- :character_id (verstorbener Charakter)
|
||||
-- :user_id (falukant_user.id des Spielers)
|
||||
-- :heir_id (optional; geplanter Erbe)
|
||||
--
|
||||
-- In psql z. B.:
|
||||
-- \set character_id 123
|
||||
-- \set user_id 45
|
||||
-- \set heir_id 678
|
||||
|
||||
-- ============================================================
|
||||
-- 0) Vorab: Existenz / Ausgangszustand prüfen
|
||||
-- ============================================================
|
||||
|
||||
SELECT c.id, c.user_id, c.health, c.updated_at
|
||||
FROM falukant_data.character c
|
||||
WHERE c.id = :character_id;
|
||||
|
||||
SELECT fu.id, fu.user_id, fu.money, fu.certificate
|
||||
FROM falukant_data.falukant_user fu
|
||||
WHERE fu.id = :user_id;
|
||||
|
||||
-- Mögliche Erben (wie QUERY_GET_HEIR priorisiert)
|
||||
SELECT child_character_id, is_heir, updated_at
|
||||
FROM falukant_data.child_relation
|
||||
WHERE father_character_id = :character_id
|
||||
OR mother_character_id = :character_id
|
||||
ORDER BY (is_heir IS TRUE) DESC, updated_at DESC
|
||||
LIMIT 20;
|
||||
|
||||
-- Fallback-Erbe (entspricht QUERY_RANDOM_HEIR-Prinzip: nur Vorschau)
|
||||
SELECT cr.child_character_id
|
||||
FROM falukant_data.child_relation cr
|
||||
JOIN falukant_data.character c ON c.id = cr.child_character_id
|
||||
WHERE (cr.father_character_id = :character_id OR cr.mother_character_id = :character_id)
|
||||
AND c.health > 0
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 1;
|
||||
|
||||
-- ============================================================
|
||||
-- 1) Queries, die der Daemon im Todespfad nutzt (Vorschau)
|
||||
-- Reihenfolge entspricht Worker-Logik.
|
||||
-- ============================================================
|
||||
|
||||
-- 1.1 get_falukant_user_id
|
||||
SELECT user_id
|
||||
FROM falukant_data.character
|
||||
WHERE id = :character_id;
|
||||
|
||||
-- 1.2 get_heir (bevorzugt is_heir=true)
|
||||
SELECT child_character_id
|
||||
FROM falukant_data.child_relation
|
||||
WHERE father_character_id = :character_id OR mother_character_id = :character_id
|
||||
ORDER BY (is_heir IS TRUE) DESC, updated_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- 1.3 random_heir (wenn 1.2 leer)
|
||||
SELECT cr.child_character_id
|
||||
FROM falukant_data.child_relation cr
|
||||
JOIN falukant_data.character c ON c.id = cr.child_character_id
|
||||
WHERE (cr.father_character_id = :character_id OR cr.mother_character_id = :character_id)
|
||||
AND c.health > 0
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 1;
|
||||
|
||||
-- 1.4 set_character_user (nur wenn Erbe vorhanden)
|
||||
-- UPDATE falukant_data.character
|
||||
-- SET user_id = :user_id, updated_at = NOW()
|
||||
-- WHERE id = :heir_id;
|
||||
|
||||
-- 1.5 Vermögensberechnung: Inputs
|
||||
SELECT money FROM falukant_data.falukant_user WHERE id = :user_id;
|
||||
|
||||
SELECT COALESCE(SUM(h.cost), 0) AS sum
|
||||
FROM falukant_data.user_house uh
|
||||
JOIN falukant_type.house h ON uh.house_type_id = h.id
|
||||
WHERE uh.user_id = :user_id;
|
||||
|
||||
SELECT COALESCE(SUM(b.base_cost), 0) AS sum
|
||||
FROM falukant_data.branch br
|
||||
JOIN falukant_type.branch b ON br.branch_type_id = b.id
|
||||
WHERE br.falukant_user_id = :user_id;
|
||||
|
||||
SELECT COALESCE(SUM(i.quantity * p.sell_cost), 0) AS sum
|
||||
FROM falukant_data.inventory i
|
||||
JOIN falukant_type.product p ON i.product_id = p.id
|
||||
JOIN falukant_data.stock s ON i.stock_id = s.id
|
||||
JOIN falukant_data.branch br ON s.branch_id = br.id
|
||||
WHERE br.falukant_user_id = :user_id;
|
||||
|
||||
SELECT COALESCE(SUM(remaining_amount), 0) AS sum
|
||||
FROM falukant_data.credit
|
||||
WHERE falukant_user_id = :user_id;
|
||||
|
||||
SELECT COUNT(DISTINCT cr.child_character_id) AS cnt
|
||||
FROM falukant_data.child_relation cr
|
||||
JOIN falukant_data.character parent
|
||||
ON (parent.id = cr.father_character_id OR parent.id = cr.mother_character_id)
|
||||
WHERE parent.user_id = :user_id;
|
||||
|
||||
-- 1.6 set_new_money (nur wenn du den errechneten Wert testen willst)
|
||||
-- UPDATE falukant_data.falukant_user
|
||||
-- SET money = '<NEW_MONEY_NUMERIC_STRING>', updated_at = NOW()
|
||||
-- WHERE id = :user_id;
|
||||
|
||||
-- 1.7 delete_director
|
||||
WITH deleted AS (
|
||||
DELETE FROM falukant_data.director
|
||||
WHERE director_character_id = :character_id
|
||||
RETURNING employer_user_id
|
||||
)
|
||||
SELECT * FROM deleted;
|
||||
|
||||
-- 1.8 delete_relationship
|
||||
WITH deleted AS (
|
||||
DELETE FROM falukant_data.relationship
|
||||
WHERE character1_id = :character_id OR character2_id = :character_id
|
||||
RETURNING CASE WHEN character1_id = :character_id THEN character2_id ELSE character1_id END AS related_character_id
|
||||
)
|
||||
SELECT c.user_id AS related_user_id
|
||||
FROM deleted d
|
||||
JOIN falukant_data.character c ON c.id = d.related_character_id;
|
||||
|
||||
-- 1.9 delete_child_relation_by_parent
|
||||
DELETE FROM falukant_data.child_relation
|
||||
WHERE father_character_id = :character_id OR mother_character_id = :character_id;
|
||||
|
||||
-- 1.10 delete_child_relation (als Kind)
|
||||
WITH deleted AS (
|
||||
DELETE FROM falukant_data.child_relation
|
||||
WHERE child_character_id = :character_id
|
||||
RETURNING father_character_id, mother_character_id
|
||||
)
|
||||
SELECT cf.user_id AS father_user_id, cm.user_id AS mother_user_id
|
||||
FROM deleted d
|
||||
JOIN falukant_data.character cf ON cf.id = d.father_character_id
|
||||
JOIN falukant_data.character cm ON cm.id = d.mother_character_id;
|
||||
|
||||
-- 1.11 zusätzliche Cleanup-Queries
|
||||
DELETE FROM falukant_data.knowledge
|
||||
WHERE character_id = :character_id;
|
||||
|
||||
DELETE FROM falukant_data.debtors_prism
|
||||
WHERE character_id = :character_id;
|
||||
|
||||
WITH removed AS (
|
||||
DELETE FROM falukant_data.political_office
|
||||
WHERE character_id = :character_id
|
||||
RETURNING character_id, office_type_id, region_id, created_at
|
||||
),
|
||||
archived_removed AS (
|
||||
INSERT INTO falukant_log.political_office_history
|
||||
(character_id, office_type_id, region_id, start_date, end_date)
|
||||
SELECT character_id, office_type_id, region_id, created_at, NOW()
|
||||
FROM removed
|
||||
),
|
||||
affected AS (
|
||||
SELECT DISTINCT office_type_id, region_id
|
||||
FROM removed
|
||||
),
|
||||
seats AS (
|
||||
SELECT pot.id AS office_type_id, rt.id AS region_id, pot.seats_per_region AS seats_total
|
||||
FROM falukant_type.political_office_type pot
|
||||
JOIN falukant_type.region rt ON pot.region_type = rt.label_tr
|
||||
JOIN affected a ON a.office_type_id = pot.id AND a.region_id = rt.id
|
||||
),
|
||||
ranked AS (
|
||||
SELECT po.id, po.character_id, po.office_type_id, po.region_id, po.created_at, s.seats_total,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY po.office_type_id, po.region_id
|
||||
ORDER BY po.created_at DESC
|
||||
) AS rn
|
||||
FROM falukant_data.political_office po
|
||||
JOIN seats s ON s.office_type_id = po.office_type_id AND s.region_id = po.region_id
|
||||
),
|
||||
to_delete AS (
|
||||
SELECT id, character_id, office_type_id, region_id, created_at
|
||||
FROM ranked
|
||||
WHERE rn > seats_total
|
||||
),
|
||||
archived_trim AS (
|
||||
INSERT INTO falukant_log.political_office_history
|
||||
(character_id, office_type_id, region_id, start_date, end_date)
|
||||
SELECT character_id, office_type_id, region_id, created_at, NOW()
|
||||
FROM to_delete
|
||||
)
|
||||
DELETE FROM falukant_data.political_office
|
||||
WHERE id IN (SELECT id FROM to_delete);
|
||||
|
||||
-- election_candidate ist nicht in allen Bestands-DBs vorhanden:
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'falukant_data'
|
||||
AND table_name = 'election_candidate'
|
||||
) AS election_candidate_table_ready;
|
||||
|
||||
-- Nur ausführen, wenn vorher TRUE:
|
||||
-- DELETE FROM falukant_data.election_candidate
|
||||
-- WHERE character_id = :character_id;
|
||||
|
||||
-- 1.12 final delete_character
|
||||
DELETE FROM falukant_data.character
|
||||
WHERE id = :character_id;
|
||||
|
||||
-- ============================================================
|
||||
-- 2) Nachkontrolle (muss leer / 0 sein)
|
||||
-- ============================================================
|
||||
|
||||
SELECT 1 AS character_still_exists
|
||||
FROM falukant_data.character
|
||||
WHERE id = :character_id;
|
||||
|
||||
SELECT COUNT(*) AS knowledge_left
|
||||
FROM falukant_data.knowledge
|
||||
WHERE character_id = :character_id;
|
||||
|
||||
SELECT COUNT(*) AS debtors_prism_left
|
||||
FROM falukant_data.debtors_prism
|
||||
WHERE character_id = :character_id;
|
||||
|
||||
SELECT COUNT(*) AS political_office_left
|
||||
FROM falukant_data.political_office
|
||||
WHERE character_id = :character_id;
|
||||
|
||||
SELECT COUNT(*) AS election_candidate_left
|
||||
FROM falukant_data.election_candidate
|
||||
WHERE character_id = :character_id;
|
||||
|
||||
SELECT *
|
||||
FROM falukant_log.political_office_history
|
||||
WHERE character_id = :character_id
|
||||
ORDER BY end_date DESC
|
||||
LIMIT 10;
|
||||
|
||||
-- Neuer aktiver Charakter des Users:
|
||||
SELECT c.id, c.user_id, c.health, c.updated_at
|
||||
FROM falukant_data.character c
|
||||
WHERE c.user_id = :user_id
|
||||
ORDER BY c.updated_at DESC, c.id DESC
|
||||
LIMIT 10;
|
||||
|
||||
-- ============================================================
|
||||
-- 3) Unabhängig: completion_count-Fehler für Zertifikat
|
||||
-- ============================================================
|
||||
|
||||
-- 3.1 Existiert Spalte?
|
||||
SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'falukant_log'
|
||||
AND table_name = 'production'
|
||||
AND column_name = 'completion_count';
|
||||
|
||||
-- 3.2 Sofort-Fix (idempotent), falls Spalte fehlt oder unvollständig:
|
||||
ALTER TABLE falukant_log.production
|
||||
ADD COLUMN IF NOT EXISTS completion_count integer;
|
||||
|
||||
UPDATE falukant_log.production
|
||||
SET completion_count = 1
|
||||
WHERE completion_count IS NULL;
|
||||
|
||||
ALTER TABLE falukant_log.production
|
||||
ALTER COLUMN completion_count SET DEFAULT 1,
|
||||
ALTER COLUMN completion_count SET NOT NULL;
|
||||
|
||||
-- 3.3 Optional: Migration historisch sauber markieren/prüfen
|
||||
-- (abhängig davon, wie ihr Migrationsstände verwaltet)
|
||||
@@ -1,59 +0,0 @@
|
||||
# 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“.
|
||||
@@ -1,29 +0,0 @@
|
||||
# Änderungsnotiz (externer Daemon / Übergabe)
|
||||
|
||||
Kurznotiz zur **Abstimmung von Spielzeit, Monatsläufen und Wirtschaftswerten** — für Teams, die den Daemon einbinden oder die Spec pflegen.
|
||||
|
||||
## Zeitmaßstab
|
||||
|
||||
- **Falukant** nutzt einen stark komprimierten Zeitmaßstab: **1 Spieltag = 1 Spieljahr**.
|
||||
- Der **Monatslauf** des Daemons entspricht in dieser Logik **ungefähr einem 2-Stunden-Schritt** (ein „Monatstick“ in der Spielzeit, nicht ein Kalendermonat in der Realität).
|
||||
|
||||
## Dienerschaft & Wirtschaft
|
||||
|
||||
- **Dienerkosten** dürfen **nicht** wie ein realistischer **Vollmonatslohn** skaliert oder interpretiert werden (kein 1:1-Vergleich mit realem Monatsgehalt).
|
||||
- Der in Spec/Datenbank **definierte Monatswert** ist ein **abstrahierter Unterhalts- und Bindungsbetrag pro Monatstick** — er beschreibt die **ökonomische Last** im Spielmodell, nicht einen historischen Lohn.
|
||||
|
||||
## Datenbank
|
||||
|
||||
- Die Datenbank ist für diese Konzeption **bereits vorbereitet**; für diese inhaltliche Festlegung sind **keine zusätzlichen DB-Anpassungen** nötig.
|
||||
|
||||
## Umsetzung im Daemon (YpDaemon)
|
||||
|
||||
- **Gemeinsamer 2-h-Tick:** Dienerschaft (`falukant_servants::run_monthly`) und Liebschafts-Unterhalt (`process_lover_installments`) laufen im selben Intervall (`GAME_MONTH_SLICE_INTERVAL`).
|
||||
- **Dienerschaft:** Abfrage über `servants_last_monthly_at` (alle 2 h fällig); Abbuchung = **1/12** des aus Stammdaten berechneten abstrakten Monatsbudgets.
|
||||
- **Liebschaft:** Abbuchung = **1/12** des Monatsunterhalts; Idempotenz `lover_last_installment_at` (Migration `006`).
|
||||
- **`process_monthly` (selten, ~30 Tage Wandzeit):** nur noch Liebschafts-Monatsmarkierung + Geburten — **keine** Dienerschaft mehr.
|
||||
|
||||
## Siehe auch
|
||||
|
||||
- Technisches Handoff: [`FALUKANT_DAEMON_HANDOFF.md`](./FALUKANT_DAEMON_HANDOFF.md)
|
||||
- Dienerschaft im Daemon: [`FALUKANT_SERVANTS_DAEMON.md`](./FALUKANT_SERVANTS_DAEMON.md)
|
||||
@@ -1,78 +0,0 @@
|
||||
# Falukant: Daemon-Handoff (YpDaemon)
|
||||
|
||||
Technische Abstimmung mit dem Übergabedokument im Backend-Projekt (`FALUKANT_LOVERS_DAEMON_SPEC.md` / `FALUKANT_LOVERS_TECHNICAL_CONCEPT.md`).
|
||||
|
||||
**Ehe & Hausfrieden (Phase A):** [`FALUKANT_MARRIAGE_HOUSEPEACE_DAEMON_HANDOFF.md`](./FALUKANT_MARRIAGE_HOUSEPEACE_DAEMON_HANDOFF.md)
|
||||
|
||||
**Zeitmaßstab & Monatstick (Übergabe extern):** [`FALUKANT_DAEMON_AENDERUNGSNOTIZ_ZEITMASSSTAB.md`](./FALUKANT_DAEMON_AENDERUNGSNOTIZ_ZEITMASSSTAB.md)
|
||||
|
||||
## Abweichungen / Zuordnung
|
||||
|
||||
| Handoff / Backend | YpDaemon |
|
||||
|-------------------|----------|
|
||||
| `relationship_state.marriage_satisfaction` (Ehe) | **`relationship.marriage_satisfaction`** für Zeilen mit `relationship_type` ∈ `married`, `engaged`, `wooing` |
|
||||
| `months_underfunded` | Spalte `months_underfunded` (Migration 001; Legacy: `002` benennt `consecutive_underpayment_months` um) |
|
||||
| Idempotenz `last_daily_processed_at` / `last_monthly_processed_at` | Gesetzt von `FalukantFamilyWorker` pro Liebschaft |
|
||||
| Idempotenz `lover_last_installment_at` (Migration `006`) | Gesetzt alle **2 h** pro Liebschaft nach Unterhalts-Tick (1/12 des Monatsbetrags) |
|
||||
| Idempotenz `servants_last_monthly_at` (Migration `004`) | Gesetzt alle **2 h** pro Haushalt nach Dienerschafts-Tick (1/12 des abstrakten Monatsbudgets), semantisch „Monatstick“ |
|
||||
|
||||
## Ticks
|
||||
|
||||
- **Daily:** nur Zeilen mit `(last_daily_processed_at IS NULL OR last_daily_processed_at::date < CURRENT_DATE)`; danach `last_daily_processed_at = NOW()`.
|
||||
- **Monthly (Kalender / Liebschaft):** nur Zeilen mit `(last_monthly_processed_at IS NULL OR date_trunc('month', last_monthly_processed_at) < date_trunc('month', CURRENT_TIMESTAMP))`; danach `last_monthly_processed_at = NOW()` — **ohne** Liebschafts-Geld; Monatsstand/Schwangerschafts-Logik + `process_lover_births`.
|
||||
- **Monatstick (~2 h, Spielzeit):** gemeinsamer Worker-Tick für **Dienerschaft** (`servants_last_monthly_at`, Abfrage alle 2 h) und **Liebschaft** (`lover_last_installment_at`). Beträge = **1/12** des abstrakten „Monats“-Budgets pro Rolle (12 Monatsticke pro **Spieltag** = 1 Spieljahr). `money_history`: `servants_monthly` bzw. `lover maintenance`. Siehe [`FALUKANT_DAEMON_AENDERUNGSNOTIZ_ZEITMASSSTAB.md`](./FALUKANT_DAEMON_AENDERUNGSNOTIZ_ZEITMASSSTAB.md).
|
||||
|
||||
**Hinweis:** Der Worker nutzt weiterhin **Wandzeit** (24 h / 30 Tage / **2 h** Unterhalt) als Intervall; die Idempotenz über die Zeitstempel verhindert Doppelverarbeitung bei Neustarts am selben Tag/Monat bzw. im selben 2-h-Fenster.
|
||||
|
||||
## WebSocket-Events (UI)
|
||||
|
||||
Pro betroffener `falukant_user.id` werden über den **MessageBroker** (Broadcast an alle WS-Clients) gesendet:
|
||||
|
||||
| Event | Payload (Beispiel) | Wann |
|
||||
|-------|-------------------|------|
|
||||
| `falukantUpdateFamily` | `{"event":"falukantUpdateFamily","user_id":N,"reason":"…"}` | Familie/Liebe relevant |
|
||||
| `falukantUpdateStatus` | `{"event":"falukantUpdateStatus","user_id":N}` | Immer gleich mit `falukantUpdateFamily` (Refresh) |
|
||||
| `children_update` | `{"event":"children_update","user_id":N}` | Kind aus Liebschaft |
|
||||
| `falukant_family_scandal_hint` | `{"event":"falukant_family_scandal_hint","relationship_id":…}` | Skandal (ohne `user_id`) |
|
||||
|
||||
**`reason`** bei `falukantUpdateFamily`: `daily`, `monthly`, `lover_installment`, `scandal`, `lover_birth`.
|
||||
|
||||
Die UI kann auf `falukantUpdateFamily` filtern und nach `reason` unterscheiden; `falukantUpdateStatus` wie bisher für allgemeinen Daten-Refresh nutzen.
|
||||
|
||||
**Detaillierte UI-Anleitung (Payloads, Handler, Checkliste):** [`FALUKANT_UI_WEBSOCKET.md`](./FALUKANT_UI_WEBSOCKET.md)
|
||||
|
||||
## Altersregeln (Spec-Erweiterung, im Daemon umgesetzt)
|
||||
|
||||
`min_age_years` = jüngeres Alter beider Partner in **ganzen Jahren** (`LEAST(…/365, …/365)` aus `birthdate`).
|
||||
|
||||
| Bereich | Ansehen (zusätzl. zur Basisformel, Spec 5a) | Ehezufriedenheit (Spec §3 neg.) | Skandalrisiko (+ %, exkl. Stufen) |
|
||||
|---------|---------------------------------------------|----------------------------------|-----------------------------------|
|
||||
| ≤ 13 | −1,5 / Tag | −1 / Tag bei aktiver Berührung | +6 % |
|
||||
| ≤ 15 | −0,8 / Tag | −1 / Tag (zusätzlich zu anderen Malus) | +3 % |
|
||||
| ≤ 17 | −0,3 / Tag | — | +1 % |
|
||||
| ≥ 18 | 0 | — | 0 |
|
||||
|
||||
Zusätzlich: wenn `min_age_years ≤ 15` **und** `visibility ≥ 50`: weiterer Ansehens-Malus **−0,5** / Tag.
|
||||
|
||||
Ehe-Malus „≤ 15“ gilt pro Ehe, wenn **irgendeine** berührende Liebschaft dieses Altersprofils hat.
|
||||
|
||||
## Migrationen
|
||||
|
||||
1. `migrations/001_falukant_family_lovers.sql`
|
||||
2. Optional: `migrations/002_falukant_family_rename_legacy_columns.sql` bei Altbestand
|
||||
3. `migrations/003_falukant_family_marriage_buffs.sql` — Ehe-Buffs (`marriage_gift_buff_days_remaining`, `marriage_pending_feast_bonus`, `marriage_house_supply`, `marriage_no_lover_bonus_counter`); Daily-Tick schreibt Zufriedenheit + Zähler via `QUERY_UPDATE_MARRIAGE_STATE_AND_BUFFS`.
|
||||
4. `migrations/004_falukant_servants_daemon.sql` — Dienerschaft: Tick-Idempotenz + `servant_discretion_modifier` (Stammdaten-Dienerfelder kommen aus dem Backend). Siehe [`FALUKANT_SERVANTS_DAEMON.md`](./FALUKANT_SERVANTS_DAEMON.md).
|
||||
5. `migrations/005_falukant_marriage_housepeace.sql` — `relationship.marriage_public_stability`, `user_house.household_tension_score`. Siehe [`FALUKANT_MARRIAGE_HOUSEPEACE_DAEMON_HANDOFF.md`](./FALUKANT_MARRIAGE_HOUSEPEACE_DAEMON_HANDOFF.md).
|
||||
6. `migrations/006_falukant_lover_installments.sql` — `relationship_state.lover_last_installment_at` (Unterhalt 12× pro Spieltag).
|
||||
|
||||
### Ehe-Buffs (Daemon)
|
||||
|
||||
- **Geschenk:** Backend setzt `marriage_gift_buff_days_remaining` (z. B. 5); Daemon **+1** Zufriedenheit/Tag, Zähler −1.
|
||||
- **Fest:** `marriage_pending_feast_bonus` einmalig (typ. +2…+5, max. 5 pro Tick) beim Daily verbrauchen.
|
||||
- **Haus:** `marriage_house_supply ≥ 65` und **keine** berührende Liebschaft: alle **4** Tage **+1** Zufriedenheit (`marriage_no_lover_bonus_counter`).
|
||||
|
||||
### Ansehen (Daily)
|
||||
|
||||
- **Ranggruppe 3:** Reputations-Multiplikator **0,7** nur bei „geordneter“ Liebschaft (`order_ok`: u. a. Unterhalt ≥ 65, Diskretion ≥ 60, Sichtbarkeit ≤ 35, höchstens eine Mätresse im Umfeld des Paares); sonst **1,0**; bei Skandal **1,5**.
|
||||
- **Zwei sichtbare Liebschaften** (`visibility ≥ 60`, mindestens zwei Beziehungen pro Charakter): zusätzlich **−4** Ansehen (einmal pro Person/Tag).
|
||||
- **Zufall:** selten **Gerücht −3** oder **Tadel −5** (niedrige Wahrscheinlichkeit) für Charaktere mit Liebschaftsbezug.
|
||||
@@ -1,110 +0,0 @@
|
||||
# Falukant Smoke-Test: Tod -> Erbwechsel -> UI-Status
|
||||
|
||||
Dieser Smoke-Test validiert den kritischen Ablauf:
|
||||
|
||||
- Spieler-Charakter stirbt
|
||||
- Erbe wird gesetzt
|
||||
- alte charakterbezogene Daten sind bereinigt
|
||||
- UI bekommt verlässlich den Status-Refresh
|
||||
|
||||
## Ziel
|
||||
|
||||
Verhindern, dass nach Tod/Erbe ein Mischzustand entsteht
|
||||
(z. B. Erben-Bild, aber alte Statusdaten des verstorbenen Charakters).
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Daemon läuft mit aktuellen Änderungen.
|
||||
- Zugriff auf DB (psql).
|
||||
- Zugriff auf WebSocket-Eventstream oder Daemon-Log.
|
||||
- Ein Test-User mit:
|
||||
- aktivem Spieler-Charakter (`user_id` gesetzt),
|
||||
- mindestens einem potenziellen Erben in `child_relation`.
|
||||
|
||||
## Testfall A: Tod über `UserCharacterWorker` (health <= 0)
|
||||
|
||||
1. Testkandidaten wählen:
|
||||
|
||||
```sql
|
||||
SELECT c.id AS character_id, c.user_id
|
||||
FROM falukant_data.character c
|
||||
WHERE c.user_id IS NOT NULL
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
2. Einen Charakter künstlich auf `health = 0` setzen:
|
||||
|
||||
```sql
|
||||
UPDATE falukant_data.character
|
||||
SET health = 0, updated_at = NOW()
|
||||
WHERE id = <CHARACTER_ID>;
|
||||
```
|
||||
|
||||
3. Worker laufen lassen (Death-Check oder nächster Verarbeitungstakt).
|
||||
|
||||
4. WebSocket/Log prüfen, erwartet:
|
||||
- `CharacterDeath` mit altem `character_id`
|
||||
- `falukantUpdateStatus` mit `user_id` des betroffenen Spielers
|
||||
|
||||
5. DB prüfen, erwartet:
|
||||
|
||||
```sql
|
||||
-- alter Charakter weg
|
||||
SELECT 1
|
||||
FROM falukant_data.character
|
||||
WHERE id = <CHARACTER_ID>;
|
||||
|
||||
-- Spieler zeigt auf Erben
|
||||
SELECT c.id, c.user_id
|
||||
FROM falukant_data.character c
|
||||
WHERE c.user_id = <USER_ID>;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Todes-Cleanup (sollte leer sein)
|
||||
SELECT COUNT(*) FROM falukant_data.knowledge WHERE character_id = <CHARACTER_ID>;
|
||||
SELECT COUNT(*) FROM falukant_data.debtors_prism WHERE character_id = <CHARACTER_ID>;
|
||||
SELECT COUNT(*) FROM falukant_data.political_office WHERE character_id = <CHARACTER_ID>;
|
||||
SELECT COUNT(*) FROM falukant_data.election_candidate WHERE character_id = <CHARACTER_ID>;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- politische Ämter historisiert (falls vorher vorhanden)
|
||||
SELECT *
|
||||
FROM falukant_log.political_office_history
|
||||
WHERE character_id = <CHARACTER_ID>
|
||||
ORDER BY end_date DESC
|
||||
LIMIT 5;
|
||||
```
|
||||
|
||||
Passkriterium:
|
||||
- alter Charakter gelöscht,
|
||||
- Nachfolger gesetzt,
|
||||
- Cleanup-Counts = 0,
|
||||
- `falukantUpdateStatus` gesendet.
|
||||
|
||||
## Testfall B: Tod über `EventsWorker`
|
||||
|
||||
Der gleiche Prüfsatz wie in Testfall A, aber mit Tod aus Event-Pfad.
|
||||
|
||||
Hinweis:
|
||||
- Falls kein reproduzierbares Zufallsereignis vorliegt, kann derselbe technische Endzustand
|
||||
pragmatisch über A validiert werden.
|
||||
- Zusätzlicher Fokus hier: auch im Event-Pfad müssen Cleanup + Status-Refresh identisch sein.
|
||||
|
||||
## UI-Schnellcheck
|
||||
|
||||
Nach Eintreffen von `falukantUpdateStatus`:
|
||||
|
||||
- Avatar/Bild entspricht dem Erben.
|
||||
- Statusdaten (Familie/Politik/bezogene Anzeigen) zeigen keine Relikte des Verstorbenen.
|
||||
- Kein inkonsistenter Mischzustand nach manuellem Reload.
|
||||
|
||||
## Regression-Checkliste
|
||||
|
||||
- [ ] `CharacterDeath` kommt genau für den verstorbenen Charakter.
|
||||
- [ ] `falukantUpdateStatus` kommt nach Erbwechsel für den betroffenen User.
|
||||
- [ ] `knowledge`/`debtors_prism`/`political_office`/`election_candidate` sind bereinigt.
|
||||
- [ ] `political_office_history` enthält entfernte Ämter (falls vorhanden).
|
||||
- [ ] UI bleibt konsistent (Bild + Status gehören zum selben Charakter).
|
||||
@@ -1,29 +0,0 @@
|
||||
# Falukant: Schuldturm & Pfändung (Daemon)
|
||||
|
||||
Der externe Daemon (`YpDaemon`) pflegt Verzugstage, Schuldturm-Eintritt, Pfändung und Freilassung. **Keine zusätzliche Migration** im Daemon nötig – Spalten in `falukant_data.debtors_prism` kommen vom Projekt.
|
||||
|
||||
## Wo im Code
|
||||
|
||||
| Komponente | Datei |
|
||||
|------------|--------|
|
||||
| SQL | `src/worker/sql.rs` (`QUERY_DEBTORS_*`) |
|
||||
| Tageslogik | `src/worker/falukant_debtors.rs` |
|
||||
| Stündliche Kreditrate + Verzug-Reset bei Zahlung | `src/worker/user_character.rs` |
|
||||
| Daily-Tick (24 h) | `src/worker/falukant_family.rs` → `falukant_debtors::run_daily` |
|
||||
|
||||
## Ablauf
|
||||
|
||||
1. **Stündlich**: `handle_credits` zieht Rate, wenn genug Geld. Bei Erfolg: `falukant_debtors::on_credit_payment_success` setzt `days_overdue` für `status = delinquent` zurück.
|
||||
2. **Täglich** (gleicher Rhythmus wie Falukant-Family-Daily): Nutzer mit offenem Kredit → Verzug erhöhen / Warnstufen / ab Tag 3 Eintritt `imprisoned` → Geld- und Fahrzeugverwertung → soziale Haftfolgen.
|
||||
|
||||
## Events (WebSocket)
|
||||
|
||||
Primär: `falukantUpdateDebt` mit `reason` (siehe Projektspezifikation). Begleitend: `falukantUpdateStatus`, `falukantUpdateFamily`, `falukantHouseUpdate`, `falukantBranchUpdate` je nach Schritt.
|
||||
|
||||
## Noch offen (größere Versionen)
|
||||
|
||||
- Waren/Lager, Hauspfändung, Niederlassungsschließung
|
||||
- `household_tension_reasons_json` um `debtorsPrison` ergänzen
|
||||
- Beziehungsabbruch nach Spec (Schwellen / Zufall)
|
||||
|
||||
Siehe die vollständige fachliche Spezifikation im Projektdokument „Schuldturm und Pfändung“.
|
||||
@@ -1,31 +0,0 @@
|
||||
# Director: Auto-Adjust Income
|
||||
|
||||
Dieses Feature koppelt das Gehalt (`income`) eines Direktors optional automatisch an sein Wunschgehalt (`wished_income`).
|
||||
|
||||
## Aktivierung
|
||||
|
||||
Feld am Director:
|
||||
|
||||
- `auto_adjust_income` (`boolean`, default `false`)
|
||||
|
||||
Bei `false` bleibt die normale Zufriedenheitslogik aktiv.
|
||||
|
||||
## Verhalten im Daemon
|
||||
|
||||
Die Prüfung läuft im täglichen Director-Satisfaction-Tick.
|
||||
|
||||
- Wenn `auto_adjust_income = true`:
|
||||
- der Daemon setzt `income = wished_income` (untere Grenze `0`).
|
||||
- die Zufriedenheit wird in diesem Tick **nicht** abgesenkt.
|
||||
- Wenn `auto_adjust_income = false`:
|
||||
- Zufriedenheit sinkt täglich abhängig vom Gehaltsgap um `1..3` Punkte.
|
||||
|
||||
## Zusammenhang mit Kündigungen
|
||||
|
||||
- Kündigungsprüfung bleibt separat (daily resignation check).
|
||||
- Zusätzlich gilt Karenzzeit: kein Rücktritt in den ersten 3 Tagen nach Einstellung/Update.
|
||||
|
||||
## UI-/API-Hinweis
|
||||
|
||||
Der Toggle „Gehalt automatisch anpassen“ wird über die bestehenden Director-Settings gespeichert.
|
||||
Nach erfolgreicher Änderung aktualisiert der nächste tägliche Tick den effektiven `income`-Wert.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Director: Stückkosten Produktion (Balancing)
|
||||
|
||||
## Was der Daemon **nicht** macht (häufige Missverständnisse)
|
||||
|
||||
- **Nicht** `6 × Kategorie` (also kein reiner Multiplikator nur aus der Klassennummer).
|
||||
- **Nicht** `2 × Kategorie` — im **ypdaemon**-Stand gibt es diese Formel **nicht**.
|
||||
- **Nicht** „höheres Zertifikat ⇒ höhere Stückkosten“: Das Zertifikat **begrenzt** nur die Produktpalette (`ftp.category <= certificate`); es wird **nicht** als `Zertifikat × 6` o. Ä. auf die Kosten drauf multipliziert.
|
||||
|
||||
Die **kanonische** Berechnung lebt in Rust: `DirectorWorker::piece_production_cost` in `src/worker/director.rs` (gleiche Logik beim **Produktionsstart** und bei der **Kostenbasis für Steuer/Marge beim Verkauf** über `resolve_production_piece_cost`).
|
||||
|
||||
## Modell
|
||||
|
||||
- **`falukant_user.certificate`** begrenzt nur, **welche** Produkte wählbar sind (`ftp.category <= certificate` in `QUERY_GET_BEST_PRODUCTION`). Es gibt **keine** höheren Stückkosten nur wegen eines höheren Zertifikats.
|
||||
- Die **Stückkosten** hängen von der **Produktklasse** (`falukant_type.product.category`) und einer **Basis** ab; optional **Headroom-Rabatt**, wenn das Zertifikat **über** der Klasse des produzierten Guts liegt (Erfahrung/Reserve, nicht „Strafe“ fürs Zertifikat).
|
||||
|
||||
## Formel (Rust, `DirectorWorker`)
|
||||
|
||||
```
|
||||
raw = PRODUCTION_COST_BASE + product_category × PRODUCTION_COST_PER_PRODUCT_CATEGORY
|
||||
headroom = max(0, certificate − product_category)
|
||||
discount = min(headroom × PRODUCTION_HEADROOM_DISCOUNT_PER_STEP, PRODUCTION_HEADROOM_DISCOUNT_CAP)
|
||||
effektiv_stückkosten = raw × (1 − discount)
|
||||
```
|
||||
|
||||
**Ausgeschrieben mit den Standardkonstanten:**
|
||||
`raw = 6 + 1 × product_category` (Kategorie mindestens 1), dann Rabatt nur bei Headroom.
|
||||
|
||||
**Beispiele** (Standardkonstanten, ohne Rundung):
|
||||
|
||||
| Kategorie | Zertifikat | raw | Headroom | effektiv (ca.) |
|
||||
|-----------|------------|-----|----------|----------------|
|
||||
| 1 | 4 | 7 | 3 | 7 × (1 − min(0,105; 0,14)) ≈ **6,27** |
|
||||
| 4 | 4 | 10 | 0 | **10** |
|
||||
|
||||
**Chargenkosten** beim Start einer Linie: `effektiv_stückkosten × Stückzahl` (Stückzahl bis 100 pro Linie, siehe `create_single_production`).
|
||||
|
||||
| Konstante | Standard | Bedeutung |
|
||||
|-----------|----------|-----------|
|
||||
| `PRODUCTION_COST_BASE` | `6.0` | fixer Basisanteil pro Stück |
|
||||
| `PRODUCTION_COST_PER_PRODUCT_CATEGORY` | `1.0` | Aufschlag pro Produktklasse (additiv zu 6, **nicht** „6×Kategorie“) |
|
||||
| `PRODUCTION_HEADROOM_DISCOUNT_PER_STEP` | `0.035` | Rabatt pro Headroom-Stufe |
|
||||
| `PRODUCTION_HEADROOM_DISCOUNT_CAP` | `0.14` | maximaler Gesamtrabatt |
|
||||
|
||||
## Ranking in SQL (`QUERY_GET_BEST_PRODUCTION`)
|
||||
|
||||
Sortierung nach einer **Legacy/UI-Formel** (historisch C++), damit die Reihenfolge zur **Produkt-Ertrags-Tabelle** passt — **nicht** identisch mit dem tatsächlichen Verkauf im Tick (`compute_piece_sell_price` in `director.rs`).
|
||||
|
||||
1. **Effektiver Markt-Prozentsatz:** `worth_percent + (2 × knowledge Spielercharakter + knowledge Direktor) / 3`, dann `× sell_cost / 100`. `worth_percent`: mit Fahrzeug `MAX(worth_percent)` über alle Filialregionen des Users, sonst nur Region der Direktor-Filiale.
|
||||
2. **Spielercharakter:** genau **einer** je User (`character.id` maximal unter `health > 0`), nicht alle `JOIN character ON user_id`.
|
||||
3. **Kosten im Ranking:** **`6 × category`** (linear, ohne Headroom-Rabatt wie bei `piece_production_cost`).
|
||||
4. **Formel:** `(sell_cost × (effektiver Prozentsatz) / 100 − 6 × category) / (300 × production_time)`. **Steuer** im Ranking nicht abgezogen.
|
||||
|
||||
**Hinweis:** Echter Stückpreis und Buchführung nutzen weiter `piece_production_cost` und das 60 %–100 %-Band auf der Basis — nur die **Auswahl „beste Produktion“** folgt der Legacy-Formel, damit Daemon und UI dieselbe Rangfolge zeigen.
|
||||
|
||||
## Parallelproduktionen
|
||||
|
||||
`MAX_PARALLEL_PRODUCTIONS` (aktuell 2) bestimmt, wie viele Linien pro Tick Geld binden — unabhängig von der Stückkostenformel.
|
||||
@@ -1,156 +0,0 @@
|
||||
# Falukant: Daemon-Handoff für Ehe und Hausfrieden
|
||||
|
||||
Dieses Dokument beschreibt den Stand nach Phase A und die Rolle des externen Daemons (Übergabe Backend ↔ YpDaemon).
|
||||
|
||||
## 1. Was im Projekt jetzt vorhanden ist
|
||||
|
||||
Backend-/API-seitig vorhanden:
|
||||
|
||||
- `relationship_state.marriage_satisfaction`
|
||||
- `relationship_state.marriage_public_stability`
|
||||
- aktive Liebschaften mit:
|
||||
- `visibility`
|
||||
- `discretion`
|
||||
- `maintenance_level`
|
||||
- `status_fit`
|
||||
- `months_underfunded`
|
||||
- `acknowledged`
|
||||
- `user_house` mit:
|
||||
- `servant_count`
|
||||
- `servant_quality`
|
||||
- `servant_pay_level`
|
||||
- `household_order`
|
||||
- Family-API liefert jetzt zusätzlich:
|
||||
- `householdTension`
|
||||
- `householdTensionScore`
|
||||
- `householdTensionReasons`
|
||||
|
||||
Direkte Spieleraktionen vorhanden:
|
||||
|
||||
- `POST /api/falukant/family/marriage/spend-time`
|
||||
- `POST /api/falukant/family/marriage/gift`
|
||||
- `POST /api/falukant/family/marriage/reconcile`
|
||||
- `POST /api/falukant/houses/order`
|
||||
|
||||
## 2. Daily-Input für den externen Daemon
|
||||
|
||||
Pro betroffenem Falukant-User:
|
||||
|
||||
- `falukant_user.id`
|
||||
- `user.id` / `user.hashed_id`
|
||||
- aktive Ehe-`relationship` mit `relationship_state`
|
||||
- aktive Liebschaften mit `relationship_state`
|
||||
- Kinder mit:
|
||||
- `birth_context`
|
||||
- `legitimacy`
|
||||
- `public_known`
|
||||
- Haus mit:
|
||||
- `servant_count`
|
||||
- `servant_quality`
|
||||
- `servant_pay_level`
|
||||
- `household_order`
|
||||
- Charakter mit:
|
||||
- `reputation`
|
||||
- `title_of_nobility`
|
||||
|
||||
## 3. Was der Daemon täglich berechnen soll
|
||||
|
||||
### Ehe
|
||||
|
||||
- Drift von `marriage_satisfaction`
|
||||
- Drift von `marriage_public_stability`
|
||||
- Einfluss aus:
|
||||
- sichtbaren Liebschaften
|
||||
- unterfinanzierten Liebschaften
|
||||
- Standesunterschieden
|
||||
- Dienerschaft / Haushaltsordnung
|
||||
- zu jungen Liebschaften
|
||||
|
||||
### Hausfrieden
|
||||
|
||||
Der Daemon soll intern einen numerischen Spannungswert pflegen oder berechnen:
|
||||
|
||||
- `householdTensionScore` `0..100`
|
||||
|
||||
Einflussfaktoren:
|
||||
|
||||
- sichtbare Liebschaften
|
||||
- anerkannte Liebschaften
|
||||
- unterfinanzierte Liebschaften
|
||||
- Kinder aus Liebschaften
|
||||
- Haushaltsordnung
|
||||
- Dienerschaft
|
||||
- schwache Ehe
|
||||
|
||||
UI-Ableitung:
|
||||
|
||||
- `0..24` => `low`
|
||||
- `25..59` => `medium`
|
||||
- `60..100` => `high`
|
||||
|
||||
## 4. Was der Daemon zurückschreiben soll
|
||||
|
||||
Pflicht:
|
||||
|
||||
- `relationship_state.marriage_satisfaction`
|
||||
- `relationship_state.marriage_public_stability`
|
||||
- lover-state-Felder bei Änderungen:
|
||||
- `visibility`
|
||||
- `discretion`
|
||||
- `months_underfunded`
|
||||
- optional `notes_json` / `flags_json`
|
||||
- falls eigener Persistenzwert eingeführt wird:
|
||||
- `household_tension_score`
|
||||
|
||||
Wenn kein eigener Persistenzwert eingeführt wird:
|
||||
|
||||
- der Daemon darf den Spannungswert auch nur berechnen
|
||||
- die API kann ihn weiterhin aus Ehe, Liebschaften, Kindern und Haus ableiten
|
||||
|
||||
## 5. Socket-/Refresh-Verhalten
|
||||
|
||||
Wenn Daily-/Monthly-Verarbeitung Ehe oder Hausfrieden betrifft:
|
||||
|
||||
- `falukantUpdateFamily` mit `reason: "daily"` oder `reason: "monthly"`
|
||||
- danach `falukantUpdateStatus`
|
||||
|
||||
Wenn ein Sonderereignis entsteht:
|
||||
|
||||
- `reason: "scandal"` zusätzlich
|
||||
|
||||
## 6. Wichtige Phase-A-Regel
|
||||
|
||||
Die neuen Direktaktionen geben nur Sofortimpulse:
|
||||
|
||||
- `spend-time`
|
||||
- `gift`
|
||||
- `reconcile`
|
||||
- `house/order`
|
||||
|
||||
Der Daemon ist weiterhin verantwortlich für:
|
||||
|
||||
- Rückdrift
|
||||
- Gegenkräfte
|
||||
- Langzeiteffekte
|
||||
- Balancing
|
||||
|
||||
Kurz:
|
||||
|
||||
- UI/Backend setzen kleine direkte Impulse
|
||||
- der Daemon bestimmt die dauerhafte Entwicklung
|
||||
|
||||
---
|
||||
|
||||
## Anhang: Abgleich YpDaemon (dieses Repo)
|
||||
|
||||
| Thema | Stand in YpDaemon |
|
||||
|-------|---------------------|
|
||||
| Ehe-Zufriedenheit, Buffs, Drift (`marriage_drift_*`) | `FalukantFamilyWorker` + Migrationen `001`/`003`, siehe [`FALUKANT_DAEMON_HANDOFF.md`](./FALUKANT_DAEMON_HANDOFF.md) |
|
||||
| **`marriage_public_stability`** (Daily-Drift) | Migration **`005`**, `QUERY_GET_MARRIAGE_ROWS` / `QUERY_UPDATE_MARRIAGE_STATE_AND_BUFFS`, Logik in `falukant_family.rs` (Einfluss: sichtbare/unterfinanzierte/anerkannte Liebschaften, Stand, Alter, Haushalt/Diener, schwache Ehe) |
|
||||
| **`household_tension_score`** (0..100) | Migration **`005`**, berechnet im Daily-Tick, persistiert in `user_house`; UI-Band **low/medium/high** weiterhin aus Score ableitbar (0–24 / 25–59 / 60–100) |
|
||||
| Liebschaften (visibility, discretion, …, **`acknowledged`**, **`months_underfunded`**) | Daily-Query erweitert; Tension-Aggregation nutzt diese Felder |
|
||||
| Dienerschaft / `household_order` | `falukant_servants.rs` + Migration `004`; zusätzlich in Ehe-Stabilität und Haus-Spannung |
|
||||
| WebSocket | Bei Änderung von Ehe oder Spannung: `falukantUpdateFamily` mit `reason: "daily"` (wie bisher) |
|
||||
| **HTTP-Routen** (`spend-time`, `gift`, …) | Liegen im **Backend** (nicht im Daemon-Repo) |
|
||||
|
||||
Verwandte Doku: [`FALUKANT_UI_WEBSOCKET.md`](./FALUKANT_UI_WEBSOCKET.md), [`FALUKANT_SERVANTS_DAEMON.md`](./FALUKANT_SERVANTS_DAEMON.md).
|
||||
@@ -1,67 +0,0 @@
|
||||
# Falukant: Produktionszertifikate (Daemon)
|
||||
|
||||
Die Zertifikatslogik läuft im **FalukantFamilyWorker** (`run_iteration`) **etwa 1× pro Minute** (`CERTIFICATE_RECALC_INTERVAL`), **nicht** in einem eigenen Worker-Thread. Der **24h-Daily-Block** enthält weiter Schuldturm und (bei Schema) Liebhaber/Ehe/Monatslogik — Zertifikat ist davon **getrennt**. Sie schreibt `falukant_user.certificate` fort (max. **+1** pro erfolgreicher Prüfung, keine normale Herabstufung außer Bankrott / Erbfolge).
|
||||
|
||||
Implementierung: `src/worker/falukant_certificate.rs` (`run_daily`).
|
||||
|
||||
## SQL
|
||||
|
||||
- `QUERY_GET_PRODUCTION_CERTIFICATE_INPUT_ROWS` – Eingangsdaten je Falukant-User (Spielercharakter, Wissen, Produktionen, Ämter, Haus …)
|
||||
- `QUERY_UPDATE_FALUKANT_USER_CERTIFICATE` – Stufe + **`certificate_productions_count_since = NOW()`** (Migration `014`)
|
||||
|
||||
## Logik (Kurz, Spec §4)
|
||||
|
||||
- **certificateScore** (Gewichte): Wissen 0,45 · Produktion 0,30 · Amt 0,08 · Adel 0,05 · Ruf 0,07 · Haus 0,05
|
||||
- **raw_target** aus Score-Schwellen: **<0,9** → 1, **≥0,9** → 2, **≥1,8** → 3, **≥2,8** → 4, **≥3,8** → 5
|
||||
- **effective_target** mit Mindestanforderungen je Stufe (Spec §4.5)
|
||||
- Aufstieg nur wenn `effective_target > current` → **`current + 1`** (gegen `effective_target` und 5 begrenzt)
|
||||
- **Bankrott** (`money <= -5000`): Zertifikat auf **1**, mit Event
|
||||
|
||||
### Wichtig: UI vs. Daemon („48 h kein Aufstieg“)
|
||||
|
||||
Die **Mindestanforderungen** (z. B. Wissen ≥ 28, Produktionen ≥ 15 für Stufe 3) sind **nur ein Teil**. Zusätzlich gilt eine **Obergrenze aus der gewichteten Wertung** (`certificateScore` → `raw_target`): Es wird die **höchste Stufe ≤ `raw_target`**, die **alle** Mindestanforderungen erfüllt (`effective_certificate_target` in `falukant_certificate.rs`).
|
||||
|
||||
Typische Folge: Die UI zeigt **nur** zwei grüne Häkchen (Wissen/Produktionen), der Spieler bleibt aber auf Stufe 2, weil:
|
||||
|
||||
1. **`raw_target` = 2** (Wertung **unter** 1,8) — dann ist Stufe 3 **fachlich ausgeschlossen**, auch wenn die Mindestzahlen für Stufe 3 erfüllt sind. Oft liegt die Wertung knapp unter 1,8, wenn z. B. **Produktionspunkte** im Daemon niedrig sind (Bucket < 20 abgeschlossene Produktionen in `falukant_log.production` trotz höherer Anzeige in der UI).
|
||||
2. **Abweichende Eingangsdaten** gegenüber der UI: anderer gewählter Charakter (`DISTINCT ON … c.id DESC`), andere Zählung `falukant_log.production` (`producer_id`), Geld/Bankrott, etc.
|
||||
|
||||
**Diagnose:** Daemon mit `YPDAEMON_CERT_VERBOSE=1` starten. Wenn jemand die Mindestanforderungen für die **nächste** Stufe erfüllt, aber **nicht** aufsteigt, erscheint eine Zeile `[falukant_certificate] fu_id=… bleibt auf Stufe …` mit `certificate_score`, `raw_target`, `effective_target` und Rohwerten.
|
||||
|
||||
**SQL:** `QUERY_GET_PRODUCTION_CERTIFICATE_INPUT_ROWS` in `src/worker/sql.rs` — für einen Betroffenen `falukant_user.id` filtern und mit der UI abgleichen.
|
||||
|
||||
## Politische Ämter
|
||||
|
||||
Rang aus **`political_office_type.name`** (Substring-Heuristik im Daemon, ohne DB-Änderung). Anpassung über `political_name_to_rank` in `falukant_certificate.rs`.
|
||||
|
||||
## Kirchliche Ämter
|
||||
|
||||
`officePoints` aus **`max(hierarchy_level)`** der aktiven `church_office`-Zeilen (gekappt 0–5).
|
||||
|
||||
## Abgeschlossene Produktionen
|
||||
|
||||
**`SUM(completion_count)`** aus `falukant_log.production` mit `producer_id = falukant_user.id` **oder** `character.id`, und **Zeitstempel** `>= certificate_productions_count_since` (Spalte auf `falukant_user`, Migration **`014`**). **`completion_count`** (Migration **`015`**): pro abgeschlossener Produktion +1; das Log **aggregiert** noch immer pro Tag/Produkt/Region (`quantity` per UPSERT), daher reicht **`COUNT(*)`** der Zeilen nicht — ohne Summe bliebe der Zähler bei vielen Abschlüssen gleich. **`NULL`** bei `certificate_productions_count_since`: alle passenden Log-Zeilen (Bestand bis zur ersten Stufenänderung nach Migration).
|
||||
|
||||
Bei jedem **Aufstieg**, **Bankrott** (Stufe 1) und **Erbfolge ohne Erben** setzt der Daemon **`certificate_productions_count_since = NOW()`** — die Mindestanforderungen für die **nächste** Stufe gelten damit nur für **neu** abgeschlossene Produktionen. **Logs werden dafür nicht gelöscht** (bleiben u. a. für Wissens-Updates / Preise); optional räumt `QUERY_DELETE_OLD_PRODUCTIONS` nur sehr alte Zeilen auf (aktuell **30 Tage** Retention, Speicherbegrenzung).
|
||||
|
||||
**UI:** Dieselbe Filterlogik wie der Daemon verwenden (`>= certificate_productions_count_since`), sonst weichen Anzeige und Aufstieg voneinander ab.
|
||||
|
||||
## Gewählter Charakter pro User
|
||||
|
||||
Bei mehreren lebenden Charakteren: **`DISTINCT ON (fu.id) … ORDER BY fu.id, c.id DESC`** — der Charakter mit der **höchsten** `character.id` (typisch zuletzt genutzter Slot), damit Wissen/Ruf/Ämter näher an der UI liegen.
|
||||
|
||||
## Events (WebSocket)
|
||||
|
||||
Bei Änderung der Stufe:
|
||||
|
||||
1. `falukantUpdateProductionCertificate` mit `reason`, `old_certificate`, `new_certificate`
|
||||
2. `falukantUpdateStatus`
|
||||
|
||||
**`reason`:** `daily_recalculation` (normaler Aufstieg), `bankruptcy` (Geld ≤ −5000), `succession_no_heir` (Tod ohne Erben → Stufe 1).
|
||||
|
||||
`user_id` in den Events: **`app_user_id`** aus der Query (`COALESCE(fu.user_id, fu.id)`), sonst Fallback `falukant_user_id`.
|
||||
|
||||
## Nicht umgesetzt (optional / später)
|
||||
|
||||
- Feinere **Bankrott**-Definition
|
||||
- **`political_office_history`** (nicht im Repo)
|
||||
@@ -1,31 +0,0 @@
|
||||
# Falukant: Dienerschaft im YpDaemon
|
||||
|
||||
Umsetzung gemäß Projektspezifikation (Daily/Monthly, Handoff).
|
||||
|
||||
**Wichtig (Spielzeit / Kosten):** Falukant arbeitet mit **1 Spieltag = 1 Spieljahr**; Monatsläufe entsprechen **ungefähr einem 2-h-Schritt**. Dienerkosten sind **kein** realistischer Vollmonatslohn, sondern ein **abstrakter Monatstick** — siehe [`FALUKANT_DAEMON_AENDERUNGSNOTIZ_ZEITMASSSTAB.md`](./FALUKANT_DAEMON_AENDERUNGSNOTIZ_ZEITMASSSTAB.md).
|
||||
|
||||
**Umsetzung:** `run_monthly` wird alle **2 h** ausgeführt (gemeinsam mit Liebschafts-Raten im `FalukantFamilyWorker`). SQL: `servants_last_monthly_at` älter als 2 h oder `NULL`. Abgebucht wird **1/12** des berechneten Monatsbudgets pro Tick (`servants_monthly` in `money_history`). Spaltenname `servants_last_monthly_at` bleibt historisch; semantisch = letzter Monatstick.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
1. **Backend:** Stammdaten in `falukant_data.user_house` (`servant_count`, `servant_quality`, `servant_pay_level`, `household_order`) — z. B. Migration aus YourPart3.
|
||||
2. **Daemon:** `migrations/004_falukant_servants_daemon.sql` ausführen (Tick-Spalten + `servant_discretion_modifier` + `servants_underfunded`).
|
||||
|
||||
## Code
|
||||
|
||||
- Logik: `src/worker/falukant_servants.rs`
|
||||
- SQL: `src/worker/sql.rs` (Abschnitt Dienerschaft)
|
||||
- Ausführung: **`run_daily`** beim täglichen Familien-Tick; **`run_monthly`** im **2-h-Monatstick** zusammen mit Liebschafts-Installments (nicht mehr im seltenen Kalender-`process_monthly`).
|
||||
|
||||
## WebSocket
|
||||
|
||||
Wie Spec: `falukantUpdateFamily` mit `reason`: `daily` oder `monthly`, danach `falukantUpdateStatus` — **kein** eigener Diener-`reason`.
|
||||
|
||||
## Liebschaften (B7 Teil)
|
||||
|
||||
`QUERY_GET_ACTIVE_LOVER_ROWS_FOR_DAILY` liefert `servant_disc_u1` / `servant_disc_u2` (MAX `servant_discretion_modifier` je `user_house` des Partners). Die Sichtbarkeits-Drift addiert `(u1+u2)/2 / 4` auf `v_net`.
|
||||
|
||||
## Noch offen (Backlog)
|
||||
|
||||
- **B8 Untergrund:** siehe [`FALUKANT_UNDERGROUND_INVESTIGATE_AFFAIR.md`](./FALUKANT_UNDERGROUND_INVESTIGATE_AFFAIR.md) (`investigate_affair` im `UndergroundWorker`).
|
||||
- **Feinbalancing** (B9).
|
||||
@@ -1,41 +0,0 @@
|
||||
# Falukant: Transportüberfälle & Wachen (Daemon)
|
||||
|
||||
## Migration
|
||||
|
||||
`migrations/008_falukant_transport_raid.sql`:
|
||||
|
||||
- `falukant_data.transport.guard_count` (Standard `0`)
|
||||
- Eintrag `falukant_type.underground.tr = 'raid_transport'`
|
||||
|
||||
## Code
|
||||
|
||||
| Komponente | Datei |
|
||||
|------------|--------|
|
||||
| Tick (ca. 60 s) | `src/worker/falukant_transport_raid.rs` → `run_tick` |
|
||||
| Aufruf | `src/worker/underground.rs` (`UndergroundWorker::tick`, vor den 1-Tage-Jobs) |
|
||||
| SQL | `src/worker/sql.rs` (`QUERY_RAID_*`) |
|
||||
|
||||
## Logik (V1)
|
||||
|
||||
1. Offene Untergrundaufträge `raid_transport` mit `result IS NULL` (ohne 24h-Wartezeit).
|
||||
2. Parameter: `regionId`, `bandSize` (JSON, auch `region_id` / `band_size`).
|
||||
3. Region muss `falukant_type.region.id IN (4,5)` und `label_tr <> 'town'` erfüllen.
|
||||
4. Kandidaten-Transporte: noch unterwegs, `size >= 2`, Fracht, Route berührt die Region (`source` oder `target`), **nicht** vom Auftraggeber.
|
||||
5. Begegnung: Zufall mit von Bandengröße und Ladungsgröße abhängiger Wahrscheinlichkeit.
|
||||
6. Kampf: `raidPower` vs. `guardPower` (Wachen aus `transport.guard_count`) → `repelled` | `partial_success` | `major_success`.
|
||||
7. Beute: Anteil der Menge, **nie** die volle Ladung; zusätzlicher Verlust beim Abtransport ins Lager (`lostDueToStorage`).
|
||||
8. Einlagerung: bevorzugt Niederlassung **in derselben Region**, sonst erste Branch-ID des Users.
|
||||
9. Opfer: reduzierte `reputation`, verkleinerte Transportmenge.
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Empfänger |
|
||||
|-------|-----------|
|
||||
| `falukantTransportRaid` | Opfer (`reason`: `transport_raided`) |
|
||||
| `falukantUndergroundUpdate` | Auftraggeber (`raid_*`) |
|
||||
| `falukantUpdateStatus` | beide |
|
||||
| `falukantBranchUpdate` | beide |
|
||||
|
||||
## Backend / UI
|
||||
|
||||
Projektseitig: API für `raid_transport`, `guardCount` am Transport, Formularfilter für Regionen. Dieses Dokument beschreibt nur den **Daemon**.
|
||||
@@ -1,224 +0,0 @@
|
||||
# Falukant: UI-Anpassung – WebSocket & Familie / Liebschaften
|
||||
|
||||
Dieses Dokument beschreibt die **Nachrichten**, die der **YpDaemon** (`FalukantFamilyWorker`) über den WebSocket-Broadcast sendet, damit die **UI gezielt** reagieren kann (Refresh, Toasts, Family-Ansicht).
|
||||
|
||||
> **Transport:** Alle Clients erhalten denselben Broadcast. Die UI sollte Nachrichten **nach `user_id` filtern** (nur Events anzeigen/verarbeiten, die zur eingeloggten Session passen).
|
||||
|
||||
---
|
||||
|
||||
## 1. Übersicht der Events
|
||||
|
||||
| `event` | Pflichtfelder | Typische UI-Reaktion |
|
||||
|---------|----------------|----------------------|
|
||||
| `falukantUpdateFamily` | `user_id`, `reason` | Gezielter Refresh Familie/Liebe/Geld je nach `reason` |
|
||||
| `falukantUpdateStatus` | `user_id` | Allgemeiner Status-/Spielstand-Refresh (wie bisher) |
|
||||
| `falukantUpdateProductionCertificate` | `user_id`, `reason`, `old_certificate`, `new_certificate` | Produkte / Produktions-UI / Zertifikat neu laden (nach Recalc, ca. 1×/Min., 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).
|
||||
|
||||
---
|
||||
|
||||
## 2. JSON-Payloads (exakt)
|
||||
|
||||
### 2.1 `falukantUpdateFamily`
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "falukantUpdateFamily",
|
||||
"user_id": 123,
|
||||
"reason": "daily"
|
||||
}
|
||||
```
|
||||
|
||||
**`reason`** ist immer einer der folgenden **festen** Strings:
|
||||
|
||||
| `reason` | Bedeutung (Daemon) | Empfehlung UI |
|
||||
|----------|---------------------|---------------|
|
||||
| `daily` | Daily-Tick: Liebschafts-/Ehe-/Ansehens-Logik; u. a. `marriage_public_stability`, `household_tension_score` | Family-API + ggf. Charakter/Ansehen/Haus neu laden |
|
||||
| `monthly` | Kalender-`monthly` (Liebschafts-Monatsmarkierung / selten) **oder** Dienerschaft zahlt Monatstick: **Geld** (`servants_monthly`) | **Geld** + Family-State neu laden |
|
||||
| `lover_installment` | Alle **2 h**: **1/12** Liebschafts-Unterhalt bzw. Unterversorgung (`money_history`: `lover maintenance`) | **Geld** + Family-State neu laden |
|
||||
| `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
|
||||
{
|
||||
"event": "falukantUpdateStatus",
|
||||
"user_id": 123
|
||||
}
|
||||
```
|
||||
|
||||
Kommt **typischerweise direkt nach** `falukantUpdateFamily` mit derselben `user_id` (gemeinsamer Refresh).
|
||||
|
||||
### 2.3 `falukantUpdateProductionCertificate`
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "falukantUpdateProductionCertificate",
|
||||
"user_id": 123,
|
||||
"reason": "daily_recalculation",
|
||||
"old_certificate": 2,
|
||||
"new_certificate": 3
|
||||
}
|
||||
```
|
||||
|
||||
- **`reason`:** in der ersten Version fest `daily_recalculation` (inkl. Bankrott-Herabstufung, falls so umgesetzt).
|
||||
- Danach sendet der Daemon **`falukantUpdateStatus`** mit derselben `user_id` (wie bei anderen Falukant-Events).
|
||||
|
||||
### 2.4 `children_update`
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "children_update",
|
||||
"user_id": 123
|
||||
}
|
||||
```
|
||||
|
||||
Tritt bei **Geburt aus Liebschaft** auf; oft zusammen mit `falukantUpdateFamily` (`reason: lover_birth`) und `falukantUpdateStatus`.
|
||||
|
||||
### 2.5 `falukant_family_scandal_hint`
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "falukant_family_scandal_hint",
|
||||
"relationship_id": 456
|
||||
}
|
||||
```
|
||||
|
||||
- **Kein** `user_id` – Betroffene erkennst du nur über die **Beziehung** (Backend: `relationship.id` → Charaktere laden) oder du ignorierst das Event und verlässt dich auf `falukantUpdateFamily` mit `reason: scandal` für deine Nutzer.
|
||||
|
||||
---
|
||||
|
||||
## 3. Empfohlene Handler-Logik (Pseudo)
|
||||
|
||||
```text
|
||||
onMessage(json):
|
||||
if json.user_id != currentUserId: return // Broadcast filtern
|
||||
|
||||
switch json.event:
|
||||
case "falukantUpdateStatus":
|
||||
refreshPlayerStatus() // bestehend
|
||||
return
|
||||
|
||||
case "falukantUpdateProductionCertificate":
|
||||
refreshProductsAndProductionUi() // Zertifikat / erlaubte Produkte
|
||||
return
|
||||
|
||||
case "children_update":
|
||||
refreshChildrenAndFamilyView()
|
||||
return
|
||||
|
||||
case "falukantUpdateFamily":
|
||||
switch json.reason:
|
||||
case "daily":
|
||||
refreshFamilyAndRelationships()
|
||||
refreshCharactersReputationIfNeeded()
|
||||
break
|
||||
case "monthly":
|
||||
refreshMoney()
|
||||
refreshFamilyAndRelationships()
|
||||
break
|
||||
case "lover_installment":
|
||||
refreshMoney()
|
||||
refreshFamilyAndRelationships()
|
||||
break
|
||||
case "scandal":
|
||||
showScandalToastOptional()
|
||||
refreshFamilyAndRelationships()
|
||||
break
|
||||
case "lover_birth":
|
||||
refreshChildrenAndFamilyView()
|
||||
break
|
||||
return
|
||||
|
||||
case "falukant_family_scandal_hint":
|
||||
// optional: relationship_id → Detail-Modal / Log
|
||||
return
|
||||
```
|
||||
|
||||
**Hinweis:** Am selben Tag kann ein Nutzer **`scandal`** und danach **`daily`** erhalten – UI kann **deduplizieren** (z. B. nur ein voller Refresh) oder beide verarbeiten (idempotente API-Calls).
|
||||
|
||||
---
|
||||
|
||||
## 4. Welche Backend-Daten neu laden?
|
||||
|
||||
| Situation | Sinnvolle Endpunkte / Daten (konzeptionell) |
|
||||
|-----------|---------------------------------------------|
|
||||
| Jede `falukantUpdateFamily` | Family-/Relationship-API mit `relationship_state`, Ehe (`married`/`engaged`/`wooing`) |
|
||||
| `reason: monthly` | **Geld** (Dienerschaft o. ä.), Family-State |
|
||||
| `reason: lover_installment` | **Geld** + Liebschafts-State (Unterhalt/Unterversorgung) |
|
||||
| `reason: daily` / `scandal` | Ansehen (`character.reputation`), Sichtbarkeit/Diskretion der Liebschaften |
|
||||
| `children_update` / `lover_birth` | `child_relation` inkl. `legitimacy`, `birth_context`, `public_known` |
|
||||
|
||||
Konkrete Routen stehen im **YourPart3**-Backend; das Frontend sollte eine zentrale Funktion `refreshFamilyContext(userId)` kapseln.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sonderfälle
|
||||
|
||||
| Fall | Verhalten |
|
||||
|------|-----------|
|
||||
| Charakter ohne `user_id` (NPC) | **Keine** Socket-Events für diesen Charakter – nur Spieler mit `falukant_user` erhalten `user_id`-Events. |
|
||||
| Mehrere Events hintereinander | Normal; Requests sollten **idempotent** sein (mehrfaches Laden ok). |
|
||||
| Nur `falukantUpdateStatus` ohne Family | Kann von **anderen** Workern kommen – nicht nur Familie. |
|
||||
|
||||
---
|
||||
|
||||
### Transportüberfälle (`raid_transport`)
|
||||
|
||||
| Event | Payload (Auszug) |
|
||||
|-------|-------------------|
|
||||
| `falukantTransportRaid` | `user_id` (Opfer), `reason`: `transport_raided` |
|
||||
| `falukantUndergroundUpdate` | `user_id` (Auftraggeber), `reason`: `raid_repelled`, `raid_success`, `raid_partial_success`, `raid_loot_stored` |
|
||||
| `falukantUpdateStatus` / `falukantBranchUpdate` | beide Seiten nach Überfall |
|
||||
|
||||
Daemon: `src/worker/falukant_transport_raid.rs`, Doku: `docs/FALUKANT_TRANSPORT_RAID_DAEMON.md`.
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
|
||||
---
|
||||
|
||||
## 7. Checkliste UI-Integration
|
||||
|
||||
- [ ] WebSocket-Handler: `user_id` mit Session abgleichen
|
||||
- [ ] Auf `falukantUpdateFamily` reagieren und **`reason`** auswerten
|
||||
- [ ] `falukantUpdateStatus` weiter nutzen (globaler Refresh)
|
||||
- [ ] `children_update` + `lover_birth`: Kinder-Ansicht
|
||||
- [ ] Optional: `falukant_family_scandal_hint` mit `relationship_id`
|
||||
- [ ] Optional: Deduplizierung bei `scandal` + `daily` am selben Tag
|
||||
|
||||
Damit kannst du die Oberfläche **gezielt** an die Daemon-Events anbinden, ohne jedes Mal den vollen Spielstand blind zu aktualisieren.
|
||||
@@ -1,49 +0,0 @@
|
||||
# Untergrund: `investigate_affair`
|
||||
|
||||
Wie die anderen Underground-Tasks: Zeile in `falukant_data.underground` mit `result IS NULL`, nach ≥1 Tag verarbeitet der `UndergroundWorker`.
|
||||
|
||||
## Typ in der Datenbank
|
||||
|
||||
In `falukant_type.underground` einen Eintrag mit **`tr = 'investigate_affair'`** und Backend-Logik, die den Auftrag anlegt.
|
||||
|
||||
## Semantik (Spec: Liebschaften & Untergrund)
|
||||
|
||||
| Feld | Bedeutung |
|
||||
|------|-----------|
|
||||
| `performer_id` | Charakter-ID des Ausspionierenden |
|
||||
| `victim_id` | Charakter-ID des Opfers (mit Liebschaft) |
|
||||
| `parameters` (JSON) | `goal`: `"expose"` (Standard) oder `"blackmail"`; optional `relationship_id` (int): feste Liebschaft — sonst **höchster `discoveryScore`** unter allen aktiven `lover`-Beziehungen |
|
||||
|
||||
### `discoveryScore` (Auswahl der Ziel-Liebschaft)
|
||||
|
||||
Gewichtung u. a.: `visibility`, `discretion`, `acknowledged`, Kinder (hidden/public, Deckel +20), Altersstufen, Haushalt/Diener (Leak-Bonus, Deckel +15), mehrere aktive Liebschaften, `status_fit`.
|
||||
|
||||
### Erfolg
|
||||
|
||||
- `successChance = clamp(20 + discoveryScore * 0.55, 5, 95)` (Prozent)
|
||||
- Wurf 0–100: `roll ≤ successChance * 0.55` → voller Erfolg; `roll ≤ successChance` → Teilerfolg; sonst Fehlschlag
|
||||
|
||||
### `goal = expose`
|
||||
|
||||
Voller/Teilerfolg: Sichtbarkeit und Diskretion des Ziel-`relationship_state` werden angepasst, Ruf des **Opfers** (`victim_id`) sinkt. Ab `visibility ≥ 60` nach Anpassung: **Sofort-Skandalprüfung** mit WebSocket (`falukant_family_scandal_hint`, `falukantUpdateFamily` mit `reason: scandal`, `falukantUpdateStatus`).
|
||||
|
||||
### `goal = blackmail`
|
||||
|
||||
Voller/Teilerfolg: geringere Sichtbarkeits-/Diskretions-Effekte als bei `expose`; **`blackmailAmount`** aus Basisformel (Ruf, Sichtbarkeit, Stand, Kinder) × `outcomeFactor` (1.0 / 0.55).
|
||||
|
||||
### Fehlschlag
|
||||
|
||||
`status: failed`, `outcome: failure`, `discoveries: null`, Hinweis `no proof` — keine DB-Änderungen an Liebschaft/Ruf.
|
||||
|
||||
## Ergebnis-JSON (`result`)
|
||||
|
||||
Mindestens: `status`, `outcome`, `discoveries` (Pflichtfelder laut Spec), `visibilityDelta`, `reputationDelta`, optional `blackmailAmount`, `discoveryScore`, `successChance`, `roll`, `tier`, `notes`.
|
||||
|
||||
## WebSocket
|
||||
|
||||
- Nach jedem Job: **`underground_processed`** (wie bisher).
|
||||
- Bei ausgelöstem **Skandal** (nur `expose` mit ausreichender Sichtbarkeit und bestandenem Wurf): wie oben.
|
||||
|
||||
## Idempotenz
|
||||
|
||||
Weiterhin nur Zeilen mit **`result IS NULL`**; nach Verarbeitung wird `result` gesetzt (Daemon fasst den Auftrag nicht erneut an).
|
||||
@@ -1,95 +0,0 @@
|
||||
# Worker-Schedules per WebSocket
|
||||
|
||||
Dieses Feature stellt eine Übersicht bereit, wann Worker-Tasks voraussichtlich wieder laufen.
|
||||
|
||||
## Zugriffsschutz
|
||||
|
||||
Der Zugriff ist nur erlaubt für Nutzer mit mindestens einem dieser Rechte:
|
||||
|
||||
- `admin`
|
||||
- `worker_schedule_read`
|
||||
|
||||
Migration für das neue Recht:
|
||||
|
||||
- `migrations/018_worker_schedule_socket_right.sql`
|
||||
|
||||
## WebSocket-Events
|
||||
|
||||
Voraussetzung: Der Client hat vorher `setUserId` gesendet.
|
||||
|
||||
### 1) Übersicht
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "getWorkerSchedules",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "getWorkerSchedulesResponse",
|
||||
"ok": true,
|
||||
"generated_at": 1715157600,
|
||||
"schedules": [
|
||||
{
|
||||
"worker": "DirectorWorker",
|
||||
"tasks": [
|
||||
{
|
||||
"task": "salary_payout",
|
||||
"cadence_seconds": 86400,
|
||||
"cadence_label": "86400s interval",
|
||||
"next_run_latest_ts": 1715244000,
|
||||
"next_run_latest_in_seconds": 86400
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Hinweis: Sekündliche/minütliche Tasks sind absichtlich nicht enthalten.
|
||||
|
||||
### 2) Detaillierte Übersicht
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "getWorkerSchedulesDetailed",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
Response (zusätzlich Live-Status aus dem Worker-Runtime-Registry):
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "getWorkerSchedulesDetailedResponse",
|
||||
"ok": true,
|
||||
"generated_at": 1715157600,
|
||||
"schedules": [
|
||||
{
|
||||
"worker": "DirectorWorker",
|
||||
"running_worker": true,
|
||||
"running_watchdog": true,
|
||||
"current_step": "DirectorWorker: pay_salary",
|
||||
"last_step_change_ts": 1715157000,
|
||||
"tasks": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Rechteprüfung (technisch)
|
||||
|
||||
Der Daemon akzeptiert sowohl:
|
||||
|
||||
- `setUserId = community.user.id`
|
||||
- `setUserId = falukant_data.falukant_user.id`
|
||||
|
||||
In beiden Fällen wird auf `community.user_right` + `"type".user_right` geprüft.
|
||||
@@ -1,90 +0,0 @@
|
||||
# Standsabhängige Liebschaften - Frontend Übersicht
|
||||
|
||||
## Title Groups (Standsgruppen)
|
||||
|
||||
Die Standsgruppe wird aus dem **höchsten Titel beider Partner** bestimmt:
|
||||
|
||||
| Stand | Titel | Anzahl |
|
||||
|-------|-------|--------|
|
||||
| **0 - Niedrig** | noncivil, civil, sir | 3 |
|
||||
| **1 - Mittel** | townlord, by, landlord | 3 |
|
||||
| **2 - Hoch** | knight, baron, count, palsgrave, margrave, landgrave | 6 |
|
||||
| **3 - Höchst** | ruler, elector, imperial-prince, duke, grand-duke, prince-regent, king | 7 |
|
||||
|
||||
---
|
||||
|
||||
## Auswirkungen auf Reputation (täglich)
|
||||
|
||||
Die Reputation der **beiden Liebhaber** wird täglich angepasst:
|
||||
|
||||
### Stand 0 - Niedrig
|
||||
- **Heimlich** (Diskretheit ≥ 75): **±0.0** (neutral)
|
||||
- **Öffentlich** (Sichtbarkeit ≥ 60): **-0.5** (täglich Reputationsverlust)
|
||||
|
||||
### Stand 1 - Mittel
|
||||
- **Heimlich** (Diskretheit ≥ 75): **+0.3** (täglich Reputationsgewinn)
|
||||
- **Öffentlich** (Sichtbarkeit ≥ 60): **-0.4** (täglich Reputationsverlust - Skandal!)
|
||||
|
||||
### Stand 2 - Hoch
|
||||
- **Jederzeit**: **+0.5** (täglich Reputationsgewinn, unabhängig von Sichtbarkeit/Diskretheit)
|
||||
|
||||
### Stand 3 - Höchst
|
||||
- **Jederzeit**: **+0.75** (täglich Reputationsgewinn, unabhängig von Sichtbarkeit/Diskretheit)
|
||||
|
||||
---
|
||||
|
||||
## Auswirkungen auf Ehe-Zufriedenheit (täglich)
|
||||
|
||||
Die Zufriedenheit der **Ehe des Liebhabers** wird täglich angepasst, wenn eine Liebschaft existiert:
|
||||
|
||||
### Stand 0 - Niedrig
|
||||
- **Heimlich**: **±0** (neutral, keine Auswirkung)
|
||||
- **Öffentlich**: **-1** (täglich Ehe-Schaden)
|
||||
|
||||
### Stand 1 - Mittel
|
||||
- **Heimlich**: **±0** (neutral - heimliche Affären sind akzeptabel)
|
||||
- **Öffentlich**: **-2** (täglich Ehe-Schaden - großer Skandal!)
|
||||
|
||||
### Stand 2 - Hoch
|
||||
- **Jederzeit**: **+1** (täglich Ehe-Gewinn - gesellschaftlich akzeptiert/bewundert)
|
||||
|
||||
### Stand 3 - Höchst
|
||||
- **Jederzeit**: **+2** (täglich Ehe-Gewinn - sehr gesellschaftlich akzeptiert)
|
||||
|
||||
---
|
||||
|
||||
## Sichtbarkeit vs. Diskretheit
|
||||
|
||||
| Eigenschaft | Beschreibung | Schwelle für "Öffentlich" |
|
||||
|------------|-------------|--------------------------|
|
||||
| **Sichtbarkeit** | Wie öffentlich bekannt ist die Liebschaft? | ≥ 60 |
|
||||
| **Diskretheit** | Wie gut versteckt ist die Liebschaft? | ≥ 75 |
|
||||
|
||||
- Sichtbarkeit wird durch **Unterhaltsniveau, Zuneigung, Ehe-Konflikt, Bedieneten-Diskretheit** erhöht
|
||||
- Diskretheit wird durch **hohes Unterhaltsniveau** erhöht und durch **hohe Sichtbarkeit** gesenkt
|
||||
|
||||
---
|
||||
|
||||
## Fazit für UI-Anzeige
|
||||
|
||||
**Bei höchstem Stand (3):**
|
||||
- ✅ "Diese Liebschaft ist für diese Position prestigeträchtig"
|
||||
- ✅ Reputation steigt stärker (+0.75/Tag)
|
||||
- ✅ Ehe profitiert besonders (Zufriedenheit +2/Tag)
|
||||
|
||||
**Bei hohem Stand (2):**
|
||||
- ✅ "Diese Liebschaft ist gesellschaftlich akzeptiert"
|
||||
- ✅ Reputation steigt (+0.5/Tag)
|
||||
- ✅ Ehe profitiert (Zufriedenheit +1/Tag)
|
||||
|
||||
**Bei mittlerem Stand (1):**
|
||||
- ⚠️ "Diese Liebschaft muss diskret bleiben"
|
||||
- ✅ Reputation steigt WENN geheim (+0.3/Tag)
|
||||
- ⚠️ "Öffentliche Liebschaft würde massiven Skandal verursachen (-0.4 Rep/Tag, -2 Ehe-Zuf./Tag)"
|
||||
- ✅ Ehe unbeeinflusst WENN geheim
|
||||
|
||||
**Bei niedrigem Stand (0):**
|
||||
- ❌ "Diese Liebschaft ist gesellschaftlich unerwünscht"
|
||||
- ❌ Reputation sinkt bei öffentlicher Affäre (-0.5/Tag)
|
||||
- ⚠️ Ehe beschädigt bei öffentlicher Affäre (-1/Tag)
|
||||
- ✅ Wenn geheim: nur neutral, kein Schaden
|
||||
@@ -1,62 +0,0 @@
|
||||
-- Falukant: Liebhaber, Ehezufriedenheit, uneheliche Kinder (Handoff: externer Daemon)
|
||||
-- Siehe docs/FALUKANT_DAEMON_HANDOFF.md
|
||||
|
||||
ALTER TABLE falukant_data.character
|
||||
ADD COLUMN IF NOT EXISTS reputation numeric(6,2) NOT NULL DEFAULT 50.00;
|
||||
|
||||
ALTER TABLE falukant_data.relationship
|
||||
ADD COLUMN IF NOT EXISTS marriage_satisfaction smallint NOT NULL DEFAULT 55
|
||||
CHECK (marriage_satisfaction >= 0 AND marriage_satisfaction <= 100),
|
||||
ADD COLUMN IF NOT EXISTS marriage_drift_high smallint NOT NULL DEFAULT 0
|
||||
CHECK (marriage_drift_high >= 0 AND marriage_drift_high < 3),
|
||||
ADD COLUMN IF NOT EXISTS marriage_drift_low smallint NOT NULL DEFAULT 0
|
||||
CHECK (marriage_drift_low >= 0 AND marriage_drift_low < 5);
|
||||
|
||||
COMMENT ON COLUMN falukant_data.relationship.marriage_satisfaction IS
|
||||
'Ehezufriedenheit 0..100 (married / engaged / wooing); Schreiben durch Daemon';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS falukant_data.relationship_state (
|
||||
relationship_id integer PRIMARY KEY
|
||||
REFERENCES falukant_data.relationship (id) ON DELETE CASCADE,
|
||||
lover_role varchar(32) NOT NULL
|
||||
CHECK (lover_role IN ('secret_affair', 'lover', 'mistress_or_favorite')),
|
||||
affection smallint NOT NULL DEFAULT 50
|
||||
CHECK (affection >= 0 AND affection <= 100),
|
||||
visibility smallint NOT NULL DEFAULT 20
|
||||
CHECK (visibility >= 0 AND visibility <= 100),
|
||||
discretion smallint NOT NULL DEFAULT 50
|
||||
CHECK (discretion >= 0 AND discretion <= 100),
|
||||
maintenance_level smallint NOT NULL DEFAULT 50
|
||||
CHECK (maintenance_level >= 0 AND maintenance_level <= 100),
|
||||
status_fit smallint NOT NULL DEFAULT 0
|
||||
CHECK (status_fit >= -2 AND status_fit <= 2),
|
||||
monthly_base_cost integer NOT NULL DEFAULT 30,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
acknowledged boolean NOT NULL DEFAULT false,
|
||||
exclusive boolean,
|
||||
months_underfunded smallint NOT NULL DEFAULT 0
|
||||
CHECK (months_underfunded >= 0 AND months_underfunded < 100),
|
||||
scandal_extra_daily_pct smallint NOT NULL DEFAULT 0
|
||||
CHECK (scandal_extra_daily_pct >= 0 AND scandal_extra_daily_pct <= 100),
|
||||
last_daily_processed_at timestamptz,
|
||||
last_monthly_processed_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_relationship_state_active
|
||||
ON falukant_data.relationship_state (active)
|
||||
WHERE active = true;
|
||||
|
||||
COMMENT ON COLUMN falukant_data.relationship_state.last_daily_processed_at IS
|
||||
'Idempotenz: kein zweiter Daily-Tick am selben Kalendertag (Serverzeit)';
|
||||
COMMENT ON COLUMN falukant_data.relationship_state.last_monthly_processed_at IS
|
||||
'Idempotenz: kein zweiter Monthly-Tick im selben Kalendermonat (Serverzeit)';
|
||||
|
||||
ALTER TABLE falukant_data.child_relation
|
||||
ADD COLUMN IF NOT EXISTS legitimacy varchar(32) NOT NULL DEFAULT 'legitimate'
|
||||
CHECK (legitimacy IN ('legitimate', 'acknowledged_bastard', 'hidden_bastard')),
|
||||
ADD COLUMN IF NOT EXISTS birth_context varchar(32) NOT NULL DEFAULT 'marriage'
|
||||
CHECK (birth_context IN ('marriage', 'lover')),
|
||||
ADD COLUMN IF NOT EXISTS public_known boolean NOT NULL DEFAULT true;
|
||||
|
||||
COMMENT ON COLUMN falukant_data.child_relation.legitimacy IS 'legitimate | acknowledged_bastard | hidden_bastard';
|
||||
COMMENT ON COLUMN falukant_data.child_relation.birth_context IS 'marriage | lover';
|
||||
@@ -1,18 +0,0 @@
|
||||
-- Upgrade falls bereits 001 mit alten Spaltennamen eingespielt wurde.
|
||||
-- Neuinstallationen nutzen 001 direkt und brauchen 002 nicht.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'falukant_data' AND table_name = 'relationship_state'
|
||||
AND column_name = 'consecutive_underpayment_months'
|
||||
) THEN
|
||||
ALTER TABLE falukant_data.relationship_state
|
||||
RENAME COLUMN consecutive_underpayment_months TO months_underfunded;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE falukant_data.relationship_state
|
||||
ADD COLUMN IF NOT EXISTS last_daily_processed_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS last_monthly_processed_at timestamptz;
|
||||
@@ -1,17 +0,0 @@
|
||||
-- Ehe-Buffs (Spec: Geschenke, Fest, Hausversorgung ohne Liebschaft) + Backend-Steuerung
|
||||
ALTER TABLE falukant_data.relationship
|
||||
ADD COLUMN IF NOT EXISTS marriage_gift_buff_days_remaining smallint NOT NULL DEFAULT 0
|
||||
CHECK (marriage_gift_buff_days_remaining >= 0 AND marriage_gift_buff_days_remaining <= 30),
|
||||
ADD COLUMN IF NOT EXISTS marriage_pending_feast_bonus smallint NOT NULL DEFAULT 0
|
||||
CHECK (marriage_pending_feast_bonus >= 0 AND marriage_pending_feast_bonus <= 20),
|
||||
ADD COLUMN IF NOT EXISTS marriage_house_supply smallint NOT NULL DEFAULT 50
|
||||
CHECK (marriage_house_supply >= 0 AND marriage_house_supply <= 100),
|
||||
ADD COLUMN IF NOT EXISTS marriage_no_lover_bonus_counter smallint NOT NULL DEFAULT 0
|
||||
CHECK (marriage_no_lover_bonus_counter >= 0 AND marriage_no_lover_bonus_counter < 4);
|
||||
|
||||
COMMENT ON COLUMN falukant_data.relationship.marriage_gift_buff_days_remaining IS
|
||||
'Backend setzt z.B. 5 nach Geschenk: Daemon +1 Ehezufriedenheit/Tag, Zähler runter';
|
||||
COMMENT ON COLUMN falukant_data.relationship.marriage_pending_feast_bonus IS
|
||||
'Einmal +2..+5 beim nächsten Daily-Tick (Backend), danach 0';
|
||||
COMMENT ON COLUMN falukant_data.relationship.marriage_house_supply IS
|
||||
'Hausversorgung 0..100; ab ~65: +1 Ehezufriedenheit alle 4 Tage ohne aktive Liebschaft';
|
||||
@@ -1,14 +0,0 @@
|
||||
-- Dienerschaft: Daemon-Spalten (Spec: Dienerschaft Daemon).
|
||||
-- Stammdaten servant_count / servant_quality / servant_pay_level / household_order:
|
||||
-- siehe YourPart3 (z. B. add_servants_to_user_house.sql) — müssen vorher existieren.
|
||||
|
||||
ALTER TABLE falukant_data.user_house
|
||||
ADD COLUMN IF NOT EXISTS servant_discretion_modifier smallint NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS servants_underfunded boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS servants_last_daily_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS servants_last_monthly_at timestamptz;
|
||||
|
||||
COMMENT ON COLUMN falukant_data.user_house.servant_discretion_modifier IS
|
||||
'Daemon Daily: negativ = bessere Geheimhaltung, positiv = höheres Entdeckungsrisiko (Liebschaften)';
|
||||
COMMENT ON COLUMN falukant_data.user_house.servants_last_daily_at IS 'Idempotenz Daily';
|
||||
COMMENT ON COLUMN falukant_data.user_house.servants_last_monthly_at IS 'Idempotenz Monthly';
|
||||
@@ -1,16 +0,0 @@
|
||||
-- Ehe: öffentliche Stabilität (Daemon-Drift). Hausfrieden: persistenter Spannungswert 0..100.
|
||||
-- Siehe docs/FALUKANT_MARRIAGE_HOUSEPEACE_DAEMON_HANDOFF.md
|
||||
|
||||
ALTER TABLE falukant_data.relationship
|
||||
ADD COLUMN IF NOT EXISTS marriage_public_stability smallint NOT NULL DEFAULT 55
|
||||
CHECK (marriage_public_stability >= 0 AND marriage_public_stability <= 100);
|
||||
|
||||
COMMENT ON COLUMN falukant_data.relationship.marriage_public_stability IS
|
||||
'Öffentliche Ehe-Stabilität 0..100; Daily-Drift durch Daemon (Liebschaften, Haus, Stand)';
|
||||
|
||||
ALTER TABLE falukant_data.user_house
|
||||
ADD COLUMN IF NOT EXISTS household_tension_score smallint NOT NULL DEFAULT 0
|
||||
CHECK (household_tension_score >= 0 AND household_tension_score <= 100);
|
||||
|
||||
COMMENT ON COLUMN falukant_data.user_house.household_tension_score IS
|
||||
'Hausfrieden-Spannung 0..100 (Daemon); UI: low 0–24, medium 25–59, high 60–100';
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Liebschafts-Unterhalt: 12 Teilzahlungen pro Spieltag (alle 2 h), 1 Spieltag = 1 Spieljahr.
|
||||
ALTER TABLE falukant_data.relationship_state
|
||||
ADD COLUMN IF NOT EXISTS lover_last_installment_at TIMESTAMPTZ;
|
||||
|
||||
COMMENT ON COLUMN falukant_data.relationship_state.lover_last_installment_at IS
|
||||
'Letzte Abbuchung eines Zwölftels des Monatsunterhalts; Intervall 2 h im Daemon';
|
||||
@@ -1,20 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,8 +0,0 @@
|
||||
-- Ehe-Schwangerschaft: Konzeption setzt Fälligkeit; Geburt erst nach Ablauf (Daemon: 5 Tage Wartezeit).
|
||||
-- Verhindert „sofortige“ Geburt beim stündlichen Wurf und entspricht der Erwartung, dass nach wenigen Tagen etwas passiert.
|
||||
|
||||
ALTER TABLE falukant_data.relationship
|
||||
ADD COLUMN IF NOT EXISTS marriage_pregnancy_due_at timestamptz NULL;
|
||||
|
||||
COMMENT ON COLUMN falukant_data.relationship.marriage_pregnancy_due_at IS
|
||||
'Ehe: Nach erfolgreicher Konzeption (stündlicher Wurf) Zeitpunkt der Geburt; NULL = nicht schwanger';
|
||||
@@ -1,14 +0,0 @@
|
||||
-- Transportwachen + Untergrundtyp Überfall auf Transporte (Daemon-Auflösung).
|
||||
-- Siehe docs/FALUKANT_TRANSPORT_RAID_DAEMON.md
|
||||
|
||||
ALTER TABLE falukant_data.transport
|
||||
ADD COLUMN IF NOT EXISTS guard_count INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN falukant_data.transport.guard_count IS 'Mitgeschickte Wachen (reduzieren Überfallchance/Beute im Daemon)';
|
||||
|
||||
-- Untergrundtyp (falukant_type.underground: Spalte tr = Schlüssel in falukant_data.underground.underground_type_id)
|
||||
INSERT INTO falukant_type.underground (tr, name)
|
||||
SELECT 'raid_transport', 'Überfälle auf Transporte'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM falukant_type.underground WHERE tr = 'raid_transport'
|
||||
);
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Optionaler Anzeigename für Regionen (Todes-/UI-Logs, Karten); ohne Spalte liefert der Daemon nur region_id.
|
||||
ALTER TABLE falukant_data.region
|
||||
ADD COLUMN IF NOT EXISTS name text;
|
||||
|
||||
COMMENT ON COLUMN falukant_data.region.name IS 'Optional: Orts-/Regionsname für Logs und UI';
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
-- Geplante Schwangerschaft auf dem Charakter (Weg A; vgl. backend/sql/add_character_pregnancy.sql).
|
||||
-- Kann bereits existieren — nur ergänzen, was fehlt.
|
||||
|
||||
ALTER TABLE falukant_data.character
|
||||
ADD COLUMN IF NOT EXISTS pregnancy_due_at timestamptz NULL,
|
||||
ADD COLUMN IF NOT EXISTS pregnancy_father_character_id integer NULL
|
||||
REFERENCES falukant_data.character (id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN falukant_data.character.pregnancy_due_at IS
|
||||
'Erwarteter Geburtstermin (Admin/Spiel); Daemon liefert Geburt wenn fällig.';
|
||||
COMMENT ON COLUMN falukant_data.character.pregnancy_father_character_id IS
|
||||
'Vater-Charakter für geplante Geburt; NULL = Daemon überspringt bis Policy geklärt.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_character_pregnancy_due
|
||||
ON falukant_data.character (pregnancy_due_at)
|
||||
WHERE pregnancy_due_at IS NOT NULL;
|
||||
@@ -1,43 +0,0 @@
|
||||
-- Daemon: Amtsvorteile (reputation_periodic Ticks, optional Ernennungs-Ablauf)
|
||||
-- Voraussetzung: Backend-Seeds `falukant_predefine.political_office_benefit` + ggf. `falukant_type.political_office_benefit_type`
|
||||
|
||||
CREATE TABLE IF NOT EXISTS falukant_data.political_benefit_last_tick (
|
||||
id SERIAL PRIMARY KEY,
|
||||
character_id INTEGER NOT NULL
|
||||
REFERENCES falukant_data.character (id) ON DELETE CASCADE,
|
||||
political_office_benefit_id INTEGER NOT NULL,
|
||||
last_tick_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ticks_count INTEGER NOT NULL DEFAULT 0,
|
||||
CONSTRAINT uq_political_benefit_last_tick UNIQUE (character_id, political_office_benefit_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_political_benefit_last_tick_character
|
||||
ON falukant_data.political_benefit_last_tick (character_id);
|
||||
|
||||
COMMENT ON TABLE falukant_data.political_benefit_last_tick IS
|
||||
'Letzter reputation_periodic-Tick pro (Charakter × Benefit-Zeile aus political_office_benefit); Daemon: YpDaemon political_benefits.rs';
|
||||
|
||||
-- Optional: Spieler-Ernennungen (Backend legt Zeilen an; Daemon setzt nur abgelaufen)
|
||||
CREATE TABLE IF NOT EXISTS falukant_data.political_appointment (
|
||||
id SERIAL PRIMARY KEY,
|
||||
appointer_character_id INTEGER NOT NULL
|
||||
REFERENCES falukant_data.character (id) ON DELETE CASCADE,
|
||||
target_character_id INTEGER
|
||||
REFERENCES falukant_data.character (id) ON DELETE SET NULL,
|
||||
office_type_id INTEGER NOT NULL
|
||||
REFERENCES falukant_type.political_office_type (id),
|
||||
region_id INTEGER NOT NULL
|
||||
REFERENCES falukant_data.region (id),
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
completed_office_id INTEGER
|
||||
REFERENCES falukant_data.political_office (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_political_appointment_status_expires
|
||||
ON falukant_data.political_appointment (status, expires_at);
|
||||
|
||||
COMMENT ON TABLE falukant_data.political_appointment IS
|
||||
'Ernennungen (Backend); Daemon markiert pending → expired wenn expires_at überschritten';
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Tägliches politisches Gehalt (Daemon): Idempotenz pro Kalendertag (UTC)
|
||||
ALTER TABLE falukant_data.falukant_user
|
||||
ADD COLUMN IF NOT EXISTS last_political_daily_salary_on DATE;
|
||||
|
||||
COMMENT ON COLUMN falukant_data.falukant_user.last_political_daily_salary_on IS
|
||||
'Letzter Tag, an dem political daily salary gutgeschrieben wurde (YpDaemon political_benefits::run_daily_political_salary).';
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Zertifikat: abgeschlossene Produktionen für Mindestanforderungen / Produktionspunkte seit letztem Stufenwechsel
|
||||
ALTER TABLE falukant_data.falukant_user
|
||||
ADD COLUMN IF NOT EXISTS certificate_productions_count_since TIMESTAMPTZ;
|
||||
|
||||
COMMENT ON COLUMN falukant_data.falukant_user.certificate_productions_count_since IS
|
||||
'Daemon: Zählt nur Produktionen in falukant_log.production mit Zeitstempel >= diesem Wert; bei Aufstieg/Bankrott/Erbfolge auf NOW() gesetzt (YpDaemon falukant_certificate). NULL = alle bisherigen Zeilen (Bestand vor Migration).';
|
||||
@@ -1,16 +0,0 @@
|
||||
-- Abgeschlossene Produktionen für Zertifikat: pro tatsächlichem Abschluss +1 (UPSERT erhöht Menge,
|
||||
-- ohne diese Spalte bliebe COUNT(*) der Log-Zeilen bei zusammengefassten Tageszeilen falsch niedrig).
|
||||
|
||||
ALTER TABLE falukant_log.production
|
||||
ADD COLUMN IF NOT EXISTS completion_count integer;
|
||||
|
||||
UPDATE falukant_log.production
|
||||
SET completion_count = 1
|
||||
WHERE completion_count IS NULL;
|
||||
|
||||
ALTER TABLE falukant_log.production
|
||||
ALTER COLUMN completion_count SET DEFAULT 1,
|
||||
ALTER COLUMN completion_count SET NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN falukant_log.production.completion_count IS
|
||||
'Anzahl abgeschlossener Produktionen in dieser aggregierten Log-Zeile; beim Einfügen 1, bei Konflikt +1 pro Abschluss (YpDaemon produce, Zertifikat: SUM(completion_count)).';
|
||||
@@ -1,28 +0,0 @@
|
||||
-- Abgeschlossene politische Ämter (Amtsende, Neubesetzung, Entfernung) für Auswertung „Karrierehöchstwert“ / UI.
|
||||
-- Wird vom YpDaemon vor Löschen aus falukant_data.political_office befüllt.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS falukant_log.political_office_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
character_id INTEGER NOT NULL,
|
||||
office_type_id INTEGER NOT NULL,
|
||||
region_id INTEGER,
|
||||
start_date TIMESTAMPTZ NOT NULL,
|
||||
end_date TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Tabelle kann bereits vom Backend existieren (ohne region_id o. Ä.): CREATE TABLE IF NOT EXISTS ergänzt keine Spalten.
|
||||
ALTER TABLE falukant_log.political_office_history
|
||||
ADD COLUMN IF NOT EXISTS region_id INTEGER;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pol_office_hist_character
|
||||
ON falukant_log.political_office_history (character_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pol_office_hist_office_type
|
||||
ON falukant_log.political_office_history (office_type_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pol_office_hist_region
|
||||
ON falukant_log.political_office_history (region_id);
|
||||
|
||||
COMMENT ON TABLE falukant_log.political_office_history IS
|
||||
'Politische Amtszeiten nach Ende; start_date/end_date aus Amtszeile bzw. NOW() bei vorzeitigem Ende (YpDaemon).';
|
||||
@@ -1,76 +0,0 @@
|
||||
-- Erhoeht Geld-Praezision fuer sehr hohe Kontostaende.
|
||||
-- Ziel: max. Betrag von 99.999.999,99 auf 999.999.999.999,99 anheben.
|
||||
|
||||
-- Hauptkontostand
|
||||
ALTER TABLE falukant_data.falukant_user
|
||||
ALTER COLUMN money TYPE NUMERIC(14,2);
|
||||
|
||||
-- Verlaufseintraege (best effort-Logging im Daemon)
|
||||
ALTER TABLE falukant_log.money_history
|
||||
ALTER COLUMN change TYPE NUMERIC(14,2);
|
||||
|
||||
-- Moneyflow auf dieselbe Praezision bringen (falls die Tabelle existiert).
|
||||
ALTER TABLE IF EXISTS falukant_log.moneyflow
|
||||
ALTER COLUMN money_before TYPE NUMERIC(14,2),
|
||||
ALTER COLUMN money_after TYPE NUMERIC(14,2),
|
||||
ALTER COLUMN change_value TYPE NUMERIC(14,2);
|
||||
|
||||
-- Kernfunktion auf neue Praezision umstellen.
|
||||
CREATE OR REPLACE FUNCTION falukant_data.update_money(
|
||||
p_falukant_user_id integer,
|
||||
p_money_change numeric,
|
||||
p_activity text,
|
||||
p_changed_by integer DEFAULT NULL::integer
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_money_before numeric(14,2);
|
||||
v_money_after numeric(14,2);
|
||||
v_moneyflow_id bigint;
|
||||
BEGIN
|
||||
SELECT money
|
||||
INTO v_money_before
|
||||
FROM falukant_data.falukant_user
|
||||
WHERE id = p_falukant_user_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'FalukantUser mit ID % nicht gefunden', p_falukant_user_id;
|
||||
END IF;
|
||||
|
||||
v_money_after := v_money_before + p_money_change;
|
||||
|
||||
INSERT INTO falukant_log.moneyflow (
|
||||
falukant_user_id,
|
||||
activity,
|
||||
money_before,
|
||||
money_after,
|
||||
change_value,
|
||||
changed_by,
|
||||
time
|
||||
)
|
||||
VALUES (
|
||||
p_falukant_user_id,
|
||||
p_activity,
|
||||
v_money_before,
|
||||
NULL, -- wird gleich aktualisiert
|
||||
p_money_change,
|
||||
p_changed_by,
|
||||
NOW()
|
||||
)
|
||||
RETURNING id INTO v_moneyflow_id;
|
||||
|
||||
UPDATE falukant_data.falukant_user
|
||||
SET money = v_money_after
|
||||
WHERE id = p_falukant_user_id;
|
||||
|
||||
UPDATE falukant_log.moneyflow
|
||||
SET money_after = (
|
||||
SELECT money
|
||||
FROM falukant_data.falukant_user
|
||||
WHERE id = p_falukant_user_id
|
||||
)
|
||||
WHERE id = v_moneyflow_id;
|
||||
END;
|
||||
$function$;
|
||||
@@ -1,9 +0,0 @@
|
||||
-- Recht für WebSocket-Endpoint `getWorkerSchedules`.
|
||||
-- Zugriff nur für Admins oder User mit diesem Recht.
|
||||
INSERT INTO "type".user_right (title)
|
||||
SELECT 'worker_schedule_read'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "type".user_right
|
||||
WHERE LOWER(title) = 'worker_schedule_read'
|
||||
);
|
||||
@@ -1,55 +0,0 @@
|
||||
# Datenbank-Migrationen (Falukant / YpDaemon)
|
||||
|
||||
Siehe auch **`docs/FALUKANT_DAEMON_HANDOFF.md`** (Abgleich mit Backend-Übergabe).
|
||||
|
||||
## `001_falukant_family_lovers.sql`
|
||||
|
||||
Voraussetzung für den **`FalukantFamilyWorker`** (Liebhaber, Ehezufriedenheit, Ansehen, Monatskosten, Kinder aus Liebschaften).
|
||||
|
||||
**Manuell ausführen** auf der Ziel-DB (nach Backup):
|
||||
|
||||
```bash
|
||||
psql "$DATABASE_URL" -f migrations/001_falukant_family_lovers.sql
|
||||
```
|
||||
|
||||
## `002_falukant_family_rename_legacy_columns.sql`
|
||||
|
||||
Nur nötig, wenn **`001`** bereits mit den **alten** Spaltennamen (`consecutive_underpayment_months`) eingespielt wurde.
|
||||
|
||||
**Backend (YourPart3):** Beim Anlegen einer `lover`-Beziehung `relationship_state` erzeugen; Ehezufriedenheit liegt auf **`relationship`** (married / engaged / wooing); Idempotenzfelder `last_daily_processed_at` / `last_monthly_processed_at` werden vom Daemon gesetzt.
|
||||
|
||||
Ohne passende Spalten (`last_daily_processed_at`) bleibt der Family-Worker inaktiv.
|
||||
|
||||
## `012_falukant_political_benefits_daemon.sql`
|
||||
|
||||
Tabellen **`political_benefit_last_tick`** und optional **`political_appointment`** für den **`PoliticsWorker`** / Modul `political_benefits.rs`:
|
||||
|
||||
- **`reputation_periodic`**: Ticks mit Persistenz (benötigt Backend-Seeds `falukant_predefine.political_office_benefit` mit JSON-Feldern `tr` oder `benefitType`, `gain`, `intervalDays`).
|
||||
- **`free_lover_slots`**: Summe `count` im Liebschafts-Monatstick (Daemon), max. 5.
|
||||
- **Ernennungen**: Daemon setzt nur `pending` → `expired`, wenn `expires_at` überschritten (Anlage durch Backend-API).
|
||||
|
||||
Die Join-Spalte auf `political_office_benefit` heißt im Repo **`political_office_type_id`** — falls das Sequelize-Modell abweicht, SQL in `src/worker/sql.rs` anpassen.
|
||||
|
||||
## `013_falukant_political_daily_salary.sql`
|
||||
|
||||
Spalte **`falukant_data.falukant_user.last_political_daily_salary_on`** (Datum): Idempotenz für **`political_benefits::run_daily_political_salary`** — einmal pro Tag Gutschrift; Beträge aus JSON-Feld **`daily_salary`** (`tr`/`benefitType` = `daily_salary`) oder gestufter Daemon-Fallback nach Amts-Rang.
|
||||
|
||||
## `014_falukant_certificate_productions_count_since.sql`
|
||||
|
||||
Spalte **`falukant_data.falukant_user.certificate_productions_count_since`**: Zertifikats-**Produktionszählung** (Mindestwerte + Produktionspunkte) ab diesem Zeitpunkt; Daemon setzt bei **Aufstieg/Bankrott/Erbfolge** auf `NOW()`. **`NULL`** = bis zur ersten Änderung weiterhin alle passenden Log-Zeilen zählen.
|
||||
|
||||
## `015_falukant_log_production_completion_count.sql`
|
||||
|
||||
Spalte **`falukant_log.production.completion_count`**: zählt **abgeschlossene Produktionen** pro aggregierter Log-Zeile (bei gleichem Tag/Produkt/Region wird die Menge per UPSERT summiert; ohne `completion_count` bliebe `COUNT(*)` über die Zeilen fälschlich niedrig). Zertifikatsabfrage nutzt **`SUM(completion_count)`** (Migration **`015`** vor Deploy des aktualisierten Produce-Workers ausführen).
|
||||
|
||||
## `016_falukant_log_political_office_history.sql`
|
||||
|
||||
Tabelle **`falukant_log.political_office_history`**: Archiv abgeschlossener politischer Amtszeiten (`character_id`, `office_type_id`, `region_id`, `start_date`, `end_date`). Der Daemon schreibt **vor** jedem relevanten `DELETE` auf **`falukant_data.political_office`** (Amtsende/Neuwahl-Pfad, Übersitz-Trim, Charaktertod). **`falukant_data.process_elections()`** (PostgreSQL) liegt außerhalb des Rust-Repos — falls dort Zeilen gelöscht werden, analog **`INSERT` in diese Historie** in der DB-Funktion ergänzen.
|
||||
|
||||
## `018_worker_schedule_socket_right.sql`
|
||||
|
||||
Neues Benutzerrecht **`worker_schedule_read`** in **`type.user_right`**.
|
||||
Dieses Recht erlaubt den WebSocket-Endpoint `getWorkerSchedules` (Daemon-Laufplan für Worker-Tasks mit Intervallen > 60 Sekunden). Zugriff haben:
|
||||
|
||||
- Nutzer mit Rechtstitel `admin`
|
||||
- Nutzer mit Rechtstitel `worker_schedule_read`
|
||||
@@ -16,7 +16,6 @@ use worker::{
|
||||
CharacterCreationWorker, ConnectionPool, DirectorWorker, EventsWorker, HouseWorker,
|
||||
PoliticsWorker, ProduceWorker, StockageManager, TransportWorker, UndergroundWorker,
|
||||
UserCharacterWorker, ValueRecalculationWorker, WeatherWorker, Worker,
|
||||
FalukantFamilyWorker,
|
||||
};
|
||||
|
||||
static KEEP_RUNNING: AtomicBool = AtomicBool::new(true);
|
||||
@@ -138,10 +137,6 @@ fn create_workers(pool: ConnectionPool, broker: MessageBroker) -> Vec<Box<dyn Wo
|
||||
pool.clone(),
|
||||
broker.clone(),
|
||||
)),
|
||||
Box::new(FalukantFamilyWorker::new(
|
||||
pool.clone(),
|
||||
broker.clone(),
|
||||
)),
|
||||
Box::new(HouseWorker::new(pool.clone(), broker.clone())),
|
||||
Box::new(PoliticsWorker::new(pool.clone(), broker.clone())),
|
||||
Box::new(TransportWorker::new(pool.clone(), broker.clone())),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::db::ConnectionPool;
|
||||
use crate::message_broker::MessageBroker;
|
||||
use crate::worker::{get_worker_runtime_snapshot, Worker, WorkerRuntimeSnapshot};
|
||||
use crate::worker::Worker;
|
||||
use futures_util::{FutureExt, SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as Json;
|
||||
@@ -60,30 +60,12 @@ struct WebSocketLogEntry {
|
||||
event: Option<String>, // event-Feld aus der Nachricht (falls JSON)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct WorkerTaskSchedule {
|
||||
task: String,
|
||||
cadence_seconds: u64,
|
||||
cadence_label: String,
|
||||
next_run_latest_ts: u64,
|
||||
next_run_latest_in_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct WorkerSchedule {
|
||||
worker: String,
|
||||
tasks: Vec<WorkerTaskSchedule>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct WebSocketLog {
|
||||
entries: Vec<WebSocketLogEntry>,
|
||||
}
|
||||
|
||||
const WS_LOG_MAX_ENTRIES: usize = 50_000;
|
||||
const RIGHT_ADMIN: &str = "admin";
|
||||
const RIGHT_MAINADMIN: &str = "mainadmin";
|
||||
const RIGHT_WORKER_SCHEDULE: &str = "worker_schedule_read";
|
||||
|
||||
async fn append_ws_log(
|
||||
log: &Arc<Mutex<WebSocketLog>>,
|
||||
@@ -299,7 +281,7 @@ async fn run_accept_loop(
|
||||
addr: String,
|
||||
running: Arc<AtomicBool>,
|
||||
tx: broadcast::Sender<String>,
|
||||
pool: ConnectionPool,
|
||||
_pool: ConnectionPool,
|
||||
registry: Arc<Mutex<ConnectionRegistry>>,
|
||||
ws_log: Arc<Mutex<WebSocketLog>>,
|
||||
tls_acceptor: Option<TlsAcceptor>,
|
||||
@@ -330,19 +312,11 @@ async fn run_accept_loop(
|
||||
let ws_log_clone = ws_log.clone();
|
||||
let tls_acceptor_clone = tls_acceptor.clone();
|
||||
|
||||
let pool_clone = pool.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Some(acc) = tls_acceptor_clone {
|
||||
match acc.accept(stream).await {
|
||||
Ok(tls_stream) => {
|
||||
handle_connection(
|
||||
tls_stream,
|
||||
peer_addr,
|
||||
rx,
|
||||
pool_clone,
|
||||
registry_clone,
|
||||
ws_log_clone,
|
||||
)
|
||||
handle_connection(tls_stream, peer_addr, rx, registry_clone, ws_log_clone)
|
||||
.await
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -352,8 +326,7 @@ async fn run_accept_loop(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
handle_connection(stream, peer_addr, rx, pool_clone, registry_clone, ws_log_clone)
|
||||
.await;
|
||||
handle_connection(stream, peer_addr, rx, registry_clone, ws_log_clone).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -363,7 +336,6 @@ async fn handle_connection<S>(
|
||||
stream: S,
|
||||
peer_addr: SocketAddr,
|
||||
mut broker_rx: broadcast::Receiver<String>,
|
||||
pool: ConnectionPool,
|
||||
registry: Arc<Mutex<ConnectionRegistry>>,
|
||||
ws_log: Arc<Mutex<WebSocketLog>>,
|
||||
) where
|
||||
@@ -395,7 +367,6 @@ async fn handle_connection<S>(
|
||||
let user_id = Arc::new(tokio::sync::Mutex::new(Option::<String>::None));
|
||||
let user_id_for_incoming = user_id.clone();
|
||||
let user_id_for_broker = user_id.clone();
|
||||
let pool_for_incoming = pool.clone();
|
||||
let registry_for_incoming = registry.clone();
|
||||
let client_tx_incoming = client_tx.clone();
|
||||
let ws_log_for_incoming = ws_log.clone();
|
||||
@@ -475,131 +446,6 @@ async fn handle_connection<S>(
|
||||
.to_string();
|
||||
let _ = client_tx_incoming.send(payload).await;
|
||||
}
|
||||
"getWorkerSchedules" => {
|
||||
let uid_opt = {
|
||||
let guard = user_id_for_incoming.lock().await;
|
||||
guard.clone()
|
||||
};
|
||||
let access = match uid_opt {
|
||||
Some(uid) => {
|
||||
let pool_for_check = pool_for_incoming.clone();
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
user_can_read_worker_schedules(&pool_for_check, &uid)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => WorkerScheduleAccessDebug {
|
||||
requested_user_id: "".to_string(),
|
||||
resolved_community_user_id: None,
|
||||
matched_by: "spawn_blocking_failed".to_string(),
|
||||
allowed_direct: false,
|
||||
allowed_falukant: false,
|
||||
allowed: false,
|
||||
error: Some(format!("spawn_blocking_failed: {e}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
None => WorkerScheduleAccessDebug {
|
||||
requested_user_id: "".to_string(),
|
||||
resolved_community_user_id: None,
|
||||
matched_by: "missing_setUserId".to_string(),
|
||||
allowed_direct: false,
|
||||
allowed_falukant: false,
|
||||
allowed: false,
|
||||
error: Some("missing_setUserId".to_string()),
|
||||
},
|
||||
};
|
||||
if !access.allowed {
|
||||
let payload = serde_json::json!({
|
||||
"event": "getWorkerSchedulesResponse",
|
||||
"ok": false,
|
||||
"error": "forbidden",
|
||||
"debug": access
|
||||
})
|
||||
.to_string();
|
||||
let _ = client_tx_incoming.send(payload).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let now_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let schedules = build_worker_schedule_overview(now_secs);
|
||||
let payload = serde_json::json!({
|
||||
"event": "getWorkerSchedulesResponse",
|
||||
"ok": true,
|
||||
"generated_at": now_secs,
|
||||
"schedules": schedules
|
||||
})
|
||||
.to_string();
|
||||
let _ = client_tx_incoming.send(payload).await;
|
||||
}
|
||||
"getWorkerSchedulesDetailed" => {
|
||||
let uid_opt = {
|
||||
let guard = user_id_for_incoming.lock().await;
|
||||
guard.clone()
|
||||
};
|
||||
let access = match uid_opt {
|
||||
Some(uid) => {
|
||||
let pool_for_check = pool_for_incoming.clone();
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
user_can_read_worker_schedules(&pool_for_check, &uid)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => WorkerScheduleAccessDebug {
|
||||
requested_user_id: "".to_string(),
|
||||
resolved_community_user_id: None,
|
||||
matched_by: "spawn_blocking_failed".to_string(),
|
||||
allowed_direct: false,
|
||||
allowed_falukant: false,
|
||||
allowed: false,
|
||||
error: Some(format!("spawn_blocking_failed: {e}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
None => WorkerScheduleAccessDebug {
|
||||
requested_user_id: "".to_string(),
|
||||
resolved_community_user_id: None,
|
||||
matched_by: "missing_setUserId".to_string(),
|
||||
allowed_direct: false,
|
||||
allowed_falukant: false,
|
||||
allowed: false,
|
||||
error: Some("missing_setUserId".to_string()),
|
||||
},
|
||||
};
|
||||
if !access.allowed {
|
||||
let payload = serde_json::json!({
|
||||
"event": "getWorkerSchedulesDetailedResponse",
|
||||
"ok": false,
|
||||
"error": "forbidden",
|
||||
"debug": access
|
||||
})
|
||||
.to_string();
|
||||
let _ = client_tx_incoming.send(payload).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let now_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let schedules = build_worker_schedule_overview(now_secs);
|
||||
let runtime = get_worker_runtime_snapshot();
|
||||
let detailed = build_worker_schedule_detailed(schedules, runtime);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"event": "getWorkerSchedulesDetailedResponse",
|
||||
"ok": true,
|
||||
"generated_at": now_secs,
|
||||
"schedules": detailed
|
||||
})
|
||||
.to_string();
|
||||
let _ = client_tx_incoming.send(payload).await;
|
||||
}
|
||||
_ => {
|
||||
// Unbekannte Events ignorieren
|
||||
}
|
||||
@@ -758,279 +604,3 @@ async fn handle_connection<S>(
|
||||
println!("[WebSocketServer] Verbindung geschlossen: {}", peer_addr);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct WorkerScheduleAccessDebug {
|
||||
requested_user_id: String,
|
||||
resolved_community_user_id: Option<i32>,
|
||||
matched_by: String,
|
||||
allowed_direct: bool,
|
||||
allowed_falukant: bool,
|
||||
allowed: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn user_can_read_worker_schedules(pool: &ConnectionPool, user_id_raw: &str) -> WorkerScheduleAccessDebug {
|
||||
let mut conn = match pool.get() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return WorkerScheduleAccessDebug {
|
||||
requested_user_id: user_id_raw.to_string(),
|
||||
resolved_community_user_id: None,
|
||||
matched_by: "none".to_string(),
|
||||
allowed_direct: false,
|
||||
allowed_falukant: false,
|
||||
allowed: false,
|
||||
error: Some(format!("db_connection_failed: {e}")),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let (uid, matched_by) = if let Ok(v) = user_id_raw.parse::<i32>() {
|
||||
if v > 0 {
|
||||
(v, "numeric".to_string())
|
||||
} else {
|
||||
return WorkerScheduleAccessDebug {
|
||||
requested_user_id: user_id_raw.to_string(),
|
||||
resolved_community_user_id: None,
|
||||
matched_by: "numeric_invalid".to_string(),
|
||||
allowed_direct: false,
|
||||
allowed_falukant: false,
|
||||
allowed: false,
|
||||
error: Some("invalid_numeric_user_id".to_string()),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Fallback: setUserId kann auch username/hashed_id aus dem Community-User sein.
|
||||
let resolve_sql = r#"
|
||||
SELECT id
|
||||
FROM community."user"
|
||||
WHERE LOWER(username) = LOWER($1::text)
|
||||
OR LOWER(COALESCE(hashed_id, '')) = LOWER($1::text)
|
||||
LIMIT 1;
|
||||
"#;
|
||||
if let Err(e) = conn.prepare("ws_resolve_community_user_id", resolve_sql) {
|
||||
return WorkerScheduleAccessDebug {
|
||||
requested_user_id: user_id_raw.to_string(),
|
||||
resolved_community_user_id: None,
|
||||
matched_by: "lookup_prepare_failed".to_string(),
|
||||
allowed_direct: false,
|
||||
allowed_falukant: false,
|
||||
allowed: false,
|
||||
error: Some(format!("resolve_prepare_failed: {e}")),
|
||||
};
|
||||
}
|
||||
let rows = match conn.execute("ws_resolve_community_user_id", &[&user_id_raw]) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return WorkerScheduleAccessDebug {
|
||||
requested_user_id: user_id_raw.to_string(),
|
||||
resolved_community_user_id: None,
|
||||
matched_by: "lookup_execute_failed".to_string(),
|
||||
allowed_direct: false,
|
||||
allowed_falukant: false,
|
||||
allowed: false,
|
||||
error: Some(format!("resolve_execute_failed: {e}")),
|
||||
};
|
||||
}
|
||||
};
|
||||
let Some(row) = rows.first() else {
|
||||
return WorkerScheduleAccessDebug {
|
||||
requested_user_id: user_id_raw.to_string(),
|
||||
resolved_community_user_id: None,
|
||||
matched_by: "username_or_hashed_id".to_string(),
|
||||
allowed_direct: false,
|
||||
allowed_falukant: false,
|
||||
allowed: false,
|
||||
error: Some("user_not_found".to_string()),
|
||||
};
|
||||
};
|
||||
let resolved = row.get("id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.filter(|v| *v > 0)
|
||||
.unwrap_or(-1);
|
||||
(resolved, "username_or_hashed_id".to_string())
|
||||
};
|
||||
if uid <= 0 {
|
||||
return WorkerScheduleAccessDebug {
|
||||
requested_user_id: user_id_raw.to_string(),
|
||||
resolved_community_user_id: None,
|
||||
matched_by,
|
||||
allowed_direct: false,
|
||||
allowed_falukant: false,
|
||||
allowed: false,
|
||||
error: Some("resolved_user_id_invalid".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let sql = r#"
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM community.user_right ur
|
||||
JOIN "type".user_right tr ON tr.id = ur.right_type_id
|
||||
WHERE ur.user_id = $1::int
|
||||
AND (
|
||||
LOWER(TRIM(COALESCE(tr.title, ''))) = $2::text
|
||||
OR LOWER(TRIM(COALESCE(tr.title, ''))) = $3::text
|
||||
OR LOWER(TRIM(COALESCE(tr.title, ''))) = $4::text
|
||||
OR LOWER(COALESCE(tr.title, '')) LIKE '%admin%'
|
||||
)
|
||||
) AS allowed_direct,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM falukant_data.falukant_user fu
|
||||
JOIN community.user_right ur ON ur.user_id = fu.user_id
|
||||
JOIN "type".user_right tr ON tr.id = ur.right_type_id
|
||||
WHERE fu.id = $1::int
|
||||
AND (
|
||||
LOWER(TRIM(COALESCE(tr.title, ''))) = $2::text
|
||||
OR LOWER(TRIM(COALESCE(tr.title, ''))) = $3::text
|
||||
OR LOWER(TRIM(COALESCE(tr.title, ''))) = $4::text
|
||||
OR LOWER(COALESCE(tr.title, '')) LIKE '%admin%'
|
||||
)
|
||||
) AS allowed_falukant;
|
||||
"#;
|
||||
|
||||
if let Err(e) = conn.prepare("ws_can_read_worker_schedules", sql) {
|
||||
return WorkerScheduleAccessDebug {
|
||||
requested_user_id: user_id_raw.to_string(),
|
||||
resolved_community_user_id: Some(uid),
|
||||
matched_by,
|
||||
allowed_direct: false,
|
||||
allowed_falukant: false,
|
||||
allowed: false,
|
||||
error: Some(format!("rights_prepare_failed: {e}")),
|
||||
};
|
||||
}
|
||||
let admin = RIGHT_ADMIN.to_string();
|
||||
let allowed_right = RIGHT_WORKER_SCHEDULE.to_string();
|
||||
let mainadmin = RIGHT_MAINADMIN.to_string();
|
||||
let rows = match conn.execute(
|
||||
"ws_can_read_worker_schedules",
|
||||
&[&uid, &admin, &allowed_right, &mainadmin],
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return WorkerScheduleAccessDebug {
|
||||
requested_user_id: user_id_raw.to_string(),
|
||||
resolved_community_user_id: Some(uid),
|
||||
matched_by,
|
||||
allowed_direct: false,
|
||||
allowed_falukant: false,
|
||||
allowed: false,
|
||||
error: Some(format!("rights_execute_failed: {e}")),
|
||||
};
|
||||
}
|
||||
};
|
||||
let Some(row) = rows.first() else {
|
||||
return WorkerScheduleAccessDebug {
|
||||
requested_user_id: user_id_raw.to_string(),
|
||||
resolved_community_user_id: Some(uid),
|
||||
matched_by,
|
||||
allowed_direct: false,
|
||||
allowed_falukant: false,
|
||||
allowed: false,
|
||||
error: Some("rights_empty_result".to_string()),
|
||||
};
|
||||
};
|
||||
let direct = row
|
||||
.get("allowed_direct")
|
||||
.map(|v| v == "true" || v == "t" || v == "1")
|
||||
.unwrap_or(false);
|
||||
let falukant = row
|
||||
.get("allowed_falukant")
|
||||
.map(|v| v == "true" || v == "t" || v == "1")
|
||||
.unwrap_or(false);
|
||||
WorkerScheduleAccessDebug {
|
||||
requested_user_id: user_id_raw.to_string(),
|
||||
resolved_community_user_id: Some(uid),
|
||||
matched_by,
|
||||
allowed_direct: direct,
|
||||
allowed_falukant: falukant,
|
||||
allowed: direct || falukant,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_worker_schedule_overview(now_secs: u64) -> Vec<WorkerSchedule> {
|
||||
fn task(name: &str, cadence_seconds: u64, now_secs: u64) -> WorkerTaskSchedule {
|
||||
WorkerTaskSchedule {
|
||||
task: name.to_string(),
|
||||
cadence_seconds,
|
||||
cadence_label: format!("{}s interval", cadence_seconds),
|
||||
next_run_latest_ts: now_secs.saturating_add(cadence_seconds),
|
||||
next_run_latest_in_seconds: cadence_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
vec![
|
||||
WorkerSchedule {
|
||||
worker: "DirectorWorker".to_string(),
|
||||
tasks: vec![
|
||||
task("salary_payout", 24 * 60 * 60, now_secs),
|
||||
task("satisfaction_or_auto_income_adjust", 24 * 60 * 60, now_secs),
|
||||
task("resignation_check", 24 * 60 * 60, now_secs),
|
||||
],
|
||||
},
|
||||
WorkerSchedule {
|
||||
worker: "ValueRecalculationWorker".to_string(),
|
||||
tasks: vec![
|
||||
task("calculate_product_knowledge", 24 * 60 * 60, now_secs),
|
||||
task("calculate_regional_sell_price", 24 * 60 * 60, now_secs),
|
||||
task("calculate_hourly_price_recalculation", 60 * 60, now_secs),
|
||||
],
|
||||
},
|
||||
WorkerSchedule {
|
||||
worker: "FalukantFamilyWorker".to_string(),
|
||||
tasks: vec![
|
||||
task("family_daily", 24 * 60 * 60, now_secs),
|
||||
task("family_monthly", 24 * 60 * 60, now_secs),
|
||||
task("lover_installment_and_servants_month_slice", 2 * 60 * 60, now_secs),
|
||||
],
|
||||
},
|
||||
WorkerSchedule {
|
||||
worker: "PoliticsWorker".to_string(),
|
||||
tasks: vec![
|
||||
task("daily_politics", 24 * 60 * 60, now_secs),
|
||||
task("auto_approve_church_applications", 60 * 60, now_secs),
|
||||
],
|
||||
},
|
||||
WorkerSchedule {
|
||||
worker: "UserCharacterWorker".to_string(),
|
||||
tasks: vec![
|
||||
task("hourly_tasks", 60 * 60, now_secs),
|
||||
task("hourly_pregnancies", 60 * 60, now_secs),
|
||||
task("daily_marriage_fertility", 24 * 60 * 60, now_secs),
|
||||
],
|
||||
},
|
||||
WorkerSchedule {
|
||||
worker: "HouseWorker".to_string(),
|
||||
tasks: vec![task("daily_house_updates", 24 * 60 * 60, now_secs)],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn build_worker_schedule_detailed(
|
||||
schedules: Vec<WorkerSchedule>,
|
||||
runtime: Vec<WorkerRuntimeSnapshot>,
|
||||
) -> Vec<serde_json::Value> {
|
||||
let mut by_worker: HashMap<String, WorkerRuntimeSnapshot> = HashMap::new();
|
||||
for r in runtime {
|
||||
by_worker.insert(r.worker.clone(), r);
|
||||
}
|
||||
|
||||
schedules
|
||||
.into_iter()
|
||||
.map(|ws| {
|
||||
let rt = by_worker.get(&ws.worker);
|
||||
serde_json::json!({
|
||||
"worker": ws.worker,
|
||||
"running_worker": rt.map(|r| r.running_worker).unwrap_or(false),
|
||||
"running_watchdog": rt.map(|r| r.running_watchdog).unwrap_or(false),
|
||||
"current_step": rt.map(|r| r.current_step.clone()).unwrap_or_else(|| "unknown".to_string()),
|
||||
"last_step_change_ts": rt.map(|r| r.last_step_change_ts).unwrap_or(0),
|
||||
"tasks": ws.tasks
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,9 @@ use crate::db::{ConnectionPool, DbError};
|
||||
use crate::worker::sql::{QUERY_UPDATE_MONEY, QUERY_GET_MONEY};
|
||||
use crate::message_broker::MessageBroker;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub trait Worker: Send {
|
||||
fn start_worker_thread(&mut self);
|
||||
@@ -15,66 +12,6 @@ pub trait Worker: Send {
|
||||
fn enable_watchdog(&mut self);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkerRuntimeSnapshot {
|
||||
pub worker: String,
|
||||
pub current_step: String,
|
||||
pub last_step_change_ts: u64,
|
||||
pub running_worker: bool,
|
||||
pub running_watchdog: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct WorkerRuntimeEntry {
|
||||
current_step: String,
|
||||
last_step_change_ts: u64,
|
||||
running_worker: bool,
|
||||
running_watchdog: bool,
|
||||
}
|
||||
|
||||
static WORKER_RUNTIME_REGISTRY: OnceLock<Mutex<HashMap<String, WorkerRuntimeEntry>>> = OnceLock::new();
|
||||
|
||||
fn runtime_registry() -> &'static Mutex<HashMap<String, WorkerRuntimeEntry>> {
|
||||
WORKER_RUNTIME_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn now_unix_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn upsert_runtime_entry(name: &str, f: impl FnOnce(&mut WorkerRuntimeEntry)) {
|
||||
if let Ok(mut reg) = runtime_registry().lock() {
|
||||
let entry = reg.entry(name.to_string()).or_insert_with(|| WorkerRuntimeEntry {
|
||||
current_step: format!("{name}: idle"),
|
||||
last_step_change_ts: now_unix_secs(),
|
||||
running_worker: false,
|
||||
running_watchdog: false,
|
||||
});
|
||||
f(entry);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_worker_runtime_snapshot() -> Vec<WorkerRuntimeSnapshot> {
|
||||
let Ok(reg) = runtime_registry().lock() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<WorkerRuntimeSnapshot> = reg
|
||||
.iter()
|
||||
.map(|(worker, e)| WorkerRuntimeSnapshot {
|
||||
worker: worker.clone(),
|
||||
current_step: e.current_step.clone(),
|
||||
last_step_change_ts: e.last_step_change_ts,
|
||||
running_worker: e.running_worker,
|
||||
running_watchdog: e.running_watchdog,
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.worker.cmp(&b.worker));
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) struct WorkerState {
|
||||
pub(crate) running_worker: AtomicBool,
|
||||
pub(crate) running_watchdog: AtomicBool,
|
||||
@@ -107,10 +44,6 @@ pub struct BaseWorker {
|
||||
|
||||
impl BaseWorker {
|
||||
pub fn new(name: &str, pool: ConnectionPool, broker: MessageBroker) -> Self {
|
||||
upsert_runtime_entry(name, |entry| {
|
||||
entry.current_step = format!("{name}: idle");
|
||||
entry.last_step_change_ts = now_unix_secs();
|
||||
});
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
pool,
|
||||
@@ -122,15 +55,9 @@ impl BaseWorker {
|
||||
}
|
||||
|
||||
pub fn set_current_step<S: Into<String>>(&self, step: S) {
|
||||
let new_step = step.into();
|
||||
if let Ok(mut guard) = self.state.current_step.lock() {
|
||||
*guard = new_step.clone();
|
||||
*guard = step.into();
|
||||
}
|
||||
let worker_name = self.name.clone();
|
||||
upsert_runtime_entry(&worker_name, |entry| {
|
||||
entry.current_step = new_step;
|
||||
entry.last_step_change_ts = now_unix_secs();
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn start_worker_with_loop<F>(&mut self, loop_fn: F)
|
||||
@@ -141,11 +68,6 @@ impl BaseWorker {
|
||||
eprintln!("[{}] Worker thread already running, skipping start.", self.name);
|
||||
return;
|
||||
}
|
||||
let worker_name = self.name.clone();
|
||||
upsert_runtime_entry(&worker_name, |entry| {
|
||||
entry.running_worker = true;
|
||||
entry.last_step_change_ts = now_unix_secs();
|
||||
});
|
||||
|
||||
let state = Arc::clone(&self.state);
|
||||
|
||||
@@ -158,11 +80,6 @@ impl BaseWorker {
|
||||
// Erst den Worker stoppen, dann auch den Watchdog beenden, damit keine
|
||||
// Hintergrund-Threads weiterlaufen.
|
||||
self.state.running_worker.store(false, Ordering::Relaxed);
|
||||
let worker_name = self.name.clone();
|
||||
upsert_runtime_entry(&worker_name, |entry| {
|
||||
entry.running_worker = false;
|
||||
entry.last_step_change_ts = now_unix_secs();
|
||||
});
|
||||
self.stop_watchdog();
|
||||
if let Some(handle) = self.worker_thread.take() {
|
||||
let _ = handle.join();
|
||||
@@ -178,11 +95,6 @@ impl BaseWorker {
|
||||
eprintln!("[{}] Watchdog already enabled, skipping.", self.name);
|
||||
return;
|
||||
}
|
||||
let worker_name = self.name.clone();
|
||||
upsert_runtime_entry(&worker_name, |entry| {
|
||||
entry.running_watchdog = true;
|
||||
entry.last_step_change_ts = now_unix_secs();
|
||||
});
|
||||
|
||||
let state = Arc::clone(&self.state);
|
||||
|
||||
@@ -212,11 +124,6 @@ impl BaseWorker {
|
||||
|
||||
pub(crate) fn stop_watchdog(&mut self) {
|
||||
self.state.running_watchdog.store(false, Ordering::Relaxed);
|
||||
let worker_name = self.name.clone();
|
||||
upsert_runtime_entry(&worker_name, |entry| {
|
||||
entry.running_watchdog = false;
|
||||
entry.last_step_change_ts = now_unix_secs();
|
||||
});
|
||||
if let Some(handle) = self.watchdog_thread.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
@@ -251,8 +158,8 @@ impl BaseWorker {
|
||||
)));
|
||||
}
|
||||
|
||||
// We must ensure the resulting money fits in numeric(14,2).
|
||||
// numeric(14,2) max absolute value is < 10^12 before rounding.
|
||||
// We must ensure the resulting money fits in numeric(10,2).
|
||||
// numeric(10,2) max absolute value is < 10^8 (100_000_000) before rounding.
|
||||
// Fetch current money for the user and clamp the delta if needed.
|
||||
conn.prepare("get_money_for_clamp", QUERY_GET_MONEY)?;
|
||||
let rows = conn.execute("get_money_for_clamp", &[&falukant_user_id])?;
|
||||
@@ -266,13 +173,13 @@ impl BaseWorker {
|
||||
// compute tentative result
|
||||
let tentative = current_money + money_change;
|
||||
|
||||
// numeric(14,2) allows values with absolute < 10^12
|
||||
const MAX_ABS: f64 = 1_000_000_000_000.0 - 0.01; // leave room for scale
|
||||
// numeric(10,2) allows values with absolute < 10^8 (100_000_000)
|
||||
const MAX_ABS: f64 = 100_000_000.0 - 0.01; // leave room for scale
|
||||
|
||||
let adjusted_money_change = if tentative >= MAX_ABS {
|
||||
let clipped = MAX_ABS - current_money;
|
||||
eprintln!(
|
||||
"[BaseWorker] Clamping money_change: tentative {} exceeds numeric(14,2) max, clipping to {}",
|
||||
"[BaseWorker] Clamping money_change: tentative {} exceeds numeric(10,2) max, clipping to {}",
|
||||
tentative, clipped
|
||||
);
|
||||
clipped
|
||||
|
||||
@@ -10,7 +10,6 @@ use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::base::{BaseWorker, Worker, WorkerState};
|
||||
use super::death_log;
|
||||
use crate::worker::sql::{
|
||||
QUERY_IS_PREVIOUS_DAY_CHARACTER_CREATED,
|
||||
QUERY_GET_TOWN_REGION_IDS,
|
||||
@@ -21,7 +20,6 @@ use crate::worker::sql::{
|
||||
QUERY_DELETE_DIRECTOR,
|
||||
QUERY_DELETE_RELATIONSHIP,
|
||||
QUERY_DELETE_CHILD_RELATION,
|
||||
QUERY_DELETE_CHILD_RELATION_BY_PARENT,
|
||||
QUERY_INSERT_NOTIFICATION,
|
||||
QUERY_MARK_CHARACTER_DECEASED,
|
||||
};
|
||||
@@ -405,14 +403,6 @@ impl CharacterCreationWorker {
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
let death_ctx = match death_log::build_deceased_context(&mut conn, character_id) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("[CharacterCreationWorker] Todes-Log Kontext: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// 1) Director löschen und User benachrichtigen
|
||||
conn.prepare("delete_director", QUERY_DELETE_DIRECTOR)?;
|
||||
let dir_result = conn.execute("delete_director", &[&character_id])?;
|
||||
@@ -421,15 +411,7 @@ impl CharacterCreationWorker {
|
||||
.get("employer_user_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
let tr = death_ctx.as_ref().map(|c| {
|
||||
death_log::wrap_death_notification(c, "director_death", user_id)
|
||||
});
|
||||
Self::notify_user(
|
||||
pool,
|
||||
broker,
|
||||
user_id,
|
||||
tr.as_deref().unwrap_or("director_death"),
|
||||
)?;
|
||||
Self::notify_user(pool, broker, user_id, "director_death")?;
|
||||
}
|
||||
|
||||
// 2) Relationships löschen und betroffene User benachrichtigen
|
||||
@@ -440,21 +422,11 @@ impl CharacterCreationWorker {
|
||||
.get("related_user_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
let tr = death_ctx.as_ref().map(|c| {
|
||||
death_log::wrap_death_notification(c, "relationship_death", related_user_id)
|
||||
});
|
||||
Self::notify_user(
|
||||
pool,
|
||||
broker,
|
||||
related_user_id,
|
||||
tr.as_deref().unwrap_or("relationship_death"),
|
||||
)?;
|
||||
Self::notify_user(pool, broker, related_user_id, "relationship_death")?;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Child-Relations löschen (als Elter zuerst, dann als Kind) und Eltern benachrichtigen
|
||||
conn.prepare("delete_child_relation_by_parent", QUERY_DELETE_CHILD_RELATION_BY_PARENT)?;
|
||||
conn.execute("delete_child_relation_by_parent", &[&character_id])?;
|
||||
// 3) Child-Relations löschen und Eltern benachrichtigen
|
||||
conn.prepare("delete_child_relation", QUERY_DELETE_CHILD_RELATION)?;
|
||||
let child_result = conn.execute("delete_child_relation", &[&character_id])?;
|
||||
for row in child_result {
|
||||
@@ -462,29 +434,13 @@ impl CharacterCreationWorker {
|
||||
.get("father_user_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
let tr = death_ctx.as_ref().map(|c| {
|
||||
death_log::wrap_death_notification(c, "child_death", father_user_id)
|
||||
});
|
||||
Self::notify_user(
|
||||
pool,
|
||||
broker,
|
||||
father_user_id,
|
||||
tr.as_deref().unwrap_or("child_death"),
|
||||
)?;
|
||||
Self::notify_user(pool, broker, father_user_id, "child_death")?;
|
||||
}
|
||||
if let Some(mother_user_id) = row
|
||||
.get("mother_user_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
let tr = death_ctx.as_ref().map(|c| {
|
||||
death_log::wrap_death_notification(c, "child_death", mother_user_id)
|
||||
});
|
||||
Self::notify_user(
|
||||
pool,
|
||||
broker,
|
||||
mother_user_id,
|
||||
tr.as_deref().unwrap_or("child_death"),
|
||||
)?;
|
||||
Self::notify_user(pool, broker, mother_user_id, "child_death")?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,27 +454,24 @@ impl CharacterCreationWorker {
|
||||
pool: &ConnectionPool,
|
||||
broker: &MessageBroker,
|
||||
user_id: i32,
|
||||
tr_payload: &str,
|
||||
event_type: &str,
|
||||
) -> Result<(), DbError> {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("insert_notification", QUERY_INSERT_NOTIFICATION)?;
|
||||
conn.execute("insert_notification", &[&user_id, &tr_payload])?;
|
||||
conn.execute("insert_notification", &[&user_id])?;
|
||||
|
||||
// falukantUpdateStatus
|
||||
let update_message =
|
||||
format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id);
|
||||
broker.publish(update_message);
|
||||
|
||||
if tr_payload.trim_start().starts_with('{') {
|
||||
broker.publish(tr_payload.to_string());
|
||||
} else {
|
||||
let message =
|
||||
format!(r#"{{"event":"{tr_payload}","user_id":{user_id}}}"#);
|
||||
broker.publish(message);
|
||||
}
|
||||
// ursprüngliche Benachrichtigung
|
||||
let message =
|
||||
format!(r#"{{"event":"{event_type}","user_id":{}}}"#, user_id);
|
||||
broker.publish(message);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
//! Kontext für `falukant_log.notification` bei Charaktertod (Direktor, Partner, Eltern).
|
||||
//! `tr` enthält JSON mit Verstorbenem, Ansässigkeit, Alter, Ehe/Kind/Liebschaft.
|
||||
//!
|
||||
//! **Aufruf vor** `DELETE` auf `relationship` / `child_relation`, sonst sind Partner/Kinder/Geliebte leer.
|
||||
//!
|
||||
//! **Geliebte** sind keine eigene Tabelle: `falukant_data.relationship` + Typ `falukant_type.relationship.tr = 'lover'`,
|
||||
//! Zusatzdaten in `relationship_state`. Zählt nur, wenn `relationship_state.active` nicht `false` ist (wie Backend-UI).
|
||||
//! `lover_role` (`secret_affair` / `lover` / `mistress_or_favorite`) wird für die Liste **nicht** gefiltert.
|
||||
//!
|
||||
//! Kinder aus Liebschaft sind in `child_relation.birth_context = 'lover'` markiert; zusätzlich unter `linked.lover_birth_children`.
|
||||
|
||||
use crate::db::{DbConnection, DbError, Row};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::worker::sql::{
|
||||
QUERY_DEATH_LOG_CHARACTER_BASE, QUERY_DEATH_LOG_CHILD_DISPLAY_NAMES,
|
||||
QUERY_DEATH_LOG_CHILD_LOVER_BIRTH_DISPLAY_NAMES, QUERY_DEATH_LOG_LOVER_DISPLAY_NAMES,
|
||||
QUERY_DEATH_LOG_SPOUSE_DISPLAY_NAMES,
|
||||
};
|
||||
|
||||
fn collect_display_names(rows: Vec<Row>, key: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
if let Some(s) = r.get(key) {
|
||||
let t = s.trim();
|
||||
if !t.is_empty() {
|
||||
out.push(t.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Verknüpfte Namen (Ehe, Kinder, Geliebte): optional — bei Schema-/SQL-Fehlern leere Liste,
|
||||
/// damit der **Kern** (`deceased`) trotzdem als JSON in `notification.tr` landet.
|
||||
fn optional_linked_names(
|
||||
conn: &mut DbConnection,
|
||||
log_label: &'static str,
|
||||
stmt: &'static str,
|
||||
query: &str,
|
||||
deceased_character_id: i32,
|
||||
) -> Vec<String> {
|
||||
if let Err(e) = conn.prepare(stmt, query) {
|
||||
eprintln!("[death_log] {log_label} prepare: {e}");
|
||||
return Vec::new();
|
||||
}
|
||||
match conn.execute(stmt, &[&deceased_character_id]) {
|
||||
Ok(rows) => collect_display_names(rows, "display_name"),
|
||||
Err(e) => {
|
||||
eprintln!("[death_log] {log_label} execute: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt Verstorbenen + Ehepartner, Kinder, **aktive Geliebte** (`tr = 'lover'`, `active IS NOT FALSE`).
|
||||
/// Muss ausgeführt werden, bevor Beziehungen aus der DB gelöscht werden.
|
||||
pub fn build_deceased_context(conn: &mut DbConnection, deceased_character_id: i32) -> Result<Value, DbError> {
|
||||
conn.prepare("death_base", QUERY_DEATH_LOG_CHARACTER_BASE)?;
|
||||
let base_rows = conn.execute("death_base", &[&deceased_character_id])?;
|
||||
let base = base_rows.first().ok_or_else(|| {
|
||||
DbError::new(format!(
|
||||
"Todes-Log: Charakter {deceased_character_id} nicht gefunden"
|
||||
))
|
||||
})?;
|
||||
|
||||
let character_id = base
|
||||
.get("character_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(deceased_character_id);
|
||||
let display_name = base
|
||||
.get("display_name")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| character_id.to_string());
|
||||
let region_id = base
|
||||
.get("region_id")
|
||||
.and_then(|v| v.parse::<i32>().ok());
|
||||
let region_label = base
|
||||
.get("region_label")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let age_years = base
|
||||
.get("age_years")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let spouses = optional_linked_names(
|
||||
conn,
|
||||
"spouse",
|
||||
"death_spouse",
|
||||
QUERY_DEATH_LOG_SPOUSE_DISPLAY_NAMES,
|
||||
deceased_character_id,
|
||||
);
|
||||
let children = optional_linked_names(
|
||||
conn,
|
||||
"child",
|
||||
"death_child",
|
||||
QUERY_DEATH_LOG_CHILD_DISPLAY_NAMES,
|
||||
deceased_character_id,
|
||||
);
|
||||
let lover_birth_children = optional_linked_names(
|
||||
conn,
|
||||
"child_lover_birth",
|
||||
"death_child_lover_birth",
|
||||
QUERY_DEATH_LOG_CHILD_LOVER_BIRTH_DISPLAY_NAMES,
|
||||
deceased_character_id,
|
||||
);
|
||||
let lovers = optional_linked_names(
|
||||
conn,
|
||||
"lover",
|
||||
"death_lover",
|
||||
QUERY_DEATH_LOG_LOVER_DISPLAY_NAMES,
|
||||
deceased_character_id,
|
||||
);
|
||||
|
||||
Ok(json!({
|
||||
"deceased": {
|
||||
"character_id": character_id,
|
||||
"display_name": display_name,
|
||||
"region_id": region_id,
|
||||
"region_label": region_label,
|
||||
"age_years": age_years,
|
||||
},
|
||||
"linked": {
|
||||
"spouses": spouses,
|
||||
"children": children,
|
||||
"lover_birth_children": lover_birth_children,
|
||||
"lovers": lovers,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/// Vollständiges `tr`-/WebSocket-JSON mit `event` und `user_id`.
|
||||
pub fn wrap_death_notification(context: &Value, event: &str, user_id: i32) -> String {
|
||||
json!({
|
||||
"event": event,
|
||||
"user_id": user_id,
|
||||
"deceased": context.get("deceased"),
|
||||
"linked": context.get("linked"),
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
1085
src/worker/events.rs
1085
src/worker/events.rs
File diff suppressed because it is too large
Load Diff
@@ -1,441 +0,0 @@
|
||||
//! Produktionszertifikat: regelmäßige Neuberechnung von `falukant_user.certificate` im **FalukantFamilyWorker**
|
||||
//! (Standard: **1× pro Minute**, `CERTIFICATE_RECALC_INTERVAL` in `falukant_family.rs`; unabhängig von der Familien-Schema-Migration).
|
||||
//! Spec: `docs/FALUKANT_PRODUCTION_CERTIFICATE.md` und
|
||||
//! „Falukant: Produktionszertifikate – Fach- und Integrationsspezifikation“.
|
||||
|
||||
use crate::db::{DbError, Row};
|
||||
use crate::message_broker::MessageBroker;
|
||||
|
||||
use super::base::BaseWorker;
|
||||
use crate::worker::sql::{
|
||||
QUERY_GET_FALUKANT_USER_CERT_AND_EVENT, QUERY_GET_PRODUCTION_CERTIFICATE_INPUT_ROWS,
|
||||
QUERY_GET_PRODUCTION_CERTIFICATE_INPUT_ROWS_LEGACY, QUERY_PRODUCTION_COMPLETION_COUNT_READY,
|
||||
QUERY_UPDATE_FALUKANT_USER_CERTIFICATE,
|
||||
};
|
||||
|
||||
/// Wenn `money` darunter liegt, gilt der Spieler als bankrott → Zertifikat auf Stufe 1 (Spec §4.7).
|
||||
const BANKRUPTCY_MONEY_THRESHOLD: f64 = -5000.0;
|
||||
|
||||
/// Periodischer Tick (`FalukantFamilyWorker::run_iteration`, Standard: 1×/Minute).
|
||||
pub fn run_daily(base: &BaseWorker, broker: &MessageBroker) -> Result<(), DbError> {
|
||||
let pool = &base.pool;
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("cert_rows_completion_ready", QUERY_PRODUCTION_COMPLETION_COUNT_READY)?;
|
||||
let ready_rows = conn.execute("cert_rows_completion_ready", &[])?;
|
||||
let completion_count_ready = ready_rows
|
||||
.first()
|
||||
.and_then(|r| r.get("ready"))
|
||||
.map(|v| v == "t" || v == "true")
|
||||
.unwrap_or(false);
|
||||
|
||||
let cert_rows_query = if completion_count_ready {
|
||||
QUERY_GET_PRODUCTION_CERTIFICATE_INPUT_ROWS
|
||||
} else {
|
||||
QUERY_GET_PRODUCTION_CERTIFICATE_INPUT_ROWS_LEGACY
|
||||
};
|
||||
conn.prepare("cert_rows", cert_rows_query)?;
|
||||
conn.prepare("cert_upd", QUERY_UPDATE_FALUKANT_USER_CERTIFICATE)?;
|
||||
let rows = conn.execute("cert_rows", &[])?;
|
||||
|
||||
for row in rows {
|
||||
let fu_id = parse_i32(&row, "falukant_user_id", -1);
|
||||
if fu_id < 0 {
|
||||
continue;
|
||||
}
|
||||
let app_uid = parse_i32(&row, "app_user_id", -1);
|
||||
let event_uid = if app_uid > 0 { app_uid } else { fu_id };
|
||||
|
||||
let current = parse_i32(&row, "certificate", 1).clamp(1, 5);
|
||||
let money = parse_f64(&row, "money", 0.0);
|
||||
let avg_knowledge = parse_f64(&row, "avg_knowledge", 0.0);
|
||||
let completed = parse_i64(&row, "completed_production_count", 0);
|
||||
let max_church_hierarchy = parse_i32(&row, "max_church_hierarchy", 0);
|
||||
let pol_names = row
|
||||
.get("political_office_names")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let reputation = parse_f64(&row, "reputation", 50.0);
|
||||
let title_level = parse_i32(&row, "title_level", 0);
|
||||
let house_position = parse_i32(&row, "house_position", 0);
|
||||
|
||||
// Bankrott: Herabsetzung (Spec §4.7)
|
||||
if money <= BANKRUPTCY_MONEY_THRESHOLD && current > 1 {
|
||||
conn.execute("cert_upd", &[&1_i32, &fu_id])?;
|
||||
publish_certificate_event(broker, event_uid, current, 1, "bankruptcy");
|
||||
continue;
|
||||
}
|
||||
|
||||
let knowledge_points = knowledge_points_from_avg(avg_knowledge);
|
||||
let production_points = production_points_from_count(completed);
|
||||
let political_rank = max_political_rank_from_names(&pol_names);
|
||||
let church_rank = church_rank_from_hierarchy(max_church_hierarchy);
|
||||
let highest_office_rank = political_rank.max(church_rank).min(5);
|
||||
let office_points = highest_office_rank.min(5);
|
||||
let nobility_points = (title_level - 1).clamp(0, 5);
|
||||
let reputation_points = reputation_points_from_rep(reputation);
|
||||
let house_points = house_points_from_position(house_position);
|
||||
|
||||
let certificate_score = knowledge_points as f64 * 0.45
|
||||
+ production_points as f64 * 0.30
|
||||
+ office_points as f64 * 0.08
|
||||
+ nobility_points as f64 * 0.05
|
||||
+ reputation_points as f64 * 0.07
|
||||
+ house_points as f64 * 0.05;
|
||||
|
||||
let raw_target = raw_target_from_score(certificate_score);
|
||||
let effective_target = effective_certificate_target(
|
||||
raw_target,
|
||||
avg_knowledge,
|
||||
completed,
|
||||
office_points,
|
||||
nobility_points,
|
||||
reputation_points,
|
||||
house_points,
|
||||
);
|
||||
|
||||
// Hilfe bei „UI sagt bereit, Stufe steigt nicht“: oft Mindestwerte erfüllt, aber
|
||||
// `raw_target` (gewichtete Wertung) zu niedrig — siehe `docs/FALUKANT_PRODUCTION_CERTIFICATE.md`.
|
||||
if current < 5 {
|
||||
let next = current + 1;
|
||||
if meets_min_for_level(
|
||||
next,
|
||||
avg_knowledge,
|
||||
completed,
|
||||
office_points,
|
||||
nobility_points,
|
||||
reputation_points,
|
||||
house_points,
|
||||
) && effective_target < next
|
||||
&& cert_verbose()
|
||||
{
|
||||
eprintln!(
|
||||
"[falukant_certificate] fu_id={} bleibt auf Stufe {}: Mindestanforderungen für Stufe {} sind erfüllt, aber die gewichtete Wertung {:.3} ergibt raw_target={} (effective_target={}). avg_knowledge={:.2} completed={} kp={} pp={} office={} nob={} rep_p={} house_p={}",
|
||||
fu_id,
|
||||
current,
|
||||
next,
|
||||
certificate_score,
|
||||
raw_target,
|
||||
effective_target,
|
||||
avg_knowledge,
|
||||
completed,
|
||||
knowledge_points,
|
||||
production_points,
|
||||
office_points,
|
||||
nobility_points,
|
||||
reputation_points,
|
||||
house_points,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let new_certificate = if effective_target > current {
|
||||
(current + 1).min(effective_target).min(5)
|
||||
} else {
|
||||
current
|
||||
};
|
||||
|
||||
if new_certificate != current {
|
||||
conn.execute("cert_upd", &[&new_certificate, &fu_id])?;
|
||||
publish_certificate_event(
|
||||
broker,
|
||||
event_uid,
|
||||
current,
|
||||
new_certificate,
|
||||
"daily_recalculation",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cert_verbose() -> bool {
|
||||
matches!(
|
||||
std::env::var("YPDAEMON_CERT_VERBOSE").map(|v| v == "1" || v.eq_ignore_ascii_case("true")),
|
||||
Ok(true)
|
||||
)
|
||||
}
|
||||
|
||||
/// Nach Tod ohne gültigen Erben: Zertifikat auf Stufe 1 (Spec: Erbfolge bricht ab).
|
||||
pub fn reset_certificate_on_succession_no_heir(
|
||||
base: &BaseWorker,
|
||||
broker: &MessageBroker,
|
||||
falukant_user_id: i32,
|
||||
) -> Result<(), DbError> {
|
||||
if falukant_user_id < 1 {
|
||||
return Ok(());
|
||||
}
|
||||
let pool = &base.pool;
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
conn.prepare("cert_get_ev", QUERY_GET_FALUKANT_USER_CERT_AND_EVENT)?;
|
||||
let rows = conn.execute("cert_get_ev", &[&falukant_user_id])?;
|
||||
let row = match rows.first() {
|
||||
Some(r) => r,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let current = parse_i32(row, "certificate", 1).clamp(1, 5);
|
||||
let event_uid = parse_i32(row, "event_user_id", falukant_user_id);
|
||||
if current <= 1 {
|
||||
return Ok(());
|
||||
}
|
||||
conn.prepare("cert_upd", QUERY_UPDATE_FALUKANT_USER_CERTIFICATE)?;
|
||||
conn.execute("cert_upd", &[&1_i32, &falukant_user_id])?;
|
||||
publish_certificate_event(broker, event_uid, current, 1, "succession_no_heir");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn publish_certificate_event(
|
||||
broker: &MessageBroker,
|
||||
user_id: i32,
|
||||
old_c: i32,
|
||||
new_c: i32,
|
||||
reason: &str,
|
||||
) {
|
||||
let msg = format!(
|
||||
r#"{{"event":"falukantUpdateProductionCertificate","user_id":{},"reason":"{}","old_certificate":{},"new_certificate":{}}}"#,
|
||||
user_id, reason, old_c, new_c
|
||||
);
|
||||
broker.publish(msg);
|
||||
let status = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id);
|
||||
broker.publish(status);
|
||||
}
|
||||
|
||||
fn parse_i32(row: &Row, key: &str, default: i32) -> i32 {
|
||||
row.get(key)
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn parse_i64(row: &Row, key: &str, default: i64) -> i64 {
|
||||
row.get(key)
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn parse_f64(row: &Row, key: &str, default: f64) -> f64 {
|
||||
row.get(key)
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn knowledge_points_from_avg(avg: f64) -> i32 {
|
||||
if avg >= 80.0 {
|
||||
5
|
||||
} else if avg >= 65.0 {
|
||||
4
|
||||
} else if avg >= 50.0 {
|
||||
3
|
||||
} else if avg >= 35.0 {
|
||||
2
|
||||
} else if avg >= 20.0 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn production_points_from_count(n: i64) -> i32 {
|
||||
if n >= 200 {
|
||||
5
|
||||
} else if n >= 100 {
|
||||
4
|
||||
} else if n >= 50 {
|
||||
3
|
||||
} else if n >= 20 {
|
||||
2
|
||||
} else if n >= 5 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn church_rank_from_hierarchy(h: i32) -> i32 {
|
||||
if h <= 0 {
|
||||
0
|
||||
} else {
|
||||
h.min(5)
|
||||
}
|
||||
}
|
||||
|
||||
/// Politische Amtsnamen → Rang 1–5 (Heuristik im Daemon, ohne DB-Änderung).
|
||||
fn political_name_to_rank(name: &str) -> i32 {
|
||||
let n = name.to_lowercase();
|
||||
if n.contains("reich")
|
||||
|| n.contains("könig")
|
||||
|| n.contains("konig")
|
||||
|| n.contains("king")
|
||||
|| n.contains("kanzler")
|
||||
|| n.contains("chancellor")
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
if n.contains("minister")
|
||||
|| n.contains("ministerpräsident")
|
||||
|| n.contains("herzog")
|
||||
|| n.contains("duke")
|
||||
|| n.contains("landeshaupt")
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
if n.contains("regierungs")
|
||||
|| n.contains("oberbürger")
|
||||
|| n.contains("oberbuerg")
|
||||
|| n.contains("präsident")
|
||||
|| n.contains("president")
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
if n.contains("bürgermeister")
|
||||
|| n.contains("buergermeister")
|
||||
|| n.contains("mayor")
|
||||
|| n.contains("landrat")
|
||||
|| n.contains("regional")
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
if !n.is_empty() {
|
||||
return 1;
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Rang 1–5 für eine Amtsbezeichnung (Politik-Tagesgehalt, Zertifikat-Heuristik). Leer → 0.
|
||||
pub fn political_office_name_rank(name: &str) -> i32 {
|
||||
political_name_to_rank(name)
|
||||
}
|
||||
|
||||
fn max_political_rank_from_names(agg: &str) -> i32 {
|
||||
if agg.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
agg.split('|')
|
||||
.map(|s| political_name_to_rank(s.trim()))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn reputation_points_from_rep(rep: f64) -> i32 {
|
||||
if rep >= 90.0 {
|
||||
5
|
||||
} else if rep >= 75.0 {
|
||||
4
|
||||
} else if rep >= 60.0 {
|
||||
3
|
||||
} else if rep >= 40.0 {
|
||||
2
|
||||
} else if rep >= 20.0 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn house_points_from_position(position: i32) -> i32 {
|
||||
if position >= 10 {
|
||||
5
|
||||
} else if position >= 8 {
|
||||
4
|
||||
} else if position >= 6 {
|
||||
3
|
||||
} else if position >= 4 {
|
||||
2
|
||||
} else if position >= 2 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Spec §4.6 Schwellen für die Zielstufe (vor Mindestanforderungen).
|
||||
fn raw_target_from_score(score: f64) -> i32 {
|
||||
if score >= 3.8 {
|
||||
5
|
||||
} else if score >= 2.8 {
|
||||
4
|
||||
} else if score >= 1.8 {
|
||||
3
|
||||
} else if score >= 0.9 {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn cert5_two_of_social(office_points: i32, nobility_points: i32, house_points: i32) -> bool {
|
||||
let mut c = 0;
|
||||
if office_points >= 2 {
|
||||
c += 1;
|
||||
}
|
||||
if nobility_points >= 1 {
|
||||
c += 1;
|
||||
}
|
||||
if house_points >= 2 {
|
||||
c += 1;
|
||||
}
|
||||
c >= 2
|
||||
}
|
||||
|
||||
/// Mindestanforderungen je Stufe (Spec §4.5).
|
||||
fn meets_min_for_level(
|
||||
level: i32,
|
||||
avg_knowledge: f64,
|
||||
completed: i64,
|
||||
office_points: i32,
|
||||
nobility_points: i32,
|
||||
reputation_points: i32,
|
||||
house_points: i32,
|
||||
) -> bool {
|
||||
match level {
|
||||
1 => true,
|
||||
2 => avg_knowledge >= 15.0 && completed >= 4,
|
||||
3 => avg_knowledge >= 28.0 && completed >= 15,
|
||||
4 => {
|
||||
avg_knowledge >= 45.0
|
||||
&& completed >= 45
|
||||
&& (office_points >= 1
|
||||
|| nobility_points >= 1
|
||||
|| reputation_points >= 2
|
||||
|| house_points >= 2)
|
||||
}
|
||||
5 => {
|
||||
avg_knowledge >= 60.0
|
||||
&& completed >= 110
|
||||
&& reputation_points >= 2
|
||||
&& cert5_two_of_social(office_points, nobility_points, house_points)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Höchste Stufe ≤ `raw_target`, die alle Mindestanforderungen erfüllt (Spec §4.6).
|
||||
fn effective_certificate_target(
|
||||
raw_target: i32,
|
||||
avg_knowledge: f64,
|
||||
completed: i64,
|
||||
office_points: i32,
|
||||
nobility_points: i32,
|
||||
reputation_points: i32,
|
||||
house_points: i32,
|
||||
) -> i32 {
|
||||
let cap = raw_target.clamp(1, 5);
|
||||
for lvl in (1..=cap).rev() {
|
||||
if meets_min_for_level(
|
||||
lvl,
|
||||
avg_knowledge,
|
||||
completed,
|
||||
office_points,
|
||||
nobility_points,
|
||||
reputation_points,
|
||||
house_points,
|
||||
) {
|
||||
return lvl;
|
||||
}
|
||||
}
|
||||
1
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
//! Schuldturm, Verzug, Pfändung (täglich). Siehe `docs/FALUKANT_DEBTORS_DAEMON.md`.
|
||||
//! Voraussetzung: erweiterte Spalten in `falukant_data.debtors_prism` (projektseitig).
|
||||
|
||||
use crate::db::DbError;
|
||||
use crate::message_broker::MessageBroker;
|
||||
use crate::worker::base::BaseWorker;
|
||||
use crate::worker::sql::{
|
||||
QUERY_DEBTORS_CREDIT_USERS_FOR_DAILY, QUERY_DEBTORS_DELETE_VEHICLE,
|
||||
QUERY_DEBTORS_ENTER_PRISON, QUERY_DEBTORS_GET_PRISM_BY_CHARACTER,
|
||||
QUERY_DEBTORS_HOUSEHOLD_TENSION_ADD, QUERY_DEBTORS_IMPRISONED_LOVER_AFF_SUB2,
|
||||
QUERY_DEBTORS_IMPRISONED_MARRIAGE_SUB1, QUERY_DEBTORS_IMPRISONED_PENALTY_PLUS1,
|
||||
QUERY_DEBTORS_IMPRISONED_REP_MALUS, QUERY_DEBTORS_IMPRISONED_TENSION_PLUS2,
|
||||
QUERY_DEBTORS_INSERT_DELINQUENT, QUERY_DEBTORS_INCREMENT_DELINQUENT,
|
||||
QUERY_DEBTORS_REACTIVATE_DELINQUENT_FROM_RELEASED,
|
||||
QUERY_DEBTORS_LOVER_AFFECTION_SUB, QUERY_DEBTORS_MARRIAGE_SATISFACTION_SUB,
|
||||
QUERY_DEBTORS_PRISM_SCHEMA_READY, QUERY_DEBTORS_RELEASE_IF_PAID,
|
||||
QUERY_DEBTORS_RESET_DELINQUENCY_SOLVENT, QUERY_DEBTORS_RESET_ON_PAYMENT_SUCCESS,
|
||||
QUERY_DEBTORS_SUBTRACT_REPUTATION, QUERY_DEBTORS_UPDATE_PRISM_REMAINING,
|
||||
QUERY_DEBTORS_UPDATE_PRISM_REMAINING_MONEY, QUERY_DEBTORS_VEHICLE_FOR_SEIZURE,
|
||||
};
|
||||
|
||||
fn can_afford_installments(money: f64, total_pay_rate: f64) -> bool {
|
||||
if total_pay_rate <= 0.0 {
|
||||
return true;
|
||||
}
|
||||
// Wie UserCharacterWorker::process_single_credit: Reserve 3× Rate
|
||||
total_pay_rate <= money - (total_pay_rate * 3.0)
|
||||
}
|
||||
|
||||
fn publish_debt(broker: &MessageBroker, user_id: i32, reason: &str) {
|
||||
let d = format!(
|
||||
r#"{{"event":"falukantUpdateDebt","user_id":{},"reason":"{}"}}"#,
|
||||
user_id, reason
|
||||
);
|
||||
broker.publish(d);
|
||||
let s = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id);
|
||||
broker.publish(s);
|
||||
}
|
||||
|
||||
fn publish_family_daily(broker: &MessageBroker, user_id: i32) {
|
||||
let m = format!(
|
||||
r#"{{"event":"falukantUpdateFamily","user_id":{},"reason":"daily"}}"#,
|
||||
user_id
|
||||
);
|
||||
broker.publish(m);
|
||||
}
|
||||
|
||||
fn publish_house(broker: &MessageBroker, user_id: i32) {
|
||||
let m = format!(r#"{{"event":"falukantHouseUpdate","user_id":{}}}"#, user_id);
|
||||
broker.publish(m);
|
||||
}
|
||||
|
||||
fn publish_branch(broker: &MessageBroker, user_id: i32) {
|
||||
let m = format!(r#"{{"event":"falukantBranchUpdate","user_id":{}}}"#, user_id);
|
||||
broker.publish(m);
|
||||
}
|
||||
|
||||
fn parse_i32_row(row: &crate::db::Row, key: &str, default: i32) -> i32 {
|
||||
row.get(key)
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn parse_f64_row(row: &crate::db::Row, key: &str, default: f64) -> f64 {
|
||||
row.get(key)
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Nach erfolgreicher Kreditrate (hourly): Verzug zurücksetzen, solange nicht im Schuldturm.
|
||||
pub fn on_credit_payment_success(base: &BaseWorker, character_id: i32) -> Result<(), DbError> {
|
||||
let mut conn = base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
conn.prepare(
|
||||
"dp_reset_pay",
|
||||
QUERY_DEBTORS_RESET_ON_PAYMENT_SUCCESS,
|
||||
)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_reset_pay: {e}")))?;
|
||||
conn.execute("dp_reset_pay", &[&character_id])
|
||||
.map_err(|e| DbError::new(format!("exec dp_reset_pay: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn schema_ready(pool: &crate::db::ConnectionPool) -> bool {
|
||||
let Ok(mut conn) = pool.get() else {
|
||||
return false;
|
||||
};
|
||||
if conn
|
||||
.prepare("dp_schema", QUERY_DEBTORS_PRISM_SCHEMA_READY)
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Ok(rows) = conn.execute("dp_schema", &[]) else {
|
||||
return false;
|
||||
};
|
||||
rows.first()
|
||||
.and_then(|r| r.get("ready"))
|
||||
.map(|v| v == "t" || v == "true")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn run_daily(base: &BaseWorker, broker: &MessageBroker) -> Result<(), DbError> {
|
||||
if !schema_ready(&base.pool) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut conn = base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("dp_users", QUERY_DEBTORS_CREDIT_USERS_FOR_DAILY)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_users: {e}")))?;
|
||||
let user_rows = conn
|
||||
.execute("dp_users", &[])
|
||||
.map_err(|e| DbError::new(format!("exec dp_users: {e}")))?;
|
||||
|
||||
for row in user_rows {
|
||||
let user_id = parse_i32_row(&row, "user_id", -1);
|
||||
let character_id = parse_i32_row(&row, "character_id", -1);
|
||||
let money = parse_f64_row(&row, "money", 0.0);
|
||||
let total_pay_rate = parse_f64_row(&row, "total_pay_rate", 0.0);
|
||||
let total_credit_remaining = parse_f64_row(&row, "total_credit_remaining", 0.0);
|
||||
if user_id < 0 || character_id < 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
drop(conn);
|
||||
process_one_user(
|
||||
base,
|
||||
broker,
|
||||
user_id,
|
||||
character_id,
|
||||
money,
|
||||
total_pay_rate,
|
||||
total_credit_remaining,
|
||||
)?;
|
||||
|
||||
conn = base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
conn.prepare("dp_users", QUERY_DEBTORS_CREDIT_USERS_FOR_DAILY)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_users: {e}")))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_one_user(
|
||||
base: &BaseWorker,
|
||||
broker: &MessageBroker,
|
||||
user_id: i32,
|
||||
character_id: i32,
|
||||
money: f64,
|
||||
total_pay_rate: f64,
|
||||
total_credit_remaining: f64,
|
||||
) -> Result<(), DbError> {
|
||||
let mut conn = base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("dp_get", QUERY_DEBTORS_GET_PRISM_BY_CHARACTER)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_get: {e}")))?;
|
||||
let prism_rows = conn
|
||||
.execute("dp_get", &[&character_id])
|
||||
.map_err(|e| DbError::new(format!("exec dp_get: {e}")))?;
|
||||
|
||||
let status = prism_rows
|
||||
.first()
|
||||
.and_then(|r| r.get("status"))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let days_overdue = prism_rows
|
||||
.first()
|
||||
.map(|r| parse_i32_row(r, "days_overdue", 0))
|
||||
.unwrap_or(0);
|
||||
|
||||
let solvent = can_afford_installments(money, total_pay_rate);
|
||||
|
||||
// Freilassung wenn Kredit getilgt
|
||||
conn.prepare("dp_rel", QUERY_DEBTORS_RELEASE_IF_PAID)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_rel: {e}")))?;
|
||||
let released = conn
|
||||
.execute("dp_rel", &[&user_id])
|
||||
.map_err(|e| DbError::new(format!("exec dp_rel: {e}")))?;
|
||||
if !released.is_empty() {
|
||||
publish_debt(broker, user_id, "debtors_prison_released");
|
||||
publish_family_daily(broker, user_id);
|
||||
publish_house(broker, user_id);
|
||||
publish_branch(broker, user_id);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if status == "imprisoned" {
|
||||
apply_imprisoned_daily_effects(base, broker, user_id, character_id)?;
|
||||
seize_assets_step(base, broker, user_id, character_id, money)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Zahlungsfähig: Verzugstage nur bei delinquent zurücksetzen (nicht im Schuldturm).
|
||||
if solvent && status == "delinquent" {
|
||||
conn.prepare("dp_sol", QUERY_DEBTORS_RESET_DELINQUENCY_SOLVENT)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_sol: {e}")))?;
|
||||
conn.execute("dp_sol", &[&character_id])
|
||||
.map_err(|e| DbError::new(format!("exec dp_sol: {e}")))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !solvent && total_credit_remaining > 0.01 {
|
||||
if prism_rows.is_empty() || status.is_empty() {
|
||||
conn.prepare("dp_ins", QUERY_DEBTORS_INSERT_DELINQUENT)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_ins: {e}")))?;
|
||||
let ins = conn
|
||||
.execute("dp_ins", &[&character_id, &total_credit_remaining])
|
||||
.map_err(|e| DbError::new(format!("exec dp_ins: {e}")))?;
|
||||
if !ins.is_empty() {
|
||||
publish_debt(broker, user_id, "delinquency");
|
||||
}
|
||||
} else if status == "released" {
|
||||
conn.prepare("dp_react", QUERY_DEBTORS_REACTIVATE_DELINQUENT_FROM_RELEASED)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_react: {e}")))?;
|
||||
let r = conn
|
||||
.execute("dp_react", &[&character_id, &total_credit_remaining])
|
||||
.map_err(|e| DbError::new(format!("exec dp_react: {e}")))?;
|
||||
if !r.is_empty() {
|
||||
publish_debt(broker, user_id, "delinquency");
|
||||
}
|
||||
} else if status == "delinquent" {
|
||||
conn.prepare("dp_inc", QUERY_DEBTORS_INCREMENT_DELINQUENT)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_inc: {e}")))?;
|
||||
let inc = conn
|
||||
.execute("dp_inc", &[&character_id, &total_credit_remaining])
|
||||
.map_err(|e| DbError::new(format!("exec dp_inc: {e}")))?;
|
||||
if !inc.is_empty() {
|
||||
publish_debt(broker, user_id, "delinquency");
|
||||
}
|
||||
}
|
||||
|
||||
drop(conn);
|
||||
let mut conn2 = base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
conn2
|
||||
.prepare("dp_get2", QUERY_DEBTORS_GET_PRISM_BY_CHARACTER)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_get2: {e}")))?;
|
||||
let pr2 = conn2
|
||||
.execute("dp_get2", &[&character_id])
|
||||
.map_err(|e| DbError::new(format!("exec dp_get2: {e}")))?;
|
||||
let d = pr2
|
||||
.first()
|
||||
.map(|r| parse_i32_row(r, "days_overdue", 0))
|
||||
.unwrap_or(days_overdue);
|
||||
|
||||
if d >= 3 {
|
||||
try_enter_prison(base, broker, user_id, character_id, total_credit_remaining)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn try_enter_prison(
|
||||
base: &BaseWorker,
|
||||
broker: &MessageBroker,
|
||||
user_id: i32,
|
||||
character_id: i32,
|
||||
total_credit_remaining: f64,
|
||||
) -> Result<(), DbError> {
|
||||
let mut conn = base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("dp_ent", QUERY_DEBTORS_ENTER_PRISON)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_ent: {e}")))?;
|
||||
let rows = conn
|
||||
.execute("dp_ent", &[&character_id, &total_credit_remaining])
|
||||
.map_err(|e| DbError::new(format!("exec dp_ent: {e}")))?;
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
conn.prepare("dp_rep12", QUERY_DEBTORS_SUBTRACT_REPUTATION)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_rep12: {e}")))?;
|
||||
conn.execute("dp_rep12", &[&character_id, &12_i32])
|
||||
.map_err(|e| DbError::new(format!("exec dp_rep12: {e}")))?;
|
||||
|
||||
conn.prepare("dp_mar10", QUERY_DEBTORS_MARRIAGE_SATISFACTION_SUB)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_mar10: {e}")))?;
|
||||
conn.execute("dp_mar10", &[&character_id, &10_i32])
|
||||
.map_err(|e| DbError::new(format!("exec dp_mar10: {e}")))?;
|
||||
|
||||
conn.prepare("dp_tens15", QUERY_DEBTORS_HOUSEHOLD_TENSION_ADD)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_tens15: {e}")))?;
|
||||
conn.execute("dp_tens15", &[&user_id, &15_i32])
|
||||
.map_err(|e| DbError::new(format!("exec dp_tens15: {e}")))?;
|
||||
|
||||
conn.prepare("dp_lov4", QUERY_DEBTORS_LOVER_AFFECTION_SUB)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_lov4: {e}")))?;
|
||||
conn.execute("dp_lov4", &[&character_id, &4_i32])
|
||||
.map_err(|e| DbError::new(format!("exec dp_lov4: {e}")))?;
|
||||
|
||||
publish_debt(broker, user_id, "debtors_prison_entered");
|
||||
publish_family_daily(broker, user_id);
|
||||
publish_house(broker, user_id);
|
||||
publish_branch(broker, user_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_imprisoned_daily_effects(
|
||||
base: &BaseWorker,
|
||||
broker: &MessageBroker,
|
||||
user_id: i32,
|
||||
character_id: i32,
|
||||
) -> Result<(), DbError> {
|
||||
let mut conn = base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("dp_r2", QUERY_DEBTORS_IMPRISONED_REP_MALUS)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_r2: {e}")))?;
|
||||
conn.execute("dp_r2", &[&character_id, &2_i32])
|
||||
.map_err(|e| DbError::new(format!("exec dp_r2: {e}")))?;
|
||||
|
||||
conn.prepare("dp_p1", QUERY_DEBTORS_IMPRISONED_PENALTY_PLUS1)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_p1: {e}")))?;
|
||||
conn.execute("dp_p1", &[&character_id])
|
||||
.map_err(|e| DbError::new(format!("exec dp_p1: {e}")))?;
|
||||
|
||||
conn.prepare("dp_m1", QUERY_DEBTORS_IMPRISONED_MARRIAGE_SUB1)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_m1: {e}")))?;
|
||||
conn.execute("dp_m1", &[&character_id])
|
||||
.map_err(|e| DbError::new(format!("exec dp_m1: {e}")))?;
|
||||
|
||||
conn.prepare("dp_t2", QUERY_DEBTORS_IMPRISONED_TENSION_PLUS2)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_t2: {e}")))?;
|
||||
conn.execute("dp_t2", &[&character_id])
|
||||
.map_err(|e| DbError::new(format!("exec dp_t2: {e}")))?;
|
||||
|
||||
conn.prepare("dp_la2", QUERY_DEBTORS_IMPRISONED_LOVER_AFF_SUB2)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_la2: {e}")))?;
|
||||
conn.execute("dp_la2", &[&character_id])
|
||||
.map_err(|e| DbError::new(format!("exec dp_la2: {e}")))?;
|
||||
|
||||
publish_family_daily(broker, user_id);
|
||||
let s = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id);
|
||||
broker.publish(s);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn seize_assets_step(
|
||||
base: &BaseWorker,
|
||||
broker: &MessageBroker,
|
||||
user_id: i32,
|
||||
character_id: i32,
|
||||
money: f64,
|
||||
) -> Result<(), DbError> {
|
||||
let mut conn = base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("dp_getr", QUERY_DEBTORS_GET_PRISM_BY_CHARACTER)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_getr: {e}")))?;
|
||||
let pr = conn
|
||||
.execute("dp_getr", &[&character_id])
|
||||
.map_err(|e| DbError::new(format!("exec dp_getr: {e}")))?;
|
||||
let remaining = pr
|
||||
.first()
|
||||
.map(|r| parse_f64_row(r, "remaining_debt", 0.0))
|
||||
.unwrap_or(0.0);
|
||||
if remaining <= 0.01 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
drop(conn);
|
||||
|
||||
if money > 0.01 {
|
||||
let take = money.min(remaining);
|
||||
base.change_falukant_user_money(user_id, -take, "debtors asset seizure")?;
|
||||
|
||||
let mut conn = base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
conn.prepare("dp_rm", QUERY_DEBTORS_UPDATE_PRISM_REMAINING_MONEY)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_rm: {e}")))?;
|
||||
conn.execute("dp_rm", &[&character_id, &take])
|
||||
.map_err(|e| DbError::new(format!("exec dp_rm: {e}")))?;
|
||||
|
||||
publish_debt(broker, user_id, "asset_seizure");
|
||||
publish_branch(broker, user_id);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut conn = base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
conn.prepare("dp_v", QUERY_DEBTORS_VEHICLE_FOR_SEIZURE)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_v: {e}")))?;
|
||||
let vr = conn
|
||||
.execute("dp_v", &[&user_id])
|
||||
.map_err(|e| DbError::new(format!("exec dp_v: {e}")))?;
|
||||
|
||||
if let Some(row) = vr.first() {
|
||||
let vid = parse_i32_row(row, "vehicle_id", -1);
|
||||
let cond = parse_i32_row(row, "veh_condition", 100).max(1) as f64;
|
||||
let type_cost = parse_f64_row(row, "type_cost", 0.0);
|
||||
if vid > 0 && type_cost > 0.0 {
|
||||
let proceeds = (type_cost * (cond / 100.0) * 0.55).max(0.0);
|
||||
conn.prepare("dp_dv", QUERY_DEBTORS_DELETE_VEHICLE)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_dv: {e}")))?;
|
||||
conn.execute("dp_dv", &[&vid])
|
||||
.map_err(|e| DbError::new(format!("exec dp_dv: {e}")))?;
|
||||
|
||||
conn.prepare("dp_rmv", QUERY_DEBTORS_UPDATE_PRISM_REMAINING)
|
||||
.map_err(|e| DbError::new(format!("prepare dp_rmv: {e}")))?;
|
||||
conn.execute("dp_rmv", &[&character_id, &proceeds, &vid])
|
||||
.map_err(|e| DbError::new(format!("exec dp_rmv: {e}")))?;
|
||||
|
||||
publish_debt(broker, user_id, "vehicle_liquidation");
|
||||
let s = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id);
|
||||
broker.publish(s);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,409 +0,0 @@
|
||||
//! Dienerschaft: Daily + „Monats“-Kosten nach Spielzeitmodell.
|
||||
//! Voraussetzung: `migrations/004_falukant_servants_daemon.sql`.
|
||||
//!
|
||||
//! **Zeitmaßstab:** 1 Spieltag = 1 Spieljahr; ein „Monatstick“ ≈ **2 h** Wandzeit.
|
||||
//! Der berechnete `monthly_cost` ist ein **abstrakter** Unterhaltsbetrag pro **Spielmonat**
|
||||
//! (kein realistischer Vollmonatslohn). Pro Tick wird nur **1/12** dieses Betrags abgebucht
|
||||
//! (12 Monatsticke pro Spieltag), analog zur Liebschafts-Logik.
|
||||
|
||||
use crate::db::DbError;
|
||||
use crate::message_broker::MessageBroker;
|
||||
use crate::worker::base::BaseWorker;
|
||||
use crate::worker::sql::{
|
||||
QUERY_COUNT_ACTIVE_LOVERS_FOR_CHARACTER, QUERY_GET_SERVANT_DAILY_ROWS,
|
||||
QUERY_GET_SERVANT_MONTHLY_ROWS, QUERY_SERVANTS_SCHEMA_READY,
|
||||
QUERY_UPDATE_CHARACTER_REPUTATION, QUERY_UPDATE_MARRIAGE_SATISFACTION_ADD_FOR_CHARACTER,
|
||||
QUERY_UPDATE_USER_HOUSE_SERVANT_DAILY, QUERY_UPDATE_USER_HOUSE_SERVANT_MONTHLY_META,
|
||||
QUERY_UPDATE_USER_HOUSE_SERVANT_UNDERFUNDED_PENALTY,
|
||||
};
|
||||
|
||||
fn pay_shift(pay: &str) -> i32 {
|
||||
match pay {
|
||||
"low" => -6,
|
||||
"high" => 6,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn base_range(position: i32) -> (i32, i32) {
|
||||
match position {
|
||||
3 => (1, 2),
|
||||
4 => (2, 4),
|
||||
5 => (3, 6),
|
||||
p if p <= 2 => (0, 1),
|
||||
p if p >= 6 => (4, 8),
|
||||
_ => (0, 1),
|
||||
}
|
||||
}
|
||||
|
||||
fn expected_range(house_position: i32, title_level: i32) -> (i32, i32) {
|
||||
let (bmin, bmax) = base_range(house_position);
|
||||
let bonus = (title_level / 3).max(0);
|
||||
(bmin + bonus, bmax + bonus)
|
||||
}
|
||||
|
||||
fn clamp_i32(v: i32, lo: i32, hi: i32) -> i32 {
|
||||
v.max(lo).min(hi)
|
||||
}
|
||||
|
||||
fn clamp_f64(v: f64, lo: f64, hi: f64) -> f64 {
|
||||
v.max(lo).min(hi)
|
||||
}
|
||||
|
||||
/// True, wenn Migration 004 aktiv ist.
|
||||
pub fn servants_schema_ready(pool: &crate::db::ConnectionPool) -> Result<bool, DbError> {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
conn.prepare("srv_schema", QUERY_SERVANTS_SCHEMA_READY)?;
|
||||
let rows = conn.execute("srv_schema", &[])?;
|
||||
Ok(rows
|
||||
.first()
|
||||
.and_then(|r| r.get("ready"))
|
||||
.map(|v| v == "true" || v == "t")
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
fn publish_falukant_family_and_status(broker: &MessageBroker, user_id: i32, reason: &str) {
|
||||
let family = format!(
|
||||
r#"{{"event":"falukantUpdateFamily","user_id":{},"reason":"{}"}}"#,
|
||||
user_id, reason
|
||||
);
|
||||
broker.publish(family);
|
||||
let status = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id);
|
||||
broker.publish(status);
|
||||
}
|
||||
|
||||
/// Daily-Tick: Haushaltsordnung, Qualität, Ruf, Diskretionsmodifikator, Ehe.
|
||||
pub fn run_daily(base: &BaseWorker, broker: &MessageBroker) -> Result<(), DbError> {
|
||||
let mut conn = base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("srv_daily", QUERY_GET_SERVANT_DAILY_ROWS)?;
|
||||
conn.prepare("upd_uh_daily", QUERY_UPDATE_USER_HOUSE_SERVANT_DAILY)?;
|
||||
conn.prepare("upd_rep", QUERY_UPDATE_CHARACTER_REPUTATION)?;
|
||||
conn.prepare("upd_mar", QUERY_UPDATE_MARRIAGE_SATISFACTION_ADD_FOR_CHARACTER)?;
|
||||
|
||||
let rows = conn.execute("srv_daily", &[])?;
|
||||
|
||||
for row in rows {
|
||||
let user_house_id = row
|
||||
.get("user_house_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(-1);
|
||||
let falukant_user_id = row
|
||||
.get("falukant_user_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(-1);
|
||||
let character_id = row
|
||||
.get("character_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(-1);
|
||||
if user_house_id < 0 || falukant_user_id < 0 || character_id < 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let title_level = row
|
||||
.get("title_level")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
let house_position = row
|
||||
.get("house_position")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
let servant_count = row
|
||||
.get("servant_count")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
let mut servant_quality = row
|
||||
.get("servant_quality")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(50);
|
||||
let pay = row
|
||||
.get("servant_pay_level")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "normal".to_string());
|
||||
let pay = pay.trim();
|
||||
let pay_owned = if pay.is_empty() {
|
||||
"normal".to_string()
|
||||
} else {
|
||||
pay.to_string()
|
||||
};
|
||||
let pay = pay_owned.as_str();
|
||||
|
||||
let mut household_order = row
|
||||
.get("household_order")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(55);
|
||||
let mut reputation = row
|
||||
.get("reputation")
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.unwrap_or(50.0);
|
||||
|
||||
let (exp_min, exp_max) = expected_range(house_position, title_level);
|
||||
let missing = (exp_min - servant_count).max(0);
|
||||
let excessive = (servant_count - exp_max).max(0);
|
||||
|
||||
let quality_part = ((servant_quality - 50) as f64 * 0.35).round() as i32;
|
||||
let pay_part = pay_shift(pay);
|
||||
let fit_penalty = missing * 10 + excessive * 4;
|
||||
let mut target_ho = 55 + quality_part + pay_part - fit_penalty;
|
||||
target_ho = clamp_i32(target_ho, 0, 100);
|
||||
|
||||
let old_ho = household_order;
|
||||
if household_order < target_ho {
|
||||
household_order += (target_ho - household_order).min(2);
|
||||
} else if household_order > target_ho {
|
||||
household_order -= (household_order - target_ho).min(2);
|
||||
}
|
||||
if pay == "low" && servant_count < exp_min {
|
||||
household_order -= 1;
|
||||
}
|
||||
if pay == "high" && servant_quality >= 65 {
|
||||
household_order += 1;
|
||||
}
|
||||
household_order = clamp_i32(household_order, 0, 100);
|
||||
|
||||
let mut qd = 0;
|
||||
if pay == "low" {
|
||||
qd -= 1;
|
||||
}
|
||||
if pay == "high" {
|
||||
qd += 1;
|
||||
}
|
||||
if servant_count < exp_min {
|
||||
qd -= 1;
|
||||
}
|
||||
if servant_count > exp_max + 2 {
|
||||
qd -= 1;
|
||||
}
|
||||
if household_order >= 80 {
|
||||
qd += 1;
|
||||
}
|
||||
if household_order <= 30 {
|
||||
qd -= 1;
|
||||
}
|
||||
qd = clamp_i32(qd, -2, 2);
|
||||
servant_quality = clamp_i32(servant_quality + qd, 0, 100);
|
||||
|
||||
let mut rep_delta = 0.0_f64;
|
||||
if title_level >= 4 && servant_count < exp_min {
|
||||
rep_delta -= 0.15 * missing as f64;
|
||||
}
|
||||
if title_level <= 1 && servant_count > exp_max {
|
||||
rep_delta -= 0.10 * excessive as f64;
|
||||
}
|
||||
if household_order >= 85 && servant_count >= exp_min && servant_count <= exp_max {
|
||||
rep_delta += 0.05;
|
||||
}
|
||||
if household_order <= 25 {
|
||||
rep_delta -= 0.20;
|
||||
}
|
||||
reputation = clamp_f64(reputation + rep_delta, 0.0, 100.0);
|
||||
|
||||
let mut disc_mod = 0;
|
||||
if servant_quality >= 70 && pay == "high" && servant_count <= exp_max {
|
||||
disc_mod -= 8;
|
||||
}
|
||||
if pay == "low" {
|
||||
disc_mod += 6;
|
||||
}
|
||||
if servant_count > exp_max + 1 {
|
||||
disc_mod += 4;
|
||||
}
|
||||
if household_order <= 35 {
|
||||
disc_mod += 5;
|
||||
}
|
||||
disc_mod = clamp_i32(disc_mod, -100, 100);
|
||||
|
||||
let mut marriage_delta = 0.0_f64;
|
||||
if household_order >= 75 {
|
||||
marriage_delta += 0.10;
|
||||
}
|
||||
if household_order <= 35 {
|
||||
marriage_delta -= 0.15;
|
||||
}
|
||||
if servant_count < exp_min {
|
||||
marriage_delta -= 0.10;
|
||||
}
|
||||
let marriage_steps = if marriage_delta >= 0.05 {
|
||||
1
|
||||
} else if marriage_delta <= -0.05 {
|
||||
-1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let ho_changed = old_ho != household_order;
|
||||
let sq_changed = qd != 0;
|
||||
let rep_changed = rep_delta.abs() > f64::EPSILON;
|
||||
|
||||
conn.execute(
|
||||
"upd_uh_daily",
|
||||
&[
|
||||
&household_order,
|
||||
&servant_quality,
|
||||
&disc_mod,
|
||||
&user_house_id,
|
||||
],
|
||||
)?;
|
||||
|
||||
let rep_s = format!("{:.2}", reputation);
|
||||
conn.execute("upd_rep", &[&rep_s, &character_id])?;
|
||||
|
||||
if marriage_steps != 0 {
|
||||
conn.execute("upd_mar", &[&marriage_steps, &character_id])?;
|
||||
}
|
||||
|
||||
if ho_changed || sq_changed || rep_changed || marriage_steps != 0 {
|
||||
publish_falukant_family_and_status(broker, falukant_user_id, "daily");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Monatstick (~2 h): Teilzahlung (1/12 des abstrakten Monatsbudgets), Unterversorgung, Boni.
|
||||
pub fn run_monthly(base: &BaseWorker, broker: &MessageBroker) -> Result<(), DbError> {
|
||||
let mut conn = base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("srv_mon", QUERY_GET_SERVANT_MONTHLY_ROWS)?;
|
||||
conn.prepare("upd_uh_mon", QUERY_UPDATE_USER_HOUSE_SERVANT_MONTHLY_META)?;
|
||||
conn.prepare("upd_pen", QUERY_UPDATE_USER_HOUSE_SERVANT_UNDERFUNDED_PENALTY)?;
|
||||
conn.prepare("upd_rep", QUERY_UPDATE_CHARACTER_REPUTATION)?;
|
||||
conn.prepare("cnt_lov", QUERY_COUNT_ACTIVE_LOVERS_FOR_CHARACTER)?;
|
||||
|
||||
let rows = conn.execute("srv_mon", &[])?;
|
||||
|
||||
for row in rows {
|
||||
let user_house_id = row
|
||||
.get("user_house_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(-1);
|
||||
let falukant_user_id = row
|
||||
.get("falukant_user_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(-1);
|
||||
let character_id = row
|
||||
.get("character_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(-1);
|
||||
if user_house_id < 0 || falukant_user_id < 0 || character_id < 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let title_level = row
|
||||
.get("title_level")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
let house_position = row
|
||||
.get("house_position")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
let house_cost = row
|
||||
.get("house_cost")
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
let servant_count = row
|
||||
.get("servant_count")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
let servant_quality = row
|
||||
.get("servant_quality")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(50);
|
||||
let pay = row
|
||||
.get("servant_pay_level")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "normal".to_string());
|
||||
let pay = pay.trim();
|
||||
let pay_owned = if pay.is_empty() {
|
||||
"normal".to_string()
|
||||
} else {
|
||||
pay.to_string()
|
||||
};
|
||||
let pay = pay_owned.as_str();
|
||||
|
||||
let household_order = row
|
||||
.get("household_order")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(55);
|
||||
let mut reputation = row
|
||||
.get("reputation")
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.unwrap_or(50.0);
|
||||
let user_money = row
|
||||
.get("user_money")
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let (exp_min, exp_max) = expected_range(house_position, title_level);
|
||||
|
||||
let base_per_servant = ((house_cost as f64 / 1000.0) + 40.0).round().max(20.0);
|
||||
let quality_factor = 1.0 + ((servant_quality - 50) as f64 / 200.0);
|
||||
let pay_factor = match pay {
|
||||
"low" => 0.8,
|
||||
"high" => 1.3,
|
||||
_ => 1.0,
|
||||
};
|
||||
// Abstraktes Monatsbudget (ein Spielmonat); Abrechnung erfolgt zwölftelweise pro Tick.
|
||||
let abstract_monthly_cost =
|
||||
(servant_count as f64 * base_per_servant * quality_factor * pay_factor * 100.0).round()
|
||||
/ 100.0;
|
||||
let installment =
|
||||
((abstract_monthly_cost / 12.0) * 100.0).round() / 100.0;
|
||||
|
||||
let lover_rows = conn.execute("cnt_lov", &[&character_id])?;
|
||||
let lover_cnt = lover_rows
|
||||
.first()
|
||||
.and_then(|r| r.get("cnt"))
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let notify = if servant_count <= 0 || abstract_monthly_cost <= 0.0 || installment <= 0.0 {
|
||||
conn.execute("upd_uh_mon", &[&false, &user_house_id])?;
|
||||
false
|
||||
} else if user_money >= installment {
|
||||
base.change_falukant_user_money(
|
||||
falukant_user_id,
|
||||
-installment,
|
||||
"servants_monthly",
|
||||
)?;
|
||||
conn.execute("upd_uh_mon", &[&false, &user_house_id])?;
|
||||
|
||||
if servant_count >= exp_min
|
||||
&& servant_count <= exp_max
|
||||
&& servant_quality >= 70
|
||||
&& household_order >= 80
|
||||
&& pay != "low"
|
||||
&& title_level >= 3
|
||||
{
|
||||
reputation = clamp_f64(reputation + 1.0, 0.0, 100.0);
|
||||
let rep_s = format!("{:.2}", reputation);
|
||||
conn.execute("upd_rep", &[&rep_s, &character_id])?;
|
||||
}
|
||||
true
|
||||
} else {
|
||||
let disc_extra = if lover_cnt > 0 { 5 } else { 0 };
|
||||
conn.execute("upd_pen", &[&disc_extra, &user_house_id])?;
|
||||
if title_level >= 4 {
|
||||
reputation = clamp_f64(reputation - 1.0, 0.0, 100.0);
|
||||
let rep_s = format!("{:.2}", reputation);
|
||||
conn.execute("upd_rep", &[&rep_s, &character_id])?;
|
||||
}
|
||||
conn.execute("upd_uh_mon", &[&true, &user_house_id])?;
|
||||
true
|
||||
};
|
||||
|
||||
if notify {
|
||||
publish_falukant_family_and_status(broker, falukant_user_id, "monthly");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,505 +0,0 @@
|
||||
//! Transportüberfälle (`raid_transport`) und Wachen (`transport.guard_count`).
|
||||
//! Siehe `docs/FALUKANT_TRANSPORT_RAID_DAEMON.md`.
|
||||
//!
|
||||
//! Läuft im Underground-Tick (ca. 60 s). Kein 1-Tage-Wartezeit wie andere Untergrundjobs.
|
||||
|
||||
use crate::db::{ConnectionPool, DbError, Row};
|
||||
use crate::message_broker::MessageBroker;
|
||||
use rand::Rng;
|
||||
use serde_json::{json, Value as Json};
|
||||
|
||||
use crate::worker::sql::{
|
||||
QUERY_GET_AVAILABLE_STOCKS, QUERY_INSERT_INVENTORY, QUERY_RAID_ACTIVE_UNDERGROUND,
|
||||
QUERY_RAID_APP_USER_FOR_FALUKANT, QUERY_RAID_CANDIDATE_TRANSPORTS,
|
||||
QUERY_RAID_FALUKANT_USER_FOR_CHARACTER, QUERY_RAID_NEAREST_BRANCH_FOR_USER,
|
||||
QUERY_RAID_REGION_ALLOWED, QUERY_RAID_SCHEMA_READY, QUERY_RAID_SUBTRACT_REP_BY_USER,
|
||||
QUERY_RAID_UPDATE_UNDERGROUND_RESULT, QUERY_UPDATE_TRANSPORT_SIZE,
|
||||
};
|
||||
|
||||
/// Wird aus `UndergroundWorker::tick` aufgerufen.
|
||||
pub fn run_tick(pool: &ConnectionPool, broker: &MessageBroker) -> Result<(), DbError> {
|
||||
if !schema_ready(pool) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("raid_active", QUERY_RAID_ACTIVE_UNDERGROUND)
|
||||
.map_err(|e| DbError::new(format!("prepare raid_active: {e}")))?;
|
||||
|
||||
let rows = conn
|
||||
.execute("raid_active", &[])
|
||||
.map_err(|e| DbError::new(format!("exec raid_active: {e}")))?;
|
||||
|
||||
drop(conn);
|
||||
|
||||
for row in rows {
|
||||
let ug_id = parse_i32(&row, "id", -1);
|
||||
let performer_cid = parse_i32(&row, "performer_id", -1);
|
||||
let params_str = row.get("parameters").cloned().unwrap_or_else(|| "{}".into());
|
||||
if ug_id < 0 || performer_cid < 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = try_resolve_one_raid(
|
||||
pool,
|
||||
broker,
|
||||
ug_id,
|
||||
performer_cid,
|
||||
¶ms_str,
|
||||
) {
|
||||
eprintln!("[falukant_transport_raid] underground_id={ug_id}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn schema_ready(pool: &ConnectionPool) -> bool {
|
||||
let Ok(mut conn) = pool.get() else {
|
||||
return false;
|
||||
};
|
||||
if conn.prepare("rs", QUERY_RAID_SCHEMA_READY).is_err() {
|
||||
return false;
|
||||
}
|
||||
let Ok(rows) = conn.execute("rs", &[]) else {
|
||||
return false;
|
||||
};
|
||||
rows.first()
|
||||
.and_then(|r| r.get("ready"))
|
||||
.map(|v| v == "t" || v == "true")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn try_resolve_one_raid(
|
||||
pool: &ConnectionPool,
|
||||
broker: &MessageBroker,
|
||||
underground_id: i32,
|
||||
performer_character_id: i32,
|
||||
params_json: &str,
|
||||
) -> Result<(), DbError> {
|
||||
let p: Json = serde_json::from_str(params_json).unwrap_or_else(|_| json!({}));
|
||||
let region_id = p
|
||||
.get("regionId")
|
||||
.or_else(|| p.get("region_id"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|x| x as i32)
|
||||
.unwrap_or(-1);
|
||||
let band_size = p
|
||||
.get("bandSize")
|
||||
.or_else(|| p.get("band_size"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|x| x as i32)
|
||||
.unwrap_or(3)
|
||||
.clamp(1, 50);
|
||||
|
||||
if region_id < 0 {
|
||||
return finish_invalid(
|
||||
pool,
|
||||
broker,
|
||||
underground_id,
|
||||
performer_character_id,
|
||||
"invalid_parameters",
|
||||
json!({"message": "regionId required"}),
|
||||
);
|
||||
}
|
||||
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
conn.prepare("raid_reg", QUERY_RAID_REGION_ALLOWED)
|
||||
.map_err(|e| DbError::new(format!("prepare raid_reg: {e}")))?;
|
||||
let ok = conn
|
||||
.execute("raid_reg", &[®ion_id])
|
||||
.map_err(|e| DbError::new(format!("exec raid_reg: {e}")))?;
|
||||
if ok.is_empty() {
|
||||
return finish_invalid(
|
||||
pool,
|
||||
broker,
|
||||
underground_id,
|
||||
performer_character_id,
|
||||
"invalid_region",
|
||||
json!({"message": "region not allowed for raid_transport"}),
|
||||
);
|
||||
}
|
||||
|
||||
conn.prepare("raid_fu", QUERY_RAID_FALUKANT_USER_FOR_CHARACTER)
|
||||
.map_err(|e| DbError::new(format!("prepare raid_fu: {e}")))?;
|
||||
let fu_rows = conn
|
||||
.execute("raid_fu", &[&performer_character_id])
|
||||
.map_err(|e| DbError::new(format!("exec raid_fu: {e}")))?;
|
||||
let (performer_falukant_id, performer_app_uid) = fu_rows
|
||||
.first()
|
||||
.map(|r| {
|
||||
(
|
||||
parse_i32(r, "falukant_user_id", -1),
|
||||
parse_i32(r, "app_user_id", -1),
|
||||
)
|
||||
})
|
||||
.unwrap_or((-1, -1));
|
||||
if performer_falukant_id < 0 {
|
||||
return finish_invalid(
|
||||
pool,
|
||||
broker,
|
||||
underground_id,
|
||||
performer_character_id,
|
||||
"performer_not_found",
|
||||
json!({}),
|
||||
);
|
||||
}
|
||||
|
||||
drop(conn);
|
||||
|
||||
conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
conn.prepare("raid_cand", QUERY_RAID_CANDIDATE_TRANSPORTS)
|
||||
.map_err(|e| DbError::new(format!("prepare raid_cand: {e}")))?;
|
||||
let candidates = conn
|
||||
.execute("raid_cand", &[®ion_id, &performer_falukant_id])
|
||||
.map_err(|e| DbError::new(format!("exec raid_cand: {e}")))?;
|
||||
|
||||
if candidates.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let cand = &candidates[0];
|
||||
let transport_id = parse_i32(cand, "transport_id", -1);
|
||||
let transport_size = parse_i32(cand, "transport_size", 0);
|
||||
let product_id = parse_i32(cand, "product_id", -1);
|
||||
let guard_count = parse_i32(cand, "guard_count", 0).max(0);
|
||||
let victim_fu = parse_i32(cand, "victim_falukant_user_id", -1);
|
||||
let vehicle_cap = parse_i32(cand, "vehicle_capacity", 1).max(1);
|
||||
|
||||
if transport_id < 0 || product_id < 0 || victim_fu < 0 || transport_size < 2 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
// Begegnung (Bande + Sichtbarkeit großer Ladung)
|
||||
let visibility_penalty = ((transport_size as f64 / vehicle_cap as f64).min(2.0) - 1.0) * 0.04;
|
||||
let encounter_p = (0.08_f64 + band_size as f64 * 0.012 + visibility_penalty).clamp(0.05, 0.45);
|
||||
if !rng.gen_bool(encounter_p) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Kampf
|
||||
let raid_power = band_size + rng.gen_range(0..=band_size);
|
||||
let gmax = guard_count.max(1);
|
||||
let guard_power = guard_count + rng.gen_range(0..=gmax);
|
||||
|
||||
let outcome = if raid_power > (guard_power as f64 * 1.25) as i32 {
|
||||
"major_success"
|
||||
} else if raid_power > guard_power {
|
||||
"partial_success"
|
||||
} else {
|
||||
"repelled"
|
||||
};
|
||||
|
||||
if outcome == "repelled" {
|
||||
let result = json!({
|
||||
"status": "completed",
|
||||
"bandSize": band_size,
|
||||
"regionId": region_id,
|
||||
"attempts": 1,
|
||||
"successes": 0,
|
||||
"lastTargetTransportId": transport_id,
|
||||
"lastLoot": Json::Null,
|
||||
"lastOutcome": "repelled"
|
||||
});
|
||||
save_result(pool, underground_id, &result)?;
|
||||
publish_underground(broker, performer_app_uid, "raid_repelled");
|
||||
publish_status(broker, performer_app_uid);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Beuteanteil (nie Totalverlust)
|
||||
let base_loot = match outcome {
|
||||
"major_success" => rng.gen_range(0.35..0.60),
|
||||
_ => rng.gen_range(0.15..0.45),
|
||||
};
|
||||
let guard_mul = (1.0_f64 - 0.05_f64 * (guard_count.min(12) as f64)).max(0.35);
|
||||
let loot_share = (base_loot * guard_mul).clamp(0.05, 0.55);
|
||||
let mut stolen = ((transport_size as f64) * loot_share).floor() as i32;
|
||||
stolen = stolen.clamp(1, transport_size - 1);
|
||||
|
||||
let carry_loss = rng.gen_range(0.65..0.90);
|
||||
let mut to_store = (stolen as f64 * carry_loss).floor() as i32;
|
||||
to_store = to_store.max(0).min(stolen);
|
||||
|
||||
// Nächstgelegenes Lager
|
||||
let (branch_id, _) = nearest_branch(pool, region_id, performer_falukant_id)?;
|
||||
let (stored, lost_storage) = if branch_id > 0 && to_store > 0 {
|
||||
let remaining = add_to_branch_inventory(pool, branch_id, product_id, to_store)?;
|
||||
(to_store - remaining, remaining)
|
||||
} else {
|
||||
(0, to_store)
|
||||
};
|
||||
|
||||
let new_size = transport_size - stolen;
|
||||
if new_size < 1 {
|
||||
return Err(DbError::new("raid: invalid new transport size"));
|
||||
}
|
||||
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
conn.prepare("upd_sz", QUERY_UPDATE_TRANSPORT_SIZE)
|
||||
.map_err(|e| DbError::new(format!("prepare upd_sz: {e}")))?;
|
||||
conn.execute("upd_sz", &[&transport_id, &new_size])
|
||||
.map_err(|e| DbError::new(format!("exec upd_sz: {e}")))?;
|
||||
|
||||
// Opfer: kleiner Rufabzug
|
||||
conn.prepare("raid_rep", QUERY_RAID_SUBTRACT_REP_BY_USER)
|
||||
.map_err(|e| DbError::new(format!("prepare raid_rep: {e}")))?;
|
||||
let _ = conn.execute("raid_rep", &[&victim_fu, &2_i32]);
|
||||
|
||||
drop(conn);
|
||||
|
||||
let victim_app_uid = app_user_for_falukant(pool, victim_fu)?;
|
||||
|
||||
let result = json!({
|
||||
"status": "completed",
|
||||
"bandSize": band_size,
|
||||
"regionId": region_id,
|
||||
"attempts": 1,
|
||||
"successes": 1,
|
||||
"lastTargetTransportId": transport_id,
|
||||
"lastLoot": {
|
||||
"productId": product_id,
|
||||
"stolenFromTransport": stolen,
|
||||
"stored": stored,
|
||||
"lostDueToStorage": lost_storage,
|
||||
"branchId": branch_id
|
||||
},
|
||||
"lastOutcome": outcome
|
||||
});
|
||||
|
||||
save_result(pool, underground_id, &result)?;
|
||||
|
||||
if victim_app_uid > 0 {
|
||||
publish_transport_raid(broker, victim_app_uid, "transport_raided");
|
||||
publish_status(broker, victim_app_uid);
|
||||
publish_branch(broker, victim_app_uid);
|
||||
}
|
||||
|
||||
let ug_reason = match outcome {
|
||||
"major_success" => "raid_success",
|
||||
_ => "raid_partial_success",
|
||||
};
|
||||
publish_underground(broker, performer_app_uid, ug_reason);
|
||||
if stored > 0 {
|
||||
publish_underground(broker, performer_app_uid, "raid_loot_stored");
|
||||
}
|
||||
publish_status(broker, performer_app_uid);
|
||||
publish_branch(broker, performer_app_uid);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish_invalid(
|
||||
pool: &ConnectionPool,
|
||||
broker: &MessageBroker,
|
||||
underground_id: i32,
|
||||
performer_character_id: i32,
|
||||
code: &str,
|
||||
detail: Json,
|
||||
) -> Result<(), DbError> {
|
||||
let performer_app_uid = falukant_user_row(pool, performer_character_id)
|
||||
.map(|(_, app)| app)
|
||||
.unwrap_or(-1);
|
||||
|
||||
let result = json!({
|
||||
"status": "error",
|
||||
"code": code,
|
||||
"detail": detail
|
||||
});
|
||||
save_result(pool, underground_id, &result)?;
|
||||
if performer_app_uid > 0 {
|
||||
publish_underground(broker, performer_app_uid, "raid_repelled");
|
||||
publish_status(broker, performer_app_uid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn falukant_user_row(pool: &ConnectionPool, character_id: i32) -> Result<(i32, i32), DbError> {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
conn.prepare("fu", QUERY_RAID_FALUKANT_USER_FOR_CHARACTER)
|
||||
.map_err(|e| DbError::new(format!("prepare fu: {e}")))?;
|
||||
let rows = conn
|
||||
.execute("fu", &[&character_id])
|
||||
.map_err(|e| DbError::new(format!("exec fu: {e}")))?;
|
||||
rows.first()
|
||||
.map(|r| {
|
||||
(
|
||||
parse_i32(r, "falukant_user_id", -1),
|
||||
parse_i32(r, "app_user_id", -1),
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| DbError::new("character not found"))
|
||||
}
|
||||
|
||||
fn app_user_for_falukant(pool: &ConnectionPool, falukant_user_id: i32) -> Result<i32, DbError> {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
conn.prepare("au", QUERY_RAID_APP_USER_FOR_FALUKANT)
|
||||
.map_err(|e| DbError::new(format!("prepare au: {e}")))?;
|
||||
let rows = conn
|
||||
.execute("au", &[&falukant_user_id])
|
||||
.map_err(|e| DbError::new(format!("exec au: {e}")))?;
|
||||
Ok(rows
|
||||
.first()
|
||||
.map(|r| parse_i32(r, "user_id", -1))
|
||||
.unwrap_or(-1))
|
||||
}
|
||||
|
||||
fn nearest_branch(
|
||||
pool: &ConnectionPool,
|
||||
raid_region_id: i32,
|
||||
performer_falukant_id: i32,
|
||||
) -> Result<(i32, i32), DbError> {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
conn.prepare("nb", QUERY_RAID_NEAREST_BRANCH_FOR_USER)
|
||||
.map_err(|e| DbError::new(format!("prepare nb: {e}")))?;
|
||||
let rows = conn
|
||||
.execute("nb", &[&raid_region_id, &performer_falukant_id])
|
||||
.map_err(|e| DbError::new(format!("exec nb: {e}")))?;
|
||||
let branch_id = rows
|
||||
.first()
|
||||
.map(|r| parse_i32(r, "branch_id", -1))
|
||||
.unwrap_or(-1);
|
||||
let reg = rows
|
||||
.first()
|
||||
.map(|r| parse_i32(r, "region_id", -1))
|
||||
.unwrap_or(-1);
|
||||
Ok((branch_id, reg))
|
||||
}
|
||||
|
||||
fn add_to_branch_inventory(
|
||||
pool: &ConnectionPool,
|
||||
branch_id: i32,
|
||||
product_id: i32,
|
||||
quantity: i32,
|
||||
) -> Result<i32, DbError> {
|
||||
use std::cmp::min;
|
||||
|
||||
let stocks = load_stocks(pool, branch_id)?;
|
||||
let mut remaining = quantity;
|
||||
for s in stocks {
|
||||
if remaining <= 0 {
|
||||
break;
|
||||
}
|
||||
let free = s.total_capacity - s.filled;
|
||||
if free <= 0 {
|
||||
continue;
|
||||
}
|
||||
let to_store = min(remaining, free);
|
||||
store_in_stock(pool, s.stock_id, product_id, to_store)?;
|
||||
remaining -= to_store;
|
||||
}
|
||||
Ok(remaining)
|
||||
}
|
||||
|
||||
struct StockInfo {
|
||||
stock_id: i32,
|
||||
total_capacity: i32,
|
||||
filled: i32,
|
||||
}
|
||||
|
||||
fn load_stocks(pool: &ConnectionPool, branch_id: i32) -> Result<Vec<StockInfo>, DbError> {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
conn.prepare("gs", QUERY_GET_AVAILABLE_STOCKS)
|
||||
.map_err(|e| DbError::new(format!("prepare gs: {e}")))?;
|
||||
let rows = conn
|
||||
.execute("gs", &[&branch_id])
|
||||
.map_err(|e| DbError::new(format!("exec gs: {e}")))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let stock_id = parse_i32(&row, "id", -1);
|
||||
let total_capacity = parse_i32(&row, "total_capacity", 0);
|
||||
let filled = parse_i32(&row, "filled", 0);
|
||||
if stock_id >= 0 {
|
||||
out.push(StockInfo {
|
||||
stock_id,
|
||||
total_capacity,
|
||||
filled,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn store_in_stock(pool: &ConnectionPool, stock_id: i32, product_id: i32, quantity: i32) -> Result<(), DbError> {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
conn.prepare("ins", QUERY_INSERT_INVENTORY)
|
||||
.map_err(|e| DbError::new(format!("prepare ins: {e}")))?;
|
||||
let quality: i32 = 100;
|
||||
conn.execute("ins", &[&stock_id, &product_id, &quantity, &quality])
|
||||
.map_err(|e| DbError::new(format!("exec ins: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_result(pool: &ConnectionPool, underground_id: i32, result: &Json) -> Result<(), DbError> {
|
||||
let s = result.to_string();
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
conn.prepare("ur", QUERY_RAID_UPDATE_UNDERGROUND_RESULT)
|
||||
.map_err(|e| DbError::new(format!("prepare ur: {e}")))?;
|
||||
conn.execute("ur", &[&underground_id, &s])
|
||||
.map_err(|e| DbError::new(format!("exec ur: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn publish_transport_raid(broker: &MessageBroker, user_id: i32, reason: &str) {
|
||||
let m = format!(
|
||||
r#"{{"event":"falukantTransportRaid","user_id":{},"reason":"{}"}}"#,
|
||||
user_id, reason
|
||||
);
|
||||
broker.publish(m);
|
||||
}
|
||||
|
||||
fn publish_underground(broker: &MessageBroker, user_id: i32, reason: &str) {
|
||||
if user_id <= 0 {
|
||||
return;
|
||||
}
|
||||
let m = format!(
|
||||
r#"{{"event":"falukantUndergroundUpdate","user_id":{},"reason":"{}"}}"#,
|
||||
user_id, reason
|
||||
);
|
||||
broker.publish(m);
|
||||
}
|
||||
|
||||
fn publish_status(broker: &MessageBroker, user_id: i32) {
|
||||
if user_id <= 0 {
|
||||
return;
|
||||
}
|
||||
let m = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id);
|
||||
broker.publish(m);
|
||||
}
|
||||
|
||||
fn publish_branch(broker: &MessageBroker, user_id: i32) {
|
||||
if user_id <= 0 {
|
||||
return;
|
||||
}
|
||||
let m = format!(r#"{{"event":"falukantBranchUpdate","user_id":{}}}"#, user_id);
|
||||
broker.publish(m);
|
||||
}
|
||||
|
||||
fn parse_i32(row: &Row, key: &str, default: i32) -> i32 {
|
||||
row.get(key)
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
@@ -68,34 +68,17 @@ impl HouseWorker {
|
||||
conn.prepare("get_new_house_data", QUERY_GET_NEW_HOUSE_DATA)?;
|
||||
let rows = conn.execute("get_new_house_data", &[])?;
|
||||
|
||||
eprintln!("[HouseWorker] Query zurückgegeben: {} Häuser gefunden", rows.len());
|
||||
|
||||
conn.prepare("add_new_buyable_house", QUERY_ADD_NEW_BUYABLE_HOUSE)?;
|
||||
|
||||
let mut inserted_count = 0;
|
||||
for row in rows {
|
||||
if let Some(house_id) = row
|
||||
.get("house_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
match conn.execute("add_new_buyable_house", &[&house_id]) {
|
||||
Ok(_) => {
|
||||
inserted_count += 1;
|
||||
eprintln!("[HouseWorker] Neues kaufbares Haus erstellt: house_type_id={}", house_id);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[HouseWorker] Fehler beim Erstellen von buyable_house (house_type_id={}): {}", house_id, err);
|
||||
}
|
||||
}
|
||||
conn.execute("add_new_buyable_house", &[&house_id])?;
|
||||
}
|
||||
}
|
||||
|
||||
if inserted_count > 0 {
|
||||
eprintln!("[HouseWorker] {} neue kaufbare Häuser erstellt", inserted_count);
|
||||
} else {
|
||||
eprintln!("[HouseWorker] Keine neuen kaufbaren Häuser erstellt");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -11,18 +11,9 @@ mod user_character;
|
||||
mod transport;
|
||||
mod weather;
|
||||
mod events;
|
||||
mod death_log;
|
||||
mod falukant_family;
|
||||
mod falukant_certificate;
|
||||
mod falukant_servants;
|
||||
mod falukant_debtors;
|
||||
mod falukant_transport_raid;
|
||||
mod political_benefits;
|
||||
mod notify;
|
||||
mod sql;
|
||||
|
||||
pub use base::Worker;
|
||||
pub use base::{get_worker_runtime_snapshot, WorkerRuntimeSnapshot};
|
||||
pub use crate::db::ConnectionPool;
|
||||
pub use character_creation::CharacterCreationWorker;
|
||||
pub use director::DirectorWorker;
|
||||
@@ -36,5 +27,4 @@ pub use user_character::UserCharacterWorker;
|
||||
pub use transport::TransportWorker;
|
||||
pub use weather::WeatherWorker;
|
||||
pub use events::EventsWorker;
|
||||
pub use falukant_family::FalukantFamilyWorker;
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
use crate::db::{DbConnection, DbError};
|
||||
use crate::worker::sql::QUERY_INSERT_NOTIFICATION;
|
||||
|
||||
/// Variante für bestehende DB-Verbindungen (spart Connect/Prepare in Loops).
|
||||
pub fn insert_notification_conn(
|
||||
conn: &mut DbConnection,
|
||||
user_id: i32,
|
||||
tr: &str,
|
||||
_character_id: Option<i32>,
|
||||
) -> Result<(), DbError> {
|
||||
conn.prepare("insert_notification_full", QUERY_INSERT_NOTIFICATION)?;
|
||||
conn.execute(
|
||||
"insert_notification_full",
|
||||
&[&user_id, &tr],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
//! Politische Amtsvorteile: `reputation_periodic` (täglich), **tägliches Politik-Gehalt** (pro Amt,
|
||||
//! gestaffelt nach Amts-Rang oder `daily_salary` im Benefit-JSON), Ablauf offener Ernennungen,
|
||||
//! Hilfsabfrage `free_lover_slots` für Liebschafts-Monatstick.
|
||||
//!
|
||||
//! Voraussetzungen: Migrationen `012_falukant_political_benefits_daemon.sql`,
|
||||
//! `013_falukant_political_daily_salary.sql`, Backend-Seeds
|
||||
//! `falukant_predefine.political_office_benefit` mit JSON (`tr` oder `benefitType`, `gain`, `intervalDays`, …).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::db::{ConnectionPool, DbError, Row};
|
||||
use crate::message_broker::MessageBroker;
|
||||
|
||||
use crate::worker::base::BaseWorker;
|
||||
use crate::worker::falukant_certificate::political_office_name_rank;
|
||||
use crate::worker::sql::{
|
||||
QUERY_POLITICAL_APPOINTMENT_EXPIRE_PENDING, QUERY_POLITICAL_APPOINTMENT_SCHEMA_READY,
|
||||
QUERY_POLITICAL_BENEFIT_DAEMON_SCHEMA_READY, QUERY_POLITICAL_DAILY_SALARY_OFFICE_ROWS,
|
||||
QUERY_POLITICAL_DAILY_SALARY_USER_COLUMN_READY, QUERY_POLITICAL_REPUTATION_APPLY_GAIN,
|
||||
QUERY_POLITICAL_REPUTATION_TICK_ROWS, QUERY_POLITICAL_REPUTATION_TICK_UPSERT,
|
||||
QUERY_SUM_FREE_LOVER_SLOTS_FOR_CHARACTER, QUERY_UPDATE_LAST_POLITICAL_DAILY_SALARY_ON,
|
||||
};
|
||||
|
||||
fn parse_i32(row: &Row, key: &str, default: i32) -> i32 {
|
||||
row.get(key)
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn parse_f64_from_row(row: &Row, key: &str) -> f64 {
|
||||
row.get(key)
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
pub fn daemon_schema_ready(pool: &ConnectionPool) -> bool {
|
||||
let mut conn = match pool.get() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if conn
|
||||
.prepare("pb_schema", QUERY_POLITICAL_BENEFIT_DAEMON_SCHEMA_READY)
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let rows = match conn.execute("pb_schema", &[]) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return false,
|
||||
};
|
||||
rows.first()
|
||||
.and_then(|r| r.get("ready"))
|
||||
.map(|v| v == "true" || v == "t")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn salary_user_column_ready(pool: &ConnectionPool) -> bool {
|
||||
let mut conn = match pool.get() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if conn
|
||||
.prepare("pds_col", QUERY_POLITICAL_DAILY_SALARY_USER_COLUMN_READY)
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let rows = match conn.execute("pds_col", &[]) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return false,
|
||||
};
|
||||
rows.first()
|
||||
.and_then(|r| r.get("ready"))
|
||||
.map(|v| v == "true" || v == "t")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Fallback-Tageseinkommen (ohne konfiguriertes `daily_salary`), gestaffelt nach Rang 1–5 (niedrig → hoch).
|
||||
fn fallback_daily_salary_for_political_rank(rank: i32) -> f64 {
|
||||
let r = if rank <= 0 { 1 } else { rank.min(5) };
|
||||
match r {
|
||||
1 => 14.0,
|
||||
2 => 28.0,
|
||||
3 => 45.0,
|
||||
4 => 65.0,
|
||||
5 => 95.0,
|
||||
_ => 14.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn appointment_schema_ready(pool: &ConnectionPool) -> bool {
|
||||
let mut conn = match pool.get() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if conn
|
||||
.prepare("pa_schema", QUERY_POLITICAL_APPOINTMENT_SCHEMA_READY)
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let rows = match conn.execute("pa_schema", &[]) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return false,
|
||||
};
|
||||
rows.first()
|
||||
.and_then(|r| r.get("ready"))
|
||||
.map(|v| v == "true" || v == "t")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Täglich: Ansehen gemäß `reputation_periodic` + Upsert `political_benefit_last_tick` + Status-Push.
|
||||
pub fn run_reputation_periodic_ticks(pool: &ConnectionPool, broker: &MessageBroker) -> Result<(), DbError> {
|
||||
if !daemon_schema_ready(pool) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("rep_tick_rows", QUERY_POLITICAL_REPUTATION_TICK_ROWS)
|
||||
.map_err(|e| DbError::new(format!("[PoliticalBenefits] prepare rep_tick_rows: {e}")))?;
|
||||
conn.prepare("rep_tick_upsert", QUERY_POLITICAL_REPUTATION_TICK_UPSERT)
|
||||
.map_err(|e| DbError::new(format!("[PoliticalBenefits] prepare rep_tick_upsert: {e}")))?;
|
||||
conn.prepare("rep_apply", QUERY_POLITICAL_REPUTATION_APPLY_GAIN)
|
||||
.map_err(|e| DbError::new(format!("[PoliticalBenefits] prepare rep_apply: {e}")))?;
|
||||
|
||||
let rows = conn
|
||||
.execute("rep_tick_rows", &[])
|
||||
.map_err(|e| DbError::new(format!("[PoliticalBenefits] exec rep_tick_rows: {e}")))?;
|
||||
|
||||
let mut notified: std::collections::HashSet<i32> = std::collections::HashSet::new();
|
||||
|
||||
for row in rows {
|
||||
let benefit_id = parse_i32(&row, "benefit_id", -1);
|
||||
let character_id = parse_i32(&row, "character_id", -1);
|
||||
let gain = parse_f64_from_row(&row, "gain");
|
||||
let user_id = parse_i32(&row, "falukant_user_id", -1);
|
||||
|
||||
if benefit_id < 1 || character_id < 1 || gain <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let gain_str = format!("{:.2}", gain);
|
||||
|
||||
conn.execute("rep_apply", &[&gain_str, &character_id])
|
||||
.map_err(|e| DbError::new(format!("[PoliticalBenefits] rep_apply cid={character_id}: {e}")))?;
|
||||
|
||||
conn.execute(
|
||||
"rep_tick_upsert",
|
||||
&[&character_id, &benefit_id],
|
||||
)
|
||||
.map_err(|e| DbError::new(format!("[PoliticalBenefits] rep_tick_upsert: {e}")))?;
|
||||
|
||||
eprintln!(
|
||||
"[PoliticalBenefits] characterId={} officeBenefitId={} action=reputation_tick gain={}",
|
||||
character_id, benefit_id, gain_str
|
||||
);
|
||||
|
||||
if user_id > 0 {
|
||||
notified.insert(user_id);
|
||||
}
|
||||
}
|
||||
|
||||
for uid in notified {
|
||||
let msg = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, uid);
|
||||
broker.publish(msg);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Täglich: einmal pro Kalendertag Gutschrift für Spieler mit aktivem politischen Amt.
|
||||
/// Betrag pro Amt: Summe `daily_salary` aus `political_office_benefit`, sonst Fallback nach Amtsbezeichnung (`political_office_name_rank`).
|
||||
pub fn run_daily_political_salary(pool: &ConnectionPool, broker: &MessageBroker) -> Result<(), DbError> {
|
||||
if !salary_user_column_ready(pool) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
if conn
|
||||
.prepare("pds_rows", QUERY_POLITICAL_DAILY_SALARY_OFFICE_ROWS)
|
||||
.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let rows = conn
|
||||
.execute("pds_rows", &[])
|
||||
.map_err(|e| DbError::new(format!("[PoliticalBenefits] exec pds_rows: {e}")))?;
|
||||
|
||||
let mut totals: HashMap<i32, f64> = HashMap::new();
|
||||
|
||||
for row in rows {
|
||||
let uid = parse_i32(&row, "falukant_user_id", -1);
|
||||
if uid < 1 {
|
||||
continue;
|
||||
}
|
||||
let configured = parse_f64_from_row(&row, "configured_daily_salary");
|
||||
let office_name = row.get("office_name").map(|s| s.as_str()).unwrap_or("");
|
||||
let piece = if configured > 0.0 {
|
||||
configured
|
||||
} else {
|
||||
let r = political_office_name_rank(office_name);
|
||||
fallback_daily_salary_for_political_rank(r)
|
||||
};
|
||||
if piece <= 0.0 || !piece.is_finite() {
|
||||
continue;
|
||||
}
|
||||
*totals.entry(uid).or_insert(0.0) += piece;
|
||||
}
|
||||
|
||||
if totals.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
conn.prepare("pds_upd_date", QUERY_UPDATE_LAST_POLITICAL_DAILY_SALARY_ON)
|
||||
.map_err(|e| DbError::new(format!("[PoliticalBenefits] prepare pds_upd_date: {e}")))?;
|
||||
|
||||
let base = BaseWorker::new("PoliticalBenefits", pool.clone(), broker.clone());
|
||||
|
||||
for (uid, total) in totals {
|
||||
if total <= 0.0 || !total.is_finite() {
|
||||
continue;
|
||||
}
|
||||
|
||||
base.change_falukant_user_money(uid, total, "political daily salary")
|
||||
.map_err(|e| {
|
||||
DbError::new(format!(
|
||||
"[PoliticalBenefits] change_falukant_user_money uid={uid} total={total}: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
conn.execute("pds_upd_date", &[&uid])
|
||||
.map_err(|e| DbError::new(format!("[PoliticalBenefits] pds_upd_date uid={uid}: {e}")))?;
|
||||
|
||||
eprintln!(
|
||||
"[PoliticalBenefits] falukantUserId={} action=political_daily_salary amount={:.2}",
|
||||
uid, total
|
||||
);
|
||||
|
||||
let msg = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, uid);
|
||||
broker.publish(msg);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Täglich: `pending` → `expired`, wenn `expires_at` überschritten.
|
||||
pub fn expire_political_appointments(pool: &ConnectionPool) -> Result<(), DbError> {
|
||||
if !appointment_schema_ready(pool) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("pa_expire", QUERY_POLITICAL_APPOINTMENT_EXPIRE_PENDING)
|
||||
.map_err(|e| DbError::new(format!("[PoliticalBenefits] prepare pa_expire: {e}")))?;
|
||||
conn.execute("pa_expire", &[])
|
||||
.map_err(|e| DbError::new(format!("[PoliticalBenefits] exec pa_expire: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Summe freier Liebschafts-Slots (max. 5) für einen Charakter mit aktivem politischen Amt.
|
||||
pub fn sum_free_lover_slots(
|
||||
conn: &mut crate::db::DbConnection,
|
||||
character_id: i32,
|
||||
) -> Result<i32, DbError> {
|
||||
if character_id < 1 {
|
||||
return Ok(0);
|
||||
}
|
||||
if conn
|
||||
.prepare("free_lover_slots", QUERY_SUM_FREE_LOVER_SLOTS_FOR_CHARACTER)
|
||||
.is_err()
|
||||
{
|
||||
return Ok(0);
|
||||
}
|
||||
let rows = conn.execute("free_lover_slots", &[&character_id])?;
|
||||
Ok(rows
|
||||
.first()
|
||||
.and_then(|r| r.get("free_slots"))
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(0)
|
||||
.max(0))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,9 +14,6 @@ use crate::worker::sql::{
|
||||
QUERY_INSERT_INVENTORY,
|
||||
QUERY_INSERT_UPDATE_PRODUCTION_LOG,
|
||||
QUERY_ADD_OVERPRODUCTION_NOTIFICATION,
|
||||
QUERY_FIND_OVERPRODUCTION_NOTIFICATION,
|
||||
QUERY_UPDATE_OVERPRODUCTION_NOTIFICATION,
|
||||
QUERY_GET_BRANCH_CAPACITY,
|
||||
};
|
||||
|
||||
/// Abbildet eine abgeschlossene Produktion aus der Datenbank.
|
||||
@@ -118,16 +115,20 @@ impl ProduceWorker {
|
||||
self.base
|
||||
.set_current_step("Fetch Finished Productions");
|
||||
|
||||
let finished_productions = self.get_finished_productions().unwrap_or_default();
|
||||
let finished_productions = match self.get_finished_productions() {
|
||||
Ok(rows) => rows,
|
||||
Err(err) => {
|
||||
eprintln!("[ProduceWorker] Fehler in getFinishedProductions: {err}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
self.base
|
||||
.set_current_step("Process Finished Productions");
|
||||
|
||||
// Nur Fehler loggen; keine Debug-Infos
|
||||
|
||||
for production in finished_productions {
|
||||
if !self.has_enough_capacity(production.branch_id, production.quantity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.handle_finished_production(&production);
|
||||
}
|
||||
}
|
||||
@@ -166,7 +167,15 @@ impl ProduceWorker {
|
||||
user_id: i32,
|
||||
) -> bool {
|
||||
let mut remaining_quantity = quantity;
|
||||
let stocks = self.get_available_stocks(branch_id).unwrap_or_default();
|
||||
let stocks = match self.get_available_stocks(branch_id) {
|
||||
Ok(rows) => rows,
|
||||
Err(err) => {
|
||||
eprintln!("[ProduceWorker] Fehler in getAvailableStocks: {err}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
// Ruhemodus: keine Info-Logs, nur Fehler
|
||||
|
||||
for stock in stocks {
|
||||
if remaining_quantity <= 0 {
|
||||
@@ -174,15 +183,18 @@ impl ProduceWorker {
|
||||
}
|
||||
|
||||
let free_capacity = stock.total_capacity - stock.filled;
|
||||
// keine Debug-Ausgabe
|
||||
if free_capacity <= 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let to_store = min(remaining_quantity, free_capacity);
|
||||
// keine Debug-Ausgabe
|
||||
if !self.store_in_stock(stock.stock_id, product_id, to_store, quality) {
|
||||
return false;
|
||||
}
|
||||
remaining_quantity -= to_store;
|
||||
// keine Debug-Ausgabe
|
||||
}
|
||||
|
||||
if remaining_quantity == 0 {
|
||||
@@ -204,40 +216,6 @@ impl ProduceWorker {
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn has_enough_capacity(&self, branch_id: i32, required_quantity: i32) -> bool {
|
||||
let mut conn = match self.base.pool.get() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
if conn.prepare("check_branch_capacity", QUERY_GET_BRANCH_CAPACITY).is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let rows = match conn.execute("check_branch_capacity", &[&branch_id]) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
if rows.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let row = &rows[0];
|
||||
let stock_size: i32 = row
|
||||
.get("stock_size")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
let used_in_stock: i32 = row
|
||||
.get("used_in_stock")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Freier Platz = aktuell befüllte Kapazität; laufende Produktionen belegen noch keinen Lagerplatz
|
||||
let free_capacity = stock_size - used_in_stock;
|
||||
free_capacity >= required_quantity
|
||||
}
|
||||
|
||||
fn store_in_stock(
|
||||
&self,
|
||||
stock_id: i32,
|
||||
@@ -252,17 +230,23 @@ impl ProduceWorker {
|
||||
return true;
|
||||
}
|
||||
// Wenn kein Update stattfand, Insert versuchen
|
||||
if self.insert_inventory(stock_id, product_id, quantity, quality).is_err() {
|
||||
if let Err(err) = self.insert_inventory(stock_id, product_id, quantity, quality) {
|
||||
eprintln!("[ProduceWorker] Fehler beim Insert in storeInStock: {err}");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
Err(err) => {
|
||||
eprintln!("[ProduceWorker] Fehler beim Update in storeInStock: {err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_production(&self, production_id: i32) {
|
||||
let _ = self.remove_production(production_id);
|
||||
if let Err(err) = self.remove_production(production_id) {
|
||||
eprintln!("[ProduceWorker] Fehler beim Löschen der Produktion: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn add_production_to_log(
|
||||
@@ -272,7 +256,9 @@ impl ProduceWorker {
|
||||
product_id: i32,
|
||||
quantity: i32,
|
||||
) {
|
||||
let _ = self.insert_or_update_production_log(region_id, user_id, product_id, quantity);
|
||||
if let Err(err) = self.insert_or_update_production_log(region_id, user_id, product_id, quantity) {
|
||||
eprintln!("[ProduceWorker] Fehler in addProductionToLog: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn send_production_ready_event(
|
||||
@@ -291,7 +277,13 @@ impl ProduceWorker {
|
||||
}
|
||||
|
||||
fn handle_overproduction(&self, user_id: i32, branch_id: i32, remaining_quantity: i32) {
|
||||
let _ = self.insert_overproduction_notification(user_id, branch_id, remaining_quantity);
|
||||
if let Err(err) =
|
||||
self.insert_overproduction_notification(user_id, branch_id, remaining_quantity)
|
||||
{
|
||||
eprintln!(
|
||||
"[ProduceWorker] Fehler beim Schreiben der Overproduction-Notification: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
let update_status =
|
||||
format!(r#"{{"event":"falukantUpdateStatus","user_id":{user_id}}}"#);
|
||||
@@ -410,15 +402,9 @@ impl ProduceWorker {
|
||||
.get()
|
||||
.map_err(|e| crate::db::DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
// Prüfe, ob bereits eine unangezeigte Überproduktions-Benachrichtigung für diesen User/Branch existiert
|
||||
conn.prepare(
|
||||
"find_overproduction_notification",
|
||||
QUERY_FIND_OVERPRODUCTION_NOTIFICATION,
|
||||
)?;
|
||||
|
||||
let existing_rows = conn.execute(
|
||||
"find_overproduction_notification",
|
||||
&[&user_id, &branch_id],
|
||||
"add_overproduction_notification",
|
||||
QUERY_ADD_OVERPRODUCTION_NOTIFICATION,
|
||||
)?;
|
||||
|
||||
// Zusätzlich zur Menge die Branch-ID in der Payload mitschicken, damit
|
||||
@@ -428,29 +414,6 @@ impl ProduceWorker {
|
||||
remaining_quantity, branch_id
|
||||
);
|
||||
|
||||
if !existing_rows.is_empty() {
|
||||
// Aktualisiere bestehende Benachrichtigung
|
||||
// PostgreSQL aggregiert die Werte automatisch in der UPDATE-Query
|
||||
conn.prepare(
|
||||
"update_overproduction_notification",
|
||||
QUERY_UPDATE_OVERPRODUCTION_NOTIFICATION,
|
||||
)?;
|
||||
// Versuche Update; wenn es fehlschlägt (z.B. ungültiges JSON), erstelle neue Benachrichtigung
|
||||
match conn.execute(
|
||||
"update_overproduction_notification",
|
||||
&[&user_id, &branch_id, &remaining_quantity],
|
||||
) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(_) => { /* Fallback: Erstelle neue Benachrichtigung */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Erstelle neue Benachrichtigung, wenn keine existiert oder Parsing fehlschlug
|
||||
conn.prepare(
|
||||
"add_overproduction_notification",
|
||||
QUERY_ADD_OVERPRODUCTION_NOTIFICATION,
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"add_overproduction_notification",
|
||||
&[&user_id, ¬ification],
|
||||
|
||||
2920
src/worker/sql.rs
2920
src/worker/sql.rs
File diff suppressed because it is too large
Load Diff
@@ -68,30 +68,16 @@ impl StockageManager {
|
||||
let dist = Uniform::from(0.0..1.0);
|
||||
|
||||
let town_ids = Self::get_town_ids(pool)?;
|
||||
eprintln!("[StockageManager] Prüfe {} Städte auf neue Lager", town_ids.len());
|
||||
|
||||
let mut stocks_added = 0;
|
||||
for town_id in town_ids {
|
||||
// Wahrscheinlichkeit: round(dist * 216) <= 1
|
||||
// Das bedeutet: 2/217 ≈ 0.92% pro Stadt pro Durchlauf
|
||||
// Pro Tag (1440 Durchläufe): ~73% Chance pro Stadt
|
||||
// Erhöht von 2/2161 (0.0926%) auf 2/217 (0.92%) = 10x höher
|
||||
let roll: f64 = dist.sample(&mut rng) * 216.0_f64;
|
||||
// Wahrscheinlichkeit analog: round(dist * 2160) <= 1
|
||||
let roll: f64 = dist.sample(&mut rng) * 2160.0_f64;
|
||||
let chance = roll.round();
|
||||
if chance <= 1.0 {
|
||||
eprintln!("[StockageManager] Erstelle Lager für Stadt {}", town_id);
|
||||
if let Err(err) = Self::add_stock_for_town(pool, broker, town_id) {
|
||||
eprintln!("[StockageManager] Fehler beim Erstellen von Lager für Stadt {}: {}", town_id, err);
|
||||
} else {
|
||||
stocks_added += 1;
|
||||
}
|
||||
Self::add_stock_for_town(pool, broker, town_id)?;
|
||||
}
|
||||
}
|
||||
|
||||
if stocks_added > 0 {
|
||||
eprintln!("[StockageManager] {} neue Lager erstellt", stocks_added);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -123,10 +109,7 @@ impl StockageManager {
|
||||
.map_err(|e| DbError::new(format!("[StockageManager] DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("add_stock", QUERY_INSERT_STOCK)?;
|
||||
let rows = conn.execute("add_stock", &[&town_id])?;
|
||||
|
||||
let inserted_count = rows.len();
|
||||
eprintln!("[StockageManager] Lager für Stadt {} erstellt: {} Einträge", town_id, inserted_count);
|
||||
conn.execute("add_stock", &[&town_id])?;
|
||||
|
||||
// Benachrichtige alle User in der Region
|
||||
let users = Self::get_region_users(&mut conn, town_id)?;
|
||||
|
||||
@@ -11,9 +11,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::base::{BaseWorker, Worker, WorkerState};
|
||||
use crate::worker::sql::{
|
||||
QUERY_COUNT_ACTIVE_LOVERS_FOR_CHARACTER, QUERY_UPDATE_CHARACTER_REPUTATION, QUERY_UPDATE_MONEY,
|
||||
};
|
||||
use crate::worker::sql::QUERY_UPDATE_MONEY;
|
||||
|
||||
pub struct UndergroundWorker {
|
||||
base: BaseWorker,
|
||||
@@ -29,7 +27,7 @@ struct HouseConditions {
|
||||
}
|
||||
|
||||
// Query-Konstanten (1:1 aus der C++-Version übernommen)
|
||||
const Q_SELECT_UNDERGROUND_BY_CHARACTER: &str = r#"
|
||||
const Q_SELECT_BY_PERFORMER: &str = r#"
|
||||
SELECT u.id,
|
||||
t.tr AS underground_type,
|
||||
u.performer_id,
|
||||
@@ -121,90 +119,6 @@ const Q_SELECT_FALUKANT_USER: &str = r#"
|
||||
LIMIT 1;
|
||||
"#;
|
||||
|
||||
/// Opfer-Charakter: Ruf, Stand, Haus/Diener (für discoveryScore / Erpressung / Leak).
|
||||
const Q_AFFAIR_VICTIM_CONTEXT: &str = r#"
|
||||
SELECT uh.id AS user_house_id,
|
||||
COALESCE(c.reputation, 50)::float8 AS reputation,
|
||||
COALESCE(t.level, 0)::int AS title_level,
|
||||
COALESCE(ht.position, 0)::int AS house_position,
|
||||
COALESCE(uh.household_order, 55)::int AS household_order,
|
||||
COALESCE(uh.servant_count, 0)::int AS servant_count,
|
||||
COALESCE(uh.servant_quality, 50)::int AS servant_quality,
|
||||
COALESCE(NULLIF(TRIM(uh.servant_pay_level), ''), 'normal') AS servant_pay_level
|
||||
FROM falukant_data.character c
|
||||
LEFT JOIN falukant_data.user_house uh ON uh.user_id = c.user_id
|
||||
LEFT JOIN falukant_type.house ht ON ht.id = uh.house_type_id
|
||||
LEFT JOIN falukant_type.title t ON t.id = c.title_of_nobility
|
||||
WHERE c.id = $1::int
|
||||
LIMIT 1;
|
||||
"#;
|
||||
|
||||
const Q_AFFAIR_VICTIM_CHILDREN: &str = r#"
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM falukant_data.child_relation cr
|
||||
WHERE (cr.father_character_id = $1::int OR cr.mother_character_id = $1::int)
|
||||
AND cr.birth_context = 'lover'
|
||||
AND cr.legitimacy = 'hidden_bastard'
|
||||
AND COALESCE(cr.public_known, false) = false
|
||||
) AS has_hidden_bastard,
|
||||
EXISTS (
|
||||
SELECT 1 FROM falukant_data.child_relation cr
|
||||
WHERE (cr.father_character_id = $1::int OR cr.mother_character_id = $1::int)
|
||||
AND cr.birth_context = 'lover'
|
||||
AND cr.legitimacy IN ('hidden_bastard', 'acknowledged_bastard')
|
||||
AND COALESCE(cr.public_known, false) = true
|
||||
) AS has_public_known_bastard;
|
||||
"#;
|
||||
|
||||
/// Alle aktiven Liebschaften des Opfers; optional feste `relationship_id` aus parameters.
|
||||
const Q_AFFAIR_ALL_LOVERS: &str = r#"
|
||||
SELECT r.id AS rel_id,
|
||||
rs.lover_role,
|
||||
rs.visibility::int AS visibility,
|
||||
rs.discretion::int AS discretion,
|
||||
rs.affection::int AS affection,
|
||||
COALESCE(rs.acknowledged, false) AS acknowledged,
|
||||
rs.status_fit::int AS status_fit,
|
||||
rs.maintenance_level::int AS maintenance_level,
|
||||
COALESCE(rs.scandal_extra_daily_pct, 0)::int AS scandal_extra,
|
||||
COALESCE(t1.tr, '') AS title1_tr,
|
||||
COALESCE(t2.tr, '') AS title2_tr,
|
||||
fu1.id AS user1_id,
|
||||
fu2.id AS user2_id,
|
||||
LEAST(
|
||||
((CURRENT_DATE - c1.birthdate::date) / 365),
|
||||
((CURRENT_DATE - c2.birthdate::date) / 365)
|
||||
)::int AS min_age_years
|
||||
FROM falukant_data.relationship r
|
||||
JOIN falukant_type.relationship rt
|
||||
ON rt.id = r.relationship_type_id AND rt.tr = 'lover'
|
||||
JOIN falukant_data.relationship_state rs
|
||||
ON rs.relationship_id = r.id AND rs.active = true
|
||||
JOIN falukant_data.character c1 ON c1.id = r.character1_id
|
||||
JOIN falukant_data.character c2 ON c2.id = r.character2_id
|
||||
LEFT JOIN falukant_type.title t1 ON t1.id = c1.title_of_nobility
|
||||
LEFT JOIN falukant_type.title t2 ON t2.id = c2.title_of_nobility
|
||||
LEFT JOIN falukant_data.falukant_user fu1 ON fu1.id = c1.user_id
|
||||
LEFT JOIN falukant_data.falukant_user fu2 ON fu2.id = c2.user_id
|
||||
WHERE (r.character1_id = $1::int OR r.character2_id = $1::int)
|
||||
AND ($2::int IS NULL OR r.id = $2::int);
|
||||
"#;
|
||||
|
||||
const Q_UPDATE_AFFAIR_VISIBILITY_DISCRETION: &str = r#"
|
||||
UPDATE falukant_data.relationship_state
|
||||
SET visibility = LEAST(100, COALESCE(visibility, 0) + $2::smallint),
|
||||
discretion = GREATEST(0, COALESCE(discretion, 0) - $3::smallint),
|
||||
updated_at = NOW()
|
||||
WHERE relationship_id = $1::int;
|
||||
"#;
|
||||
|
||||
const Q_UPDATE_AFFAIR_VISIBILITY_ONLY: &str = r#"
|
||||
UPDATE falukant_data.relationship_state
|
||||
SET visibility = LEAST(100, COALESCE(visibility, 0) + $2::smallint),
|
||||
updated_at = NOW()
|
||||
WHERE relationship_id = $1::int;
|
||||
"#;
|
||||
|
||||
// Use centralized QUERY_UPDATE_MONEY from src/worker/sql.rs
|
||||
|
||||
impl UndergroundWorker {
|
||||
@@ -231,10 +145,6 @@ impl UndergroundWorker {
|
||||
}
|
||||
|
||||
fn tick(pool: &ConnectionPool, broker: &MessageBroker) -> Result<(), DbError> {
|
||||
if let Err(err) = super::falukant_transport_raid::run_tick(pool, broker) {
|
||||
eprintln!("[UndergroundWorker] falukant_transport_raid::run_tick: {err}");
|
||||
}
|
||||
|
||||
let rows = Self::fetch_pending(pool)?;
|
||||
|
||||
for row in rows {
|
||||
@@ -243,7 +153,7 @@ impl UndergroundWorker {
|
||||
None => continue,
|
||||
};
|
||||
|
||||
match Self::execute_row(pool, broker, &row) {
|
||||
match Self::execute_row(pool, &row) {
|
||||
Ok(res) => {
|
||||
Self::update_result(pool, id, &res)?;
|
||||
let event = json!({
|
||||
@@ -274,18 +184,17 @@ impl UndergroundWorker {
|
||||
conn.execute("ug_select_pending", &[])
|
||||
}
|
||||
|
||||
fn execute_row(pool: &ConnectionPool, broker: &MessageBroker, r: &Row) -> Result<Json, DbError> {
|
||||
fn execute_row(pool: &ConnectionPool, r: &Row) -> Result<Json, DbError> {
|
||||
let performer_id = parse_i32(r, "performer_id", -1);
|
||||
let victim_id = parse_i32(r, "victim_id", -1);
|
||||
let task_type = r.get("underground_type").cloned().unwrap_or_default();
|
||||
let params = r.get("parameters").cloned().unwrap_or_else(|| "{}".into());
|
||||
|
||||
Self::handle_task(pool, broker, &task_type, performer_id, victim_id, ¶ms)
|
||||
Self::handle_task(pool, &task_type, performer_id, victim_id, ¶ms)
|
||||
}
|
||||
|
||||
fn handle_task(
|
||||
pool: &ConnectionPool,
|
||||
broker: &MessageBroker,
|
||||
task_type: &str,
|
||||
performer_id: i32,
|
||||
victim_id: i32,
|
||||
@@ -299,7 +208,6 @@ impl UndergroundWorker {
|
||||
"sabotage" => Self::sabotage(pool, performer_id, victim_id, &p),
|
||||
"corrupt_politician" => Ok(Self::corrupt_politician(performer_id, victim_id, &p)),
|
||||
"rob" => Self::rob(pool, performer_id, victim_id, &p),
|
||||
"investigate_affair" => Self::investigate_affair(pool, broker, performer_id, victim_id, &p),
|
||||
_ => Ok(json!({
|
||||
"status": "unknown_type",
|
||||
"type": task_type
|
||||
@@ -316,8 +224,8 @@ impl UndergroundWorker {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
conn.prepare("ug_select_underground_by_character", Q_SELECT_UNDERGROUND_BY_CHARACTER)?;
|
||||
let rows = conn.execute("ug_select_underground_by_character", &[&victim_id])?;
|
||||
conn.prepare("ug_select_by_performer", Q_SELECT_BY_PERFORMER)?;
|
||||
let rows = conn.execute("ug_select_by_performer", &[&victim_id])?;
|
||||
|
||||
let mut activities = Vec::new();
|
||||
for r in rows {
|
||||
@@ -972,348 +880,6 @@ impl UndergroundWorker {
|
||||
}
|
||||
}
|
||||
|
||||
/// `investigate_affair` nach Spec „Liebschaften & Untergrund“: `discoveryScore`, Ziele
|
||||
/// `expose` / `blackmail`, Teilerfolg, Erpressungssumme, optional Sofort-Skandal (WebSocket).
|
||||
fn investigate_affair(
|
||||
pool: &ConnectionPool,
|
||||
broker: &MessageBroker,
|
||||
performer_id: i32,
|
||||
victim_id: i32,
|
||||
p: &Json,
|
||||
) -> Result<Json, DbError> {
|
||||
let goal = p
|
||||
.get("goal")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("expose");
|
||||
let expose_mode = goal == "expose";
|
||||
|
||||
let rel_id_param = p
|
||||
.get("relationship_id")
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|x| x as i32);
|
||||
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("aff_ctx", Q_AFFAIR_VICTIM_CONTEXT)?;
|
||||
conn.prepare("aff_children", Q_AFFAIR_VICTIM_CHILDREN)?;
|
||||
conn.prepare("aff_lovers_all", Q_AFFAIR_ALL_LOVERS)?;
|
||||
conn.prepare("aff_cnt_lovers", QUERY_COUNT_ACTIVE_LOVERS_FOR_CHARACTER)?;
|
||||
conn.prepare("aff_upd_vis_disc", Q_UPDATE_AFFAIR_VISIBILITY_DISCRETION)?;
|
||||
conn.prepare("aff_upd_vis", Q_UPDATE_AFFAIR_VISIBILITY_ONLY)?;
|
||||
conn.prepare("aff_upd_rep", QUERY_UPDATE_CHARACTER_REPUTATION)?;
|
||||
|
||||
let ctx_row = conn.execute("aff_ctx", &[&victim_id])?;
|
||||
let ctx = ctx_row.first();
|
||||
|
||||
let has_house_data = ctx
|
||||
.and_then(|r| r.get("user_house_id"))
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
let victim_reputation = ctx
|
||||
.map(|r| parse_f64(r, "reputation", 50.0))
|
||||
.unwrap_or(50.0);
|
||||
let title_level = ctx.map(|r| parse_i32(r, "title_level", 0)).unwrap_or(0);
|
||||
let house_position = ctx.map(|r| parse_i32(r, "house_position", 0)).unwrap_or(0);
|
||||
let household_order = ctx.map(|r| parse_i32(r, "household_order", 55)).unwrap_or(55);
|
||||
let servant_count = ctx.map(|r| parse_i32(r, "servant_count", 0)).unwrap_or(0);
|
||||
let servant_quality = ctx.map(|r| parse_i32(r, "servant_quality", 50)).unwrap_or(50);
|
||||
let servant_pay = ctx
|
||||
.and_then(|r| r.get("servant_pay_level"))
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "normal".into());
|
||||
|
||||
let child_row = conn.execute("aff_children", &[&victim_id])?;
|
||||
let (has_hidden_bastard, has_public_known_bastard) = child_row
|
||||
.first()
|
||||
.map(|r| {
|
||||
(
|
||||
parse_bool_cell(r, "has_hidden_bastard"),
|
||||
parse_bool_cell(r, "has_public_known_bastard"),
|
||||
)
|
||||
})
|
||||
.unwrap_or((false, false));
|
||||
|
||||
let cnt_row = conn.execute("aff_cnt_lovers", &[&victim_id])?;
|
||||
let active_lover_count = cnt_row
|
||||
.first()
|
||||
.map(|r| parse_i32(r, "cnt", 0))
|
||||
.unwrap_or(0);
|
||||
|
||||
let lover_rows = conn.execute("aff_lovers_all", &[&victim_id, &rel_id_param])?;
|
||||
|
||||
if lover_rows.is_empty() {
|
||||
return Ok(json!({
|
||||
"status": "failed",
|
||||
"outcome": "failure",
|
||||
"action": "investigate_affair",
|
||||
"performer_id": performer_id,
|
||||
"victim_id": victim_id,
|
||||
"goal": goal,
|
||||
"notes": "no active lover relationship",
|
||||
"details": p
|
||||
}));
|
||||
}
|
||||
|
||||
let (_exp_min, exp_max) = affair_expected_range(house_position, title_level);
|
||||
|
||||
let household_leak_bonus = if has_house_data {
|
||||
affair_household_leak_bonus(
|
||||
household_order,
|
||||
servant_pay.as_str(),
|
||||
servant_count,
|
||||
exp_max,
|
||||
servant_quality,
|
||||
)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let child_bonus = affair_child_discovery_bonus(has_hidden_bastard, has_public_known_bastard);
|
||||
let multiple_affair_bonus = affair_multiple_affair_bonus(active_lover_count);
|
||||
|
||||
let mut scored: Vec<(f64, usize)> = Vec::new();
|
||||
for (i, row) in lover_rows.iter().enumerate() {
|
||||
let visibility = parse_i32(row, "visibility", 0);
|
||||
let discretion = parse_i32(row, "discretion", 50);
|
||||
let acknowledged = parse_bool_cell(row, "acknowledged");
|
||||
let status_fit = parse_i32(row, "status_fit", 0);
|
||||
let min_age = parse_i32(row, "min_age_years", 99);
|
||||
|
||||
let score = affair_discovery_score(
|
||||
visibility,
|
||||
discretion,
|
||||
acknowledged,
|
||||
min_age,
|
||||
status_fit,
|
||||
child_bonus,
|
||||
household_leak_bonus,
|
||||
multiple_affair_bonus,
|
||||
);
|
||||
scored.push((score, i));
|
||||
}
|
||||
|
||||
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let best_score = scored[0].0;
|
||||
let mut best_indices: Vec<usize> = scored
|
||||
.iter()
|
||||
.filter(|(s, _)| (*s - best_score).abs() < 1e-4)
|
||||
.map(|(_, i)| *i)
|
||||
.collect();
|
||||
if best_indices.is_empty() {
|
||||
best_indices.push(scored[0].1);
|
||||
}
|
||||
let pick = *best_indices
|
||||
.as_slice()
|
||||
.choose(&mut rand::thread_rng())
|
||||
.unwrap_or(&scored[0].1);
|
||||
let target = &lover_rows[pick];
|
||||
|
||||
let rel_id = parse_i32(target, "rel_id", -1);
|
||||
let visibility_before = parse_i32(target, "visibility", 0);
|
||||
let discretion_before = parse_i32(target, "discretion", 50);
|
||||
let lover_role = target
|
||||
.get("lover_role")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let min_age = parse_i32(target, "min_age_years", 99);
|
||||
let status_fit = parse_i32(target, "status_fit", 0);
|
||||
let maintenance_level = parse_i32(target, "maintenance_level", 50);
|
||||
let scandal_extra = parse_i32(target, "scandal_extra", 0);
|
||||
let title1_tr = target.get("title1_tr").cloned().unwrap_or_default();
|
||||
let title2_tr = target.get("title2_tr").cloned().unwrap_or_default();
|
||||
let user1_id = parse_opt_i32(target, "user1_id");
|
||||
let user2_id = parse_opt_i32(target, "user2_id");
|
||||
|
||||
let discovery_score = affair_discovery_score(
|
||||
visibility_before,
|
||||
discretion_before,
|
||||
parse_bool_cell(target, "acknowledged"),
|
||||
min_age,
|
||||
status_fit,
|
||||
child_bonus,
|
||||
household_leak_bonus,
|
||||
multiple_affair_bonus,
|
||||
);
|
||||
|
||||
let success_chance = (20.0 + discovery_score * 0.55).clamp(5.0, 95.0);
|
||||
let roll = random_double(0.0, 100.0);
|
||||
|
||||
let tier = if roll <= success_chance * 0.55 {
|
||||
"full"
|
||||
} else if roll <= success_chance {
|
||||
"partial"
|
||||
} else {
|
||||
"failure"
|
||||
};
|
||||
|
||||
let mut blackmail_amount: Option<i64> = None;
|
||||
let mut outcome_str = "failure";
|
||||
let mut status_str = "failed";
|
||||
let mut notes = String::new();
|
||||
|
||||
if tier == "failure" {
|
||||
notes.push_str("no proof");
|
||||
return Ok(json!({
|
||||
"status": status_str,
|
||||
"outcome": outcome_str,
|
||||
"action": "investigate_affair",
|
||||
"performer_id": performer_id,
|
||||
"victim_id": victim_id,
|
||||
"goal": goal,
|
||||
"discoveries": null,
|
||||
"relationshipId": rel_id,
|
||||
"discoveryScore": (discovery_score * 100.0).round() / 100.0,
|
||||
"successChance": (success_chance * 100.0).round() / 100.0,
|
||||
"roll": (roll * 100.0).round() / 100.0,
|
||||
"notes": notes,
|
||||
"details": p
|
||||
}));
|
||||
}
|
||||
|
||||
outcome_str = if tier == "full" { "success" } else { "partial" };
|
||||
status_str = "resolved";
|
||||
|
||||
let visibility_delta: i32;
|
||||
let discretion_delta: i32;
|
||||
let mut reputation_delta: i32;
|
||||
|
||||
if expose_mode {
|
||||
if tier == "full" {
|
||||
visibility_delta = random_int(18, 30);
|
||||
discretion_delta = random_int(8, 18);
|
||||
reputation_delta = random_int(-6, -2);
|
||||
} else {
|
||||
visibility_delta = random_int(8, 15);
|
||||
discretion_delta = random_int(3, 8);
|
||||
reputation_delta = random_int(-3, -1);
|
||||
}
|
||||
if has_public_known_bastard {
|
||||
reputation_delta -= 1;
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"aff_upd_vis_disc",
|
||||
&[&rel_id, &visibility_delta, &discretion_delta],
|
||||
)?;
|
||||
|
||||
let new_rep = (victim_reputation + reputation_delta as f64).clamp(0.0, 100.0);
|
||||
let rep_s = format!("{:.2}", new_rep);
|
||||
conn.execute("aff_upd_rep", &[&rep_s, &victim_id])?;
|
||||
|
||||
let visibility_after = (visibility_before + visibility_delta).min(100);
|
||||
|
||||
if visibility_after >= 60 {
|
||||
let mut p_scandal = 1.0
|
||||
+ (visibility_after as f64) / 25.0
|
||||
+ if status_fit == -2 { 2.0 } else { 0.0 }
|
||||
+ if maintenance_level < 25 { 3.0 } else { 0.0 }
|
||||
+ if active_lover_count >= 2 { 3.0 } else { 0.0 }
|
||||
+ scandal_extra as f64
|
||||
+ affair_scandal_age_extra_pct(min_age);
|
||||
if discretion_before >= 75 {
|
||||
p_scandal -= 2.0;
|
||||
}
|
||||
p_scandal = p_scandal.clamp(0.0, 25.0);
|
||||
if min_age <= 15 {
|
||||
p_scandal += 10.0;
|
||||
}
|
||||
if has_public_known_bastard {
|
||||
p_scandal += 6.0;
|
||||
}
|
||||
if household_order <= 35 {
|
||||
p_scandal += 5.0;
|
||||
}
|
||||
p_scandal = p_scandal.clamp(0.0, 100.0);
|
||||
if random_double(0.0, 100.0) < p_scandal {
|
||||
affair_publish_scandal_hint(broker, rel_id, user1_id, user2_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// blackmail
|
||||
if tier == "full" {
|
||||
visibility_delta = random_int(4, 9);
|
||||
discretion_delta = random_int(2, 6);
|
||||
reputation_delta = random_int(-1, 0);
|
||||
} else {
|
||||
visibility_delta = random_int(2, 5);
|
||||
discretion_delta = 0;
|
||||
reputation_delta = 0;
|
||||
}
|
||||
|
||||
if tier == "full" || tier == "partial" {
|
||||
if discretion_delta > 0 {
|
||||
conn.execute(
|
||||
"aff_upd_vis_disc",
|
||||
&[&rel_id, &visibility_delta, &discretion_delta],
|
||||
)?;
|
||||
} else {
|
||||
conn.execute("aff_upd_vis", &[&rel_id, &visibility_delta])?;
|
||||
}
|
||||
if reputation_delta != 0 {
|
||||
let new_rep = (victim_reputation + reputation_delta as f64).clamp(0.0, 100.0);
|
||||
let rep_s = format!("{:.2}", new_rep);
|
||||
conn.execute("aff_upd_rep", &[&rep_s, &victim_id])?;
|
||||
}
|
||||
}
|
||||
|
||||
let outcome_factor = if tier == "full" { 1.0 } else { 0.55 };
|
||||
let tg = affair_title_group_bonus(&title1_tr, &title2_tr);
|
||||
let child_bm = affair_child_blackmail_bonus(has_hidden_bastard, has_public_known_bastard);
|
||||
let vis_for_bm = visibility_before;
|
||||
let base = 500.0
|
||||
+ (vis_for_bm as f64) * 12.0
|
||||
+ victim_reputation.max(0.0) * 15.0
|
||||
+ tg as f64
|
||||
+ child_bm as f64;
|
||||
blackmail_amount = Some((base * outcome_factor).round() as i64);
|
||||
}
|
||||
|
||||
let visibility_after = (visibility_before + visibility_delta).min(100);
|
||||
let household_leak_flag = has_house_data && household_leak_bonus > 0.001;
|
||||
|
||||
let min_age_bracket = if min_age <= 13 {
|
||||
"<=13"
|
||||
} else if min_age <= 15 {
|
||||
"<=15"
|
||||
} else if min_age <= 17 {
|
||||
"<=17"
|
||||
} else {
|
||||
"adult"
|
||||
};
|
||||
|
||||
Ok(json!({
|
||||
"status": status_str,
|
||||
"outcome": outcome_str,
|
||||
"action": "investigate_affair",
|
||||
"performer_id": performer_id,
|
||||
"victim_id": victim_id,
|
||||
"goal": goal,
|
||||
"discoveries": {
|
||||
"relationshipId": rel_id,
|
||||
"loverRole": lover_role,
|
||||
"visibility": visibility_after,
|
||||
"acknowledged": parse_bool_cell(target, "acknowledged"),
|
||||
"publicKnownChild": has_public_known_bastard,
|
||||
"hiddenChild": has_hidden_bastard,
|
||||
"householdLeak": household_leak_flag,
|
||||
"minAgeBracket": min_age_bracket,
|
||||
"multipleAffairs": active_lover_count >= 2
|
||||
},
|
||||
"visibilityDelta": visibility_delta,
|
||||
"reputationDelta": reputation_delta,
|
||||
"blackmailAmount": blackmail_amount,
|
||||
"discoveryScore": (discovery_score * 100.0).round() / 100.0,
|
||||
"successChance": (success_chance * 100.0).round() / 100.0,
|
||||
"roll": (roll * 100.0).round() / 100.0,
|
||||
"tier": tier,
|
||||
"notes": notes,
|
||||
"details": p
|
||||
}))
|
||||
}
|
||||
|
||||
fn update_result(pool: &ConnectionPool, id: i32, result: &Json) -> Result<(), DbError> {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
@@ -1345,200 +911,6 @@ impl Worker for UndergroundWorker {
|
||||
}
|
||||
}
|
||||
|
||||
// --- investigate_affair: Spec „Liebschaften & Untergrund“ (Hilfsfunktionen) ---
|
||||
|
||||
fn affair_base_range(position: i32) -> (i32, i32) {
|
||||
match position {
|
||||
3 => (1, 2),
|
||||
4 => (2, 4),
|
||||
5 => (3, 6),
|
||||
p if p <= 2 => (0, 1),
|
||||
p if p >= 6 => (4, 8),
|
||||
_ => (0, 1),
|
||||
}
|
||||
}
|
||||
|
||||
fn affair_expected_range(house_position: i32, title_level: i32) -> (i32, i32) {
|
||||
let (bmin, bmax) = affair_base_range(house_position);
|
||||
let bonus = (title_level / 3).max(0);
|
||||
(bmin + bonus, bmax + bonus)
|
||||
}
|
||||
|
||||
fn affair_title_group(tr: &str) -> u8 {
|
||||
match tr {
|
||||
"noncivil" | "civil" | "sir" => 0,
|
||||
"townlord" | "by" | "landlord" => 1,
|
||||
"knight" | "baron" | "count" | "palsgrave" | "margrave" | "landgrave" => 2,
|
||||
"ruler" | "elector"
|
||||
| "imperial-prince"
|
||||
| "duke"
|
||||
| "grand-duke"
|
||||
| "prince-regent"
|
||||
| "king" => 3,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn affair_title_group_bonus(t1: &str, t2: &str) -> i32 {
|
||||
let g = affair_title_group(t1).max(affair_title_group(t2));
|
||||
match g {
|
||||
0 => 0,
|
||||
1 => 600,
|
||||
2 => 1800,
|
||||
3 => 4200,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn affair_child_blackmail_bonus(hidden: bool, public: bool) -> i32 {
|
||||
let mut b = 0;
|
||||
if hidden {
|
||||
b += 900;
|
||||
}
|
||||
if public {
|
||||
b += 1600;
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn age_malus_visibility_bonus(min_age: i32) -> f64 {
|
||||
if min_age <= 13 {
|
||||
18.0
|
||||
} else if min_age <= 15 {
|
||||
12.0
|
||||
} else if min_age <= 17 {
|
||||
6.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn status_mismatch_bonus(status_fit: i32) -> f64 {
|
||||
match status_fit {
|
||||
-2 => 10.0,
|
||||
-1 => 5.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn affair_multiple_affair_bonus(cnt: i32) -> f64 {
|
||||
if cnt >= 3 {
|
||||
14.0
|
||||
} else if cnt == 2 {
|
||||
8.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn affair_child_discovery_bonus(has_hidden: bool, has_public: bool) -> f64 {
|
||||
let mut s: f64 = 0.0;
|
||||
if has_hidden {
|
||||
s += 8.0;
|
||||
}
|
||||
if has_public {
|
||||
s += 18.0;
|
||||
}
|
||||
s.min(20.0)
|
||||
}
|
||||
|
||||
fn affair_household_leak_bonus(
|
||||
household_order: i32,
|
||||
pay: &str,
|
||||
servant_count: i32,
|
||||
exp_max: i32,
|
||||
servant_quality: i32,
|
||||
) -> f64 {
|
||||
let mut s: f64 = 0.0;
|
||||
if household_order <= 35 {
|
||||
s += 8.0;
|
||||
}
|
||||
if pay == "low" {
|
||||
s += 5.0;
|
||||
}
|
||||
if servant_count > exp_max + 1 {
|
||||
s += 4.0;
|
||||
}
|
||||
if servant_quality <= 35 {
|
||||
s += 6.0;
|
||||
}
|
||||
s.min(15.0)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn affair_discovery_score(
|
||||
visibility: i32,
|
||||
discretion: i32,
|
||||
acknowledged: bool,
|
||||
min_age: i32,
|
||||
status_fit: i32,
|
||||
child_bonus: f64,
|
||||
household_leak_bonus: f64,
|
||||
multiple_affair_bonus: f64,
|
||||
) -> f64 {
|
||||
visibility as f64 * 0.45
|
||||
+ (100.0 - discretion as f64) * 0.30
|
||||
+ if acknowledged { 10.0 } else { 0.0 }
|
||||
+ child_bonus
|
||||
+ age_malus_visibility_bonus(min_age)
|
||||
+ household_leak_bonus
|
||||
+ multiple_affair_bonus
|
||||
+ status_mismatch_bonus(status_fit)
|
||||
}
|
||||
|
||||
fn affair_scandal_age_extra_pct(min_age_years: i32) -> f64 {
|
||||
if min_age_years <= 13 {
|
||||
6.0
|
||||
} else if min_age_years <= 15 {
|
||||
3.0
|
||||
} else if min_age_years <= 17 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn affair_publish_scandal_hint(
|
||||
broker: &MessageBroker,
|
||||
relationship_id: i32,
|
||||
user1_id: Option<i32>,
|
||||
user2_id: Option<i32>,
|
||||
) {
|
||||
broker.publish(format!(
|
||||
r#"{{"event":"falukant_family_scandal_hint","relationship_id":{}}}"#,
|
||||
relationship_id
|
||||
));
|
||||
for uid in [user1_id, user2_id] {
|
||||
if let Some(id) = uid.filter(|x| *x > 0) {
|
||||
let family = format!(
|
||||
r#"{{"event":"falukantUpdateFamily","user_id":{},"reason":"scandal"}}"#,
|
||||
id
|
||||
);
|
||||
broker.publish(family);
|
||||
let status = format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, id);
|
||||
broker.publish(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_f64(row: &Row, key: &str, default: f64) -> f64 {
|
||||
row.get(key)
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn parse_bool_cell(row: &Row, key: &str) -> bool {
|
||||
row.get(key)
|
||||
.map(|v| matches!(v.as_str(), "t" | "true" | "True" | "1"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn parse_opt_i32(row: &Row, key: &str) -> Option<i32> {
|
||||
row.get(key)
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.filter(|&x| x > 0)
|
||||
}
|
||||
|
||||
// Hilfsfunktionen für Zufall und Parsing
|
||||
|
||||
fn random_int(lo: i32, hi: i32) -> i32 {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::db::{ConnectionPool, DbError, Rows};
|
||||
use crate::message_broker::MessageBroker;
|
||||
use chrono::{Local, NaiveDate};
|
||||
use rand::distributions::{Distribution, Uniform};
|
||||
use rand::rngs::StdRng;
|
||||
use rand::SeedableRng;
|
||||
@@ -9,9 +8,7 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::base::{BaseWorker, Worker, WorkerState};
|
||||
use super::death_log;
|
||||
use crate::worker::sql::{
|
||||
QUERY_GET_CHARACTERS_ZERO_HEALTH,
|
||||
QUERY_GET_USERS_TO_UPDATE,
|
||||
QUERY_UPDATE_CHARACTERS_HEALTH,
|
||||
QUERY_UPDATE_MOOD,
|
||||
@@ -22,39 +19,29 @@ use crate::worker::sql::{
|
||||
QUERY_GET_OPEN_CREDITS,
|
||||
QUERY_UPDATE_CREDIT,
|
||||
QUERY_CLEANUP_CREDITS,
|
||||
QUERY_ADD_CHARACTER_TO_DEBTORS_PRISM,
|
||||
QUERY_GET_CURRENT_MONEY,
|
||||
QUERY_GET_HOUSE_VALUE,
|
||||
QUERY_GET_SETTLEMENT_VALUE,
|
||||
QUERY_GET_INVENTORY_VALUE,
|
||||
QUERY_GET_CREDIT_DEBT,
|
||||
QUERY_COUNT_CHILDREN_BY_USER,
|
||||
QUERY_COUNT_CHILDREN,
|
||||
QUERY_GET_HEIR,
|
||||
QUERY_RANDOM_HEIR,
|
||||
QUERY_SET_CHARACTER_USER,
|
||||
QUERY_UPDATE_USER_MONEY,
|
||||
QUERY_GET_FALUKANT_USER_ID,
|
||||
QUERY_AUTOBATISM,
|
||||
QUERY_CLEAR_MARRIAGE_PREGNANCY_DUE, QUERY_CLEAR_STALE_MARRIAGE_PREGNANCY_DUE,
|
||||
QUERY_GET_LEGACY_MARRIAGE_INSTANT_PREGNANCY_CANDIDATES,
|
||||
QUERY_GET_MARRIAGE_BIRTH_DELIVERIES, QUERY_MARRIAGE_PREGNANCY_COLUMN_READY,
|
||||
QUERY_TRY_MARRIAGE_CONCEPTION_UPDATE,
|
||||
QUERY_CHARACTER_PLANNED_PREGNANCY_COLUMNS_READY,
|
||||
QUERY_CLEAR_CHARACTER_PREGNANCY_AFTER_BIRTH,
|
||||
QUERY_GET_PLANNED_CHARACTER_BIRTH_DELIVERIES,
|
||||
QUERY_GET_PREGNANCY_CANDIDATES,
|
||||
QUERY_INSERT_CHILD,
|
||||
QUERY_INSERT_CHILD_RELATION,
|
||||
QUERY_INSERT_CHILD_RELATION_PLANNED_BIRTH,
|
||||
QUERY_HAS_MOTHER_BIRTH_TODAY,
|
||||
QUERY_INSERT_NOTIFICATION,
|
||||
QUERY_DELETE_DIRECTOR,
|
||||
QUERY_DELETE_RELATIONSHIP,
|
||||
QUERY_DELETE_CHILD_RELATION,
|
||||
QUERY_DELETE_CHILD_RELATION_BY_PARENT,
|
||||
QUERY_DELETE_KNOWLEDGE,
|
||||
QUERY_DELETE_DEBTORS_PRISM,
|
||||
QUERY_DELETE_POLITICAL_OFFICE,
|
||||
QUERY_DELETE_ELECTION_CANDIDATE,
|
||||
QUERY_ELECTION_CANDIDATE_TABLE_READY,
|
||||
};
|
||||
|
||||
/// Vereinfachtes Abbild eines Characters aus `QUERY_GET_USERS_TO_UPDATE`.
|
||||
@@ -71,14 +58,7 @@ pub struct UserCharacterWorker {
|
||||
dist: Uniform<f64>,
|
||||
last_hourly_run: Option<Instant>,
|
||||
last_pregnancy_run: Option<Instant>,
|
||||
/// Letzter Kalendertag, an dem Ehe-Konzeption (oder Legacy-Instant) gelaufen ist — höchstens 1×/Tag.
|
||||
last_marriage_fertility_date: Option<NaiveDate>,
|
||||
/// `None` = noch nicht geprüft, ob Migration 008 (`marriage_pregnancy_due_at`) existiert.
|
||||
marriage_pregnancy_column_ready: Option<bool>,
|
||||
/// Migration `011` — `character.pregnancy_due_at` / geplante Geburt (Weg A).
|
||||
character_planned_pregnancy_columns_ready: Option<bool>,
|
||||
last_mood_run: Option<Instant>,
|
||||
last_death_check_run: Option<Instant>,
|
||||
}
|
||||
|
||||
// SQL moved to `src/worker/sql.rs`
|
||||
@@ -95,11 +75,7 @@ impl UserCharacterWorker {
|
||||
dist,
|
||||
last_hourly_run: None,
|
||||
last_pregnancy_run: None,
|
||||
last_marriage_fertility_date: None,
|
||||
marriage_pregnancy_column_ready: None,
|
||||
character_planned_pregnancy_columns_ready: None,
|
||||
last_mood_run: None,
|
||||
last_death_check_run: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,9 +83,8 @@ impl UserCharacterWorker {
|
||||
self.base.set_current_step("UserCharacterWorker iteration");
|
||||
|
||||
self.maybe_run_hourly_tasks();
|
||||
self.maybe_run_death_check();
|
||||
self.maybe_run_mood_updates();
|
||||
self.maybe_run_hourly_pregnancies();
|
||||
self.maybe_run_daily_pregnancies();
|
||||
|
||||
// Entspricht in etwa der 1-Sekunden-Schleife im C++-Code
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
@@ -141,85 +116,27 @@ impl UserCharacterWorker {
|
||||
self.last_hourly_run = Some(now);
|
||||
}
|
||||
|
||||
/// Alle 12 Stunden: Charaktere mit health <= 0 finden und Tod verarbeiten
|
||||
fn maybe_run_death_check(&mut self) {
|
||||
const DEATH_CHECK_INTERVAL_SECS: u64 = 12 * 3600;
|
||||
let now = Instant::now();
|
||||
let should_run = match self.last_death_check_run {
|
||||
None => true,
|
||||
Some(last) => now.saturating_duration_since(last) >= Duration::from_secs(DEATH_CHECK_INTERVAL_SECS),
|
||||
};
|
||||
|
||||
if !should_run {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = self.process_zero_health_deaths() {
|
||||
eprintln!("[UserCharacterWorker] Fehler bei Tod-Prüfung (health <= 0): {err}");
|
||||
}
|
||||
|
||||
self.last_death_check_run = Some(now);
|
||||
}
|
||||
|
||||
fn process_zero_health_deaths(&mut self) -> Result<(), DbError> {
|
||||
let ids: Vec<i32> = {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("get_characters_zero_health", QUERY_GET_CHARACTERS_ZERO_HEALTH)?;
|
||||
let rows = conn.execute("get_characters_zero_health", &[])?;
|
||||
|
||||
rows.into_iter()
|
||||
.filter_map(|r| r.get("id").and_then(|v| v.parse::<i32>().ok()))
|
||||
.collect()
|
||||
};
|
||||
|
||||
for character_id in ids {
|
||||
if let Err(e) = self.handle_character_death(character_id) {
|
||||
eprintln!("[UserCharacterWorker] handle_character_death für Charakter {}: {e}", character_id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_hourly_tasks(&mut self) -> Result<(), DbError> {
|
||||
self.process_character_events()?;
|
||||
self.handle_credits()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ehe: Geburten/stale stündlich; Konzeption höchstens einmal pro Kalendertag (ein Jahreswurf, `prob_year`).
|
||||
/// Konzeption setzt `marriage_pregnancy_due_at` (+18 Stunden); Geburt über `QUERY_GET_MARRIAGE_BIRTH_DELIVERIES`.
|
||||
/// Kein separates „Hochzeitsnacht“-Event — erste Konzeption am nächsten Tageslauf nach der Hochzeit.
|
||||
fn maybe_run_hourly_pregnancies(&mut self) {
|
||||
fn maybe_run_daily_pregnancies(&mut self) {
|
||||
let now = Instant::now();
|
||||
let today = Local::now().date_naive();
|
||||
|
||||
let run_hourly = match self.last_pregnancy_run {
|
||||
let should_run = match self.last_pregnancy_run {
|
||||
None => true,
|
||||
Some(last) => now.saturating_duration_since(last) >= Duration::from_secs(3600),
|
||||
Some(last) => now.saturating_duration_since(last) >= Duration::from_secs(24 * 3600),
|
||||
};
|
||||
|
||||
let run_daily_fertility = self.last_marriage_fertility_date != Some(today);
|
||||
|
||||
if !run_hourly && !run_daily_fertility {
|
||||
if !should_run {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = self.process_pregnancies(run_hourly, run_daily_fertility) {
|
||||
if let Err(err) = self.process_pregnancies() {
|
||||
eprintln!("[UserCharacterWorker] Fehler in processPregnancies: {err}");
|
||||
}
|
||||
|
||||
if run_hourly {
|
||||
self.last_pregnancy_run = Some(now);
|
||||
}
|
||||
if run_daily_fertility {
|
||||
self.last_marriage_fertility_date = Some(today);
|
||||
}
|
||||
self.last_pregnancy_run = Some(now);
|
||||
}
|
||||
|
||||
fn process_character_events(&mut self) -> Result<(), DbError> {
|
||||
@@ -258,12 +175,6 @@ impl UserCharacterWorker {
|
||||
}
|
||||
|
||||
fn update_character_health(&mut self, character: &mut Character) -> Result<(), DbError> {
|
||||
// Bereits verstorbene Charaktere (health <= 0) sofort behandeln
|
||||
if character.health <= 0 {
|
||||
self.handle_character_death(character.id)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let health_change = self.calculate_health_change(character.age);
|
||||
if health_change == 0 {
|
||||
return Ok(());
|
||||
@@ -479,6 +390,10 @@ impl UserCharacterWorker {
|
||||
conn.prepare("get_open_credits", QUERY_GET_OPEN_CREDITS)?;
|
||||
conn.prepare("update_credit", QUERY_UPDATE_CREDIT)?;
|
||||
conn.prepare("cleanup_credits", QUERY_CLEANUP_CREDITS)?;
|
||||
conn.prepare(
|
||||
"add_character_to_debtors_prism",
|
||||
QUERY_ADD_CHARACTER_TO_DEBTORS_PRISM,
|
||||
)?;
|
||||
|
||||
let credits_rows = conn.execute("get_open_credits", &[])?;
|
||||
for row in credits_rows {
|
||||
@@ -534,15 +449,9 @@ impl UserCharacterWorker {
|
||||
eprintln!(
|
||||
"[UserCharacterWorker] Fehler bei change_falukant_user_money (credit pay rate): {err}"
|
||||
);
|
||||
} else if let Err(err) =
|
||||
super::falukant_debtors::on_credit_payment_success(&self.base, character_id)
|
||||
{
|
||||
eprintln!(
|
||||
"[UserCharacterWorker] falukant_debtors::on_credit_payment_success: {err}"
|
||||
);
|
||||
}
|
||||
} else if prism_started_previously
|
||||
&& let Err(err) = self
|
||||
} else if prism_started_previously {
|
||||
if let Err(err) = self
|
||||
.base
|
||||
.change_falukant_user_money(user_id, pay_rate, "debitor_prism")
|
||||
{
|
||||
@@ -550,181 +459,43 @@ impl UserCharacterWorker {
|
||||
"[UserCharacterWorker] Fehler bei change_falukant_user_money (debitor_prism): {err}"
|
||||
);
|
||||
}
|
||||
// Verzug / Schuldturm: täglich `falukant_debtors::run_daily` (FalukantFamilyWorker)
|
||||
} else {
|
||||
conn.execute("add_character_to_debtors_prism", &[&character_id])?;
|
||||
}
|
||||
|
||||
conn.execute("update_credit", &[&remaining_amount, &user_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Schwangerschafts-Logik (portiert aus processPregnancies)
|
||||
fn process_pregnancies(&mut self, hourly: bool, daily_fertility: bool) -> Result<(), DbError> {
|
||||
self.ensure_falukant_pregnancy_schema()?;
|
||||
|
||||
fn process_pregnancies(&mut self) -> Result<(), DbError> {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("autobatism", QUERY_AUTOBATISM)?;
|
||||
conn.execute("autobatism", &[])?;
|
||||
|
||||
conn.prepare("get_pregnancy_candidates", QUERY_GET_PREGNANCY_CANDIDATES)?;
|
||||
let rows = conn.execute("get_pregnancy_candidates", &[])?;
|
||||
|
||||
conn.prepare("insert_child", QUERY_INSERT_CHILD)?;
|
||||
conn.prepare("insert_child_relation", QUERY_INSERT_CHILD_RELATION)?;
|
||||
conn.prepare("insert_child_rel_planned", QUERY_INSERT_CHILD_RELATION_PLANNED_BIRTH)?;
|
||||
conn.prepare("clear_char_preg", QUERY_CLEAR_CHARACTER_PREGNANCY_AFTER_BIRTH)?;
|
||||
conn.prepare("has_mother_birth_today", QUERY_HAS_MOTHER_BIRTH_TODAY)?;
|
||||
|
||||
let use_gestation = self.marriage_pregnancy_column_ready.unwrap_or(false);
|
||||
let planned_pregnancy_ready = self.character_planned_pregnancy_columns_ready.unwrap_or(false);
|
||||
|
||||
if hourly {
|
||||
conn.prepare("autobatism", QUERY_AUTOBATISM)?;
|
||||
conn.execute("autobatism", &[])?;
|
||||
|
||||
if planned_pregnancy_ready {
|
||||
conn.prepare("get_planned_births", QUERY_GET_PLANNED_CHARACTER_BIRTH_DELIVERIES)?;
|
||||
let planned_rows = conn.execute("get_planned_births", &[])?;
|
||||
for row in planned_rows {
|
||||
if let Err(e) = self.process_single_planned_character_birth(&mut conn, &row) {
|
||||
eprintln!("[UserCharacterWorker] geplante Geburt: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if use_gestation {
|
||||
conn.prepare("get_deliveries", QUERY_GET_MARRIAGE_BIRTH_DELIVERIES)?;
|
||||
conn.prepare("clear_preg", QUERY_CLEAR_MARRIAGE_PREGNANCY_DUE)?;
|
||||
|
||||
let delivery_rows = conn.execute("get_deliveries", &[])?;
|
||||
for row in delivery_rows {
|
||||
self.process_single_marriage_delivery(&mut conn, &row)?;
|
||||
}
|
||||
|
||||
conn.prepare("clear_stale_preg", QUERY_CLEAR_STALE_MARRIAGE_PREGNANCY_DUE)?;
|
||||
conn.execute("clear_stale_preg", &[])?;
|
||||
}
|
||||
}
|
||||
|
||||
if daily_fertility {
|
||||
if use_gestation {
|
||||
conn.prepare("try_conception", QUERY_TRY_MARRIAGE_CONCEPTION_UPDATE)?;
|
||||
conn.execute("try_conception", &[])?;
|
||||
} else {
|
||||
conn.prepare("legacy_preg", QUERY_GET_LEGACY_MARRIAGE_INSTANT_PREGNANCY_CANDIDATES)?;
|
||||
let legacy_rows = conn.execute("legacy_preg", &[])?;
|
||||
for row in legacy_rows {
|
||||
self.process_single_marriage_delivery(&mut conn, &row)?;
|
||||
}
|
||||
}
|
||||
for row in rows {
|
||||
self.process_single_pregnancy_candidate(&mut conn, &row)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_falukant_pregnancy_schema(&mut self) -> Result<(), DbError> {
|
||||
if self.marriage_pregnancy_column_ready.is_some()
|
||||
&& self.character_planned_pregnancy_columns_ready.is_some()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
if self.marriage_pregnancy_column_ready.is_none() {
|
||||
conn.prepare("mp_ready", QUERY_MARRIAGE_PREGNANCY_COLUMN_READY)?;
|
||||
let ready_rows = conn.execute("mp_ready", &[])?;
|
||||
self.marriage_pregnancy_column_ready = Some(
|
||||
ready_rows
|
||||
.first()
|
||||
.and_then(|r| r.get("ready"))
|
||||
.map(|v| v == "true" || v == "t")
|
||||
.unwrap_or(false),
|
||||
);
|
||||
}
|
||||
|
||||
if self.character_planned_pregnancy_columns_ready.is_none() {
|
||||
conn.prepare("cpp_ready", QUERY_CHARACTER_PLANNED_PREGNANCY_COLUMNS_READY)?;
|
||||
let ready_rows = conn.execute("cpp_ready", &[])?;
|
||||
self.character_planned_pregnancy_columns_ready = Some(
|
||||
ready_rows
|
||||
.first()
|
||||
.and_then(|r| r.get("ready"))
|
||||
.map(|v| v == "true" || v == "t")
|
||||
.unwrap_or(false),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Weg A: `character.pregnancy_due_at` / `pregnancy_father_character_id` (Admin/Spiel).
|
||||
fn process_single_planned_character_birth(
|
||||
fn process_single_pregnancy_candidate(
|
||||
&mut self,
|
||||
conn: &mut crate::db::DbConnection,
|
||||
row: &crate::db::Row,
|
||||
) -> Result<(), DbError> {
|
||||
let mother_cid = parse_i32(row, "mother_cid", -1);
|
||||
let father_cid = parse_i32(row, "father_cid", -1);
|
||||
if mother_cid < 0 || father_cid < 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let title_of_nobility = parse_i32(row, "title_of_nobility", 0);
|
||||
let last_name = parse_i32(row, "last_name", 0);
|
||||
let region_id = parse_i32(row, "region_id", 0);
|
||||
|
||||
let father_uid = parse_opt_i32(row, "father_uid");
|
||||
let mother_uid = parse_opt_i32(row, "mother_uid");
|
||||
if mother_already_had_birth_today(conn, mother_cid)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let birth_context = row
|
||||
.get("birth_context")
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("marriage");
|
||||
|
||||
let gender = if self.dist.sample(&mut self.rng) < 0.5 {
|
||||
"male"
|
||||
} else {
|
||||
"female"
|
||||
};
|
||||
|
||||
let inserted =
|
||||
conn.execute("insert_child", &[®ion_id, &gender, &last_name, &title_of_nobility])?;
|
||||
let child_cid = inserted
|
||||
.first()
|
||||
.and_then(|r| r.get("child_cid"))
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(-1);
|
||||
if child_cid < 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"insert_child_rel_planned",
|
||||
&[&father_cid, &mother_cid, &child_cid, &birth_context],
|
||||
)?;
|
||||
conn.execute("clear_char_preg", &[&mother_cid])?;
|
||||
|
||||
if let Some(f_uid) = father_uid {
|
||||
self.send_children_update_and_status(f_uid);
|
||||
}
|
||||
if let Some(m_uid) = mother_uid {
|
||||
self.send_children_update_and_status(m_uid);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_single_marriage_delivery(
|
||||
&mut self,
|
||||
conn: &mut crate::db::DbConnection,
|
||||
row: &crate::db::Row,
|
||||
) -> Result<(), DbError> {
|
||||
let relationship_id = parse_i32(row, "relationship_id", -1);
|
||||
let father_cid = parse_i32(row, "father_cid", -1);
|
||||
let mother_cid = parse_i32(row, "mother_cid", -1);
|
||||
if father_cid < 0 || mother_cid < 0 {
|
||||
@@ -737,9 +508,6 @@ impl UserCharacterWorker {
|
||||
|
||||
let father_uid = parse_opt_i32(row, "father_uid");
|
||||
let mother_uid = parse_opt_i32(row, "mother_uid");
|
||||
if mother_already_had_birth_today(conn, mother_cid)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let gender = if self.dist.sample(&mut self.rng) < 0.5 {
|
||||
"male"
|
||||
@@ -763,10 +531,6 @@ impl UserCharacterWorker {
|
||||
&[&father_cid, &mother_cid, &child_cid],
|
||||
)?;
|
||||
|
||||
if relationship_id >= 0 {
|
||||
conn.execute("clear_preg", &[&relationship_id])?;
|
||||
}
|
||||
|
||||
if let Some(f_uid) = father_uid {
|
||||
self.send_children_update_and_status(f_uid);
|
||||
}
|
||||
@@ -787,51 +551,8 @@ impl UserCharacterWorker {
|
||||
self.base.broker.publish(update_status);
|
||||
}
|
||||
|
||||
/// Wie `EventsWorker::notify_user`: Eintrag in `falukant_log.notification` (`tr` = Payload) + WS.
|
||||
/// `tr_payload` kann ein Kurz-String (`director_death`) oder vollständiges JSON sein.
|
||||
fn notify_user_death(
|
||||
&self,
|
||||
conn: &mut crate::db::DbConnection,
|
||||
user_id: i32,
|
||||
tr_payload: &str,
|
||||
) -> Result<(), DbError> {
|
||||
if user_id <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
conn.execute("insert_notification", &[&user_id, &tr_payload])?;
|
||||
let update_message =
|
||||
format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id);
|
||||
self.base.broker.publish(update_message);
|
||||
if tr_payload.trim_start().starts_with('{') {
|
||||
self.base.broker.publish(tr_payload.to_string());
|
||||
} else {
|
||||
let message =
|
||||
format!(r#"{{"event":"{tr_payload}","user_id":{user_id}}}"#);
|
||||
self.base.broker.publish(message);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Todes- und Erb-Logik
|
||||
fn handle_character_death(&mut self, character_id: i32) -> Result<(), DbError> {
|
||||
// Todes-Kontext **vor** Erbfolge und vor allen DELETEs (wie EventsWorker / CharacterCreationWorker).
|
||||
// So ist der Charakter garantiert noch in `character`, und bei Race mit anderem Worker schlägt
|
||||
// spätestens hier fehl — nicht erst nach `set_heir`.
|
||||
let death_ctx = {
|
||||
let mut conn = self
|
||||
.base
|
||||
.pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
match death_log::build_deceased_context(&mut conn, character_id) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("[UserCharacterWorker] Todes-Log Kontext: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
self.set_heir(character_id)?;
|
||||
|
||||
let death_event = format!(
|
||||
@@ -849,90 +570,18 @@ impl UserCharacterWorker {
|
||||
conn.prepare("delete_director", QUERY_DELETE_DIRECTOR)?;
|
||||
conn.prepare("delete_relationship", QUERY_DELETE_RELATIONSHIP)?;
|
||||
conn.prepare("delete_child_relation", QUERY_DELETE_CHILD_RELATION)?;
|
||||
conn.prepare("delete_child_relation_by_parent", QUERY_DELETE_CHILD_RELATION_BY_PARENT)?;
|
||||
conn.prepare("delete_knowledge", QUERY_DELETE_KNOWLEDGE)?;
|
||||
conn.prepare("delete_debtors_prism", QUERY_DELETE_DEBTORS_PRISM)?;
|
||||
conn.prepare("delete_political_office", QUERY_DELETE_POLITICAL_OFFICE)?;
|
||||
conn.prepare("insert_notification", QUERY_INSERT_NOTIFICATION)?;
|
||||
conn.prepare("delete_election_candidate", QUERY_DELETE_ELECTION_CANDIDATE)?;
|
||||
|
||||
let dir_result = conn.execute("delete_director", &[&character_id])?;
|
||||
for row in dir_result {
|
||||
if let Some(user_id) = row
|
||||
.get("employer_user_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
let tr = death_ctx.as_ref().map(|c| {
|
||||
death_log::wrap_death_notification(c, "director_death", user_id)
|
||||
});
|
||||
self.notify_user_death(
|
||||
&mut conn,
|
||||
user_id,
|
||||
tr.as_deref().unwrap_or("director_death"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
let rel_result = conn.execute("delete_relationship", &[&character_id])?;
|
||||
for row in rel_result {
|
||||
if let Some(related_user_id) = row
|
||||
.get("related_user_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
let tr = death_ctx.as_ref().map(|c| {
|
||||
death_log::wrap_death_notification(c, "relationship_death", related_user_id)
|
||||
});
|
||||
self.notify_user_death(
|
||||
&mut conn,
|
||||
related_user_id,
|
||||
tr.as_deref().unwrap_or("relationship_death"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
conn.execute("delete_child_relation_by_parent", &[&character_id])?;
|
||||
let child_result = conn.execute("delete_child_relation", &[&character_id])?;
|
||||
for row in child_result {
|
||||
if let Some(father_user_id) = row
|
||||
.get("father_user_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
let tr = death_ctx.as_ref().map(|c| {
|
||||
death_log::wrap_death_notification(c, "child_death", father_user_id)
|
||||
});
|
||||
self.notify_user_death(
|
||||
&mut conn,
|
||||
father_user_id,
|
||||
tr.as_deref().unwrap_or("child_death"),
|
||||
)?;
|
||||
}
|
||||
if let Some(mother_user_id) = row
|
||||
.get("mother_user_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
let tr = death_ctx.as_ref().map(|c| {
|
||||
death_log::wrap_death_notification(c, "child_death", mother_user_id)
|
||||
});
|
||||
self.notify_user_death(
|
||||
&mut conn,
|
||||
mother_user_id,
|
||||
tr.as_deref().unwrap_or("child_death"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
conn.execute("delete_director", &[&character_id])?;
|
||||
conn.execute("delete_relationship", &[&character_id])?;
|
||||
conn.execute("delete_child_relation", &[&character_id])?;
|
||||
conn.execute("delete_knowledge", &[&character_id])?;
|
||||
conn.execute("delete_debtors_prism", &[&character_id])?;
|
||||
conn.execute("delete_political_office", &[&character_id])?;
|
||||
conn.prepare("election_candidate_table_ready", QUERY_ELECTION_CANDIDATE_TABLE_READY)?;
|
||||
let election_ready_rows = conn.execute("election_candidate_table_ready", &[])?;
|
||||
let election_ready = election_ready_rows
|
||||
.first()
|
||||
.and_then(|r| r.get("ready"))
|
||||
.map(|v| v == "t" || v == "true")
|
||||
.unwrap_or(false);
|
||||
if election_ready {
|
||||
conn.prepare("delete_election_candidate", QUERY_DELETE_ELECTION_CANDIDATE)?;
|
||||
conn.execute("delete_election_candidate", &[&character_id])?;
|
||||
}
|
||||
conn.execute("delete_election_candidate", &[&character_id])?;
|
||||
|
||||
// Character selbst löschen
|
||||
conn.prepare(
|
||||
@@ -960,22 +609,9 @@ impl UserCharacterWorker {
|
||||
|
||||
if heir_id > 0 {
|
||||
self.set_new_character(falukant_user_id, heir_id)?;
|
||||
} else if let Err(e) = super::falukant_certificate::reset_certificate_on_succession_no_heir(
|
||||
&self.base,
|
||||
&self.base.broker,
|
||||
falukant_user_id,
|
||||
) {
|
||||
eprintln!("[UserCharacterWorker] reset_certificate_on_succession_no_heir: {e}");
|
||||
}
|
||||
self.set_new_money(falukant_user_id, new_money)?;
|
||||
|
||||
// Nach Erbwechsel den Status sicher neu laden lassen.
|
||||
let update_status = format!(
|
||||
r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#,
|
||||
falukant_user_id
|
||||
);
|
||||
self.base.broker.publish(update_status);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1056,17 +692,8 @@ impl UserCharacterWorker {
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
// Endliche Werte und gültiger Bereich für numeric(14,2), sonst Serialisierungsfehler
|
||||
let clamped = if !new_amount.is_finite() {
|
||||
1000.0
|
||||
} else {
|
||||
const MAX_ABS: f64 = 999_999_999_999.99;
|
||||
new_amount.clamp(-MAX_ABS, MAX_ABS)
|
||||
};
|
||||
let money_str = format!("{:.2}", clamped);
|
||||
|
||||
conn.prepare("update_user_money", QUERY_UPDATE_USER_MONEY)?;
|
||||
conn.execute("update_user_money", &[&money_str, &falukant_user_id])?;
|
||||
conn.execute("update_user_money", &[&new_amount, &falukant_user_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1162,8 +789,8 @@ impl UserCharacterWorker {
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("count_children_by_user", QUERY_COUNT_CHILDREN_BY_USER)?;
|
||||
let rows = conn.execute("count_children_by_user", &[&deceased_user_id])?;
|
||||
conn.prepare("count_children", QUERY_COUNT_CHILDREN)?;
|
||||
let rows = conn.execute("count_children", &[&deceased_user_id])?;
|
||||
|
||||
Ok(rows
|
||||
.first()
|
||||
@@ -1217,18 +844,6 @@ fn parse_opt_i32(row: &crate::db::Row, key: &str) -> Option<i32> {
|
||||
row.get(key).and_then(|v| v.parse::<i32>().ok())
|
||||
}
|
||||
|
||||
fn mother_already_had_birth_today(
|
||||
conn: &mut crate::db::DbConnection,
|
||||
mother_character_id: i32,
|
||||
) -> Result<bool, DbError> {
|
||||
let rows = conn.execute("has_mother_birth_today", &[&mother_character_id])?;
|
||||
Ok(rows
|
||||
.first()
|
||||
.and_then(|r| r.get("has_birth_today"))
|
||||
.map(|v| v == "t" || v == "true")
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Credit {
|
||||
amount: f64,
|
||||
|
||||
@@ -14,7 +14,6 @@ use crate::worker::sql::{
|
||||
QUERY_DELETE_REGION_SELL_PRICE,
|
||||
QUERY_GET_SELL_REGIONS,
|
||||
QUERY_HOURLY_PRICE_RECALCULATION,
|
||||
QUERY_INSERT_PRODUCT_PRICE_HISTORY,
|
||||
QUERY_SET_MARRIAGES_BY_PARTY,
|
||||
QUERY_GET_STUDYINGS_TO_EXECUTE,
|
||||
QUERY_GET_OWN_CHARACTER_ID,
|
||||
@@ -140,17 +139,11 @@ impl ValueRecalculationWorker {
|
||||
conn.prepare("update_region_sell_price", QUERY_UPDATE_REGION_SELL_PRICE)?;
|
||||
conn.execute("update_region_sell_price", &[])?;
|
||||
|
||||
// Preis-Historie für betroffene Regionen erfassen
|
||||
conn.prepare("insert_price_history", QUERY_INSERT_PRODUCT_PRICE_HISTORY)?;
|
||||
|
||||
conn.prepare("get_sell_regions", QUERY_GET_SELL_REGIONS)?;
|
||||
let regions = conn.execute("get_sell_regions", &[])?;
|
||||
|
||||
for row in regions {
|
||||
if let Some(region_id) = row.get("region_id").and_then(|v| v.parse::<i32>().ok()) {
|
||||
// Aktuelle Preise dieser Region in die Historie schreiben
|
||||
let _ = conn.execute("insert_price_history", &[®ion_id]);
|
||||
|
||||
let message =
|
||||
format!(r#"{{"event":"price_update","region_id":{}}}"#, region_id);
|
||||
broker.publish(message);
|
||||
@@ -174,9 +167,6 @@ impl ValueRecalculationWorker {
|
||||
conn.prepare("hourly_price_recalculation", QUERY_HOURLY_PRICE_RECALCULATION)?;
|
||||
let _updated_rows = conn.execute("hourly_price_recalculation", &[])?;
|
||||
|
||||
// Prepared Statement für Preis-Historie
|
||||
conn.prepare("insert_price_history", QUERY_INSERT_PRODUCT_PRICE_HISTORY)?;
|
||||
|
||||
// Sammle alle betroffenen Regionen für Event-Benachrichtigungen
|
||||
let mut affected_regions = HashSet::new();
|
||||
|
||||
@@ -201,9 +191,6 @@ impl ValueRecalculationWorker {
|
||||
|
||||
// Benachrichtige alle betroffenen Regionen über Preisänderungen
|
||||
for region_id in affected_regions {
|
||||
// aktuellen Preisstand in die Historie schreiben
|
||||
let _ = conn.execute("insert_price_history", &[®ion_id]);
|
||||
|
||||
let message =
|
||||
format!(r#"{{"event":"price_update","region_id":{}}}"#, region_id);
|
||||
broker.publish(message);
|
||||
|
||||
@@ -5,13 +5,38 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::base::{BaseWorker, Worker, WorkerState};
|
||||
use crate::worker::sql::QUERY_UPDATE_WEATHER;
|
||||
|
||||
pub struct WeatherWorker {
|
||||
base: BaseWorker,
|
||||
}
|
||||
|
||||
// Reuse QUERY_UPDATE_WEATHER from centralized SQL module
|
||||
// Query zum Aktualisieren des Wetters für alle Regionen
|
||||
// Wählt für jede Region ein zufälliges Wetter aus allen verfügbaren Wettertypen aus
|
||||
// Wichtig: Jede Region bekommt ein individuelles, zufälliges Wetter
|
||||
const QUERY_UPDATE_WEATHER: &str = r#"
|
||||
WITH all_regions AS (
|
||||
SELECT DISTINCT r.id AS region_id
|
||||
FROM falukant_data.region r
|
||||
JOIN falukant_type.region tr ON r.region_type_id = tr.id
|
||||
WHERE tr.label_tr = 'city'
|
||||
),
|
||||
random_weather AS (
|
||||
SELECT
|
||||
ar.region_id,
|
||||
(
|
||||
SELECT wt.id
|
||||
FROM falukant_type.weather wt
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 1
|
||||
) AS weather_type_id
|
||||
FROM all_regions ar
|
||||
)
|
||||
INSERT INTO falukant_data.weather (region_id, weather_type_id)
|
||||
SELECT rw.region_id, rw.weather_type_id
|
||||
FROM random_weather rw
|
||||
ON CONFLICT (region_id)
|
||||
DO UPDATE SET weather_type_id = EXCLUDED.weather_type_id;
|
||||
"#;
|
||||
|
||||
impl WeatherWorker {
|
||||
pub fn new(pool: ConnectionPool, broker: MessageBroker) -> Self {
|
||||
@@ -63,17 +88,17 @@ impl WeatherWorker {
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
// Run the prepared SQL that uses per-row RANDOM() trick
|
||||
conn.prepare("update_weather", QUERY_UPDATE_WEATHER)?;
|
||||
let updated_rows = conn.execute("update_weather", &[])?;
|
||||
|
||||
eprintln!(
|
||||
"[WeatherWorker] Wetter aktualisiert (per-row random). {} Regionen betroffen.",
|
||||
"[WeatherWorker] Wetter aktualisiert. {} Regionen betroffen.",
|
||||
updated_rows.len()
|
||||
);
|
||||
|
||||
// Benachrichtige alle Clients über Wetteränderungen
|
||||
broker.publish("{\"event\":\"weather_updated\"}".to_string());
|
||||
let message = r#"{"event":"weather_updated"}"#;
|
||||
broker.publish(message.to_string());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user