145 Commits

Author SHA1 Message Date
Torsten Schulz (local)
e5e17c2c5e Updated politics worker
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 2m2s
2026-06-05 20:28:06 +02:00
Torsten Schulz (local)
facd11b14f Füge zusätzliche Informationen zu Lagerzerstörungen hinzu: Erweitere die Funktionen zur Verarbeitung von persönlichen und regionalen Lager-Schäden, um die ID und den Titel des Ereignisses als Schadensursache zu übergeben und in den Rückgaben zu integrieren.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m56s
2026-05-27 18:23:02 +02:00
Torsten Schulz (local)
f09033a99d Füge neue SQL-Abfragen für die Abfrage von Lagerbeständen nach Typ hinzu: Implementiere QUERY_GET_REGION_STOCKS_BY_TYPE und QUERY_GET_USER_STOCKS_BY_TYPE, um die Abfrage von Lagerbeständen zu optimieren. Aktualisiere die Logik in events.rs, um die neuen Abfragen zu verwenden und erweitere die Struktur StorageDamageInfo, um zusätzliche Informationen zu zerstörten Einheiten und betroffenen Regionen zu speichern. Ergänze die Dokumentation in AGENTS.md mit Projektleitlinien und Entwicklungsbefehlen.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m48s
2026-05-27 17:51:39 +02:00
Torsten Schulz (local)
86d79c90a5 Füge Logik zur Durchsetzung der Wahlvorlaufzeit hinzu: Implementiere die Funktion enforce_election_lead_time, um sicherzustellen, dass Wahlen erst nach Ablauf von 2 Tagen seit ihrer Erstellung ausgewertet werden. Aktualisiere SQL-Abfragen zur Berücksichtigung des Vorlaufs bei der Planung neuer Wahlen.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m41s
2026-05-26 10:04:37 +02:00
Torsten Schulz (local)
88eda493ff Verbessere Logik zur Berechnung von Reputation und Ehezufriedenheit: Aktualisiere die Kategorien und täglichen Deltas für Reputation und Ehezufriedenheit basierend auf Standsgruppen, um die Auswirkungen von Liebschaften präziser darzustellen. Füge Dokumentation zu Standsgruppen und deren Effekten hinzu.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m31s
2026-05-21 11:01:41 +02:00
Torsten Schulz (local)
342eb512b4 Füge Logik zur Berechnung von Reputation und Ehezufriedenheit basierend auf Liebschaften hinzu: Implementiere Funktionen zur Bewertung der Standsgruppe und zur Berechnung täglicher Deltas für Reputation und Ehezufriedenheit, um die Auswirkungen von Liebschaften auf die Beziehungen zu berücksichtigen.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m30s
2026-05-21 10:54:28 +02:00
Torsten Schulz (local)
773665635d Behebe unnötige Zählvariablen und verbessere die Lesbarkeit in mehreren Modulen: Entferne die Zählvariable für Transporte in DirectorWorker, optimiere die Filterung in FalukantFamilyWorker und passe die Bedingungen in FalukantServants und Underground an, um die Lesbarkeit zu erhöhen.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m34s
2026-05-21 09:42:12 +02:00
Torsten Schulz (local)
173776fb52 Behebe Warnung über ungenutzte logische Operation in DirectorWorker: Ändere die Bedingung für den Transportstart, um das Ergebnis der Funktion zu ignorieren und die Lesbarkeit zu verbessern.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m28s
2026-05-21 09:40:17 +02:00
Torsten Schulz (local)
43cb58294c Verbessere Fehlerbehandlung in Transport- und Produktionslogik: Optimiere die Bedingungen für den Transportstart und die Verarbeitung abgeschlossener Produktionen, um die Lesbarkeit zu erhöhen und Fehler effizienter zu handhaben.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m24s
2026-05-21 09:38:02 +02:00
Torsten Schulz (local)
87f6e7315d Aktualisiere Logik zur Schwangerschaftsplanung: Ändere die Intervalle für die Schwangerschaftsplanung in den SQL-Abfragen auf 18 Stunden und entferne nicht benötigte Abfragen zur Geburt. Dies verbessert die Einhaltung der Regel, dass eine Mutter pro Kalendertag nur ein Kind gebären kann.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m44s
2026-05-21 09:31:02 +02:00
Torsten Schulz (local)
cb22b111a3 Enhance birth delivery queries to prevent multiple births per mother per day: Updated SQL queries for marriage and planned character birth deliveries to include checks that prevent a mother from having more than one child born on the same calendar day. This change ensures compliance with the business rule of one child per mother per day, regardless of the father.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m46s
2026-05-13 22:06:09 +02:00
Torsten Schulz (local)
208f16e30b Refactor satisfaction update logic in DirectorWorker: Simplified the SQL query for updating director satisfaction by introducing a common table expression (CTE) for updated satisfaction values. This change enhances readability and maintains the integrity of satisfaction updates while ensuring accurate data retrieval from both new and adjusted income calculations.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 2m23s
2026-05-09 09:19:17 +02:00
Torsten Schulz (local)
2d62ba4b51 Enhance income update logic in DirectorWorker: Introduced a nested SQL query to calculate desired income based on title of nobility and knowledge averages. Updated the income setting logic to utilize this calculated desired income, ensuring more accurate adjustments for directors with auto-adjust settings enabled.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m30s
2026-05-08 11:28:56 +02:00
Torsten Schulz (local)
dd8d8be79b Refactor WebSocket server access control logic for worker schedules: Updated the handling of user permissions in handle_connection to utilize tokio::task::spawn_blocking, improving error handling and debugging information in the WorkerScheduleAccessDebug struct. This change enhances the robustness of access checks and provides clearer feedback when access is denied.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m40s
2026-05-08 11:15:53 +02:00
Torsten Schulz (local)
d27230a0b5 Enhance worker schedule access control with detailed debugging information: Updated the user_can_read_worker_schedules function to return a WorkerScheduleAccessDebug struct, providing detailed error messages and access information. Improved WebSocket server responses to include debug data when access is denied, facilitating better troubleshooting and monitoring of user permissions.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m38s
2026-05-08 10:45:32 +02:00
Torsten Schulz (local)
f7eb53ae90 Add user right for main admin and enhance worker schedule access control: Introduced a new user right mainadmin to improve access management. Updated the logic in user_can_read_worker_schedules to include checks for this new right, enhancing the SQL queries for user permissions and ensuring robust access control for worker schedules.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m32s
2026-05-08 10:31:22 +02:00
Torsten Schulz (local)
42f71d831f Implement worker schedule access control and runtime tracking: Added a new user right worker_schedule_read to manage access to the getWorkerSchedules WebSocket endpoint. Enhanced the WebSocket server to handle requests for worker schedules, including detailed responses. Introduced runtime tracking for workers, allowing for monitoring of their current state and activity. Updated relevant SQL and Rust structures to support these features.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m36s
2026-05-08 09:04:21 +02:00
Torsten Schulz (local)
b7d7705bbe Enhance satisfaction update logic in DirectorWorker: Introduced an adjusted income calculation to update director incomes based on their wished income and auto-adjust settings. Modified the SQL query for satisfaction updates to include this new logic, ensuring accurate satisfaction calculations while maintaining existing functionality for directors not utilizing auto-adjust income.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m36s
2026-05-07 13:32:42 +02:00
Torsten Schulz (local)
48a374e3ac Add satisfaction check to DirectorWorker: Introduced a new field for tracking the last satisfaction check and updated the calculation logic to ensure it runs only once every 24 hours. Enhanced SQL query for updating satisfaction to include a salary gap calculation, improving the accuracy of satisfaction updates based on income and title of nobility.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m30s
2026-05-07 11:30:27 +02:00
Torsten Schulz (local)
d0ff6cad68 Add branch and region details to DirectorWorker: Enhanced resignation candidate structure by including branch_id, region_id, and region_label. Updated resignation risk and resignation payloads to incorporate these new fields. Modified SQL query to retrieve branch and region information, ensuring accurate data handling for director resignations.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m49s
2026-05-07 11:25:48 +02:00
Torsten Schulz (local)
9022b58dc7 Refactor revenue and tax handling in DirectorWorker: Updated logic to separately account for gross revenue and tax deductions during product sales. Enhanced monetary transactions to ensure accurate tax income distribution, particularly when the seller differs from the treasury user, improving financial tracking and compliance.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 2m8s
2026-05-06 11:09:42 +02:00
Torsten Schulz (local)
8509a7e171 Update monetary limits for numeric precision in BaseWorker and UserCharacterWorker: Changed maximum absolute values from numeric(10,2) to numeric(14,2) to accommodate larger monetary amounts. Adjusted related comments and clamping logic to reflect the new limits, ensuring accurate handling of monetary values.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 2m19s
2026-05-06 11:04:45 +02:00
Torsten Schulz (local)
43eb6ed0ab Refactor event effect handling in EventsWorker: Improved logic for handling empty effect results to prevent unnecessary database writes. Enhanced error handling for retrieving current money, ensuring robust behavior when user data is unavailable. Simplified the response for insufficient funds scenarios in the "theft" event, returning None instead of a detailed JSON response.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m52s
2026-04-24 10:00:45 +02:00
Torsten Schulz (local)
05f713c5e1 Refactor Falukant certificate processing logic: Introduced a new SQL query to check for the existence of the completion_count column in the production log, allowing for conditional selection of certificate input rows. Added a legacy query for environments lacking this column, enhancing compatibility and robustness in certificate processing.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m34s
2026-04-21 15:34:58 +02:00
Torsten Schulz (local)
397b612bf1 Implement election candidate table readiness check in character deletion process: Added a new SQL query to verify the existence of the election candidate table before attempting to delete associated records. Updated the deletion logic in both EventsWorker and UserCharacterWorker to conditionally execute the deletion of election candidates based on the table's readiness, improving robustness and preventing errors in environments where the table may not exist.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m39s
2026-04-21 11:48:53 +02:00
Torsten Schulz (local)
a38beadb8e Enhance character deletion process in EventsWorker and UserCharacterWorker: Added SQL queries to delete associated data such as knowledge, debtors prism, political offices, and election candidates upon character deletion. Implemented event publishing for character death and status updates after inheritance changes, improving event handling and UI responsiveness.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m41s
2026-04-20 15:35:08 +02:00
Torsten Schulz (local)
0892e2db8b Implement political office history logging: Added SQL logic to archive political office records upon expiration and deletion, ensuring historical tracking of office terms. Updated relevant queries to insert records into falukant_log.political_office_history for better compliance with data retention policies.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m33s
2026-04-17 17:30:40 +02:00
Torsten Schulz (local)
75a1e5b306 Update production log SQL queries to include completion count: Modified the QUERY_INSERT_UPDATE_PRODUCTION_LOG to add a new completion_count field, ensuring accurate tracking of completed productions. Updated related documentation to reflect changes in production counting logic and SQL migrations.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m51s
2026-04-17 16:59:22 +02:00
Torsten Schulz (local)
48d8e9b293 Enhance event effect handling in EventsWorker: Added logic to store detailed notes for price and production quality changes in notifications. Updated notification JSON structure to include these notes, improving the clarity of event effects communicated to users. Additionally, refined money change logic for the "theft" event to handle insufficient funds scenarios more effectively.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m35s
2026-04-14 11:39:03 +02:00
Torsten Schulz (local)
b0ee0da722 Add logic to prevent multiple births for mothers in a single day: Implemented a new SQL query to check if a mother has already given birth today. Updated the FalukantFamilyWorker and UserCharacterWorker to utilize this check, enhancing the birth registration process and ensuring compliance with the new birth rule.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m35s
2026-04-14 07:50:56 +02:00
Torsten Schulz (local)
6e931c1069 Implement director resignation processing in DirectorWorker: Added logic to check for director resignations based on satisfaction levels, including new SQL queries for retrieving candidates and deleting directors. Enhanced notification system to alert users of high resignation risks, improving overall management of director statuses.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m32s
2026-04-13 15:55:34 +02:00
Torsten Schulz (local)
3af0ab1156 Refactor best production SQL query: Simplified and optimized the QUERY_GET_BEST_PRODUCTION by restructuring the query to use a WITH clause for better readability and performance. This change enhances the accuracy of production candidate selection by incorporating additional metrics and calculations.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m33s
2026-04-13 15:45:05 +02:00
Torsten Schulz (local)
00ce7fc655 Refactor SQL query for candidate selection: Updated the SQL query to include a new FROM candidates clause, ensuring accurate retrieval of the top production candidates based on worth calculations. This change enhances the clarity and functionality of the production selection process.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m54s
2026-04-13 15:34:28 +02:00
Torsten Schulz (local)
cd21293fbd Enhance DirectorWorker logic for tax calculations: Updated the DirectorWorker to compute the cumulative tax percentage for net ranking and adjusted SQL queries to include this new parameter in production selection. Improved logging to capture additional metrics related to tax and net piece calculations, enhancing the traceability of production decisions.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m34s
2026-04-13 15:29:11 +02:00
Torsten Schulz (local)
2eaea2481c Refactor SQL queries for type consistency: Updated various SQL queries to ensure consistent data types by casting results to text. This change enhances compatibility and improves the handling of JSON data in the application.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m37s
2026-04-13 15:20:47 +02:00
Torsten Schulz (local)
3df83b507e Add debug functionality for best production selection: Introduced a new SQL query QUERY_GET_BEST_PRODUCTION_DEBUG to retrieve the top 5 production candidates with detailed metrics. Enhanced the DirectorWorker to log debug information during production selection, improving traceability of decision-making processes.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m35s
2026-04-13 15:13:27 +02:00
Torsten Schulz (local)
df092d1790 Add notify module and update notification SQL query: Introduced a new notify module for handling notifications. Updated the SQL query in insert_notification_conn to use QUERY_INSERT_NOTIFICATION instead of QUERY_INSERT_NOTIFICATION_FULL, simplifying the notification insertion process.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m45s
2026-04-13 15:07:39 +02:00
Torsten Schulz (local)
670ea093d0 Update YpDaemon version to 0.5.0 and change edition to 2024 in Cargo.toml and Cargo.lock for improved compatibility and feature enhancements.
Some checks failed
Deploy yourpart (blue-green) / deploy (push) Failing after 1m17s
2026-04-13 15:05:01 +02:00
Torsten Schulz (local)
8593824a73 Enhance sell log SQL query: Updated the QUERY_ADD_SELL_LOG to include a sell_timestamp field in the conflict resolution clause, ensuring accurate tracking of sell events. This change improves the logging mechanism for sales data in the database.
Some checks failed
Deploy yourpart (blue-green) / deploy (push) Failing after 2s
2026-04-13 15:02:52 +02:00
Torsten Schulz (local)
7ca43da52d Refactor product start calculation and SQL query: Updated the documentation for the best product start logic to clarify the ranking criteria and cost calculations. Adjusted the SQL query to enhance the worth calculation by incorporating new logic for category-based costs and headroom discounts, ensuring consistency with the updated pricing strategy.
Some checks failed
Deploy yourpart (blue-green) / deploy (push) Failing after 2s
2026-04-13 14:53:08 +02:00
Torsten Schulz (local)
2cca0da750 Update version and edition in Cargo.toml; enhance breakup risk management in FalukantFamilyWorker: Added logic to automatically deactivate low-affection relationships and notify users of high breakup risks. Updated SQL queries to support these changes.
Some checks failed
Deploy yourpart (blue-green) / deploy (push) Failing after 3s
2026-04-13 12:21:35 +02:00
Torsten Schulz (local)
10f71ece0e Update deployment script path in workflow: Changed the script executed by the deploy-daemon workflow from deploy-yourpart-bluegreen.sh to actualize-daemon.sh for improved deployment management.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 1m36s
2026-04-10 08:50:15 +02:00
Torsten Schulz (local)
7043e8d90c Update production certificate logic for more frequent recalculations: Changed the certificate recalculation interval to approximately once per minute, updated related documentation, and adjusted the FalukantFamilyWorker to reflect these changes. Enhanced the UI WebSocket documentation to clarify the conditions under which production certificates are refreshed.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 2m56s
2026-04-10 08:43:19 +02:00
Torsten Schulz (local)
1111377913 Enhance money change event handling: Added a new loss field to the JSON response in the EventsWorker to display the loss amount as a positive value, improving clarity for UI text. Updated the calculation logic to ensure accurate representation of financial changes.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 2m58s
2026-04-10 08:39:45 +02:00
Torsten Schulz (local)
ebf20c4424 Refactor production cost calculations and SQL query: Updated the QUERY_GET_BEST_PRODUCTION to implement a legacy formula for ranking based on effective market percentage and knowledge levels. Adjusted the calculation logic for costs and clarified documentation to ensure consistency with the new pricing strategy. Enhanced SQL query structure for improved clarity and performance in production worth assessment.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 2m41s
2026-04-09 09:36:44 +02:00
Torsten Schulz (local)
bb1c1c4133 Refactor production cost calculations and SQL query: Updated the QUERY_GET_BEST_PRODUCTION to align profit calculations with the new logic for selling prices based on knowledge levels. Enhanced documentation to clarify the ranking criteria and the relationship between production costs and pricing strategies. Adjusted related functions in director.rs to ensure consistency across the application.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 2m54s
2026-04-09 09:08:53 +02:00
Torsten Schulz (local)
dda559cbe7 Refactor SQL query for best production calculation: Updated the QUERY_GET_BEST_PRODUCTION to enhance the worth calculation by incorporating vehicle presence checks and adjusting the logic for market pricing based on regional data. Improved documentation to clarify the new formula for profit per minute and its implications for product evaluation across different regions.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 2m46s
2026-04-09 08:57:27 +02:00
Torsten Schulz (local)
7d56f51ec9 Refactor SQL query for best production calculation: Updated the QUERY_GET_BEST_PRODUCTION to incorporate vehicle presence checks for users, adjusting the worth calculation based on regional market data. Enhanced the query structure for improved clarity and performance, ensuring accurate production worth assessment across different regions. Added documentation to clarify the new logic and its implications for product evaluation.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 2m59s
2026-04-09 08:48:51 +02:00
Torsten Schulz (local)
e460792357 Enhance production certificate logic and SQL queries: Updated the QUERY_UPDATE_FALUKANT_USER_CERTIFICATE to include certificate_productions_count_since for tracking production counts since the last level change. Modified the QUERY_GET_PRODUCTION_CERTIFICATE_INPUT_ROWS to filter completed productions based on this timestamp. Improved documentation for clarity on the new logic and its implications for user progression and data retention.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 3m12s
2026-04-09 08:13:17 +02:00
Torsten Schulz (local)
731c39dfa4 Implement daily political salary management: Introduced a new function run_daily_political_salary to calculate and distribute daily salaries for players with active political offices, utilizing configured values or fallback based on office rank. Updated SQL queries to support this functionality, including checks for the readiness of the necessary database column. Enhanced the PoliticsWorker to trigger daily salary processing, ensuring timely updates for users. Improved documentation for clarity on the new salary management features and their integration into the existing political benefits system.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 2m51s
2026-04-07 15:24:12 +02:00
Torsten Schulz (local)
ac024a8d14 Implement political benefits management in FalukantFamilyWorker and SQL: Introduced a new structure for handling lover relationships, including political slots and reputation ticks. Updated SQL queries to support political benefits, ensuring proper handling of appointments and reputation gains. Enhanced the FalukantFamilyWorker logic to manage free political slots and maintain relationships effectively. Improved documentation for clarity on the new political benefits features and their integration into the existing system.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 2m55s
2026-04-02 15:46:37 +02:00
Torsten Schulz (local)
21525ec125 Enhance production certificate and election result handling: Updated the logic in run_daily to improve clarity on level progression based on weighted scores, including detailed logging for cases where players meet minimum requirements but do not advance. Introduced a new function to notify players of election results, integrating SQL queries for efficient data retrieval and notification management. Enhanced documentation for the production certificate process to clarify UI vs. daemon discrepancies.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 2m54s
2026-04-02 14:48:39 +02:00
Torsten Schulz (local)
619e5e5123 Refactor death log handling in UserCharacterWorker: Introduced a new function optional_linked_names to streamline the retrieval of linked names (spouses, children, lovers) in the death context. This change enhances error handling and maintains the integrity of the deceased character's data in JSON format. Updated the handle_character_death method to utilize the new function, improving code readability and maintainability.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 2m55s
2026-03-31 15:14:01 +02:00
Torsten Schulz (local)
b3578c65b8 Enhance production certificate logic and event handling: Updated the daily recalculation process in FalukantFamilyWorker to decouple it from family migration, ensuring consistent execution. Introduced a new mechanism to reset certificates upon succession without heirs, improving user experience. Refined SQL queries for better data retrieval and event publishing, including distinct character selection logic. Enhanced documentation for clarity on the updated processes and their implications.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 2m55s
2026-03-31 10:29:05 +02:00
Torsten Schulz (local)
64080211d1 Refactor SSH connection steps in deployment workflow: Updated environment variable names for clarity and added logging for resolved configuration. Enhanced SSH connection commands to utilize the new variable names, improving maintainability and readability of the deployment process.
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 2m56s
2026-03-31 10:14:25 +02:00
Torsten Schulz (local)
57f3d27eb7 Update deployment workflow for blue-green strategy: Renamed workflow to 'Deploy yourpart (blue-green)' and refactored SSH connection steps to utilize environment variables for host, port, and user. Changed deployment script to align with blue-green deployment practices, enhancing clarity and maintainability.
Some checks failed
Deploy yourpart (blue-green) / deploy (push) Failing after 0s
2026-03-31 10:03:04 +02:00
Torsten Schulz (local)
a7a6ea1ff9 Refactor planned pregnancy management in UserCharacterWorker and SQL: Updated queries to handle planned births and clear pregnancy records post-birth. Streamlined pregnancy processing logic with readiness checks and improved child relation insertion. Enhanced documentation for better understanding of planned pregnancy features and their integration.
Some checks failed
Deploy daemon / deploy-daemon (push) Failing after 0s
2026-03-31 09:58:33 +02:00
Torsten Schulz (local)
b1bf96a2ce Implement planned pregnancy management in UserCharacterWorker and SQL: Added new queries for handling planned births and clearing character pregnancy records after birth. Refactored pregnancy processing logic to incorporate checks for planned pregnancy readiness and streamlined the insertion of child relations based on birth context. Enhanced documentation for clarity on the new planned pregnancy features and their integration into the existing system. 2026-03-31 09:03:58 +02:00
Torsten Schulz (local)
1258abbb42 Refactor marriage pregnancy logic in UserCharacterWorker and SQL: Updated the conception process to occur daily instead of hourly, simplifying the fertility mechanics. Enhanced documentation for clarity on the new daily conception approach and adjusted related SQL queries to reflect these changes. Introduced a mechanism to track the last fertility date to prevent multiple conceptions in a single day. 2026-03-30 10:21:05 +02:00
Torsten Schulz (local)
ac7bd1d7ed Enhance marriage pregnancy management in SQL and UserCharacterWorker: Added a new query to clear stale marriage pregnancy records after 30 days. Updated existing queries to include shared children count for more accurate pregnancy probability calculations. Refactored logic in maybe_run_hourly_pregnancies to incorporate the new query and improved documentation for clarity on marriage pregnancy mechanics. 2026-03-30 10:16:50 +02:00
Torsten Schulz (local)
ac059f688d Integrate death log functionality into character and event processing: Added death context handling in CharacterCreationWorker and EventsWorker to enhance user notifications related to character deaths. Updated SQL queries for retrieving deceased context and modified notification logic to support detailed death notifications. Enhanced user notification methods to accommodate both short event names and JSON payloads for improved clarity in messaging. 2026-03-30 10:11:55 +02:00
Torsten Schulz (local)
1013af594d Enhance documentation for production cost calculation: Added clarifications on common misconceptions regarding the cost formula, including the role of certificates and product categories. Updated examples and detailed the calculation process to improve understanding of effective piece costs and headroom discounts. 2026-03-26 20:22:47 +01:00
Torsten Schulz (local)
84b32d72aa Update production worth calculation in SQL and documentation: Refined the worth formula to focus on regional revenue per time unit, removing knowledge factors and old cost penalties. Enhanced documentation to clarify the new sorting criteria and its implications for production management. 2026-03-26 19:42:24 +01:00
Torsten Schulz (local)
3289a0c129 Refactor production cost calculation in DirectorWorker: Updated the cost formula to depend on product category rather than certificate level, introducing a base cost and headroom discount mechanism. Modified SQL queries to retrieve product category and user certificate for accurate cost assessment. Enhanced documentation for clarity on the new cost structure and its implications for production management. 2026-03-25 12:16:52 +01:00
Torsten Schulz (local)
65772fb7de Implement marriage pregnancy management in UserCharacterWorker: Added SQL queries for handling marriage-related births and conception updates. Refactored pregnancy processing logic to accommodate new marriage pregnancy features, including checks for migration readiness and streamlined delivery processing. Enhanced documentation for clarity on marriage pregnancy mechanics. 2026-03-25 11:56:25 +01:00
Torsten Schulz (local)
083fa26297 Refactor Falukant certificate management: Consolidated certificate logic into the FalukantFamilyWorker's daily processing, removing the separate FalukantCertificateWorker. Updated SQL queries to include app_user_id and enhanced documentation for clarity on certificate scoring and daily recalculation logic. 2026-03-25 11:12:55 +01:00
Torsten Schulz (local)
3b25f8c3a0 Add transport raid functionality to Falukant daemon: Introduced falukant_transport_raid module with SQL queries for managing transport raids, including candidate transports and user reputation updates. Updated documentation to reflect new WebSocket events and integrated the module into the UndergroundWorker for seamless processing of transport-related events. 2026-03-23 14:25:21 +01:00
Torsten Schulz (local)
df143e2531 Enhance sales and salary processing in DirectorWorker: Introduced a flag for conditional WebSocket message publishing in sell_single_inventory_item, allowing for more flexible event notifications. Refactored salary processing to aggregate total income by employer and ensure unique status updates, improving financial accuracy and reducing redundant notifications. 2026-03-23 14:16:34 +01:00
Torsten Schulz (local)
c5e78cb87f Refactor production cost handling in DirectorWorker: Update create_single_production to return production costs instead of void, enabling aggregated cost calculations. Implement bundled money deductions for productions initiated by the director, enhancing financial accuracy and efficiency in production management. 2026-03-23 14:10:16 +01:00
Torsten Schulz (local)
195bf49d14 Refactor sales processing in DirectorWorker: Aggregate local sales payouts and taxes for improved efficiency. Update sell_single_inventory_item to return payout and tax amounts, ensuring accurate financial updates. Enhance money transfer logic to handle aggregated sales data, streamlining financial interactions. 2026-03-23 12:21:23 +01:00
Torsten Schulz (local)
e811ec6264 Integrate debt management features into Falukant daemon: Added falukant_debtors module for handling debtor logic, including daily processing and SQL queries for managing debtors' status and actions. Updated FalukantFamilyWorker to incorporate debtor checks and error handling, enhancing financial interactions and family dynamics. 2026-03-23 11:56:12 +01:00
Torsten Schulz (local)
9d7f61a329 Enhance church office management in Falukant daemon: Introduced falukantUpdateChurch event for church applications and appointments, updated SQL queries for church office processing, and refactored the PoliticsWorker to streamline daily tasks related to church offices. Improved handling of church application scoring and interim appointments, enhancing overall church dynamics and character interactions. 2026-03-23 11:02:19 +01:00
Torsten Schulz (local)
708ffc3eda Refactor Falukant daemon logic for time-based processing: Updated the handling of monthly servant costs and lover installments to align with the new game time model, introducing a unified "Monatstick" concept for cost calculations. Enhanced SQL queries and worker logic to ensure accurate processing every 2 hours, improving financial interactions and family dynamics. 2026-03-23 10:11:49 +01:00
Torsten Schulz (local)
c82fbc0f7c Implement lover installment logic in FalukantFamilyWorker: Added support for processing lover maintenance payments every 2 hours, including new SQL queries for installment tracking and updates to relationship states. Enhanced WebSocket events to notify UI of installment changes, improving family dynamics and financial interactions. 2026-03-23 10:01:34 +01:00
Torsten Schulz (local)
fe0361971d Enhance Falukant family and production dynamics: Updated FalukantFamilyWorker to include public stability and household tension calculations, integrating new SQL queries for managing marriage states and household attributes. Added FalukantCertificateWorker for production certificate management, enhancing overall family interaction and production tracking. 2026-03-23 09:02:51 +01:00
Torsten Schulz (local)
d921dc2f7e Integrate servant management into Falukant family dynamics: Added support for servant-related logic in FalukantFamilyWorker, including daily and monthly processing of servant data. Updated SQL queries to handle servant attributes and integrated servant discretion modifiers into relationship calculations, enhancing family interaction and satisfaction tracking. 2026-03-22 10:09:26 +01:00
Torsten Schulz (local)
c209c41b52 Implement notification system for character death events: Added a new method in UserCharacterWorker to notify users of character deaths, including directors, relationships, and children. Updated SQL queries to support the insertion of notifications with event types, enhancing user engagement and real-time updates. 2026-03-22 09:48:16 +01:00
Torsten Schulz (local)
4086e9a207 Refactor pregnancy handling in UserCharacterWorker: Changed the pregnancy check from a daily to an hourly basis, updating the associated method and SQL query to reflect this new frequency. This adjustment improves the accuracy of pregnancy probability calculations and aligns with the updated family dynamics logic. 2026-03-22 09:43:36 +01:00
Torsten Schulz (local)
ac5ec3a245 Enhance Falukant family dynamics: Updated the FalukantFamilyWorker to incorporate marriage buffs and detailed age rules for relationships. Added new WebSocket events for real-time updates and expanded SQL queries to support marriage state and buff management, improving overall family interaction and satisfaction tracking. 2026-03-20 11:02:28 +01:00
Torsten Schulz (local)
6a5ff4557e Add FalukantFamilyWorker and related SQL queries: Introduced the FalukantFamilyWorker to manage family-related logic, including marriage satisfaction and relationship states. Added new SQL queries for handling lover relationships and marriage updates, enhancing the overall functionality of family dynamics in the application. 2026-03-20 10:26:15 +01:00
Torsten Schulz (local)
f9c80bbd6b Enhance pregnancy candidate query: Refactored SQL query to calculate pregnancy probabilities based on mother's age, utilizing a common table expression (CTE) for improved clarity and maintainability. The new logic replaces the previous average age calculation with a more detailed age-based probability assessment, ensuring accurate filtering of candidates. 2026-03-19 09:13:36 +01:00
Torsten Schulz (local)
6757aa363f Refactor event handling in EventsWorker: Consolidated money change, storage capacity change, and character health change logic into dedicated methods for improved clarity and maintainability. This change enhances the handling of various event effects while preserving existing functionality. 2026-03-10 22:04:54 +01:00
Torsten Schulz (local)
9961b91c4f Enhance event handling for money changes in EventsWorker: Introduced special cases for 'windfall' and 'theft' events, allowing for random monetary adjustments based on current user funds. Updated logic to ensure accurate percentage and absolute changes while maintaining existing functionality for standard money change events. 2026-03-10 17:07:14 +01:00
Torsten Schulz (local)
5d0dd41c3f Refactor event handling for character death: Updated the EventsWorker to ensure only player characters are notified of sudden child deaths, while NPCs are excluded. Adjusted health update logic to prevent player characters from reaching zero health due to accidents or illnesses. Enhanced SQL queries to filter characters based on user ID, improving clarity and functionality. 2026-03-02 00:36:21 +01:00
Torsten Schulz (local)
69dad6854a Enhance money change functionality in EventsWorker: Updated the MoneyChange event effect to include an optional minimum absolute positive value, ensuring that money changes do not result in zero for positive adjustments. Introduced a new SQL query for inserting money history, improving reliability in tracking monetary changes for users. 2026-02-06 07:40:25 +01:00
Torsten Schulz (local)
4baf88b0cf Enhance SQL query for character region filtering: Updated the query to use a recursive common table expression (CTE) for retrieving region IDs, allowing for more comprehensive region hierarchy handling. This change improves the accuracy of character selection based on regional relationships while maintaining existing filtering criteria. 2026-02-05 23:59:20 +01:00
Torsten Schulz (local)
1e565e6dd9 Refactor money change handling in EventsWorker: Updated the logic for applying money changes to use the current money value for calculations, ensuring accurate adjustments. Introduced a new method to retrieve the current money of a user, enhancing error handling and improving the overall clarity of the code. 2026-02-05 08:16:54 +01:00
Torsten Schulz (local)
deaf5ac6ca Refactor variable naming in DirectorWorker: Updated variable names to improve code clarity by prefixing with underscores for unused variables. This change enhances readability and maintains functionality without altering logic. 2026-02-04 14:50:39 +01:00
Torsten Schulz (local)
d94fa791b3 Add child relation deletion by parent query: Introduced a new SQL query to delete child relations where the character is either a father or mother. Updated relevant workers to execute this query before deleting child relations, ensuring proper cleanup of relationships during character deletion. 2026-02-04 14:46:57 +01:00
Torsten Schulz (local)
2293c1204b Add SQL query for product price history: Introduced a new query to insert regional product prices into the history table, calculating prices based on sell cost and worth percentage. Updated ValueRecalculationWorker to log current prices for affected regions, enhancing price tracking functionality. 2026-02-04 09:10:31 +01:00
Torsten Schulz (local)
00cb97ec57 Enhance logging for sales checks in DirectorWorker: Added detailed logging statements to track the sales verification process, including cases with no items to sell and the number of inventory items found. This improves visibility into the sales workflow and aids in debugging. 2026-02-02 15:48:53 +01:00
Torsten Schulz (local)
85b4c73c11 Update user money handling to prevent serialization errors: Adjusted the SQL query to cast money as numeric and modified the UserCharacterWorker to clamp and format the money value before executing the update. This change ensures valid numeric input and enhances error handling for user transactions. 2026-02-02 15:29:53 +01:00
Torsten Schulz (local)
658a9034ed Add SQL query to count children by user: Introduced a new query to count distinct children associated with a user across all their characters. Updated UserCharacterWorker to utilize this new query, replacing the previous count query. This enhances user-specific data retrieval capabilities. 2026-02-02 15:25:13 +01:00
Torsten Schulz (local)
460310ae89 Refactor error handling in ProduceWorker: Simplify error logging by removing detailed error messages and replacing them with silent error handling. This change streamlines the code and reduces console output during production processing, while maintaining functionality. 2026-02-02 15:18:44 +01:00
Torsten Schulz (local)
2f875d6c1c Refactor SQL query for finished productions: Update to use DISTINCT ON for unique production IDs, change JOINs to LEFT JOINs for character and director relationships, and simplify capacity checks to ensure sufficient storage for productions. Adjust ordering for improved query performance. 2026-02-02 15:04:56 +01:00
Torsten Schulz (local)
340c658ad1 Implement character death handling and periodic checks: Add logic to process characters with health <= 0 in EventsWorker and UserCharacterWorker, including immediate death handling during health updates and a scheduled death check every 12 hours. Introduce a new SQL query to retrieve characters with zero health. 2026-02-02 08:05:12 +01:00
Torsten Schulz (local)
972ef2b714 Reduce probabilities and effects of various random events in EventsWorker: Adjusted the likelihood and impact of events such as character illness, accidents, regional epidemics, and earthquakes to balance gameplay dynamics. 2026-01-31 08:58:15 +01:00
Torsten Schulz (local)
f6d566427a Add immediate handling for deceased characters in health update: Implement logic to check if a character's health is less than or equal to zero and handle their death accordingly, improving character state management. 2026-01-31 08:55:49 +01:00
Torsten Schulz (local)
c24aa63f97 Fix SQL query parameter order in inventory update: Swap parameters in the execute call to correctly update inventory quantity based on the new quantity and inventory ID. Add comment for clarity on SQL operation. 2026-01-29 16:10:34 +01:00
Torsten Schulz (local)
9f1c54fde9 automatic apply on church jobs 2026-01-29 11:20:45 +01:00
Torsten Schulz (local)
55a86f75f1 Improve stock creation logging in StockageManager: Add detailed logging for the number of new stocks created and errors encountered during the stock addition process. Update randomness threshold for stock creation to increase the likelihood of stock generation per town. 2026-01-29 08:21:48 +01:00
Torsten Schulz (local)
45287837bc Enhance house creation logging in HouseWorker: Add detailed logging for the number of new buyable houses created and errors encountered during the process. Update SQL query to increase the randomness threshold for house selection, improving the variety of houses retrieved. 2026-01-29 08:18:56 +01:00
Torsten Schulz (local)
31998e310c Implement capacity check in ProduceWorker: Add a method to verify available storage before processing finished productions, ensuring that production only occurs if sufficient capacity is present. Update SQL query to reflect the new capacity validation logic for improved reliability in production handling. 2026-01-29 08:10:38 +01:00
Torsten Schulz (local)
05e6155e38 Enhance capacity management in DirectorWorker: Retrieve current capacity values directly from the database to prevent race conditions, calculate free capacity based on real-time data, and ensure production only occurs if sufficient storage is available. Update SQL query to check for adequate storage before returning production results. 2026-01-28 16:36:30 +01:00
Torsten Schulz (local)
4b4d84b15c Implement overproduction notification handling in ProduceWorker: Add logic to check for existing notifications and update them if necessary, or create a new notification if none exist. Introduce SQL queries for finding and updating overproduction notifications to enhance database interactions. 2026-01-28 15:06:48 +01:00
Torsten Schulz (local)
710a2a62b2 Refactor SQL query in DirectorWorker: Remove original_sell_cost from selection and enhance cost resolution logic for improved clarity and efficiency in database interactions. 2026-01-28 14:46:57 +01:00
Torsten Schulz (local)
1d9a7ba8a0 Refactor product cost retrieval in DirectorWorker: Simplify the SQL query by removing the original_sell_cost from the selection, and adjust the cost resolution logic to improve clarity and efficiency in database interactions. 2026-01-28 14:41:27 +01:00
Torsten Schulz (local)
c9e0781b61 Update dependencies and enhance WebSocket server logging: Add 'chrono' and 'android_system_properties' to Cargo.lock, improve error handling and logging in websocket_server.rs, and streamline character creation notifications in worker modules for better clarity and maintainability. 2026-01-28 14:21:28 +01:00
Torsten Schulz (local)
2ac474fe0c Refactor relationship type retrieval in user character workers: Simplify the extraction of relationship types in UserCharacterWorker, CharacterCreationWorker, and EventsWorker by replacing and_then with map for improved readability and consistency. 2026-01-23 10:52:59 +01:00
Torsten Schulz (local)
c8668c59eb Refactor relationship type retrieval in character workers: Simplify the extraction of relationship types in CharacterCreationWorker, EventsWorker, and UserCharacterWorker by streamlining the use of and_then for better readability and maintainability. 2026-01-23 10:48:15 +01:00
Torsten Schulz (local)
0399333163 Enhance relationship deletion logging across workers: Implement detailed logging for the number of deleted relationships in CharacterCreationWorker, EventsWorker, and UserCharacterWorker, improving traceability and user awareness during character interactions. 2026-01-23 10:46:32 +01:00
Torsten Schulz (local)
7305a71438 Enhance relationship deletion process in character management: Implement detailed logging for deleted relationships, including related user and character IDs, and relationship types. Introduce special notifications for engaged relationships to improve user awareness during character events. 2026-01-22 09:35:21 +01:00
Torsten Schulz (local)
70d8e53216 Add new random events in EventsWorker: Introduce 'character_rest' and 'character_healer' events to enhance gameplay dynamics. Each event includes unique probabilities, descriptions, and health effects, enriching the personal event experience for characters. 2026-01-21 11:26:23 +01:00
Torsten Schulz (local)
0083d7be23 Add daily cleanup of old notifications in EventsWorker: Implement a new method to delete notifications older than 30 days, enhancing database management and reducing clutter. Update run loop to trigger this cleanup daily, ensuring timely maintenance of notification records. 2026-01-15 13:45:31 +01:00
Torsten Schulz (local)
fc1b0c2259 Refactor personal event handling in EventsWorker: Introduce a new CharacterInfo struct to encapsulate character details extracted from event effects. Streamline the application of personal effects by consolidating logic into a single method, improving clarity and maintainability. Enhance notification creation by directly incorporating character information, ensuring accurate event communication. 2026-01-15 13:33:39 +01:00
Torsten Schulz (local)
63a89588ac Refactor event notification handling in EventsWorker: Simplify notification creation by removing unnecessary value object and directly incorporating character information into the notification JSON. Add early exit for events with no effects, improving clarity and efficiency in event processing. 2026-01-15 13:27:30 +01:00
Torsten Schulz (local)
f32c4b1456 Add sleep management for productions in ProduceWorker: Implement logic to set productions to sleep when storage capacity is insufficient. Introduce methods for processing and retrieving sleep productions, enhancing inventory management and production flow control. 2026-01-14 15:26:15 +01:00
Torsten Schulz (local)
f133f3999e Update SQL query to exclude characters without associated user IDs: Modify the QUERY_GET_REGION_CHARACTERS to ensure only characters with a valid user_id are selected, enhancing data integrity and relevance in region-specific character retrieval. 2026-01-14 10:28:13 +01:00
Torsten Schulz (local)
e1e4c08d38 Improve logging in WebSocket server: Enhance connection acceptance and WebSocket handshake messages for better clarity. Update error messages for connection failures and refine logging for TLS and non-TLS connections, ensuring comprehensive diagnostics and user awareness. 2026-01-12 16:54:08 +01:00
Torsten Schulz (local)
1505d3d004 Refactor production initiation logic in DirectorWorker: Update the create_single_production method to return the produced quantity, enhancing the loop's decision-making process. Improve error handling and logging for production failures, ensuring better capacity management and preventing race conditions during production checks. 2026-01-12 16:52:18 +01:00
Torsten Schulz (local)
40df17ee91 Enhance logging in WebSocket server: Add detailed logging for TLS initialization, TCP connection acceptance, and WebSocket handshake processes. Improve error messages to provide clearer feedback on potential issues, enhancing overall server diagnostics and user awareness. 2026-01-12 16:46:30 +01:00
Torsten Schulz (local)
5f55aeea99 Refactor money change action string in EventsWorker: Simplify the action string for money history by differentiating between positive and negative changes. Remove unnecessary parameters from the function signature to enhance clarity and maintainability. 2026-01-12 16:38:38 +01:00
Torsten Schulz (local)
408221ffb4 Refactor notification structure in EventsWorker: Simplify the JSON notification format by separating the value object and ensuring it only contains relevant data without translations. Update action string for money changes to include specific event titles, enhancing clarity for frontend access. 2026-01-12 16:35:47 +01:00
Torsten Schulz (local)
5475ae24f6 Refactor production calculation in DirectorWorker: Update the logic for determining the number of items to produce, ensuring it adheres to capacity constraints and maximum production limits. Add safety checks and logging for potential errors in production calculations, enhancing reliability and debugging capabilities. 2026-01-12 14:01:18 +01:00
Torsten Schulz (local)
b858f5c385 Adjust event probabilities in EventsWorker: Reduce the occurrence rates of various random events to 25% of their original values for better game balance. Update character health change effects to limit maximum health reduction based on user feedback. Enhance notification structure to include money change details for improved frontend access. 2026-01-09 09:30:59 +01:00
Torsten Schulz (local)
01d45d5f65 Enhance character event notifications in EventsWorker: Update character health change and death handling to include first and last names in notifications. Modify SQL queries to retrieve character names, ensuring accurate user feedback. Refactor notification structure for better organization and clarity. 2026-01-07 11:33:23 +01:00
Torsten Schulz (local)
282f6542fe event adjusted for epidemic 2026-01-07 11:15:20 +01:00
Torsten Schulz (local)
f96e474f32 Add random heir fallback logic in EventsWorker: Implement a new SQL query to select a random NPC heir from the same region when no children are available. Enhance error handling to log cases where no heir is found, ensuring better user feedback and maintaining character associations. Update UserCharacterWorker to utilize this new logic for heir assignment. 2026-01-07 10:14:24 +01:00
Torsten Schulz (local)
b7eafe28a1 Enhance TLS key handling in WebSocket server: Add support for SEC1 (EC PRIVATE KEY) in key loading process, improving flexibility for private key formats. Update error messages for clarity on key requirements. Include warning for clients when SSL is enabled but TLS is not active, ensuring proper connection protocols are used. 2026-01-05 16:10:10 +01:00
Torsten Schulz (local)
604c8ba300 Add character user clearing logic in EventsWorker: Implement a new SQL query to clear the user association from a deceased character before assigning a new heir. This ensures compliance with unique constraints and prevents errors during user reassignment. Enhance error handling and logging for better debugging. 2026-01-05 15:46:54 +01:00
Torsten Schulz (local)
e2630eb32a Add heartbeat and null production start fix in ProduceWorker: Implement periodic logging to monitor worker activity and add functionality to update productions with null start timestamps. Enhance SQL queries for better data integrity and debugging capabilities. 2026-01-05 15:30:39 +01:00
Torsten Schulz (local)
108ac6c82b Refactor QUERY_GET_BEST_PRODUCTION SQL query in Worker: Improve readability and maintainability by restructuring the query with clearer aliases and consistent use of COALESCE. Enhance join conditions and ensure proper handling of character health, resulting in more accurate production worth calculations. 2026-01-05 11:44:44 +01:00
Torsten Schulz (local)
713504d3ab Refactor DirectorWorker SQL queries: Remove unused QUERY_GET_DIRECTOR_USER and update vehicle count retrieval to use consistent column names. This improves code clarity and maintains consistency in data handling. 2026-01-05 11:32:56 +01:00
Torsten Schulz (local)
1e4689212b Add cleanup functionality for collapsed houses in HouseWorker: Implement methods to delete collapsed buyable houses and user houses, including notifications for affected users. Update SQL queries to support this new feature, enhancing house management and user experience. 2025-12-23 14:35:51 +01:00
Torsten Schulz (local)
da30ab0b15 Enhance character health change handling in EventsWorker: Update health change functions to return character death status, allowing for better tracking of character deaths. Implement logging of character deaths in both individual and regional health change scenarios, improving event processing and state management. 2025-12-23 14:20:41 +01:00
Torsten Schulz (local)
ade3bff5cf Refactor revenue and tax calculations in DirectorWorker: Update pricing logic to compute revenue based on piece price and quantity, adjust profit calculations, and implement optional treasury user ID from environment variables for tax handling. This enhances accuracy in financial computations and improves code clarity. 2025-12-23 12:16:58 +01:00
Torsten Schulz (local)
431a6aff08 Implement heartbeat logging in EventsWorker: Add periodic logging to monitor the worker's activity and event triggering status. Introduce Mutex for thread-safe access to heartbeat timestamps, enhancing visibility into the worker's operational state. 2025-12-23 10:56:14 +01:00
Torsten Schulz (local)
08563c956c Add vehicle repair automation in DirectorWorker: Implement functionality to automatically repair vehicles based on their condition and region. Update SQL queries to support vehicle selection and repair operations, enhancing the Director's capabilities in managing vehicle maintenance. 2025-12-22 15:07:02 +01:00
Torsten Schulz (local)
fce7400303 Refactor notification handling in Workers: Centralize notification insertion and frontend updates by replacing direct SQL queries with dedicated functions. This improves code maintainability and reduces redundancy across character creation, events, and transport processing. 2025-12-22 13:58:17 +01:00
Torsten Schulz (local)
a82d554494 Enhance SQL query in Worker to include character reputation: Add COALESCE for reputation in the vote count query and adjust the GROUP BY and ORDER BY clauses to incorporate reputation, improving data accuracy in election results. 2025-12-20 23:06:51 +01:00
Torsten Schulz (local)
833202344b Refactor SQL query in Worker to improve distance calculations: Replace the existing LEFT JOIN with a LATERAL join to enhance the selection of region distances based on transport mode, ensuring more accurate results. Update polling frequency in TransportWorker from once per second to once per minute for better resource management. 2025-12-20 22:20:29 +01:00
Torsten Schulz (local)
cd1b33a474 Update SQL query in Worker to exclude vehicles involved in transport: Modify the vehicle selection query to filter out vehicles that have been used in transport, enhancing data accuracy and integrity. 2025-12-20 21:20:05 +01:00
Torsten Schulz (local)
1719af2344 Refactor revenue and tax calculations in DirectorWorker: Adjust the pricing logic to account for cumulative tax inflation, ensuring accurate revenue and payout calculations. Update SQL query for sell logs to change conflict resolution order for better data integrity. 2025-12-20 15:20:03 +01:00
Torsten Schulz (local)
398e0ba677 Enhance SQL insert query for sell logs and improve error logging in TransportWorker: Add sell_timestamp to the insert statement and refine error messages to include transport details for better debugging. 2025-12-20 14:20:09 +01:00
Torsten Schulz (local)
4fca4b4d75 Enhance SQL queries and logging in TransportWorker and WeatherWorker: Update weather assignment logic to ensure unique weather types per region, improve distance calculations in transport queries, and refine logging for transport processing. Additionally, adjust notification insert queries to include character_id. 2025-12-20 11:17:32 +01:00
73 changed files with 20896 additions and 944 deletions

