Compare commits
147 Commits
renovate/t
...
bad5b86951
| Author | SHA1 | Date | |
|---|---|---|---|
| bad5b86951 | |||
|
|
e5e17c2c5e | ||
|
|
facd11b14f | ||
|
|
f09033a99d | ||
|
|
86d79c90a5 | ||
|
|
88eda493ff | ||
|
|
342eb512b4 | ||
|
|
773665635d | ||
|
|
173776fb52 | ||
|
|
43cb58294c | ||
|
|
87f6e7315d | ||
|
|
cb22b111a3 | ||
|
|
208f16e30b | ||
|
|
2d62ba4b51 | ||
|
|
dd8d8be79b | ||
|
|
d27230a0b5 | ||
|
|
f7eb53ae90 | ||
|
|
42f71d831f | ||
|
|
b7d7705bbe | ||
|
|
48a374e3ac | ||
|
|
d0ff6cad68 | ||
|
|
9022b58dc7 | ||
|
|
8509a7e171 | ||
|
|
43eb6ed0ab | ||
|
|
05f713c5e1 | ||
|
|
397b612bf1 | ||
|
|
a38beadb8e | ||
|
|
0892e2db8b | ||
|
|
75a1e5b306 | ||
|
|
48d8e9b293 | ||
|
|
b0ee0da722 | ||
|
|
6e931c1069 | ||
|
|
3af0ab1156 | ||
|
|
00ce7fc655 | ||
|
|
cd21293fbd | ||
|
|
2eaea2481c | ||
|
|
3df83b507e | ||
|
|
df092d1790 | ||
|
|
670ea093d0 | ||
|
|
8593824a73 | ||
|
|
7ca43da52d | ||
|
|
2cca0da750 | ||
|
|
10f71ece0e | ||
|
|
7043e8d90c | ||
|
|
1111377913 | ||
|
|
ebf20c4424 | ||
|
|
bb1c1c4133 | ||
|
|
dda559cbe7 | ||
|
|
7d56f51ec9 | ||
|
|
e460792357 | ||
|
|
731c39dfa4 | ||
|
|
ac024a8d14 | ||
|
|
21525ec125 | ||
|
|
619e5e5123 | ||
|
|
b3578c65b8 | ||
|
|
64080211d1 | ||
|
|
57f3d27eb7 | ||
|
|
a7a6ea1ff9 | ||
|
|
b1bf96a2ce | ||
|
|
1258abbb42 | ||
|
|
ac7bd1d7ed | ||
|
|
ac059f688d | ||
|
|
1013af594d | ||
|
|
84b32d72aa | ||
|
|
3289a0c129 | ||
|
|
65772fb7de | ||
|
|
083fa26297 | ||
|
|
3b25f8c3a0 | ||
|
|
df143e2531 | ||
|
|
c5e78cb87f | ||
|
|
195bf49d14 | ||
|
|
e811ec6264 | ||
|
|
9d7f61a329 | ||
|
|
708ffc3eda | ||
|
|
c82fbc0f7c | ||
|
|
fe0361971d | ||
|
|
d921dc2f7e | ||
|
|
c209c41b52 | ||
|
|
4086e9a207 | ||
|
|
ac5ec3a245 | ||
|
|
6a5ff4557e | ||
|
|
f9c80bbd6b | ||
|
|
6757aa363f | ||
|
|
9961b91c4f | ||
|
|
5d0dd41c3f | ||
|
|
69dad6854a | ||
|
|
4baf88b0cf | ||
|
|
1e565e6dd9 | ||
|
|
deaf5ac6ca | ||
|
|
d94fa791b3 | ||
|
|
2293c1204b | ||
|
|
00cb97ec57 | ||
|
|
85b4c73c11 | ||
|
|
658a9034ed | ||
|
|
460310ae89 | ||
|
|
2f875d6c1c | ||
|
|
340c658ad1 | ||
|
|
972ef2b714 | ||
|
|
f6d566427a | ||
|
|
c24aa63f97 | ||
|
|
9f1c54fde9 | ||
|
|
55a86f75f1 | ||
|
|
45287837bc | ||
|
|
31998e310c | ||
|
|
05e6155e38 | ||
|
|
4b4d84b15c | ||
|
|
710a2a62b2 | ||
|
|
1d9a7ba8a0 | ||
|
|
c9e0781b61 | ||
|
|
2ac474fe0c | ||
|
|
c8668c59eb | ||
|
|
0399333163 | ||
|
|
7305a71438 | ||
|
|
70d8e53216 | ||
|
|
0083d7be23 | ||
|
|
fc1b0c2259 | ||
|
|
63a89588ac | ||
|
|
f32c4b1456 | ||
|
|
f133f3999e | ||
|
|
e1e4c08d38 | ||
|
|
1505d3d004 | ||
|
|
40df17ee91 | ||
|
|
5f55aeea99 | ||
|
|
408221ffb4 | ||
|
|
5475ae24f6 | ||
|
|
b858f5c385 | ||
|
|
01d45d5f65 | ||
|
|
282f6542fe | ||
|
|
f96e474f32 | ||
|
|
b7eafe28a1 | ||
|
|
604c8ba300 | ||
|
|
e2630eb32a | ||
|
|
108ac6c82b | ||
|
|
713504d3ab | ||
|
|
1e4689212b | ||
|
|
da30ab0b15 | ||
|
|
ade3bff5cf | ||
|
|
431a6aff08 | ||
|
|
08563c956c | ||
|
|
fce7400303 | ||
|
|
a82d554494 | ||
|
|
833202344b | ||
|
|
cd1b33a474 | ||
|
|
1719af2344 | ||
|
|
398e0ba677 | ||
|
|
4fca4b4d75 | ||
| 5eb98f33c9 |
52
.gitea/workflows/deploy-daemon.yml
Normal file
52
.gitea/workflows/deploy-daemon.yml
Normal file
@@ -0,0 +1,52 @@
|
||||
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
Normal file
29
AGENTS.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 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,8 +4,9 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "YpDaemon"
|
||||
version = "0.1.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"ctrlc",
|
||||
"futures-util",
|
||||
"libsystemd",
|
||||
@@ -19,6 +20,15 @@ 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"
|
||||
@@ -106,6 +116,25 @@ 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"
|
||||
@@ -296,6 +325,30 @@ 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"
|
||||
@@ -432,6 +485,15 @@ 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"
|
||||
@@ -1169,12 +1231,65 @@ 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.1.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
rand = "0.8"
|
||||
rand = "0.9"
|
||||
postgres = "0.19"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
@@ -15,3 +15,4 @@ ctrlc = "3"
|
||||
tokio-rustls = "0.25"
|
||||
rustls-pemfile = "2"
|
||||
libsystemd = "0.7"
|
||||
chrono = "0.4"
|
||||
18
YpDaemon/Cargo.toml
Normal file
18
YpDaemon/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[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"
|
||||
250
YpDaemon/src/worker/base.rs
Normal file
250
YpDaemon/src/worker/base.rs
Normal file
@@ -0,0 +1,250 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
496
YpDaemon/src/worker/character_creation.rs
Normal file
496
YpDaemon/src/worker/character_creation.rs
Normal file
@@ -0,0 +1,496 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1426
YpDaemon/src/worker/director.rs
Normal file
1426
YpDaemon/src/worker/director.rs
Normal file
File diff suppressed because it is too large
Load Diff
2052
YpDaemon/src/worker/events.rs
Normal file
2052
YpDaemon/src/worker/events.rs
Normal file
File diff suppressed because it is too large
Load Diff
1127
YpDaemon/src/worker/politics.rs
Normal file
1127
YpDaemon/src/worker/politics.rs
Normal file
File diff suppressed because it is too large
Load Diff
1949
YpDaemon/src/worker/sql.rs
Normal file
1949
YpDaemon/src/worker/sql.rs
Normal file
File diff suppressed because it is too large
Load Diff
900
YpDaemon/src/worker/user_character.rs
Normal file
900
YpDaemon/src/worker/user_character.rs
Normal file
@@ -0,0 +1,900 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16
build_output.txt
Normal file
16
build_output.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
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
|
||||
270
docs/DEBUG_DEATH_SUCCESSION_QUERIES.sql
Normal file
270
docs/DEBUG_DEATH_SUCCESSION_QUERIES.sql
Normal file
@@ -0,0 +1,270 @@
|
||||
-- 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)
|
||||
59
docs/FALUKANT_CHURCH_DAEMON.md
Normal file
59
docs/FALUKANT_CHURCH_DAEMON.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Falukant: Kirchenämter (YpDaemon / PoliticsWorker)
|
||||
|
||||
Umsetzung des Zielmodells: Laufbahn `highest_church_hierarchy_ever`, NPC-Bewerbungen, NPC-Vorgesetzte mit Score, Spieler-Vorgesetzte ohne Daemon-Entscheidung, Interimsbesetzung, Events `falukantUpdateChurch`.
|
||||
|
||||
## Migration
|
||||
|
||||
- `migrations/007_falukant_character_church_career.sql` — Spalte `falukant_data.character.highest_church_hierarchy_ever` + Backfill aus bestehenden `church_office`.
|
||||
|
||||
**Ohne Migration** schlagen Bewerbungs-Checks fehl, die die Spalte referenzieren.
|
||||
|
||||
## Ticks
|
||||
|
||||
| Was | Wann |
|
||||
|-----|------|
|
||||
| Kirchen-Gesamtprozess | Täglich im **gleichen** Lauf wie `perform_daily_politics_task` (nicht mehr nur 13:00) |
|
||||
| Auto-Approve alter Bewerbungen | Stündlich, **nur** wenn `supervisor.user_id IS NULL` (NPC-Vorgesetzter), 36 h+ |
|
||||
|
||||
## Logik (Kurz)
|
||||
|
||||
1. **Freie Sitze** je `church_office_type` / Region (`QUERY_FIND_AVAILABLE_CHURCH_OFFICES`, inkl. `hierarchy_level`).
|
||||
2. **Vorgesetzter** = nächsthöhere besetzte Hierarchie in der Region (`QUERY_FIND_CHURCH_SUPERVISOR`).
|
||||
3. **Spieler-Vorgesetzter** (`character.user_id IS NOT NULL`): `falukantUpdateChurch` mit `reason: applications` — **keine** automatische Annahme/Ablehnung durch den Daemon.
|
||||
4. **NPC-Vorgesetzter**: Bewerber mit `churchCandidateScore` (Reputation, höchste Hierarchie, aktuelle Ämter, Titel, Alter, Zufallsanteil abhängig von `supervisor.reputation`). Pro `(office_type_id, region)` werden nur so viele Zusagen erteilt, wie Plätze frei sind.
|
||||
5. **NPC-Bewerbungen**: Nur Charaktere mit `user_id IS NULL`; nur wenn `pending < freie Sitze` (nachzüglernde Jobs).
|
||||
6. **Interimsbesetzung**: Kein Vorgesetzter, `hierarchy_level <= 6` (bis einschließlich „Bishop“-Ebene im Typ-Stammbaum — anpassbar über Konstante `INTERIM_MAX_CHURCH_HIERARCHY` in `politics.rs`): bester NPC-Kandidat + direktes `INSERT` in `church_office`, Update `highest_church_hierarchy_ever`, Entfernen niedrigerer kirchlicher Ämter.
|
||||
|
||||
## Bewerbungsvoraussetzungen (Daemon-SQL)
|
||||
|
||||
`QUERY_CHECK_CHARACTER_ELIGIBILITY`: Für ein gefordertes Voramt (`prerequisite_office_type_id`) gilt **Erfüllt**, wenn
|
||||
|
||||
- das exakte Amt aktuell gehalten wird, **oder**
|
||||
- `GREATEST(highest_church_hierarchy_ever, max. aktuelle Hierarchie aus church_office) >= hierarchy_level` des Voramts.
|
||||
|
||||
Titelbedingungen unverändert (`min_title_level`).
|
||||
|
||||
## Genehmigung / Amtsverlust
|
||||
|
||||
- `QUERY_APPROVE_CHURCH_APPLICATION` und `QUERY_AUTO_APPROVE_CHURCH_APPLICATION`: nach Eintrag `church_office` → Update `highest_church_hierarchy_ever`, dann `DELETE` niedrigerer konkurrierender `church_office` desselben Charakters (`remove_lower_ranked`).
|
||||
|
||||
## WebSocket
|
||||
|
||||
| Event | Payload |
|
||||
|-------|---------|
|
||||
| `falukantUpdateChurch` | `{"event":"falukantUpdateChurch","user_id":N,"reason":"…"}` |
|
||||
| `falukantUpdateStatus` | wie üblich, direkt danach |
|
||||
|
||||
**reason:** `applications` (Spieler-Vorgesetzter), `npc_decision` (NPC hat zugesagt), `appointment` (Auto-Approve 36 h, nur NPC-Supervisor), `vacancy_fill` (Interimsbesetzung, nur wenn Bewerber ein Spielercharakter ist).
|
||||
|
||||
Details: [`FALUKANT_UI_WEBSOCKET.md`](./FALUKANT_UI_WEBSOCKET.md).
|
||||
|
||||
## Backend (außerhalb YpDaemon)
|
||||
|
||||
- `getAvailableChurchPositions()` muss dieselbe Laufbahn-Logik nutzen (Spec Abschnitt 11).
|
||||
- Spieleranträge und gleiche Bewertungsregeln wie im Daemon.
|
||||
|
||||
## Code
|
||||
|
||||
- `src/worker/politics.rs` — `perform_church_office_task`, `process_church_supervisor_queue`, `npc_resolve_church_applications_for_supervisor`, `try_interim_church_appointment`, Hilfsfunktionen.
|
||||
- `src/worker/sql.rs` — Abschnitt „Church Office Queries“.
|
||||
29
docs/FALUKANT_DAEMON_AENDERUNGSNOTIZ_ZEITMASSSTAB.md
Normal file
29
docs/FALUKANT_DAEMON_AENDERUNGSNOTIZ_ZEITMASSSTAB.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Ä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)
|
||||
78
docs/FALUKANT_DAEMON_HANDOFF.md
Normal file
78
docs/FALUKANT_DAEMON_HANDOFF.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 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.
|
||||
110
docs/FALUKANT_DEATH_SUCCESSION_SMOKE_TEST.md
Normal file
110
docs/FALUKANT_DEATH_SUCCESSION_SMOKE_TEST.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 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).
|
||||
29
docs/FALUKANT_DEBTORS_DAEMON.md
Normal file
29
docs/FALUKANT_DEBTORS_DAEMON.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 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“.
|
||||
31
docs/FALUKANT_DIRECTOR_AUTO_ADJUST_INCOME.md
Normal file
31
docs/FALUKANT_DIRECTOR_AUTO_ADJUST_INCOME.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 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.
|
||||
57
docs/FALUKANT_DIRECTOR_PRODUCTION_COST.md
Normal file
57
docs/FALUKANT_DIRECTOR_PRODUCTION_COST.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 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.
|
||||
156
docs/FALUKANT_MARRIAGE_HOUSEPEACE_DAEMON_HANDOFF.md
Normal file
156
docs/FALUKANT_MARRIAGE_HOUSEPEACE_DAEMON_HANDOFF.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# 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).
|
||||
67
docs/FALUKANT_PRODUCTION_CERTIFICATE.md
Normal file
67
docs/FALUKANT_PRODUCTION_CERTIFICATE.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 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)
|
||||
31
docs/FALUKANT_SERVANTS_DAEMON.md
Normal file
31
docs/FALUKANT_SERVANTS_DAEMON.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 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).
|
||||
41
docs/FALUKANT_TRANSPORT_RAID_DAEMON.md
Normal file
41
docs/FALUKANT_TRANSPORT_RAID_DAEMON.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 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**.
|
||||
224
docs/FALUKANT_UI_WEBSOCKET.md
Normal file
224
docs/FALUKANT_UI_WEBSOCKET.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# 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.
|
||||
49
docs/FALUKANT_UNDERGROUND_INVESTIGATE_AFFAIR.md
Normal file
49
docs/FALUKANT_UNDERGROUND_INVESTIGATE_AFFAIR.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 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).
|
||||
95
docs/FALUKANT_WORKER_SCHEDULE_SOCKET.md
Normal file
95
docs/FALUKANT_WORKER_SCHEDULE_SOCKET.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# 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.
|
||||
90
docs/LOVER_STAND_EFFECTS.md
Normal file
90
docs/LOVER_STAND_EFFECTS.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 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
|
||||
62
migrations/001_falukant_family_lovers.sql
Normal file
62
migrations/001_falukant_family_lovers.sql
Normal file
@@ -0,0 +1,62 @@
|
||||
-- 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';
|
||||
18
migrations/002_falukant_family_rename_legacy_columns.sql
Normal file
18
migrations/002_falukant_family_rename_legacy_columns.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- 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;
|
||||
17
migrations/003_falukant_family_marriage_buffs.sql
Normal file
17
migrations/003_falukant_family_marriage_buffs.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- 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';
|
||||
14
migrations/004_falukant_servants_daemon.sql
Normal file
14
migrations/004_falukant_servants_daemon.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- 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';
|
||||
16
migrations/005_falukant_marriage_housepeace.sql
Normal file
16
migrations/005_falukant_marriage_housepeace.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- 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';
|
||||
6
migrations/006_falukant_lover_installments.sql
Normal file
6
migrations/006_falukant_lover_installments.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- 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';
|
||||
20
migrations/007_falukant_character_church_career.sql
Normal file
20
migrations/007_falukant_character_church_career.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Höchste erreichte kirchliche Hierarchiestufe (Laufbahn), nicht zurücksetzen bei Amtsverlust.
|
||||
-- Siehe docs/FALUKANT_CHURCH_DAEMON.md
|
||||
|
||||
ALTER TABLE falukant_data.character
|
||||
ADD COLUMN IF NOT EXISTS highest_church_hierarchy_ever SMALLINT;
|
||||
|
||||
COMMENT ON COLUMN falukant_data.character.highest_church_hierarchy_ever IS
|
||||
'Max. hierarchy_level (church_office_type) jemals erreicht; für Bewerbungsvoraussetzungen neben aktuellem Amt';
|
||||
|
||||
UPDATE falukant_data.character c
|
||||
SET highest_church_hierarchy_ever = sub.mh::smallint
|
||||
FROM (
|
||||
SELECT co.character_id AS cid,
|
||||
MAX(cot.hierarchy_level)::int AS mh
|
||||
FROM falukant_data.church_office co
|
||||
JOIN falukant_type.church_office_type cot ON cot.id = co.office_type_id
|
||||
GROUP BY co.character_id
|
||||
) sub
|
||||
WHERE c.id = sub.cid
|
||||
AND (c.highest_church_hierarchy_ever IS NULL OR c.highest_church_hierarchy_ever < sub.mh);
|
||||
8
migrations/008_falukant_marriage_pregnancy_due.sql
Normal file
8
migrations/008_falukant_marriage_pregnancy_due.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- 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';
|
||||
14
migrations/008_falukant_transport_raid.sql
Normal file
14
migrations/008_falukant_transport_raid.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- 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'
|
||||
);
|
||||
5
migrations/009_falukant_region_display_name.sql
Normal file
5
migrations/009_falukant_region_display_name.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- 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
migrations/010_falukant_marriage_started_at.sql
Normal file
1
migrations/010_falukant_marriage_started_at.sql
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
16
migrations/011_falukant_character_planned_pregnancy.sql
Normal file
16
migrations/011_falukant_character_planned_pregnancy.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- 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;
|
||||
43
migrations/012_falukant_political_benefits_daemon.sql
Normal file
43
migrations/012_falukant_political_benefits_daemon.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- 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';
|
||||
6
migrations/013_falukant_political_daily_salary.sql
Normal file
6
migrations/013_falukant_political_daily_salary.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- 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).';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 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).';
|
||||
16
migrations/015_falukant_log_production_completion_count.sql
Normal file
16
migrations/015_falukant_log_production_completion_count.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- 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)).';
|
||||
28
migrations/016_falukant_log_political_office_history.sql
Normal file
28
migrations/016_falukant_log_political_office_history.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- 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).';
|
||||
76
migrations/017_falukant_money_precision_14_2.sql
Normal file
76
migrations/017_falukant_money_precision_14_2.sql
Normal file
@@ -0,0 +1,76 @@
|
||||
-- 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$;
|
||||
9
migrations/018_worker_schedule_socket_right.sql
Normal file
9
migrations/018_worker_schedule_socket_right.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- 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'
|
||||
);
|
||||
55
migrations/README.md
Normal file
55
migrations/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 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,6 +16,7 @@ use worker::{
|
||||
CharacterCreationWorker, ConnectionPool, DirectorWorker, EventsWorker, HouseWorker,
|
||||
PoliticsWorker, ProduceWorker, StockageManager, TransportWorker, UndergroundWorker,
|
||||
UserCharacterWorker, ValueRecalculationWorker, WeatherWorker, Worker,
|
||||
FalukantFamilyWorker,
|
||||
};
|
||||
|
||||
static KEEP_RUNNING: AtomicBool = AtomicBool::new(true);
|
||||
@@ -137,6 +138,10 @@ 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::Worker;
|
||||
use crate::worker::{get_worker_runtime_snapshot, Worker, WorkerRuntimeSnapshot};
|
||||
use futures_util::{FutureExt, SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as Json;
|
||||
@@ -60,12 +60,30 @@ 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>>,
|
||||
@@ -281,7 +299,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>,
|
||||
@@ -312,11 +330,19 @@ 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, registry_clone, ws_log_clone)
|
||||
handle_connection(
|
||||
tls_stream,
|
||||
peer_addr,
|
||||
rx,
|
||||
pool_clone,
|
||||
registry_clone,
|
||||
ws_log_clone,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -326,7 +352,8 @@ async fn run_accept_loop(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
handle_connection(stream, peer_addr, rx, registry_clone, ws_log_clone).await;
|
||||
handle_connection(stream, peer_addr, rx, pool_clone, registry_clone, ws_log_clone)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -336,6 +363,7 @@ 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
|
||||
@@ -367,6 +395,7 @@ 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();
|
||||
@@ -446,6 +475,131 @@ 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
|
||||
}
|
||||
@@ -604,3 +758,279 @@ 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,9 +2,12 @@ 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);
|
||||
@@ -12,6 +15,66 @@ 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,
|
||||
@@ -44,6 +107,10 @@ 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,
|
||||
@@ -55,9 +122,15 @@ 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 = step.into();
|
||||
*guard = new_step.clone();
|
||||
}
|
||||
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)
|
||||
@@ -68,6 +141,11 @@ 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);
|
||||
|
||||
@@ -80,6 +158,11 @@ 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();
|
||||
@@ -95,6 +178,11 @@ 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);
|
||||
|
||||
@@ -124,6 +212,11 @@ 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();
|
||||
}
|
||||
@@ -158,8 +251,8 @@ impl BaseWorker {
|
||||
)));
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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])?;
|
||||
@@ -173,13 +266,13 @@ impl BaseWorker {
|
||||
// compute tentative result
|
||||
let tentative = current_money + money_change;
|
||||
|
||||
// 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
|
||||
// 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(10,2) max, clipping to {}",
|
||||
"[BaseWorker] Clamping money_change: tentative {} exceeds numeric(14,2) max, clipping to {}",
|
||||
tentative, clipped
|
||||
);
|
||||
clipped
|
||||
|
||||
@@ -10,6 +10,7 @@ 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,
|
||||
@@ -20,6 +21,7 @@ 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,
|
||||
};
|
||||
@@ -403,6 +405,14 @@ 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])?;
|
||||
@@ -411,7 +421,15 @@ impl CharacterCreationWorker {
|
||||
.get("employer_user_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
Self::notify_user(pool, broker, user_id, "director_death")?;
|
||||
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"),
|
||||
)?;
|
||||
}
|
||||
|
||||
// 2) Relationships löschen und betroffene User benachrichtigen
|
||||
@@ -422,11 +440,21 @@ impl CharacterCreationWorker {
|
||||
.get("related_user_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
Self::notify_user(pool, broker, related_user_id, "relationship_death")?;
|
||||
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"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Child-Relations löschen und Eltern benachrichtigen
|
||||
// 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 {
|
||||
@@ -434,13 +462,29 @@ impl CharacterCreationWorker {
|
||||
.get("father_user_id")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
{
|
||||
Self::notify_user(pool, broker, father_user_id, "child_death")?;
|
||||
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"),
|
||||
)?;
|
||||
}
|
||||
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")?;
|
||||
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"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,24 +498,27 @@ impl CharacterCreationWorker {
|
||||
pool: &ConnectionPool,
|
||||
broker: &MessageBroker,
|
||||
user_id: i32,
|
||||
event_type: &str,
|
||||
tr_payload: &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])?;
|
||||
conn.execute("insert_notification", &[&user_id, &tr_payload])?;
|
||||
|
||||
// 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);
|
||||
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);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
142
src/worker/death_log.rs
Normal file
142
src/worker/death_log.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
//! 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
1077
src/worker/events.rs
1077
src/worker/events.rs
File diff suppressed because it is too large
Load Diff
441
src/worker/falukant_certificate.rs
Normal file
441
src/worker/falukant_certificate.rs
Normal file
@@ -0,0 +1,441 @@
|
||||
//! 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
|
||||
}
|
||||
440
src/worker/falukant_debtors.rs
Normal file
440
src/worker/falukant_debtors.rs
Normal file
@@ -0,0 +1,440 @@
|
||||
//! 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(())
|
||||
}
|
||||
1541
src/worker/falukant_family.rs
Normal file
1541
src/worker/falukant_family.rs
Normal file
File diff suppressed because it is too large
Load Diff
409
src/worker/falukant_servants.rs
Normal file
409
src/worker/falukant_servants.rs
Normal file
@@ -0,0 +1,409 @@
|
||||
//! 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(())
|
||||
}
|
||||
505
src/worker/falukant_transport_raid.rs
Normal file
505
src/worker/falukant_transport_raid.rs
Normal file
@@ -0,0 +1,505 @@
|
||||
//! 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,17 +68,34 @@ 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())
|
||||
{
|
||||
conn.execute("add_new_buyable_house", &[&house_id])?;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if inserted_count > 0 {
|
||||
eprintln!("[HouseWorker] {} neue kaufbare Häuser erstellt", inserted_count);
|
||||
} else {
|
||||
eprintln!("[HouseWorker] Keine neuen kaufbaren Häuser erstellt");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,18 @@ 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;
|
||||
@@ -27,4 +36,5 @@ pub use user_character::UserCharacterWorker;
|
||||
pub use transport::TransportWorker;
|
||||
pub use weather::WeatherWorker;
|
||||
pub use events::EventsWorker;
|
||||
pub use falukant_family::FalukantFamilyWorker;
|
||||
|
||||
|
||||
18
src/worker/notify.rs
Normal file
18
src/worker/notify.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
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(())
|
||||
}
|
||||
|
||||
291
src/worker/political_benefits.rs
Normal file
291
src/worker/political_benefits.rs
Normal file
@@ -0,0 +1,291 @@
|
||||
//! 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,6 +14,9 @@ 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.
|
||||
@@ -115,20 +118,16 @@ impl ProduceWorker {
|
||||
self.base
|
||||
.set_current_step("Fetch Finished Productions");
|
||||
|
||||
let finished_productions = match self.get_finished_productions() {
|
||||
Ok(rows) => rows,
|
||||
Err(err) => {
|
||||
eprintln!("[ProduceWorker] Fehler in getFinishedProductions: {err}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let finished_productions = self.get_finished_productions().unwrap_or_default();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -167,15 +166,7 @@ impl ProduceWorker {
|
||||
user_id: i32,
|
||||
) -> bool {
|
||||
let mut remaining_quantity = quantity;
|
||||
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
|
||||
let stocks = self.get_available_stocks(branch_id).unwrap_or_default();
|
||||
|
||||
for stock in stocks {
|
||||
if remaining_quantity <= 0 {
|
||||
@@ -183,18 +174,15 @@ 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 {
|
||||
@@ -216,6 +204,40 @@ 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,
|
||||
@@ -230,23 +252,17 @@ impl ProduceWorker {
|
||||
return true;
|
||||
}
|
||||
// Wenn kein Update stattfand, Insert versuchen
|
||||
if let Err(err) = self.insert_inventory(stock_id, product_id, quantity, quality) {
|
||||
eprintln!("[ProduceWorker] Fehler beim Insert in storeInStock: {err}");
|
||||
if self.insert_inventory(stock_id, product_id, quantity, quality).is_err() {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[ProduceWorker] Fehler beim Update in storeInStock: {err}");
|
||||
false
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_production(&self, production_id: i32) {
|
||||
if let Err(err) = self.remove_production(production_id) {
|
||||
eprintln!("[ProduceWorker] Fehler beim Löschen der Produktion: {err}");
|
||||
}
|
||||
let _ = self.remove_production(production_id);
|
||||
}
|
||||
|
||||
fn add_production_to_log(
|
||||
@@ -256,9 +272,7 @@ impl ProduceWorker {
|
||||
product_id: i32,
|
||||
quantity: i32,
|
||||
) {
|
||||
if let Err(err) = self.insert_or_update_production_log(region_id, user_id, product_id, quantity) {
|
||||
eprintln!("[ProduceWorker] Fehler in addProductionToLog: {err}");
|
||||
}
|
||||
let _ = self.insert_or_update_production_log(region_id, user_id, product_id, quantity);
|
||||
}
|
||||
|
||||
fn send_production_ready_event(
|
||||
@@ -277,13 +291,7 @@ impl ProduceWorker {
|
||||
}
|
||||
|
||||
fn handle_overproduction(&self, user_id: i32, branch_id: i32, remaining_quantity: i32) {
|
||||
if let Err(err) =
|
||||
self.insert_overproduction_notification(user_id, branch_id, remaining_quantity)
|
||||
{
|
||||
eprintln!(
|
||||
"[ProduceWorker] Fehler beim Schreiben der Overproduction-Notification: {err}"
|
||||
);
|
||||
}
|
||||
let _ = self.insert_overproduction_notification(user_id, branch_id, remaining_quantity);
|
||||
|
||||
let update_status =
|
||||
format!(r#"{{"event":"falukantUpdateStatus","user_id":{user_id}}}"#);
|
||||
@@ -402,9 +410,15 @@ 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(
|
||||
"add_overproduction_notification",
|
||||
QUERY_ADD_OVERPRODUCTION_NOTIFICATION,
|
||||
"find_overproduction_notification",
|
||||
QUERY_FIND_OVERPRODUCTION_NOTIFICATION,
|
||||
)?;
|
||||
|
||||
let existing_rows = conn.execute(
|
||||
"find_overproduction_notification",
|
||||
&[&user_id, &branch_id],
|
||||
)?;
|
||||
|
||||
// Zusätzlich zur Menge die Branch-ID in der Payload mitschicken, damit
|
||||
@@ -414,6 +428,29 @@ 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,16 +68,30 @@ 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 analog: round(dist * 2160) <= 1
|
||||
let roll: f64 = dist.sample(&mut rng) * 2160.0_f64;
|
||||
// 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;
|
||||
let chance = roll.round();
|
||||
if chance <= 1.0 {
|
||||
Self::add_stock_for_town(pool, broker, town_id)?;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if stocks_added > 0 {
|
||||
eprintln!("[StockageManager] {} neue Lager erstellt", stocks_added);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -109,7 +123,10 @@ impl StockageManager {
|
||||
.map_err(|e| DbError::new(format!("[StockageManager] DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("add_stock", QUERY_INSERT_STOCK)?;
|
||||
conn.execute("add_stock", &[&town_id])?;
|
||||
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);
|
||||
|
||||
// Benachrichtige alle User in der Region
|
||||
let users = Self::get_region_users(&mut conn, town_id)?;
|
||||
|
||||
@@ -11,7 +11,9 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::base::{BaseWorker, Worker, WorkerState};
|
||||
use crate::worker::sql::QUERY_UPDATE_MONEY;
|
||||
use crate::worker::sql::{
|
||||
QUERY_COUNT_ACTIVE_LOVERS_FOR_CHARACTER, QUERY_UPDATE_CHARACTER_REPUTATION, QUERY_UPDATE_MONEY,
|
||||
};
|
||||
|
||||
pub struct UndergroundWorker {
|
||||
base: BaseWorker,
|
||||
@@ -27,7 +29,7 @@ struct HouseConditions {
|
||||
}
|
||||
|
||||
// Query-Konstanten (1:1 aus der C++-Version übernommen)
|
||||
const Q_SELECT_BY_PERFORMER: &str = r#"
|
||||
const Q_SELECT_UNDERGROUND_BY_CHARACTER: &str = r#"
|
||||
SELECT u.id,
|
||||
t.tr AS underground_type,
|
||||
u.performer_id,
|
||||
@@ -119,6 +121,90 @@ 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 {
|
||||
@@ -145,6 +231,10 @@ 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 {
|
||||
@@ -153,7 +243,7 @@ impl UndergroundWorker {
|
||||
None => continue,
|
||||
};
|
||||
|
||||
match Self::execute_row(pool, &row) {
|
||||
match Self::execute_row(pool, broker, &row) {
|
||||
Ok(res) => {
|
||||
Self::update_result(pool, id, &res)?;
|
||||
let event = json!({
|
||||
@@ -184,17 +274,18 @@ impl UndergroundWorker {
|
||||
conn.execute("ug_select_pending", &[])
|
||||
}
|
||||
|
||||
fn execute_row(pool: &ConnectionPool, r: &Row) -> Result<Json, DbError> {
|
||||
fn execute_row(pool: &ConnectionPool, broker: &MessageBroker, 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, &task_type, performer_id, victim_id, ¶ms)
|
||||
Self::handle_task(pool, broker, &task_type, performer_id, victim_id, ¶ms)
|
||||
}
|
||||
|
||||
fn handle_task(
|
||||
pool: &ConnectionPool,
|
||||
broker: &MessageBroker,
|
||||
task_type: &str,
|
||||
performer_id: i32,
|
||||
victim_id: i32,
|
||||
@@ -208,6 +299,7 @@ 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
|
||||
@@ -224,8 +316,8 @@ impl UndergroundWorker {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
conn.prepare("ug_select_by_performer", Q_SELECT_BY_PERFORMER)?;
|
||||
let rows = conn.execute("ug_select_by_performer", &[&victim_id])?;
|
||||
conn.prepare("ug_select_underground_by_character", Q_SELECT_UNDERGROUND_BY_CHARACTER)?;
|
||||
let rows = conn.execute("ug_select_underground_by_character", &[&victim_id])?;
|
||||
|
||||
let mut activities = Vec::new();
|
||||
for r in rows {
|
||||
@@ -880,6 +972,348 @@ 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()
|
||||
@@ -911,6 +1345,200 @@ 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,5 +1,6 @@
|
||||
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;
|
||||
@@ -8,7 +9,9 @@ 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,
|
||||
@@ -19,29 +22,39 @@ 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,
|
||||
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_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_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`.
|
||||
@@ -58,7 +71,14 @@ 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`
|
||||
@@ -75,7 +95,11 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,8 +107,9 @@ 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_daily_pregnancies();
|
||||
self.maybe_run_hourly_pregnancies();
|
||||
|
||||
// Entspricht in etwa der 1-Sekunden-Schleife im C++-Code
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
@@ -116,27 +141,85 @@ impl UserCharacterWorker {
|
||||
self.last_hourly_run = Some(now);
|
||||
}
|
||||
|
||||
fn run_hourly_tasks(&mut self) -> Result<(), DbError> {
|
||||
self.process_character_events()?;
|
||||
self.handle_credits()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn maybe_run_daily_pregnancies(&mut self) {
|
||||
/// 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_pregnancy_run {
|
||||
let should_run = match self.last_death_check_run {
|
||||
None => true,
|
||||
Some(last) => now.saturating_duration_since(last) >= Duration::from_secs(24 * 3600),
|
||||
Some(last) => now.saturating_duration_since(last) >= Duration::from_secs(DEATH_CHECK_INTERVAL_SECS),
|
||||
};
|
||||
|
||||
if !should_run {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = self.process_pregnancies() {
|
||||
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) {
|
||||
let now = Instant::now();
|
||||
let today = Local::now().date_naive();
|
||||
|
||||
let run_hourly = match self.last_pregnancy_run {
|
||||
None => true,
|
||||
Some(last) => now.saturating_duration_since(last) >= Duration::from_secs(3600),
|
||||
};
|
||||
|
||||
let run_daily_fertility = self.last_marriage_fertility_date != Some(today);
|
||||
|
||||
if !run_hourly && !run_daily_fertility {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = self.process_pregnancies(run_hourly, run_daily_fertility) {
|
||||
eprintln!("[UserCharacterWorker] Fehler in processPregnancies: {err}");
|
||||
}
|
||||
self.last_pregnancy_run = Some(now);
|
||||
|
||||
if run_hourly {
|
||||
self.last_pregnancy_run = Some(now);
|
||||
}
|
||||
if run_daily_fertility {
|
||||
self.last_marriage_fertility_date = Some(today);
|
||||
}
|
||||
}
|
||||
|
||||
fn process_character_events(&mut self) -> Result<(), DbError> {
|
||||
@@ -175,6 +258,12 @@ 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(());
|
||||
@@ -390,10 +479,6 @@ 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 {
|
||||
@@ -449,9 +534,15 @@ 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 {
|
||||
if let Err(err) = self
|
||||
} else if prism_started_previously
|
||||
&& let Err(err) = self
|
||||
.base
|
||||
.change_falukant_user_money(user_id, pay_rate, "debitor_prism")
|
||||
{
|
||||
@@ -459,43 +550,181 @@ impl UserCharacterWorker {
|
||||
"[UserCharacterWorker] Fehler bei change_falukant_user_money (debitor_prism): {err}"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
conn.execute("add_character_to_debtors_prism", &[&character_id])?;
|
||||
}
|
||||
// Verzug / Schuldturm: täglich `falukant_debtors::run_daily` (FalukantFamilyWorker)
|
||||
|
||||
conn.execute("update_credit", &[&remaining_amount, &user_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Schwangerschafts-Logik (portiert aus processPregnancies)
|
||||
fn process_pregnancies(&mut self) -> Result<(), DbError> {
|
||||
fn process_pregnancies(&mut self, hourly: bool, daily_fertility: bool) -> Result<(), DbError> {
|
||||
self.ensure_falukant_pregnancy_schema()?;
|
||||
|
||||
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)?;
|
||||
|
||||
for row in rows {
|
||||
self.process_single_pregnancy_candidate(&mut conn, &row)?;
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_single_pregnancy_candidate(
|
||||
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(
|
||||
&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 {
|
||||
@@ -508,6 +737,9 @@ 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"
|
||||
@@ -531,6 +763,10 @@ 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);
|
||||
}
|
||||
@@ -551,8 +787,51 @@ 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!(
|
||||
@@ -570,18 +849,90 @@ 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("delete_election_candidate", QUERY_DELETE_ELECTION_CANDIDATE)?;
|
||||
conn.prepare("insert_notification", QUERY_INSERT_NOTIFICATION)?;
|
||||
|
||||
conn.execute("delete_director", &[&character_id])?;
|
||||
conn.execute("delete_relationship", &[&character_id])?;
|
||||
conn.execute("delete_child_relation", &[&character_id])?;
|
||||
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_knowledge", &[&character_id])?;
|
||||
conn.execute("delete_debtors_prism", &[&character_id])?;
|
||||
conn.execute("delete_political_office", &[&character_id])?;
|
||||
conn.execute("delete_election_candidate", &[&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])?;
|
||||
}
|
||||
|
||||
// Character selbst löschen
|
||||
conn.prepare(
|
||||
@@ -609,9 +960,22 @@ 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(())
|
||||
}
|
||||
|
||||
@@ -692,8 +1056,17 @@ 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", &[&new_amount, &falukant_user_id])?;
|
||||
conn.execute("update_user_money", &[&money_str, &falukant_user_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -789,8 +1162,8 @@ impl UserCharacterWorker {
|
||||
.get()
|
||||
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
|
||||
|
||||
conn.prepare("count_children", QUERY_COUNT_CHILDREN)?;
|
||||
let rows = conn.execute("count_children", &[&deceased_user_id])?;
|
||||
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()
|
||||
@@ -844,6 +1217,18 @@ 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,6 +14,7 @@ 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,
|
||||
@@ -139,11 +140,17 @@ 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);
|
||||
@@ -167,6 +174,9 @@ 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();
|
||||
|
||||
@@ -191,6 +201,9 @@ 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,38 +5,13 @@ 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,
|
||||
}
|
||||
|
||||
// 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;
|
||||
"#;
|
||||
// Reuse QUERY_UPDATE_WEATHER from centralized SQL module
|
||||
|
||||
impl WeatherWorker {
|
||||
pub fn new(pool: ConnectionPool, broker: MessageBroker) -> Self {
|
||||
@@ -88,17 +63,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. {} Regionen betroffen.",
|
||||
"[WeatherWorker] Wetter aktualisiert (per-row random). {} Regionen betroffen.",
|
||||
updated_rows.len()
|
||||
);
|
||||
|
||||
// Benachrichtige alle Clients über Wetteränderungen
|
||||
let message = r#"{"event":"weather_updated"}"#;
|
||||
broker.publish(message.to_string());
|
||||
broker.publish("{\"event\":\"weather_updated\"}".to_string());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user