View 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
View 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
View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "YpDaemon"
version = "0.1.0"
version = "0.5.0"
edition = "2024"
[dependencies]
@@ -12,6 +12,7 @@ tokio = { version = "1.48", features = ["rt-multi-thread", "macros", "net", "syn
tokio-tungstenite = "0.23"
futures-util = "0.3"
ctrlc = "3"
tokio-rustls = "0.26"
tokio-rustls = "0.25"
rustls-pemfile = "2"
libsystemd = "0.7"
chrono = "0.4"

18
YpDaemon/Cargo.toml Normal file
View 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
View 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(())
}
}

View 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",
&[
&region_id,
&first_name_id,
&last_name_id,
&gender,
&title_of_nobility,
],
)?;
Ok(())
}
fn monitor_character_deaths(
pool: &ConnectionPool,
broker: &MessageBroker,
) -> Result<(), DbError> {
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
conn.prepare(
"get_eligible_npc_for_death",
QUERY_GET_ELIGIBLE_NPC_FOR_DEATH,
)?;
let rows = conn.execute("get_eligible_npc_for_death", &[])?;
for row in rows {
let character_id = row
.get("id")
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(-1);
let age = row
.get("age")
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(0);
if character_id > 0 && Self::calculate_death_probability(age)
&& let Err(err) = Self::handle_character_death(pool, broker, character_id)
{
eprintln!(
"[CharacterCreationWorker] Fehler beim Bearbeiten des NPC-Todes (id={character_id}): {err}"
);
}
}
Ok(())
}
fn calculate_death_probability(age: i32) -> bool {
if age < 60 {
return false;
}
let base_probability = 0.01_f64;
let increase_per_year = 0.01_f64;
let death_probability =
base_probability + increase_per_year * (age.saturating_sub(60) as f64);
let mut rng = thread_rng();
let dist = Uniform::from(0.0..1.0);
let roll: f64 = dist.sample(&mut rng);
roll < death_probability
}
fn handle_character_death(
pool: &ConnectionPool,
broker: &MessageBroker,
character_id: i32,
) -> Result<(), DbError> {
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
// 1) Director löschen und User benachrichtigen
conn.prepare("delete_director", QUERY_DELETE_DIRECTOR)?;
let dir_result = conn.execute("delete_director", &[&character_id])?;
if let Some(row) = dir_result.first()
&& let Some(user_id) = row
.get("employer_user_id")
.and_then(|v| v.parse::<i32>().ok())
{
Self::notify_user(pool, broker, user_id, "director_death")?;
}
// 2) Relationships löschen und betroffene User benachrichtigen
conn.prepare("delete_relationship", QUERY_DELETE_RELATIONSHIP)?;
let rel_result = conn.execute("delete_relationship", &[&character_id])?;
for row in rel_result {
if let Some(related_user_id) = row
.get("related_user_id")
.and_then(|v| v.parse::<i32>().ok())
{
Self::notify_user(pool, broker, related_user_id, "relationship_death")?;
}
}
// 3) Child-Relations löschen (als Elter zuerst, dann als Kind) und Eltern benachrichtigen
conn.prepare("delete_child_relation_by_parent", QUERY_DELETE_CHILD_RELATION_BY_PARENT)?;
conn.execute("delete_child_relation_by_parent", &[&character_id])?;
conn.prepare("delete_child_relation", QUERY_DELETE_CHILD_RELATION)?;
let child_result = conn.execute("delete_child_relation", &[&character_id])?;
for row in child_result {
if let Some(father_user_id) = row
.get("father_user_id")
.and_then(|v| v.parse::<i32>().ok())
{
Self::notify_user(pool, broker, father_user_id, "child_death")?;
}
if let Some(mother_user_id) = row
.get("mother_user_id")
.and_then(|v| v.parse::<i32>().ok())
{
Self::notify_user(pool, broker, mother_user_id, "child_death")?;
}
}
// 4) Charakter als verstorben markieren
Self::mark_character_as_deceased(pool, character_id)?;
Ok(())
}
fn notify_user(
pool: &ConnectionPool,
broker: &MessageBroker,
user_id: i32,
event_type: &str,
) -> Result<(), DbError> {
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
conn.prepare("insert_notification", QUERY_INSERT_NOTIFICATION)?;
conn.execute("insert_notification", &[&user_id])?;
// falukantUpdateStatus
let update_message =
format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id);
broker.publish(update_message);
// ursprüngliche Benachrichtigung
let message =
format!(r#"{{"event":"{event_type}","user_id":{}}}"#, user_id);
broker.publish(message);
Ok(())
}
fn mark_character_as_deceased(
pool: &ConnectionPool,
character_id: i32,
) -> Result<(), DbError> {
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
conn.prepare("mark_character_deceased", QUERY_MARK_CHARACTER_DECEASED)?;
conn.execute("mark_character_deceased", &[&character_id])?;
Ok(())
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1949
YpDaemon/src/worker/sql.rs Normal file

File diff suppressed because it is too large Load Diff

View 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", &[&region_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
View 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

View 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)

View File

@@ -0,0 +1,59 @@
# Falukant: Kirchenämter (YpDaemon / PoliticsWorker)
Umsetzung des Zielmodells: Laufbahn `highest_church_hierarchy_ever`, NPC-Bewerbungen, NPC-Vorgesetzte mit Score, Spieler-Vorgesetzte ohne Daemon-Entscheidung, Interimsbesetzung, Events `falukantUpdateChurch`.
## Migration
- `migrations/007_falukant_character_church_career.sql` — Spalte `falukant_data.character.highest_church_hierarchy_ever` + Backfill aus bestehenden `church_office`.
**Ohne Migration** schlagen Bewerbungs-Checks fehl, die die Spalte referenzieren.
## Ticks
| Was | Wann |
|-----|------|
| Kirchen-Gesamtprozess | Täglich im **gleichen** Lauf wie `perform_daily_politics_task` (nicht mehr nur 13:00) |
| Auto-Approve alter Bewerbungen | Stündlich, **nur** wenn `supervisor.user_id IS NULL` (NPC-Vorgesetzter), 36 h+ |
## Logik (Kurz)
1. **Freie Sitze** je `church_office_type` / Region (`QUERY_FIND_AVAILABLE_CHURCH_OFFICES`, inkl. `hierarchy_level`).
2. **Vorgesetzter** = nächsthöhere besetzte Hierarchie in der Region (`QUERY_FIND_CHURCH_SUPERVISOR`).
3. **Spieler-Vorgesetzter** (`character.user_id IS NOT NULL`): `falukantUpdateChurch` mit `reason: applications`**keine** automatische Annahme/Ablehnung durch den Daemon.
4. **NPC-Vorgesetzter**: Bewerber mit `churchCandidateScore` (Reputation, höchste Hierarchie, aktuelle Ämter, Titel, Alter, Zufallsanteil abhängig von `supervisor.reputation`). Pro `(office_type_id, region)` werden nur so viele Zusagen erteilt, wie Plätze frei sind.
5. **NPC-Bewerbungen**: Nur Charaktere mit `user_id IS NULL`; nur wenn `pending < freie Sitze` (nachzüglernde Jobs).
6. **Interimsbesetzung**: Kein Vorgesetzter, `hierarchy_level <= 6` (bis einschließlich „Bishop“-Ebene im Typ-Stammbaum — anpassbar über Konstante `INTERIM_MAX_CHURCH_HIERARCHY` in `politics.rs`): bester NPC-Kandidat + direktes `INSERT` in `church_office`, Update `highest_church_hierarchy_ever`, Entfernen niedrigerer kirchlicher Ämter.
## Bewerbungsvoraussetzungen (Daemon-SQL)
`QUERY_CHECK_CHARACTER_ELIGIBILITY`: Für ein gefordertes Voramt (`prerequisite_office_type_id`) gilt **Erfüllt**, wenn
- das exakte Amt aktuell gehalten wird, **oder**
- `GREATEST(highest_church_hierarchy_ever, max. aktuelle Hierarchie aus church_office) >= hierarchy_level` des Voramts.
Titelbedingungen unverändert (`min_title_level`).
## Genehmigung / Amtsverlust
- `QUERY_APPROVE_CHURCH_APPLICATION` und `QUERY_AUTO_APPROVE_CHURCH_APPLICATION`: nach Eintrag `church_office` → Update `highest_church_hierarchy_ever`, dann `DELETE` niedrigerer konkurrierender `church_office` desselben Charakters (`remove_lower_ranked`).
## WebSocket
| Event | Payload |
|-------|---------|
| `falukantUpdateChurch` | `{"event":"falukantUpdateChurch","user_id":N,"reason":"…"}` |
| `falukantUpdateStatus` | wie üblich, direkt danach |
**reason:** `applications` (Spieler-Vorgesetzter), `npc_decision` (NPC hat zugesagt), `appointment` (Auto-Approve 36 h, nur NPC-Supervisor), `vacancy_fill` (Interimsbesetzung, nur wenn Bewerber ein Spielercharakter ist).
Details: [`FALUKANT_UI_WEBSOCKET.md`](./FALUKANT_UI_WEBSOCKET.md).
## Backend (außerhalb YpDaemon)
- `getAvailableChurchPositions()` muss dieselbe Laufbahn-Logik nutzen (Spec Abschnitt 11).
- Spieleranträge und gleiche Bewertungsregeln wie im Daemon.
## Code
- `src/worker/politics.rs``perform_church_office_task`, `process_church_supervisor_queue`, `npc_resolve_church_applications_for_supervisor`, `try_interim_church_appointment`, Hilfsfunktionen.
- `src/worker/sql.rs` — Abschnitt „Church Office Queries“.

View File

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

View 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.

View 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).

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

View 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.

View 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.

View 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 (024 / 2559 / 60100) |
| 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).

View 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: **&lt;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 &lt; 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 05).
## 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)

View 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).

View 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**.

View 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.

View 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 0100: `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).

View 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.

View 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

View 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';

View 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;

View 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';

View 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';

View 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 024, medium 2559, high 60100';

View 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';

View File

@@ -0,0 +1,20 @@
-- Höchste erreichte kirchliche Hierarchiestufe (Laufbahn), nicht zurücksetzen bei Amtsverlust.
-- Siehe docs/FALUKANT_CHURCH_DAEMON.md
ALTER TABLE falukant_data.character
ADD COLUMN IF NOT EXISTS highest_church_hierarchy_ever SMALLINT;
COMMENT ON COLUMN falukant_data.character.highest_church_hierarchy_ever IS
'Max. hierarchy_level (church_office_type) jemals erreicht; für Bewerbungsvoraussetzungen neben aktuellem Amt';
UPDATE falukant_data.character c
SET highest_church_hierarchy_ever = sub.mh::smallint
FROM (
SELECT co.character_id AS cid,
MAX(cot.hierarchy_level)::int AS mh
FROM falukant_data.church_office co
JOIN falukant_type.church_office_type cot ON cot.id = co.office_type_id
GROUP BY co.character_id
) sub
WHERE c.id = sub.cid
AND (c.highest_church_hierarchy_ever IS NULL OR c.highest_church_hierarchy_ever < sub.mh);

View File

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

View 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'
);

View 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';

View File

@@ -0,0 +1 @@

View 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;

View 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';

View 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).';

View File

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

View 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)).';

View 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).';

View 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$;

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

View File

@@ -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())),

View File

@@ -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()
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View 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 15 (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 15 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
}

View 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(())
}

File diff suppressed because it is too large Load Diff

View 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(())
}

View 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,
&params_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", &[&region_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", &[&region_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)
}

View File

@@ -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(())
}

View File

@@ -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
View 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(())
}

View 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 15 (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

View File

@@ -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, &notification],

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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, &params)
Self::handle_task(pool, broker, &task_type, performer_id, victim_id, &params)
}
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 {

View File

@@ -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", &[&region_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,

View File

@@ -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", &[&region_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", &[&region_id]);
let message =
format!(r#"{{"event":"price_update","region_id":{}}}"#, region_id);
broker.publish(message);

View File

@@ -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(())
}