Compare commits

...

70 Commits

Author SHA1 Message Date
Torsten Schulz (local)
d74f7b852b Refactor proposal generation in FalukantService to improve character selection logic
- Removed the tracking of used character IDs and streamlined the exclusion of characters already proposed or currently active as directors.
- Enhanced logging for SQL queries and fallback mechanisms to ensure better visibility during character selection.
- Implemented a more efficient approach to gather and process character knowledge for proposal creation, ensuring accurate average calculations.
- Improved error handling to provide clearer feedback when no eligible characters are found.
2026-01-12 08:33:26 +01:00
Torsten Schulz (local)
92d6b15c3f Enhance proposal generation logic in FalukantService to prevent duplicate character usage
- Introduced a mechanism to track used character IDs, ensuring that previously proposed characters are excluded from future proposals.
- Added error handling and logging for scenarios where no eligible characters are found, improving traceability and user feedback.
- Implemented a fallback to include newer characters if older ones are unavailable, enhancing the robustness of the proposal generation process.
2026-01-12 08:24:00 +01:00
Torsten Schulz (local)
91f59062f5 Update BranchView to refresh active tab data on tab change and modify 3D model for female toddler character
- Enhanced the activeTab watcher to refresh data only when the selected branch changes and the tab is switched.
- Introduced a new refreshActiveTab method to load data for the currently active tab, improving data management and user experience.
- Updated the female toddler 3D model file for better integration in the application.
2026-01-12 08:07:50 +01:00
Torsten Schulz (local)
1674086c73 Enhance partner search and gift loading functionality in FalukantService and FamilyView
- Added detailed logging for partner search criteria and results in FalukantService to improve debugging and traceability.
- Refactored partner search logic to use a dynamic where clause for better readability and maintainability.
- Implemented error handling in FamilyView for gift loading, ensuring an empty array is set on failure to load gifts, enhancing user experience.
2026-01-09 14:28:01 +01:00
Torsten Schulz (local)
5ddb099f5a Add 3D character model integration and update dependencies
- Introduced a new CharacterModel3D component for rendering 3D character models in OverviewView.
- Updated package.json and package-lock.json to include 'three' library for 3D graphics support.
- Enhanced Vite configuration to allow access to external files and ensure proper handling of GLB/GLTF assets.
- Improved layout and styling in OverviewView for better visualization of character and avatar.
2026-01-09 13:29:32 +01:00
Torsten Schulz (local)
3722bcf8c8 Enhance parameter extraction in MessagesDialog component for money and effect changes
- Added extraction of money_change_absolute and money_change_percent from value to parameters.
- Updated effects handling to include money_change, storage_damage, production_quality_change, transport_speed_change, and storage_capacity_change types.
- Improved logic to prevent overwriting parameters already extracted from value, ensuring accurate data representation.
2026-01-09 09:31:33 +01:00
Torsten Schulz (local)
0372d213c0 Refine city filtering in NPC creation process within AdminService
- Added explicit filtering for city-type regions to ensure only valid cities are processed during NPC creation.
- Enhanced logging to provide feedback on the number of cities found and their names, improving traceability in the NPC creation workflow.
- Updated comments for clarity on the importance of using city regions exclusively.
2026-01-07 17:15:17 +01:00
Torsten Schulz (local)
c322eb1e5a Add NPC creation status tracking and progress reporting in Admin module
- Implemented getNPCsCreationStatus method in AdminController to retrieve the status of NPC creation jobs.
- Enhanced AdminService to manage NPC creation jobs, including job ID generation, progress updates, and error handling.
- Updated frontend CreateNPCView to display progress of NPC creation, including estimated time remaining and job status.
- Added localization strings for progress reporting in both German and English.
- Improved overall user experience by providing real-time feedback during NPC creation processes.
2026-01-07 17:09:54 +01:00
Torsten Schulz (local)
b34dcac685 Refactor CreateNPCView layout for improved structure and styling
- Updated the template structure in CreateNPCView.vue to enhance layout with additional div wrappers for better styling and organization.
- Ensured the main content is encapsulated within a scrollable area, improving user experience during NPC creation.
2026-01-07 17:00:56 +01:00
Torsten Schulz (local)
4850f50c66 Update package-lock.json dependencies for improved stability and security
- Upgraded 'glob' to version 10.5.0.
- Updated 'body-parser' to version 1.20.4 and adjusted its dependencies.
- Enhanced 'express' to version 4.22.1 with updated dependencies.
- Bumped 'qs' to version 6.14.1 and modified its dependencies.
- Updated 'raw-body' to version 2.5.3 and adjusted its dependencies.
- Ensured compatibility with newer versions of dependencies across the project.
2026-01-07 16:59:42 +01:00
Torsten Schulz (local)
5996f819e8 Enhance NPC creation functionality and validation in Admin module
- Updated AdminController to validate the count parameter, ensuring it is between 1 and 500.
- Refactored NPC creation logic in AdminService to create NPCs for each city-title combination, improving efficiency.
- Enhanced frontend localization files to reflect changes in count descriptions and validation messages.
- Updated CreateNPCView to provide user guidance on count input and display detailed creation results.
2026-01-07 16:57:50 +01:00
Torsten Schulz (local)
4d967fe7a2 Update German and English navigation localization files to include user rights translations 2026-01-07 16:49:33 +01:00
Torsten Schulz (local)
bb91c2bbe5 Add NPC creation and titles retrieval functionality in Admin module
- Implemented createNPCs method in AdminController to handle NPC creation with specified parameters including region, age, title, and count.
- Added getTitlesOfNobility method in AdminController to retrieve available titles for users.
- Updated adminRouter to include new routes for creating NPCs and fetching titles.
- Enhanced navigationController and frontend localization files to support new NPC creation feature.
- Introduced corresponding UI components and routes for NPC management in the admin interface.
2026-01-07 16:45:39 +01:00
Torsten Schulz (local)
511df52c3c Enhance MessagesDialog component to support HTML content and improve parameter extraction
- Updated notification description rendering to allow HTML content using v-html directive.
- Refactored formatBody method to better handle JSON formatted notifications and extract parameters from nested structures.
- Introduced new method for extracting parameters from value objects, improving compatibility with various notification types.
- Enhanced description formatting to include details from effects, providing richer user feedback in notifications.
2026-01-07 12:09:25 +01:00
Torsten Schulz (local)
d42e1da14b Refactor character creation and heir fetching logic in FalukantService and OverviewView
- Updated character creation process to utilize transactions for improved data integrity during heir generation.
- Enhanced heir fetching logic in OverviewView to check for both mainBranchRegion.id and mainBranchRegionId, adding error handling for missing regions.
- Added warnings for empty heir responses from the API to improve debugging and user feedback.
2026-01-07 11:20:03 +01:00
Torsten Schulz (local)
75dbd78da1 Add regional event handling and character creation logic in FalukantService
- Introduced new regional events ('regional_storm', 'regional_epidemic', 'earthquake') with specific health impact mechanics to enhance gameplay dynamics.
- Updated health modification logic to ensure regional events cause moderate health loss rather than fatal outcomes.
- Implemented character creation logic to generate potential heirs when none are found, including random gender, names, and age attributes.
- Enhanced heir retrieval process to include newly created characters, ensuring a seamless user experience.
2026-01-07 11:13:54 +01:00
Torsten Schulz (local)
c90b7785c0 Add heir selection functionality in Falukant module
- Implemented getPotentialHeirs and selectHeir methods in FalukantService to allow users to retrieve and select potential heirs based on specific criteria.
- Updated FalukantController to wrap new methods with user authentication and added corresponding routes in FalukantRouter.
- Enhanced OverviewView component to display heir selection UI when no character is present, including loading states and error handling.
- Added translations for heir selection messages in both German and English locales to improve user experience.
2026-01-07 10:29:16 +01:00
Torsten Schulz (local)
c17af04cbf Refactor vocabulary search functionality in VocabService and update UI components
- Modified the searchVocabs method in VocabService to consolidate search parameters into a single query term for improved flexibility.
- Updated VocabSearchDialog to replace separate input fields for mother tongue and learning language with a unified search term input.
- Adjusted button logic to enable search only when a term is provided, enhancing user experience.
- Added new translations for the search term in both German and English locales to support the updated UI.
2026-01-05 16:58:18 +01:00
Torsten Schulz (local)
f5e3a9a4a2 Add search functionality for vocabulary in VocabController and VocabService
- Implemented a new searchVocabs method in VocabService to allow users to search for vocabulary based on learning and mother tongue terms.
- Updated VocabController to include the searchVocabs method wrapped with user authentication.
- Added a new route in vocabRouter for searching vocabulary by language ID.
- Enhanced VocabChapterView and VocabLanguageView components to include a button for opening the search dialog.
- Added translations for search-related terms in both German and English locales, improving user accessibility.
2026-01-05 16:53:38 +01:00
Torsten Schulz (local)
dab3391aa2 Refactor socket.io URL normalization logic for improved clarity and robustness
- Simplified the normalization process for socket.io URLs by removing unnecessary production checks.
- Enhanced comments for better understanding of URL handling, particularly regarding environment variables and port management.
- Maintained fallback mechanisms for URL parsing failures to ensure consistent behavior across environments.
2026-01-05 16:43:42 +01:00
Torsten Schulz (local)
0336c55560 Enhance socket.io URL handling for production environments
- Added logic to normalize the socket.io URL based on the current origin and environment.
- Implemented fallback mechanisms for unusual ports in production to ensure secure connections.
- Included error handling for URL parsing failures to default to the current origin, improving robustness.
2026-01-05 16:26:23 +01:00
Torsten Schulz (local)
8e618ab443 Implement TLS support in WebSocket server for secure connections
- Added environment variable configuration for enabling TLS in the WebSocket server.
- Implemented logic to read TLS key, certificate, and optional CA paths from environment variables.
- Enhanced server initialization to handle both secure (WSS) and non-secure (WS) connections based on the TLS setting.
- Included error handling for missing TLS configuration to prevent server startup failures.
2026-01-05 16:06:37 +01:00
Torsten Schulz (local)
352d672bdd Add keyboard handling in VocabPracticeDialog for improved user interaction
- Implemented keydown event listener to manage Enter key functionality for navigating questions and submitting answers.
- Enhanced user experience by allowing Enter to trigger actions based on the current dialog state, improving accessibility during practice sessions.
- Ensured proper cleanup of event listeners when closing the dialog to prevent memory leaks.
2025-12-30 20:22:04 +01:00
Torsten Schulz (local)
df64c0a4b5 Update VocabPracticeDialog and VocabChapterView components to manage practice dialog state
- Changed the close button visibility in VocabPracticeDialog to false for a cleaner UI.
- Enhanced VocabChapterView to manage the practice dialog state with a new `practiceOpen` flag.
- Updated the `openPractice` method to handle the dialog's open and close events, ensuring proper state management.
2025-12-30 18:44:35 +01:00
Torsten Schulz (local)
83597d9e02 Add Vocab Trainer feature with routing, database schema, and translations
- Introduced Vocab Trainer functionality, including new routes for managing languages and chapters.
- Implemented database schema for vocab-related tables to ensure data integrity.
- Updated navigation and UI components to include Vocab Trainer in the social network menu.
- Added translations for Vocab Trainer in both German and English locales, enhancing user accessibility.
2025-12-30 18:34:32 +01:00
Torsten Schulz (local)
a09220b881 Add translations for reputation action school funding in German and English locales 2025-12-23 14:37:33 +01:00
Torsten Schulz (local)
5623f3af09 Refactor error handling in FalukantService and enhance user feedback in HealthView
- Changed error throwing in FalukantService to use PreconditionError for better clarity.
- Added translations for "too close" error in both German and English locales.
- Improved user feedback in HealthView by displaying error messages in a dialog upon measure execution failure.
2025-12-23 12:20:37 +01:00
Torsten Schulz (local)
820b5e8570 Enhance health management in FalukantService to improve user feedback and error handling
- Updated health deduction logic to clarify the distinction between health and monetary costs.
- Added error handling for money update failures to ensure robust transaction processing.
- Modified return value to include updated health status, providing clearer information for UI updates.
2025-12-23 10:51:22 +01:00
Torsten Schulz (local)
dc72ed2feb Add taxFromSaleProduct translation and enhance MoneyHistoryView for product loading
- Added new translation keys for "taxFromSaleProduct" in both German and English locales.
- Updated MoneyHistoryView component to load product data asynchronously and map product IDs to their labels for improved activity translation handling.
- Enhanced activity translation logic to support both legacy and new formats for tax-related activities.
2025-12-22 15:17:51 +01:00
Torsten Schulz (local)
ea468c9878 Improve conditionLabel method in BranchView component to handle edge cases and ensure accurate state representation. Added checks for null and undefined values, and clarified the return value for zero or negative conditions. 2025-12-22 14:54:51 +01:00
Torsten Schulz (local)
d1b683344e Update condition handling in FalukantService and syncDatabase utility for legacy data
- Enhanced condition processing in FalukantService to clamp values between 0 and 100, ensuring UI displays valid data.
- Implemented database cleanup in syncDatabase utility to set NULL conditions to 100 and clamp out-of-range values, improving data integrity.
2025-12-22 13:20:16 +01:00
Torsten Schulz (local)
a82ec7de3f Add cooldown feature for reputation actions in FalukantService and update UI components
- Introduced a cooldown mechanism for reputation actions, limiting execution to once per configured interval.
- Updated FalukantService to handle cooldown logic and return remaining cooldown time.
- Enhanced ReputationView component to display cooldown status and prevent action execution during cooldown.
- Added translations for cooldown messages in both German and English locales.
2025-12-21 22:18:29 +01:00
Torsten Schulz (local)
560a9efc69 Refactor ReputationView component to streamline action display and remove deprecated tab
- Added a new section for displaying reputation actions, including daily limits and action details.
- Removed the 'actions' tab from the navigation and adjusted the logic to reflect this change.
- Enhanced the user interface for executing reputation actions with improved button states and translations.
2025-12-21 21:37:22 +01:00
Torsten Schulz (local)
4f8b1e33fa Update message dialog parameters in ReputationView component for improved clarity
- Swapped the order of parameters in the message dialog open method to prioritize message content over the title, enhancing user experience during reputation action notifications.
2025-12-21 21:14:06 +01:00
Torsten Schulz (local)
38dd51f757 Add reputation actions feature to Falukant module
- Introduced new endpoints for retrieving and executing reputation actions in FalukantController and falukantRouter.
- Implemented service methods in FalukantService to handle reputation actions, including daily limits and action execution logic.
- Updated the frontend ReputationView component to display available actions and their details, including cost and potential reputation gain.
- Added translations for reputation actions in both German and English locales.
- Enhanced initialization logic to set up reputation action types in the database.
2025-12-21 21:09:31 +01:00
Torsten Schulz (local)
38f23cc6ae Enhance getFalukantUserOrFail and createParty methods in FalukantService to support transaction options
- Updated getFalukantUserOrFail to accept an options parameter for transaction handling.
- Refactored createParty to utilize transaction support, ensuring atomic operations for party creation and related financial transactions.
- Improved error handling for party creation, including checks for existing parties within a 24-hour window and validation of selected options.
2025-12-20 23:30:10 +01:00
Torsten Schulz (local)
6cf8fa8a9c Add reputation attribute to FalukantCharacter model and update related services and views
- Introduced a new 'reputation' attribute in the FalukantCharacter model with a default value and validation.
- Updated FalukantService to include 'reputation' in character attributes for API responses.
- Enhanced ReputationView component to display current reputation and load it from the API.
- Added translations for reputation in both German and English locales.
2025-12-20 23:00:31 +01:00
Torsten Schulz (local)
f9ea4715d7 Refactor BranchView component to replace JavaScript alerts with a message dialog for success and error notifications. This improves user experience by providing a more integrated feedback mechanism within the UI. 2025-12-20 22:14:39 +01:00
Torsten Schulz (local)
b34b374f76 Refactor sellAllProducts method in FalukantService to ensure atomic transactions for selling products, updating inventory, and handling financial transactions. Implement batch processing for sell items and enhance error handling for inventory deletions. Update updateFalukantUserMoney function to support transactions, improving consistency and reliability in financial operations. 2025-12-20 22:04:29 +01:00
Torsten Schulz (local)
83d1168f26 Refactor speedLabel method in SaleSection component to move it from computed properties to methods for better compatibility with Vue3. This change ensures the function is callable and maintains its intended functionality. 2025-12-20 21:32:53 +01:00
Torsten Schulz (local)
91009f52cd Refactor SaleSection component to utilize direct property assignment for betterPrices, enhancing reactivity in Vue3. Update inventory mapping to ensure betterPrices is always an array. 2025-12-20 21:28:01 +01:00
Torsten Schulz (local)
c6dfca7052 Enhance SaleSection component to improve UI responsiveness during sales. Reset selling state immediately after sell actions and update inventory handling to ensure user feedback is timely and accurate. 2025-12-20 21:09:10 +01:00
Torsten Schulz (local)
aaeaeeed24 Add request and SQL performance logging features to backend
- Implement request timing middleware in app.js to log slow requests and all requests based on environment variables.
- Enhance sequelize.js with optional SQL query timing and logging capabilities, allowing for performance monitoring of database queries.
2025-12-20 16:35:30 +01:00
Torsten Schulz (local)
c5804f764c Optimize getInventory method in FalukantService by replacing nested includes with a single SQL query for improved performance. Add error handling for invalid branchId input. 2025-12-20 16:13:33 +01:00
Torsten Schulz (local)
fbe0d1bcd1 Add error handling for missing branches in sell batch processing in FalukantService. Ensure that missing branch IDs trigger an error to prevent accounting mismatches. 2025-12-20 16:01:18 +01:00
Torsten Schulz (local)
2fb440f033 Implement synchronous price calculation for batch operations in FalukantService, optimizing performance by reducing database queries. Update inventory handling to batch delete items and enhance revenue calculations. Fix translation formatting in German locale for sellAllSuccess message. 2025-12-20 15:37:16 +01:00
Torsten Schulz (local)
a8a136a9ce Enhance SaleSection component to manage selling state with improved user feedback. Disable buttons during selling, show status messages for sellAll actions, and update translations for new statuses. 2025-12-20 15:35:20 +01:00
Torsten Schulz (local)
fcbb903839 Backend error fixed 2025-12-20 15:06:43 +01:00
Torsten Schulz (local)
ac45a2ba26 Refactor SQL joins in calcRegionalSellPrice function of FalukantService to use updated region type table for improved clarity and consistency in tax calculations. 2025-12-20 15:03:03 +01:00
Torsten Schulz (local)
afe15dd4f5 Refactor tax calculation in calcRegionalSellPrice function of FalukantService to convert exemptTypes Set to PostgreSQL array string for improved query performance and clarity. 2025-12-20 14:54:32 +01:00
Torsten Schulz (local)
e3df88bea0 Enhance getCumulativeTaxPercentWithExemptions function in FalukantService to first retrieve the character associated with the userId, ensuring accurate filtering of political offices by characterId for regional tax calculations. 2025-12-20 11:09:03 +01:00
Torsten Schulz (local)
c69a414f78 Fix cumulative tax calculation in FalukantService by using falukantUser.id instead of user.id for accurate regional tax assessments. 2025-12-20 11:04:21 +01:00
Torsten Schulz (local)
d08022ab94 Merge branch 'main' of ssh://tsschulz.de:2222/torsten/yourpart3 2025-12-20 10:54:58 +01:00
Torsten Schulz (local)
66e6fab663 Refactor getCumulativeTaxPercentWithExemptions function in falukantService.js to filter political offices by userId through the FalukantCharacter model. Update query structure to enhance clarity and ensure accurate data retrieval for regional tax calculations. 2025-12-20 10:48:56 +01:00
4da572822e Merge pull request 'Update dependency nodemon to v3.1.11' (#3) from renovate/nodemon-3.x-lockfile into main
Reviewed-on: #3
2025-12-19 16:16:21 +01:00
ee23bb3ba3 Merge pull request 'Replace dependency npm-run-all with npm-run-all2 ^5.0.0' (#2) from renovate/npm-run-all-replacement into main
Reviewed-on: #2
2025-12-19 16:16:12 +01:00
d002e340dd Update dependency nodemon to v3.1.11 2025-12-19 16:14:33 +01:00
0e1d87ddab Replace dependency npm-run-all with npm-run-all2 ^5.0.0 2025-12-19 16:14:29 +01:00
2a4928c1b6 Merge pull request 'Configure Renovate' (#1) from renovate/configure into main
Reviewed-on: #1
2025-12-19 16:07:26 +01:00
efe2bd57ab Add renovate.json 2025-12-19 16:00:42 +01:00
Torsten Schulz (local)
a0aa678e7d Implement logic to create tables without Foreign Key constraints in sequelize.js when referenced tables do not exist. Enhance error handling and logging to provide clear feedback during synchronization attempts, improving robustness in model management. 2025-12-19 08:37:40 +01:00
Torsten Schulz (local)
a1b6e6ab59 Enhance error handling in sequelize.js for Foreign Key Constraint Errors by adding logging for orphaned records and skipping problematic models during synchronization. Update syncDatabase.js to include cleanup logic for orphaned political_office entries, improving database integrity and user feedback during sync operations. 2025-12-19 08:34:04 +01:00
Torsten Schulz (local)
73acf1d1cd Refactor error handling in sequelize.js to skip model synchronization for cases with duplicate pg_description entries or multiple tables with the same name. Update logging to provide clearer feedback on sync failures and the reasons for skipping models, enhancing user understanding of potential issues. 2025-12-19 08:13:52 +01:00
Torsten Schulz (local)
48110e9a6f Improve error handling and logging for duplicate pg_description cleanup in sequelize.js. Update comments for clarity on permission requirements and provide detailed instructions for manual cleanup by database administrators. Enhance user feedback during synchronization attempts to address potential permission issues. 2025-12-19 07:56:07 +01:00
Torsten Schulz (local)
642e215c69 Refactor duplicate entry cleanup in sequelize.js by replacing DO $$ blocks with direct parameter substitution in SQL queries. This change enhances performance and security while maintaining the logic for cleaning up duplicate pg_description entries before and after model synchronization. 2025-12-19 07:53:34 +01:00
Torsten Schulz (local)
091b9ff70a Enhance model synchronization in sequelize.js by adding logic to clean up duplicate pg_description entries before and after sync attempts. Implement error handling for potential sync failures related to duplicate entries, improving robustness and clarity in foreign key management during model synchronization. 2025-12-18 17:53:24 +01:00
Torsten Schulz (local)
86f753c745 Refactor associations in models to include constraints: false, preventing automatic foreign key creation. Update sequelize.js to enhance foreign key management during model synchronization, ensuring associations are restored correctly after sync operations. 2025-12-18 17:44:17 +01:00
Torsten Schulz (local)
c28f8b1384 Enhance foreign key management in sequelize.js by refining schema handling and improving logging for foreign key removal during model synchronization. Add detailed console outputs for better visibility on foreign key operations and error handling. 2025-12-18 16:45:56 +01:00
Torsten Schulz (local)
9b36297171 Implement foreign key removal before model synchronization in sequelize.js to prevent conflicts during sync. Add error handling and logging for better visibility on foreign key management. 2025-12-18 16:39:34 +01:00
Torsten Schulz (local)
7beed235d7 Improve model synchronization in sequelize.js by temporarily removing associations to prevent automatic foreign key creation. Add logging for association management during the sync process, ensuring clarity in model handling. 2025-12-18 16:36:26 +01:00
94 changed files with 8939 additions and 1827 deletions

230
PERFORMANCE_ANALYSIS.md Normal file
View File

@@ -0,0 +1,230 @@
# Backend Performance-Analyse: Sell-Funktionen
## Identifizierte Performance-Probleme
### 1. **N+1 Query Problem in `sellAllProducts()`**
**Problem:**
Die Funktion `sellAllProducts()` macht für jedes Inventory-Item mehrere separate Datenbankabfragen:
1. **Erste Schleife (Zeile 1702-1711):**
- `calcRegionalSellPrice()` → macht `TownProductWorth.findOne()` für jedes Item
- `getCumulativeTaxPercentWithExemptions()` → macht mehrere Queries pro Item:
- `FalukantCharacter.findOne()`
- `PoliticalOffice.findAll()` mit Includes
- Rekursive SQL-Query für Steuerberechnung
- `addSellItem()` → macht `Branch.findOne()` und `DaySell.findOne()`/`create()` für jedes Item
2. **Zweite Schleife (Zeile 1714-1724):**
- `RegionData.findOne()` für jedes Item
- `getCumulativeTaxPercent()` → rekursive SQL-Query für jedes Item
- `calcRegionalSellPrice()` → erneut `TownProductWorth.findOne()` für jedes Item
**Beispiel:** Bei 10 Items werden gemacht:
- 10x `TownProductWorth.findOne()` (2x pro Item)
- 10x `RegionData.findOne()`
- 10x `getCumulativeTaxPercentWithExemptions()` (mit mehreren Queries)
- 10x `getCumulativeTaxPercent()` (rekursive SQL)
- 10x `addSellItem()` (mit 2 Queries pro Item)
- = **~70+ Datenbankabfragen für 10 Items**
### 2. **Ineffiziente `addSellItem()` Implementierung**
**Problem:**
- Wird für jedes Item einzeln aufgerufen
- Macht `Branch.findOne()` für jedes Item (könnte gecacht werden)
- `DaySell.findOne()` und `create()`/`update()` für jedes Item
**Lösung:** Batch-Operation implementieren, die alle DaySell Einträge auf einmal verarbeitet.
### 3. **Doppelte Berechnungen in `sellAllProducts()`**
**Problem:**
- Preis wird zweimal berechnet (Zeile 1705 und 1718)
- Steuer wird zweimal berechnet (Zeile 1706 und 1717)
- `calcRegionalSellPrice()` wird zweimal aufgerufen mit denselben Parametern
### 4. **Fehlende Indizes**
**Potenzielle fehlende Indizes:**
- `falukant_data.town_product_worth(product_id, region_id)` - sollte unique sein
- `falukant_data.inventory(stock_id, product_id, quality)` - für schnelle Lookups
- `falukant_data.knowledge(character_id, product_id)` - für Knowledge-Lookups
- `falukant_data.political_office(character_id)` - für Steuerbefreiungen
### 5. **Ineffiziente `getCumulativeTaxPercentWithExemptions()`**
**Problem:**
- Lädt alle PoliticalOffices jedes Mal neu, auch wenn sich nichts geändert hat
- Macht komplexe rekursive SQL-Query für jedes Item separat
- Könnte gecacht werden (z.B. pro User+Region Kombination)
## Empfohlene Optimierungen
### 1. **Batch-Loading für `sellAllProducts()`**
```javascript
async sellAllProducts(hashedUserId, branchId) {
// ... existing code ...
// Batch-Load alle benötigten Daten VOR den Schleifen
const regionIds = [...new Set(inventory.map(item => item.stock.branch.regionId))];
const productIds = [...new Set(inventory.map(item => item.productType.id))];
// 1. Lade alle TownProductWorth Einträge auf einmal
const townWorths = await TownProductWorth.findAll({
where: {
productId: { [Op.in]: productIds },
regionId: { [Op.in]: regionIds }
}
});
const worthMap = new Map();
townWorths.forEach(tw => {
worthMap.set(`${tw.productId}-${tw.regionId}`, tw.worthPercent);
});
// 2. Lade alle RegionData auf einmal
const regions = await RegionData.findAll({
where: { id: { [Op.in]: regionIds } }
});
const regionMap = new Map(regions.map(r => [r.id, r]));
// 3. Berechne Steuern für alle Regionen auf einmal
const taxMap = new Map();
for (const regionId of regionIds) {
const tax = await getCumulativeTaxPercentWithExemptions(falukantUser.id, regionId);
taxMap.set(regionId, tax);
}
// 4. Berechne Preise und Steuern in einer Schleife
const sellItems = [];
for (const item of inventory) {
const regionId = item.stock.branch.regionId;
const worthPercent = worthMap.get(`${item.productType.id}-${regionId}`) || 50;
const knowledgeVal = item.productType.knowledges[0]?.knowledge || 0;
const pricePerUnit = calcRegionalSellPrice(item.productType, knowledgeVal, regionId, worthPercent);
const cumulativeTax = taxMap.get(regionId);
// ... rest of calculation ...
sellItems.push({ branchId: item.stock.branch.id, productId: item.productType.id, quantity: item.quantity });
}
// 5. Batch-Update DaySell Einträge
await this.addSellItemsBatch(sellItems);
// ... rest of code ...
}
```
### 2. **Batch-Operation für `addSellItem()`**
```javascript
async addSellItemsBatch(sellItems) {
// Gruppiere nach (regionId, productId, sellerId)
const grouped = new Map();
for (const item of sellItems) {
const branch = await Branch.findByPk(item.branchId);
if (!branch) continue;
const key = `${branch.regionId}-${item.productId}-${item.sellerId}`;
if (!grouped.has(key)) {
grouped.set(key, {
regionId: branch.regionId,
productId: item.productId,
sellerId: item.sellerId,
quantity: 0
});
}
grouped.get(key).quantity += item.quantity;
}
// Batch-Update oder Create
for (const [key, data] of grouped) {
const [daySell, created] = await DaySell.findOrCreate({
where: {
regionId: data.regionId,
productId: data.productId,
sellerId: data.sellerId
},
defaults: { quantity: data.quantity }
});
if (!created) {
daySell.quantity += data.quantity;
await daySell.save();
}
}
}
```
### 3. **Caching für `getCumulativeTaxPercentWithExemptions()`**
```javascript
// Cache für Steuerberechnungen (z.B. 5 Minuten)
const taxCache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 Minuten
async function getCumulativeTaxPercentWithExemptions(userId, regionId) {
const cacheKey = `${userId}-${regionId}`;
const cached = taxCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.value;
}
// ... existing calculation ...
taxCache.set(cacheKey, { value: tax, timestamp: Date.now() });
return tax;
}
```
### 4. **Optimierte `calcRegionalSellPrice()`**
```javascript
async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null) {
// Wenn worthPercent nicht übergeben wurde UND wir es nicht aus dem Cache haben,
// dann hole es aus der DB
if (worthPercent === null) {
const townWorth = await TownProductWorth.findOne({
where: { productId: product.id, regionId: regionId }
});
worthPercent = townWorth?.worthPercent || 50;
}
// ... rest of calculation ...
}
```
### 5. **Datenbank-Indizes hinzufügen**
```sql
-- Index für town_product_worth (sollte unique sein)
CREATE UNIQUE INDEX IF NOT EXISTS idx_town_product_worth_product_region
ON falukant_data.town_product_worth(product_id, region_id);
-- Index für inventory Lookups
CREATE INDEX IF NOT EXISTS idx_inventory_stock_product_quality
ON falukant_data.inventory(stock_id, product_id, quality);
-- Index für knowledge Lookups
CREATE INDEX IF NOT EXISTS idx_knowledge_character_product
ON falukant_data.knowledge(character_id, product_id);
-- Index für political_office Lookups
CREATE INDEX IF NOT EXISTS idx_political_office_character
ON falukant_data.political_office(character_id);
```
## Geschätzter Performance-Gewinn
- **Vorher:** ~70+ Queries für 10 Items
- **Nachher:** ~15-20 Queries für 10 Items (Batch-Loading + Caching)
- **Geschätzte Verbesserung:** 70-80% weniger Datenbankabfragen
## Priorität
1. **Hoch:** Batch-Loading für `sellAllProducts()` (größter Impact)
2. **Hoch:** Batch-Operation für `addSellItem()`
3. **Mittel:** Caching für Steuerberechnungen
4. **Mittel:** Datenbank-Indizes
5. **Niedrig:** Doppelte Berechnungen entfernen

601
SELL_OVERVIEW.md Normal file
View File

@@ -0,0 +1,601 @@
# Übersicht: Sell-Funktionen und verwendete Models/Tabellen
## Sell-Funktionen in `falukantService.js`
### 1. `sellProduct(hashedUserId, branchId, productId, quality, quantity)`
Verkauft ein einzelnes Produkt mit bestimmter Qualität.
**Ablauf:**
1. Lädt User, Branch, Character, Stock
2. Lädt Inventory mit ProductType und Knowledge
3. Berechnet Preis pro Einheit mit `calcRegionalSellPrice()`
4. Berechnet kumulative Steuer mit politischen Befreiungen
5. Passt Preis an (Inflation basierend auf Steuer)
6. Berechnet Revenue, Tax, Net
7. Aktualisiert Geld für Verkäufer und Treasury
8. Entfernt verkaufte Items aus Inventory
9. Erstellt/aktualisiert DaySell Eintrag
10. Sendet Socket-Notifications
**Verwendete Models/Tabellen:**
- `FalukantUser` (`falukant_data.falukant_user`)
- `Branch` (`falukant_data.branch`)
- `FalukantCharacter` (`falukant_data.character`)
- `FalukantStock` (`falukant_data.stock`)
- `Inventory` (`falukant_data.inventory`)
- `ProductType` (`falukant_type.product`)
- `Knowledge` (`falukant_data.knowledge`)
- `TownProductWorth` (`falukant_data.town_product_worth`)
- `RegionData` (`falukant_data.region`)
- `RegionType` (`falukant_type.region`)
- `PoliticalOffice` (`falukant_data.political_office`)
- `PoliticalOfficeType` (`falukant_type.political_office_type`)
- `DaySell` (`falukant_log.day_sell`)
- `MoneyFlow` (via `updateFalukantUserMoney`)
### 2. `sellAllProducts(hashedUserId, branchId)`
Verkauft alle Produkte eines Branches.
**Ablauf:**
1. Lädt User, Branch mit Stocks
2. Lädt alle Inventory Items mit ProductType, Knowledge, Stock, Branch
3. Für jedes Item:
- Berechnet Preis pro Einheit
- Berechnet kumulative Steuer
- Passt Preis an
- Erstellt/aktualisiert DaySell Eintrag
4. Berechnet Gesamt-Tax pro Region
5. Aktualisiert Geld für Verkäufer und Treasury
6. Löscht alle Inventory Items
7. Sendet Socket-Notifications
**Verwendete Models/Tabellen:**
- `FalukantUser` (`falukant_data.falukant_user`)
- `Branch` (`falukant_data.branch`)
- `FalukantStock` (`falukant_data.stock`)
- `FalukantStockType` (`falukant_type.stock`)
- `FalukantCharacter` (`falukant_data.character`)
- `Inventory` (`falukant_data.inventory`)
- `ProductType` (`falukant_type.product`)
- `Knowledge` (`falukant_data.knowledge`)
- `TownProductWorth` (`falukant_data.town_product_worth`)
- `RegionData` (`falukant_data.region`)
- `RegionType` (`falukant_type.region`)
- `PoliticalOffice` (`falukant_data.political_office`)
- `PoliticalOfficeType` (`falukant_type.political_office_type`)
- `DaySell` (`falukant_log.day_sell`)
- `MoneyFlow` (via `updateFalukantUserMoney`)
### 3. `addSellItem(branchId, userId, productId, quantity)`
Erstellt oder aktualisiert einen DaySell Eintrag für einen Verkauf.
**Ablauf:**
1. Lädt Branch
2. Sucht nach existierendem DaySell Eintrag
3. Erstellt neuen oder aktualisiert existierenden Eintrag
**Verwendete Models/Tabellen:**
- `Branch` (`falukant_data.branch`)
- `DaySell` (`falukant_log.day_sell`)
## Hilfsfunktionen
### `calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null)`
Berechnet den Verkaufspreis eines Produkts basierend auf:
- Basispreis (`product.sellCost`)
- Regionalem Worth-Percent (`town_product_worth.worth_percent`)
- Knowledge-Faktor (0-100)
**Verwendete Models/Tabellen:**
- `ProductType` (`falukant_type.product`)
- `TownProductWorth` (`falukant_data.town_product_worth`)
### `getCumulativeTaxPercent(regionId)`
Berechnet die kumulative Steuer für eine Region und alle Vorfahren (rekursiv).
**SQL Query:**
```sql
WITH RECURSIVE ancestors AS (
SELECT id, parent_id, tax_percent
FROM falukant_data.region r
WHERE id = :id
UNION ALL
SELECT reg.id, reg.parent_id, reg.tax_percent
FROM falukant_data.region reg
JOIN ancestors a ON reg.id = a.parent_id
)
SELECT COALESCE(SUM(tax_percent),0) AS total FROM ancestors;
```
**Verwendete Tabellen:**
- `falukant_data.region`
### `getCumulativeTaxPercentWithExemptions(userId, regionId)`
Berechnet die kumulative Steuer mit politischen Befreiungen.
**Ablauf:**
1. Lädt Character des Users
2. Lädt alle PoliticalOffices des Characters
3. Bestimmt befreite Region-Typen basierend auf Ämtern
4. Berechnet kumulative Steuer, aber schließt befreite Region-Typen aus
**SQL Query:**
```sql
WITH RECURSIVE ancestors AS (
SELECT r.id, r.parent_id, r.tax_percent, rt.label_tr as region_type
FROM falukant_data.region r
JOIN falukant_type.region rt ON rt.id = r.region_type_id
WHERE r.id = :id
UNION ALL
SELECT reg.id, reg.parent_id, reg.tax_percent, rt2.label_tr
FROM falukant_data.region reg
JOIN falukant_type.region rt2 ON rt2.id = reg.region_type_id
JOIN ancestors a ON reg.id = a.parent_id
)
SELECT COALESCE(SUM(CASE WHEN ARRAY[...] && ARRAY[region_type]::text[] THEN 0 ELSE tax_percent END),0) AS total FROM ancestors;
```
**Verwendete Models/Tabellen:**
- `FalukantCharacter` (`falukant_data.character`)
- `PoliticalOffice` (`falukant_data.political_office`)
- `PoliticalOfficeType` (`falukant_type.political_office_type`)
- `RegionData` (`falukant_data.region`)
- `RegionType` (`falukant_type.region`)
**Politische Steuerbefreiungen:**
```javascript
const POLITICAL_TAX_EXEMPTIONS = {
'council': ['city'],
'taxman': ['city', 'county'],
'treasurerer': ['city', 'county', 'shire'],
'super-state-administrator': ['city', 'county', 'shire', 'markgrave', 'duchy'],
'chancellor': ['city','county','shire','markgrave','duchy'] // = alle Typen
};
```
## Model-Definitionen
### Inventory (`falukant_data.inventory`)
```javascript
// backend/models/falukant/data/inventory.js
- id
- stockId (FK zu falukant_data.stock)
- productId (FK zu falukant_type.product)
- quantity
- quality
- producedAt
```
### DaySell (`falukant_log.day_sell`)
```javascript
// backend/models/falukant/log/daysell.js
- id
- regionId (FK zu falukant_data.region)
- productId (FK zu falukant_type.product)
- sellerId (FK zu falukant_data.falukant_user)
- quantity
- createdAt
- updatedAt
```
### TownProductWorth (`falukant_data.town_product_worth`)
```javascript
// backend/models/falukant/data/town_product_worth.js
- id
- productId (FK zu falukant_type.product)
- regionId (FK zu falukant_data.region)
- worthPercent (0-100)
```
### Knowledge (`falukant_data.knowledge`)
```javascript
// backend/models/falukant/data/product_knowledge.js
- id
- productId (FK zu falukant_type.product)
- characterId (FK zu falukant_data.character)
- knowledge (0-99)
```
## Wichtige SQL-Queries
### 1. Inventory mit ProductType und Knowledge laden
```javascript
Inventory.findAll({
where: { quality },
include: [
{
model: ProductType,
as: 'productType',
required: true,
where: { id: productId },
include: [
{
model: Knowledge,
as: 'knowledges',
required: false,
where: { characterId: character.id }
}
]
}
]
})
```
### 2. Kumulative Steuer mit Befreiungen berechnen
Siehe `getCumulativeTaxPercentWithExemptions()` oben.
## Preisberechnung
### Formel für `calcRegionalSellPrice`:
1. Basispreis = `product.sellCost * (worthPercent / 100)`
2. Min = `basePrice * 0.6`
3. Max = `basePrice`
4. Preis = `min + (max - min) * (knowledgeFactor / 100)`
### Steueranpassung:
1. `inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100))`
2. `adjustedPricePerUnit = pricePerUnit * inflationFactor`
3. `revenue = quantity * adjustedPricePerUnit`
4. `taxValue = revenue * cumulativeTax / 100`
5. `net = revenue - taxValue`
## Vollständige Code-Snippets
### `calcRegionalSellPrice()`
```javascript
async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null) {
if (worthPercent === null) {
const townWorth = await TownProductWorth.findOne({
where: { productId: product.id, regionId: regionId }
});
worthPercent = townWorth?.worthPercent || 50; // Default 50% wenn nicht gefunden
}
// Basispreis basierend auf regionalem worthPercent
const basePrice = product.sellCost * (worthPercent / 100);
// Dann Knowledge-Faktor anwenden
const min = basePrice * 0.6;
const max = basePrice;
return min + (max - min) * (knowledgeFactor / 100);
}
```
### `getCumulativeTaxPercent()`
```javascript
async function getCumulativeTaxPercent(regionId) {
if (!regionId) return 0;
const rows = await sequelize.query(
`WITH RECURSIVE ancestors AS (
SELECT id, parent_id, tax_percent
FROM falukant_data.region r
WHERE id = :id
UNION ALL
SELECT reg.id, reg.parent_id, reg.tax_percent
FROM falukant_data.region reg
JOIN ancestors a ON reg.id = a.parent_id
)
SELECT COALESCE(SUM(tax_percent),0) AS total FROM ancestors;`,
{
replacements: { id: regionId },
type: sequelize.QueryTypes.SELECT
}
);
const val = rows?.[0]?.total ?? 0;
return parseFloat(val) || 0;
}
```
### `getCumulativeTaxPercentWithExemptions()` (vereinfacht)
```javascript
async function getCumulativeTaxPercentWithExemptions(userId, regionId) {
if (!regionId) return 0;
// Character finden
const character = await FalukantCharacter.findOne({
where: { userId },
attributes: ['id']
});
if (!character) return 0;
// Politische Ämter laden
const offices = await PoliticalOffice.findAll({
where: { characterId: character.id },
include: [
{ model: PoliticalOfficeType, as: 'type', attributes: ['name'] },
{
model: RegionData,
as: 'region',
include: [{
model: RegionType,
as: 'regionType',
attributes: ['labelTr']
}]
}
]
});
// Befreite Region-Typen bestimmen
const exemptTypes = new Set();
let hasChancellor = false;
for (const o of offices) {
const name = o.type?.name;
if (!name) continue;
if (name === 'chancellor') { hasChancellor = true; break; }
const allowed = POLITICAL_TAX_EXEMPTIONS[name];
if (allowed && Array.isArray(allowed)) {
for (const t of allowed) exemptTypes.add(t);
}
}
if (hasChancellor) return 0;
// SQL Query mit Befreiungen
const exemptTypesArray = Array.from(exemptTypes);
const exemptTypesString = exemptTypesArray.length > 0
? `ARRAY[${exemptTypesArray.map(t => `'${t.replace(/'/g, "''")}'`).join(',')}]`
: `ARRAY[]::text[]`;
const rows = await sequelize.query(
`WITH RECURSIVE ancestors AS (
SELECT r.id, r.parent_id, r.tax_percent, rt.label_tr as region_type
FROM falukant_data.region r
JOIN falukant_type.region rt ON rt.id = r.region_type_id
WHERE r.id = :id
UNION ALL
SELECT reg.id, reg.parent_id, reg.tax_percent, rt2.label_tr
FROM falukant_data.region reg
JOIN falukant_type.region rt2 ON rt2.id = reg.region_type_id
JOIN ancestors a ON reg.id = a.parent_id
)
SELECT COALESCE(SUM(CASE WHEN ${exemptTypesString} && ARRAY[region_type]::text[] THEN 0 ELSE tax_percent END),0) AS total FROM ancestors;`,
{
replacements: { id: regionId },
type: sequelize.QueryTypes.SELECT
}
);
const val = rows?.[0]?.total ?? 0;
return parseFloat(val) || 0;
}
```
### `sellProduct()` (Kern-Logik)
```javascript
async sellProduct(hashedUserId, branchId, productId, quality, quantity) {
const user = await getFalukantUserOrFail(hashedUserId);
const branch = await getBranchOrFail(user.id, branchId);
const character = await FalukantCharacter.findOne({ where: { userId: user.id } });
if (!character) throw new Error('No character found for user');
const stock = await FalukantStock.findOne({ where: { branchId: branch.id } });
if (!stock) throw new Error('Stock not found');
// Inventory laden
const inventory = await Inventory.findAll({
where: { quality },
include: [
{
model: ProductType,
as: 'productType',
required: true,
where: { id: productId },
include: [
{
model: Knowledge,
as: 'knowledges',
required: false,
where: { characterId: character.id }
}
]
}
]
});
if (!inventory.length) throw new Error('No inventory found');
const available = inventory.reduce((sum, i) => sum + i.quantity, 0);
if (available < quantity) throw new Error('Not enough inventory available');
const item = inventory[0].productType;
const knowledgeVal = item.knowledges?.[0]?.knowledge || 0;
const pricePerUnit = await calcRegionalSellPrice(item, knowledgeVal, branch.regionId);
// Steuer berechnen
const cumulativeTax = await getCumulativeTaxPercentWithExemptions(user.id, branch.regionId);
const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100));
const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100;
const revenue = quantity * adjustedPricePerUnit;
// Tax und Net berechnen
const taxValue = Math.round((revenue * cumulativeTax / 100) * 100) / 100;
const net = Math.round((revenue - taxValue) * 100) / 100;
// Geld aktualisieren
const moneyResult = await updateFalukantUserMoney(user.id, net, `Product sale (net)`, user.id);
if (!moneyResult.success) throw new Error('Failed to update money for seller');
// Steuer an Treasury buchen
const treasuryId = process.env.TREASURY_FALUKANT_USER_ID;
if (treasuryId && taxValue > 0) {
const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), taxValue, `Sales tax (${cumulativeTax}%)`, user.id);
if (!taxResult.success) throw new Error('Failed to update money for treasury');
}
// Inventory aktualisieren
let remaining = quantity;
for (const inv of inventory) {
if (inv.quantity <= remaining) {
remaining -= inv.quantity;
await inv.destroy();
} else {
await inv.update({ quantity: inv.quantity - remaining });
remaining = 0;
break;
}
}
// DaySell Eintrag erstellen/aktualisieren
await this.addSellItem(branchId, user.id, productId, quantity);
// Notifications senden
notifyUser(user.user.hashedId, 'falukantUpdateStatus', {});
notifyUser(user.user.hashedId, 'falukantBranchUpdate', { branchId: branch.id });
return { success: true };
}
```
### `addSellItem()`
```javascript
async addSellItem(branchId, userId, productId, quantity) {
const branch = await Branch.findOne({
where: { id: branchId },
});
const daySell = await DaySell.findOne({
where: {
regionId: branch.regionId,
productId: productId,
sellerId: userId,
}
});
if (daySell) {
daySell.quantity += quantity;
await daySell.save();
} else {
await DaySell.create({
regionId: branch.regionId,
productId: productId,
sellerId: userId,
quantity: quantity,
});
}
}
```
## Wichtige Hinweise
1. **Inventory wird nach Verkauf gelöscht/aktualisiert**: Items werden aus der Inventory entfernt oder die Menge reduziert.
2. **DaySell wird aggregiert**: Wenn bereits ein DaySell Eintrag für Region/Product/Seller existiert, wird die Menge addiert.
3. **Steuer wird an Treasury gebucht**: Wenn `TREASURY_FALUKANT_USER_ID` gesetzt ist, wird die Steuer an diesen User gebucht.
4. **Socket-Notifications**: Nach jedem Verkauf werden `falukantUpdateStatus` und `falukantBranchUpdate` Events gesendet.
5. **Politische Befreiungen**: Bestimmte politische Ämter befreien von Steuern in bestimmten Region-Typen. Chancellor befreit von allen Steuern.
6. **Preis-Inflation**: Der Preis wird basierend auf der Steuer inflatiert, damit der Netto-Betrag für den Verkäufer gleich bleibt.
## Tabellenübersicht
### `falukant_data.inventory`
- `id` (PK)
- `stock_id` (FK zu `falukant_data.stock`)
- `product_id` (FK zu `falukant_type.product`)
- `quantity` (INTEGER)
- `quality` (INTEGER)
- `produced_at` (DATE)
### `falukant_log.sell` (DaySell)
- `id` (PK)
- `region_id` (FK zu `falukant_data.region`)
- `product_id` (FK zu `falukant_type.product`)
- `seller_id` (FK zu `falukant_data.falukant_user`)
- `quantity` (INTEGER)
- `sell_timestamp` (DATE)
- **Unique Index**: `(seller_id, product_id, region_id)`
### `falukant_data.town_product_worth`
- `id` (PK)
- `product_id` (FK zu `falukant_type.product`)
- `region_id` (FK zu `falukant_data.region`)
- `worth_percent` (INTEGER, 0-100)
### `falukant_data.knowledge`
- `id` (PK)
- `product_id` (FK zu `falukant_type.product`)
- `character_id` (FK zu `falukant_data.character`)
- `knowledge` (INTEGER, 0-99)
### `falukant_data.political_office`
- `id` (PK)
- `office_type_id` (FK zu `falukant_type.political_office_type`)
- `character_id` (FK zu `falukant_data.character`)
- `region_id` (FK zu `falukant_data.region`)
- `created_at`, `updated_at`
### `falukant_type.political_office_type`
- `id` (PK)
- `name` (STRING) - z.B. 'council', 'taxman', 'treasurerer', 'super-state-administrator', 'chancellor'
- `seats_per_region` (INTEGER)
- `region_type` (STRING)
- `term_length` (INTEGER)
### `falukant_data.region`
- `id` (PK)
- `name` (STRING)
- `region_type_id` (FK zu `falukant_type.region`)
- `parent_id` (FK zu `falukant_data.region`, nullable)
- `map` (JSONB)
- `tax_percent` (DECIMAL)
### `falukant_type.region`
- `id` (PK)
- `label_tr` (STRING) - z.B. 'city', 'county', 'shire', 'markgrave', 'duchy'
- `parent_id` (FK zu `falukant_type.region`, nullable)
### `falukant_data.falukant_user`
- `id` (PK)
- `user_id` (FK zu `community.user`)
- `money` (DECIMAL)
- `credit_amount`, `today_credit_taken`, `credit_interest_rate`
- `certificate`
- `main_branch_region_id`
- `last_nobility_advance_at`
- `created_at`, `updated_at`
### `falukant_data.character`
- `id` (PK)
- `user_id` (FK zu `falukant_data.falukant_user`)
- `region_id` (FK zu `falukant_data.region`)
- `first_name`, `last_name`
- `birthdate`, `gender`, `health`
- `title_of_nobility` (FK zu `falukant_type.title_of_nobility`)
- `mood_id` (FK zu `falukant_type.mood`)
- `created_at`, `updated_at`
### `falukant_data.branch`
- `id` (PK)
- `branch_type_id` (FK zu `falukant_type.branch`)
- `region_id` (FK zu `falukant_data.region`)
- `falukant_user_id` (FK zu `falukant_data.falukant_user`)
### `falukant_data.stock`
- `id` (PK)
- `branch_id` (FK zu `falukant_data.branch`)
- `stock_type_id` (FK zu `falukant_type.stock`)
- `quantity` (INTEGER)
- `product_quality` (INTEGER, nullable)
### `falukant_type.product`
- `id` (PK)
- `label_tr` (STRING, unique)
- `category` (INTEGER)
- `production_time` (INTEGER)
- `sell_cost` (INTEGER)
## Dateipfade
- **Service**: `backend/services/falukantService.js`
- **Models**:
- `backend/models/falukant/data/inventory.js`
- `backend/models/falukant/log/daysell.js`
- `backend/models/falukant/data/town_product_worth.js`
- `backend/models/falukant/data/product_knowledge.js`
- `backend/models/falukant/data/political_office.js`
- `backend/models/falukant/type/political_office_type.js`
- `backend/models/falukant/data/region.js`
- `backend/models/falukant/type/region.js`
- `backend/models/falukant/data/character.js`
- `backend/models/falukant/data/user.js`
- `backend/models/falukant/data/branch.js`
- `backend/models/falukant/data/stock.js`
- `backend/models/falukant/type/product.js`

View File

@@ -1,6 +1,7 @@
import express from 'express'; import express from 'express';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import crypto from 'crypto';
import chatRouter from './routers/chatRouter.js'; import chatRouter from './routers/chatRouter.js';
import authRouter from './routers/authRouter.js'; import authRouter from './routers/authRouter.js';
import navigationRouter from './routers/navigationRouter.js'; import navigationRouter from './routers/navigationRouter.js';
@@ -17,6 +18,7 @@ import taxiRouter from './routers/taxiRouter.js';
import taxiMapRouter from './routers/taxiMapRouter.js'; import taxiMapRouter from './routers/taxiMapRouter.js';
import taxiHighscoreRouter from './routers/taxiHighscoreRouter.js'; import taxiHighscoreRouter from './routers/taxiHighscoreRouter.js';
import termineRouter from './routers/termineRouter.js'; import termineRouter from './routers/termineRouter.js';
import vocabRouter from './routers/vocabRouter.js';
import cors from 'cors'; import cors from 'cors';
import './jobs/sessionCleanup.js'; import './jobs/sessionCleanup.js';
@@ -25,6 +27,25 @@ const __dirname = path.dirname(__filename);
const app = express(); const app = express();
// Request-Timing (aktivierbar per ENV)
// - LOG_SLOW_REQ_MS=200: Logge Requests, die länger dauern als X ms (Default 500)
// - LOG_ALL_REQ=1: Logge alle Requests
const LOG_ALL_REQ = process.env.LOG_ALL_REQ === '1';
const LOG_SLOW_REQ_MS = Number.parseInt(process.env.LOG_SLOW_REQ_MS || '500', 10);
app.use((req, res, next) => {
const reqId = req.headers['x-request-id'] || (crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(8).toString('hex'));
req.reqId = reqId;
res.setHeader('x-request-id', reqId);
const t0 = Date.now();
res.on('finish', () => {
const ms = Date.now() - t0;
if (LOG_ALL_REQ || ms >= LOG_SLOW_REQ_MS) {
console.log(`⏱️ REQ ${ms}ms ${res.statusCode} ${req.method} ${req.originalUrl} rid=${reqId}`);
}
});
next();
});
const corsOptions = { const corsOptions = {
origin: ['http://localhost:3000', 'http://localhost:5173', 'http://127.0.0.1:3000', 'http://127.0.0.1:5173'], origin: ['http://localhost:3000', 'http://localhost:5173', 'http://127.0.0.1:3000', 'http://127.0.0.1:5173'],
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'], methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
@@ -49,6 +70,7 @@ app.use('/api/taxi/highscores', taxiHighscoreRouter);
app.use('/images', express.static(path.join(__dirname, '../frontend/public/images'))); app.use('/images', express.static(path.join(__dirname, '../frontend/public/images')));
app.use('/api/contact', contactRouter); app.use('/api/contact', contactRouter);
app.use('/api/socialnetwork', socialnetworkRouter); app.use('/api/socialnetwork', socialnetworkRouter);
app.use('/api/vocab', vocabRouter);
app.use('/api/forum', forumRouter); app.use('/api/forum', forumRouter);
app.use('/api/falukant', falukantRouter); app.use('/api/falukant', falukantRouter);
app.use('/api/friendships', friendshipRouter); app.use('/api/friendships', friendshipRouter);

View File

@@ -43,6 +43,9 @@ class AdminController {
this.getRegionDistances = this.getRegionDistances.bind(this); this.getRegionDistances = this.getRegionDistances.bind(this);
this.upsertRegionDistance = this.upsertRegionDistance.bind(this); this.upsertRegionDistance = this.upsertRegionDistance.bind(this);
this.deleteRegionDistance = this.deleteRegionDistance.bind(this); this.deleteRegionDistance = this.deleteRegionDistance.bind(this);
this.createNPCs = this.createNPCs.bind(this);
this.getTitlesOfNobility = this.getTitlesOfNobility.bind(this);
this.getNPCsCreationStatus = this.getNPCsCreationStatus.bind(this);
} }
async getOpenInterests(req, res) { async getOpenInterests(req, res) {
@@ -383,6 +386,59 @@ class AdminController {
} }
} }
async createNPCs(req, res) {
try {
const { userid: userId } = req.headers;
const { regionIds, minAge, maxAge, minTitleId, maxTitleId, count } = req.body;
const countValue = parseInt(count) || 1;
if (countValue < 1 || countValue > 500) {
return res.status(400).json({ error: 'Count must be between 1 and 500' });
}
console.log('[createNPCs] Request received:', { userId, regionIds, minAge, maxAge, minTitleId, maxTitleId, count: countValue });
const result = await AdminService.createNPCs(userId, {
regionIds: regionIds && regionIds.length > 0 ? regionIds : null,
minAge: parseInt(minAge) || 0,
maxAge: parseInt(maxAge) || 100,
minTitleId: parseInt(minTitleId) || 1,
maxTitleId: parseInt(maxTitleId) || 19,
count: countValue
});
console.log('[createNPCs] Job created:', result);
res.status(200).json(result);
} catch (error) {
console.error('[createNPCs] Error:', error);
console.error('[createNPCs] Error stack:', error.stack);
const status = error.message === 'noaccess' ? 403 : 500;
res.status(status).json({ error: error.message || 'Internal server error' });
}
}
async getTitlesOfNobility(req, res) {
try {
const { userid: userId } = req.headers;
const titles = await AdminService.getTitlesOfNobility(userId);
res.status(200).json(titles);
} catch (error) {
console.log(error);
const status = error.message === 'noaccess' ? 403 : 500;
res.status(status).json({ error: error.message });
}
}
async getNPCsCreationStatus(req, res) {
try {
const { userid: userId } = req.headers;
const { jobId } = req.params;
const status = await AdminService.getNPCsCreationStatus(userId, jobId);
res.status(200).json(status);
} catch (error) {
console.log(error);
const status = error.message === 'noaccess' || error.message === 'Access denied' ? 403 :
error.message === 'Job not found' ? 404 : 500;
res.status(status).json({ error: error.message });
}
}
async getRoomTypes(req, res) { async getRoomTypes(req, res) {
try { try {
const userId = req.headers.userid; const userId = req.headers.userid;

View File

@@ -93,6 +93,8 @@ class FalukantController {
return result; return result;
}); });
this.setHeir = this._wrapWithUser((userId, req) => this.service.setHeir(userId, req.body.childCharacterId)); this.setHeir = this._wrapWithUser((userId, req) => this.service.setHeir(userId, req.body.childCharacterId));
this.getPotentialHeirs = this._wrapWithUser((userId) => this.service.getPotentialHeirs(userId));
this.selectHeir = this._wrapWithUser((userId, req) => this.service.selectHeir(userId, req.body.heirId));
this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId)); this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId));
this.getGifts = this._wrapWithUser((userId) => { this.getGifts = this._wrapWithUser((userId) => {
console.log('🔍 getGifts called with userId:', userId); console.log('🔍 getGifts called with userId:', userId);
@@ -116,6 +118,12 @@ class FalukantController {
}, { successStatus: 201 }); }, { successStatus: 201 });
this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId)); this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId));
this.getReputationActions = this._wrapWithUser((userId) => this.service.getReputationActions(userId));
this.executeReputationAction = this._wrapWithUser((userId, req) => {
const { actionTypeId } = req.body;
return this.service.executeReputationAction(userId, actionTypeId);
}, { successStatus: 201 });
this.getNotBaptisedChildren = this._wrapWithUser((userId) => this.service.getNotBaptisedChildren(userId)); this.getNotBaptisedChildren = this._wrapWithUser((userId) => this.service.getNotBaptisedChildren(userId));
this.baptise = this._wrapWithUser((userId, req) => { this.baptise = this._wrapWithUser((userId, req) => {
const { characterId: childId, firstName } = req.body; const { characterId: childId, firstName } = req.body;

View File

@@ -4,6 +4,7 @@ import UserRight from '../models/community/user_right.js';
import UserRightType from '../models/type/user_right.js'; import UserRightType from '../models/type/user_right.js';
import UserParamType from '../models/type/user_param.js'; import UserParamType from '../models/type/user_param.js';
import FalukantUser from '../models/falukant/data/user.js'; import FalukantUser from '../models/falukant/data/user.js';
import VocabService from '../services/vocabService.js';
const menuStructure = { const menuStructure = {
home: { home: {
@@ -49,6 +50,11 @@ const menuStructure = {
visible: ["all"], visible: ["all"],
path: "/socialnetwork/gallery" path: "/socialnetwork/gallery"
}, },
vocabtrainer: {
visible: ["all"],
path: "/socialnetwork/vocab",
children: {}
},
blockedUsers: { blockedUsers: {
visible: ["all"], visible: ["all"],
path: "/socialnetwork/blocked" path: "/socialnetwork/blocked"
@@ -274,6 +280,10 @@ const menuStructure = {
visible: ["mainadmin", "falukant"], visible: ["mainadmin", "falukant"],
path: "/admin/falukant/map" path: "/admin/falukant/map"
}, },
createNPC: {
visible: ["mainadmin", "falukant"],
path: "/admin/falukant/create-npc"
},
} }
}, },
minigames: { minigames: {
@@ -296,6 +306,7 @@ const menuStructure = {
class NavigationController { class NavigationController {
constructor() { constructor() {
this.menu = this.menu.bind(this); this.menu = this.menu.bind(this);
this.vocabService = new VocabService();
} }
calculateAge(birthDate) { calculateAge(birthDate) {
@@ -365,6 +376,24 @@ class NavigationController {
const age = this.calculateAge(birthDate); const age = this.calculateAge(birthDate);
const rights = userRights.map(ur => ur.rightType?.title).filter(Boolean); const rights = userRights.map(ur => ur.rightType?.title).filter(Boolean);
const filteredMenu = await this.filterMenu(menuStructure, rights, age, user.id); const filteredMenu = await this.filterMenu(menuStructure, rights, age, user.id);
// Dynamisches Submenü: Treffpunkt → Vokabeltrainer → (Neue Sprache + abonnierte/angelegte)
// Wichtig: "Neue Sprache" soll IMMER sichtbar sein auch wenn die DB-Abfrage (noch) fehlschlägt.
if (filteredMenu?.socialnetwork?.children?.vocabtrainer) {
const children = {
newLanguage: { path: '/socialnetwork/vocab/new' },
};
try {
const langs = await this.vocabService.listLanguagesForMenu(user.id);
for (const l of langs) {
children[`lang_${l.id}`] = { path: `/socialnetwork/vocab/${l.id}`, label: l.name };
}
} catch (e) {
console.warn('[menu] Konnte Vokabeltrainer-Sprachen nicht laden:', e?.message || e);
}
filteredMenu.socialnetwork.children.vocabtrainer.children = children;
}
res.status(200).json(filteredMenu); res.status(200).json(filteredMenu);
} catch (error) { } catch (error) {
console.error('Error fetching menu:', error); console.error('Error fetching menu:', error);

View File

@@ -0,0 +1,46 @@
import VocabService from '../services/vocabService.js';
function extractHashedUserId(req) {
return req.headers?.userid;
}
class VocabController {
constructor() {
this.service = new VocabService();
this.listLanguages = this._wrapWithUser((userId) => this.service.listLanguages(userId));
this.createLanguage = this._wrapWithUser((userId, req) => this.service.createLanguage(userId, req.body), { successStatus: 201 });
this.subscribe = this._wrapWithUser((userId, req) => this.service.subscribeByShareCode(userId, req.body), { successStatus: 201 });
this.getLanguage = this._wrapWithUser((userId, req) => this.service.getLanguage(userId, req.params.languageId));
this.listChapters = this._wrapWithUser((userId, req) => this.service.listChapters(userId, req.params.languageId));
this.createChapter = this._wrapWithUser((userId, req) => this.service.createChapter(userId, req.params.languageId, req.body), { successStatus: 201 });
this.listLanguageVocabs = this._wrapWithUser((userId, req) => this.service.listLanguageVocabs(userId, req.params.languageId));
this.searchVocabs = this._wrapWithUser((userId, req) => this.service.searchVocabs(userId, req.params.languageId, req.query));
this.getChapter = this._wrapWithUser((userId, req) => this.service.getChapter(userId, req.params.chapterId));
this.listChapterVocabs = this._wrapWithUser((userId, req) => this.service.listChapterVocabs(userId, req.params.chapterId));
this.addVocabToChapter = this._wrapWithUser((userId, req) => this.service.addVocabToChapter(userId, req.params.chapterId, req.body), { successStatus: 201 });
}
_wrapWithUser(fn, { successStatus = 200 } = {}) {
return async (req, res) => {
try {
const hashedUserId = extractHashedUserId(req);
if (!hashedUserId) {
return res.status(400).json({ error: 'Missing user identifier' });
}
const result = await fn(hashedUserId, req, res);
res.status(successStatus).json(result);
} catch (error) {
console.error('Controller error:', error);
const status = error.status && typeof error.status === 'number' ? error.status : 500;
res.status(status).json({ error: error.message || 'Internal error' });
}
};
}
}
export default VocabController;

View File

@@ -1,14 +1,37 @@
import WebSocket, { WebSocketServer } from 'ws'; import WebSocket, { WebSocketServer } from 'ws';
import https from 'https';
import fs from 'fs';
const PORT = 4551; const PORT = Number.parseInt(process.env.DAEMON_PORT || '4551', 10);
const USE_TLS = process.env.DAEMON_TLS === '1';
const TLS_KEY_PATH = process.env.DAEMON_TLS_KEY_PATH;
const TLS_CERT_PATH = process.env.DAEMON_TLS_CERT_PATH;
const TLS_CA_PATH = process.env.DAEMON_TLS_CA_PATH; // optional
// Einfache In-Memory-Struktur für Verbindungen (für spätere Erweiterungen) // Einfache In-Memory-Struktur für Verbindungen (für spätere Erweiterungen)
const connections = new Set(); const connections = new Set();
function createServer() { function createServer() {
const wss = new WebSocketServer({ port: PORT }); let wss;
console.log(`[Daemon] WebSocket-Server startet auf Port ${PORT} ...`); if (USE_TLS) {
if (!TLS_KEY_PATH || !TLS_CERT_PATH) {
console.error('[Daemon] DAEMON_TLS=1 gesetzt, aber DAEMON_TLS_KEY_PATH/DAEMON_TLS_CERT_PATH fehlen.');
process.exit(1);
}
const httpsServer = https.createServer({
key: fs.readFileSync(TLS_KEY_PATH),
cert: fs.readFileSync(TLS_CERT_PATH),
ca: TLS_CA_PATH ? fs.readFileSync(TLS_CA_PATH) : undefined,
});
wss = new WebSocketServer({ server: httpsServer });
httpsServer.listen(PORT, '0.0.0.0', () => {
console.log(`[Daemon] WSS (TLS) Server gestartet auf Port ${PORT}`);
});
} else {
wss = new WebSocketServer({ port: PORT });
console.log(`[Daemon] WS (ohne TLS) Server startet auf Port ${PORT} ...`);
}
wss.on('connection', (ws, req) => { wss.on('connection', (ws, req) => {
const peer = req.socket.remoteAddress + ':' + req.socket.remotePort; const peer = req.socket.remoteAddress + ':' + req.socket.remotePort;

View File

@@ -0,0 +1,79 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
// falukant_data.character.reputation (integer, default random 20..80)
// Wichtig: Schema explizit angeben
// Vorgehen:
// - Spalte anlegen (falls noch nicht vorhanden)
// - bestehende Zeilen initialisieren (random 20..80)
// - DEFAULT setzen (random 20..80)
// - NOT NULL + CHECK 0..100 erzwingen
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'falukant_data'
AND table_name = 'character'
AND column_name = 'reputation'
) THEN
ALTER TABLE falukant_data."character"
ADD COLUMN reputation integer;
END IF;
END$$;
`);
// Backfill: nur NULLs initialisieren (damit bestehende Werte nicht überschrieben werden)
await queryInterface.sequelize.query(`
UPDATE falukant_data."character"
SET reputation = (floor(random()*61)+20)::int
WHERE reputation IS NULL;
`);
// DEFAULT + NOT NULL (nach Backfill)
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data."character"
ALTER COLUMN reputation SET DEFAULT (floor(random()*61)+20)::int;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data."character"
ALTER COLUMN reputation SET NOT NULL;
`);
// Enforce 0..100 at DB level (percent)
// (IF NOT EXISTS pattern, because deployments can be re-run)
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.conname = 'character_reputation_0_100_chk'
AND n.nspname = 'falukant_data'
AND t.relname = 'character'
) THEN
ALTER TABLE falukant_data."character"
ADD CONSTRAINT character_reputation_0_100_chk
CHECK (reputation >= 0 AND reputation <= 100);
END IF;
END$$;
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data."character"
DROP CONSTRAINT IF EXISTS character_reputation_0_100_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data."character"
DROP COLUMN IF EXISTS reputation;
`);
},
};

View File

@@ -0,0 +1,47 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
// Typ-Tabelle (konfigurierbar ohne Code): falukant_type.reputation_action
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS falukant_type.reputation_action (
id serial PRIMARY KEY,
tr text NOT NULL UNIQUE,
cost integer NOT NULL CHECK (cost >= 0),
base_gain integer NOT NULL CHECK (base_gain >= 0),
decay_factor double precision NOT NULL CHECK (decay_factor > 0 AND decay_factor <= 1),
min_gain integer NOT NULL DEFAULT 0 CHECK (min_gain >= 0),
decay_window_days integer NOT NULL DEFAULT 7 CHECK (decay_window_days >= 1 AND decay_window_days <= 365)
);
`);
// Log-Tabelle: falukant_log.reputation_action
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS falukant_log.reputation_action (
id serial PRIMARY KEY,
falukant_user_id integer NOT NULL,
action_type_id integer NOT NULL,
cost integer NOT NULL CHECK (cost >= 0),
base_gain integer NOT NULL CHECK (base_gain >= 0),
gain integer NOT NULL CHECK (gain >= 0),
times_used_before integer NOT NULL CHECK (times_used_before >= 0),
action_timestamp timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS reputation_action_log_user_type_idx
ON falukant_log.reputation_action (falukant_user_id, action_type_id);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS reputation_action_log_ts_idx
ON falukant_log.reputation_action (action_timestamp);
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS falukant_log.reputation_action;`);
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS falukant_type.reputation_action;`);
},
};

View File

@@ -0,0 +1,46 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
// Für bereits existierende Installationen: Spalte sicherstellen + Backfill
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
ADD COLUMN IF NOT EXISTS decay_window_days integer;
`);
await queryInterface.sequelize.query(`
UPDATE falukant_type.reputation_action
SET decay_window_days = 7
WHERE decay_window_days IS NULL;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
ALTER COLUMN decay_window_days SET DEFAULT 7;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
ALTER COLUMN decay_window_days SET NOT NULL;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
DROP CONSTRAINT IF EXISTS reputation_action_decay_window_days_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
ADD CONSTRAINT reputation_action_decay_window_days_chk
CHECK (decay_window_days >= 1 AND decay_window_days <= 365);
`);
},
async down(queryInterface, Sequelize) {
// optional: wieder entfernen
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
DROP CONSTRAINT IF EXISTS reputation_action_decay_window_days_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
DROP COLUMN IF EXISTS decay_window_days;
`);
},
};

View File

@@ -0,0 +1,50 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
// Idempotentes Seed: legt Ruf-Aktionen an bzw. aktualisiert sie anhand "tr"
await queryInterface.sequelize.query(`
INSERT INTO falukant_type.reputation_action
(tr, cost, base_gain, decay_factor, min_gain, decay_window_days)
VALUES
('soup_kitchen', 500, 2, 0.85, 0, 7),
('library_donation', 5000, 4, 0.88, 0, 7),
('well_build', 8000, 4, 0.87, 0, 7),
('scholarships', 10000, 5, 0.87, 0, 7),
('church_hospice', 12000, 5, 0.87, 0, 7),
('school_funding', 15000, 6, 0.88, 0, 7),
('orphanage_build', 20000, 7, 0.90, 0, 7),
('bridge_build', 25000, 7, 0.90, 0, 7),
('hospital_donation', 30000, 8, 0.90, 0, 7),
('patronage', 40000, 9, 0.91, 0, 7),
('statue_build', 50000, 10, 0.92, 0, 7)
ON CONFLICT (tr) DO UPDATE SET
cost = EXCLUDED.cost,
base_gain = EXCLUDED.base_gain,
decay_factor = EXCLUDED.decay_factor,
min_gain = EXCLUDED.min_gain,
decay_window_days = EXCLUDED.decay_window_days;
`);
},
async down(queryInterface, Sequelize) {
// Entfernt nur die gesetzten Seeds (tr-basiert)
await queryInterface.sequelize.query(`
DELETE FROM falukant_type.reputation_action
WHERE tr IN (
'soup_kitchen',
'library_donation',
'well_build',
'scholarships',
'church_hospice',
'school_funding',
'orphanage_build',
'bridge_build',
'hospital_donation',
'patronage',
'statue_build'
);
`);
},
};

View File

@@ -0,0 +1,60 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
// Ensure column exists
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.vehicle
ADD COLUMN IF NOT EXISTS condition integer;
`);
// Backfill nulls (legacy data)
await queryInterface.sequelize.query(`
UPDATE falukant_data.vehicle
SET condition = 100
WHERE condition IS NULL;
`);
// Clamp out-of-range values defensively
await queryInterface.sequelize.query(`
UPDATE falukant_data.vehicle
SET condition = GREATEST(0, LEAST(100, condition))
WHERE condition < 0 OR condition > 100;
`);
// Default + NOT NULL
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.vehicle
ALTER COLUMN condition SET DEFAULT 100;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.vehicle
ALTER COLUMN condition SET NOT NULL;
`);
// Check constraint 0..100
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.vehicle
DROP CONSTRAINT IF EXISTS vehicle_condition_0_100_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.vehicle
ADD CONSTRAINT vehicle_condition_0_100_chk
CHECK (condition >= 0 AND condition <= 100);
`);
},
async down(queryInterface, Sequelize) {
// Keep the column, but remove constraint/default to be reversible
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.vehicle
DROP CONSTRAINT IF EXISTS vehicle_condition_0_100_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.vehicle
ALTER COLUMN condition DROP DEFAULT;
`);
// NOT NULL not reverted to avoid introducing NULLs on rollback; can be adjusted if needed
},
};

View File

@@ -0,0 +1,32 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.director
ADD COLUMN IF NOT EXISTS may_repair_vehicles boolean;
`);
await queryInterface.sequelize.query(`
UPDATE falukant_data.director
SET may_repair_vehicles = true
WHERE may_repair_vehicles IS NULL;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.director
ALTER COLUMN may_repair_vehicles SET DEFAULT true;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.director
ALTER COLUMN may_repair_vehicles SET NOT NULL;
`);
},
async down(queryInterface, Sequelize) {
// optional rollback: drop column
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.director
DROP COLUMN IF EXISTS may_repair_vehicles;
`);
},
};

View File

@@ -0,0 +1,61 @@
/* eslint-disable */
'use strict';
module.exports = {
async up(queryInterface) {
// Sprache / Set, das geteilt werden kann
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS community.vocab_language (
id SERIAL PRIMARY KEY,
owner_user_id INTEGER NOT NULL,
name TEXT NOT NULL,
share_code TEXT NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_language_owner_fk
FOREIGN KEY (owner_user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_language_share_code_uniq UNIQUE (share_code)
);
`);
// Abos (Freunde)
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS community.vocab_language_subscription (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
language_id INTEGER NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_language_subscription_user_fk
FOREIGN KEY (user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_language_subscription_language_fk
FOREIGN KEY (language_id)
REFERENCES community.vocab_language(id)
ON DELETE CASCADE,
CONSTRAINT vocab_language_subscription_uniq UNIQUE (user_id, language_id)
);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS vocab_language_owner_idx
ON community.vocab_language(owner_user_id);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS vocab_language_subscription_user_idx
ON community.vocab_language_subscription(user_id);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS vocab_language_subscription_language_idx
ON community.vocab_language_subscription(language_id);
`);
},
async down(queryInterface) {
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_language_subscription;`);
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_language;`);
}
};

View File

@@ -0,0 +1,106 @@
/* eslint-disable */
'use strict';
module.exports = {
async up(queryInterface) {
// Kapitel innerhalb einer Sprache
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS community.vocab_chapter (
id SERIAL PRIMARY KEY,
language_id INTEGER NOT NULL,
title TEXT NOT NULL,
created_by_user_id INTEGER NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_chapter_language_fk
FOREIGN KEY (language_id)
REFERENCES community.vocab_language(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chapter_creator_fk
FOREIGN KEY (created_by_user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE
);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS vocab_chapter_language_idx
ON community.vocab_chapter(language_id);
`);
// Lexeme/Wörter (wir deduplizieren pro Sprache über normalized)
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS community.vocab_lexeme (
id SERIAL PRIMARY KEY,
language_id INTEGER NOT NULL,
text TEXT NOT NULL,
normalized TEXT NOT NULL,
created_by_user_id INTEGER NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_lexeme_language_fk
FOREIGN KEY (language_id)
REFERENCES community.vocab_language(id)
ON DELETE CASCADE,
CONSTRAINT vocab_lexeme_creator_fk
FOREIGN KEY (created_by_user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_lexeme_unique_per_language UNIQUE (language_id, normalized)
);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS vocab_lexeme_language_idx
ON community.vocab_lexeme(language_id);
`);
// n:m Zuordnung pro Kapitel: Lernwort ↔ Referenzwort (Mehrdeutigkeiten möglich)
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS community.vocab_chapter_lexeme (
id SERIAL PRIMARY KEY,
chapter_id INTEGER NOT NULL,
learning_lexeme_id INTEGER NOT NULL,
reference_lexeme_id INTEGER NOT NULL,
created_by_user_id INTEGER NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_chlex_chapter_fk
FOREIGN KEY (chapter_id)
REFERENCES community.vocab_chapter(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chlex_learning_fk
FOREIGN KEY (learning_lexeme_id)
REFERENCES community.vocab_lexeme(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chlex_reference_fk
FOREIGN KEY (reference_lexeme_id)
REFERENCES community.vocab_lexeme(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chlex_creator_fk
FOREIGN KEY (created_by_user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chlex_unique UNIQUE (chapter_id, learning_lexeme_id, reference_lexeme_id)
);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS vocab_chlex_chapter_idx
ON community.vocab_chapter_lexeme(chapter_id);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS vocab_chlex_learning_idx
ON community.vocab_chapter_lexeme(learning_lexeme_id);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS vocab_chlex_reference_idx
ON community.vocab_chapter_lexeme(reference_lexeme_id);
`);
},
async down(queryInterface) {
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_chapter_lexeme;`);
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_lexeme;`);
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_chapter;`);
}
};

View File

@@ -598,44 +598,52 @@ export default function setupAssociations() {
Learning.belongsTo(LearnRecipient, { Learning.belongsTo(LearnRecipient, {
foreignKey: 'learningRecipientId', foreignKey: 'learningRecipientId',
as: 'recipient' as: 'recipient',
constraints: false
} }
); );
LearnRecipient.hasMany(Learning, { LearnRecipient.hasMany(Learning, {
foreignKey: 'learningRecipientId', foreignKey: 'learningRecipientId',
as: 'learnings' as: 'learnings',
constraints: false
}); });
Learning.belongsTo(FalukantUser, { Learning.belongsTo(FalukantUser, {
foreignKey: 'associatedFalukantUserId', foreignKey: 'associatedFalukantUserId',
as: 'learner' as: 'learner',
constraints: false
} }
); );
FalukantUser.hasMany(Learning, { FalukantUser.hasMany(Learning, {
foreignKey: 'associatedFalukantUserId', foreignKey: 'associatedFalukantUserId',
as: 'learnings' as: 'learnings',
constraints: false
}); });
Learning.belongsTo(ProductType, { Learning.belongsTo(ProductType, {
foreignKey: 'productId', foreignKey: 'productId',
as: 'productType' as: 'productType',
constraints: false
}); });
ProductType.hasMany(Learning, { ProductType.hasMany(Learning, {
foreignKey: 'productId', foreignKey: 'productId',
as: 'learnings' as: 'learnings',
constraints: false
}); });
Learning.belongsTo(FalukantCharacter, { Learning.belongsTo(FalukantCharacter, {
foreignKey: 'associatedLearningCharacterId', foreignKey: 'associatedLearningCharacterId',
as: 'learningCharacter' as: 'learningCharacter',
constraints: false
}); });
FalukantCharacter.hasMany(Learning, { FalukantCharacter.hasMany(Learning, {
foreignKey: 'associatedLearningCharacterId', foreignKey: 'associatedLearningCharacterId',
as: 'learningsCharacter' as: 'learningsCharacter',
constraints: false
}); });
FalukantUser.hasMany(Credit, { FalukantUser.hasMany(Credit, {

View File

@@ -34,6 +34,18 @@ FalukantCharacter.init(
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
defaultValue: 1} defaultValue: 1}
,
reputation: {
type: DataTypes.INTEGER,
allowNull: false,
// Initialisierung: zufällig 20..80 (Prozent)
// DB-seitig per DEFAULT umgesetzt, damit es auch ohne App-Logic gilt.
defaultValue: sequelize.literal('(floor(random()*61)+20)'),
validate: {
min: 0,
max: 100
}
}
}, },
{ {
sequelize, sequelize,

View File

@@ -29,6 +29,10 @@ Director.init({
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
allowNull: false, allowNull: false,
defaultValue: true}, defaultValue: true},
mayRepairVehicles: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true},
lastSalaryPayout: { lastSalaryPayout: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false, allowNull: false,

View File

@@ -0,0 +1,59 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
class ReputationActionLog extends Model {}
ReputationActionLog.init(
{
falukantUserId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'falukant_user_id',
},
actionTypeId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'action_type_id',
},
cost: {
type: DataTypes.INTEGER,
allowNull: false,
},
baseGain: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'base_gain',
},
gain: {
type: DataTypes.INTEGER,
allowNull: false,
},
timesUsedBefore: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'times_used_before',
},
actionTimestamp: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: sequelize.literal('CURRENT_TIMESTAMP'),
field: 'action_timestamp',
},
},
{
sequelize,
modelName: 'ReputationActionLog',
tableName: 'reputation_action',
schema: 'falukant_log',
timestamps: false,
underscored: true,
indexes: [
{ fields: ['falukant_user_id', 'action_type_id'] },
{ fields: ['action_timestamp'] },
],
}
);
export default ReputationActionLog;

View File

@@ -0,0 +1,51 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
class ReputationActionType extends Model {}
ReputationActionType.init(
{
tr: {
type: DataTypes.STRING,
allowNull: false,
},
cost: {
type: DataTypes.INTEGER,
allowNull: false,
},
baseGain: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'base_gain',
},
decayFactor: {
type: DataTypes.FLOAT,
allowNull: false,
field: 'decay_factor',
},
minGain: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'min_gain',
},
decayWindowDays: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 7,
field: 'decay_window_days',
},
},
{
sequelize,
modelName: 'ReputationActionType',
tableName: 'reputation_action',
schema: 'falukant_type',
timestamps: false,
underscored: true,
}
);
export default ReputationActionType;

View File

@@ -79,6 +79,8 @@ import Party from './falukant/data/party.js';
import MusicType from './falukant/type/music.js'; import MusicType from './falukant/type/music.js';
import BanquetteType from './falukant/type/banquette.js'; import BanquetteType from './falukant/type/banquette.js';
import PartyInvitedNobility from './falukant/data/partyInvitedNobility.js'; import PartyInvitedNobility from './falukant/data/partyInvitedNobility.js';
import ReputationActionType from './falukant/type/reputation_action.js';
import ReputationActionLog from './falukant/log/reputation_action.js';
import ChildRelation from './falukant/data/child_relation.js'; import ChildRelation from './falukant/data/child_relation.js';
import LearnRecipient from './falukant/type/learn_recipient.js'; import LearnRecipient from './falukant/type/learn_recipient.js';
import Learning from './falukant/data/learning.js'; import Learning from './falukant/data/learning.js';
@@ -208,6 +210,8 @@ const models = {
BanquetteType, BanquetteType,
Party, Party,
PartyInvitedNobility, PartyInvitedNobility,
ReputationActionType,
ReputationActionLog,
ChildRelation, ChildRelation,
LearnRecipient, LearnRecipient,
Learning, Learning,

View File

@@ -994,29 +994,58 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.3", "version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "3.1.2", "bytes": "~3.1.2",
"content-type": "~1.0.5", "content-type": "~1.0.5",
"debug": "2.6.9", "debug": "2.6.9",
"depd": "2.0.0", "depd": "2.0.0",
"destroy": "1.2.0", "destroy": "~1.2.0",
"http-errors": "2.0.0", "http-errors": "~2.0.1",
"iconv-lite": "0.4.24", "iconv-lite": "~0.4.24",
"on-finished": "2.4.1", "on-finished": "~2.4.1",
"qs": "6.13.0", "qs": "~6.14.0",
"raw-body": "2.5.2", "raw-body": "~2.5.3",
"type-is": "~1.6.18", "type-is": "~1.6.18",
"unpipe": "1.0.0" "unpipe": "~1.0.0"
}, },
"engines": { "engines": {
"node": ">= 0.8", "node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/body-parser/node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@@ -1647,39 +1676,39 @@
} }
}, },
"node_modules/express": { "node_modules/express": {
"version": "4.21.2", "version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
"body-parser": "1.20.3", "body-parser": "~1.20.3",
"content-disposition": "0.5.4", "content-disposition": "~0.5.4",
"content-type": "~1.0.4", "content-type": "~1.0.4",
"cookie": "0.7.1", "cookie": "~0.7.1",
"cookie-signature": "1.0.6", "cookie-signature": "~1.0.6",
"debug": "2.6.9", "debug": "2.6.9",
"depd": "2.0.0", "depd": "2.0.0",
"encodeurl": "~2.0.0", "encodeurl": "~2.0.0",
"escape-html": "~1.0.3", "escape-html": "~1.0.3",
"etag": "~1.8.1", "etag": "~1.8.1",
"finalhandler": "1.3.1", "finalhandler": "~1.3.1",
"fresh": "0.5.2", "fresh": "~0.5.2",
"http-errors": "2.0.0", "http-errors": "~2.0.0",
"merge-descriptors": "1.0.3", "merge-descriptors": "1.0.3",
"methods": "~1.1.2", "methods": "~1.1.2",
"on-finished": "2.4.1", "on-finished": "~2.4.1",
"parseurl": "~1.3.3", "parseurl": "~1.3.3",
"path-to-regexp": "0.1.12", "path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7", "proxy-addr": "~2.0.7",
"qs": "6.13.0", "qs": "~6.14.0",
"range-parser": "~1.2.1", "range-parser": "~1.2.1",
"safe-buffer": "5.2.1", "safe-buffer": "5.2.1",
"send": "0.19.0", "send": "~0.19.0",
"serve-static": "1.16.2", "serve-static": "~1.16.2",
"setprototypeof": "1.2.0", "setprototypeof": "1.2.0",
"statuses": "2.0.1", "statuses": "~2.0.1",
"type-is": "~1.6.18", "type-is": "~1.6.18",
"utils-merge": "1.0.1", "utils-merge": "1.0.1",
"vary": "~1.1.2" "vary": "~1.1.2"
@@ -2910,12 +2939,12 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.13.0", "version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.0.6" "side-channel": "^1.1.0"
}, },
"engines": { "engines": {
"node": ">=0.6" "node": ">=0.6"
@@ -2949,20 +2978,49 @@
} }
}, },
"node_modules/raw-body": { "node_modules/raw-body": {
"version": "2.5.2", "version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "3.1.2", "bytes": "~3.1.2",
"http-errors": "2.0.0", "http-errors": "~2.0.1",
"iconv-lite": "0.4.24", "iconv-lite": "~0.4.24",
"unpipe": "1.0.0" "unpipe": "~1.0.0"
}, },
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/raw-body/node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body/node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/readable-stream": { "node_modules/readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",

View File

@@ -46,6 +46,9 @@ router.put('/falukant/regions/:id/map', authenticate, adminController.updateFalu
router.get('/falukant/region-distances', authenticate, adminController.getRegionDistances); router.get('/falukant/region-distances', authenticate, adminController.getRegionDistances);
router.post('/falukant/region-distances', authenticate, adminController.upsertRegionDistance); router.post('/falukant/region-distances', authenticate, adminController.upsertRegionDistance);
router.delete('/falukant/region-distances/:id', authenticate, adminController.deleteRegionDistance); router.delete('/falukant/region-distances/:id', authenticate, adminController.deleteRegionDistance);
router.post('/falukant/npcs/create', authenticate, adminController.createNPCs);
router.get('/falukant/npcs/status/:jobId', authenticate, adminController.getNPCsCreationStatus);
router.get('/falukant/titles', authenticate, adminController.getTitlesOfNobility);
// --- Minigames Admin --- // --- Minigames Admin ---
router.get('/minigames/match3/campaigns', authenticate, adminController.getMatch3Campaigns); router.get('/minigames/match3/campaigns', authenticate, adminController.getMatch3Campaigns);

View File

@@ -39,6 +39,8 @@ router.get('/directors', falukantController.getAllDirectors);
router.post('/directors', falukantController.updateDirector); router.post('/directors', falukantController.updateDirector);
router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal); router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal);
router.post('/family/set-heir', falukantController.setHeir); router.post('/family/set-heir', falukantController.setHeir);
router.get('/heirs/potential', falukantController.getPotentialHeirs);
router.post('/heirs/select', falukantController.selectHeir);
router.get('/family/gifts', falukantController.getGifts); router.get('/family/gifts', falukantController.getGifts);
router.get('/family/children', falukantController.getChildren); router.get('/family/children', falukantController.getChildren);
router.post('/family/gift', falukantController.sendGift); router.post('/family/gift', falukantController.sendGift);
@@ -53,6 +55,8 @@ router.post('/houses', falukantController.buyUserHouse);
router.get('/party/types', falukantController.getPartyTypes); router.get('/party/types', falukantController.getPartyTypes);
router.post('/party', falukantController.createParty); router.post('/party', falukantController.createParty);
router.get('/party', falukantController.getParties); router.get('/party', falukantController.getParties);
router.get('/reputation/actions', falukantController.getReputationActions);
router.post('/reputation/actions', falukantController.executeReputationAction);
router.get('/family/notbaptised', falukantController.getNotBaptisedChildren); router.get('/family/notbaptised', falukantController.getNotBaptisedChildren);
router.post('/church/baptise', falukantController.baptise); router.post('/church/baptise', falukantController.baptise);
router.get('/education', falukantController.getEducation); router.get('/education', falukantController.getEducation);

View File

@@ -0,0 +1,27 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import VocabController from '../controllers/vocabController.js';
const router = express.Router();
const vocabController = new VocabController();
router.use(authenticate);
router.get('/languages', vocabController.listLanguages);
router.post('/languages', vocabController.createLanguage);
router.post('/subscribe', vocabController.subscribe);
router.get('/languages/:languageId', vocabController.getLanguage);
// Kapitel
router.get('/languages/:languageId/chapters', vocabController.listChapters);
router.post('/languages/:languageId/chapters', vocabController.createChapter);
router.get('/languages/:languageId/vocabs', vocabController.listLanguageVocabs);
router.get('/languages/:languageId/search', vocabController.searchVocabs);
router.get('/chapters/:chapterId', vocabController.getChapter);
router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs);
router.post('/chapters/:chapterId/vocabs', vocabController.addVocabToChapter);
export default router;

View File

@@ -10,7 +10,7 @@ import UserParamType from "../models/type/user_param.js";
import ContactMessage from "../models/service/contactmessage.js"; import ContactMessage from "../models/service/contactmessage.js";
import ContactService from "./ContactService.js"; import ContactService from "./ContactService.js";
import { sendAnswerEmail } from './emailService.js'; import { sendAnswerEmail } from './emailService.js';
import { Op } from 'sequelize'; import { Op, Sequelize } from 'sequelize';
import FalukantUser from "../models/falukant/data/user.js"; import FalukantUser from "../models/falukant/data/user.js";
import FalukantCharacter from "../models/falukant/data/character.js"; import FalukantCharacter from "../models/falukant/data/character.js";
import FalukantPredefineFirstname from "../models/falukant/predefine/firstname.js"; import FalukantPredefineFirstname from "../models/falukant/predefine/firstname.js";
@@ -24,6 +24,10 @@ import BranchType from "../models/falukant/type/branch.js";
import RegionDistance from "../models/falukant/data/region_distance.js"; import RegionDistance from "../models/falukant/data/region_distance.js";
import Room from '../models/chat/room.js'; import Room from '../models/chat/room.js';
import UserParam from '../models/community/user_param.js'; import UserParam from '../models/community/user_param.js';
import TitleOfNobility from "../models/falukant/type/title_of_nobility.js";
import { sequelize } from '../utils/sequelize.js';
import npcCreationJobService from './npcCreationJobService.js';
import { v4 as uuidv4 } from 'uuid';
class AdminService { class AdminService {
async hasUserAccess(userId, section) { async hasUserAccess(userId, section) {
@@ -321,6 +325,17 @@ class AdminService {
return regions; return regions;
} }
async getTitlesOfNobility(userId) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess');
}
const titles = await TitleOfNobility.findAll({
order: [['id', 'ASC']],
attributes: ['id', 'labelTr', 'level']
});
return titles;
}
async updateFalukantRegionMap(userId, regionId, map) { async updateFalukantRegionMap(userId, regionId, map) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) { if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess'); throw new Error('noaccess');
@@ -1085,6 +1100,216 @@ class AdminService {
ageGroups ageGroups
}; };
} }
async createNPCs(userId, options) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess');
}
const {
regionIds, // Array von Region-IDs oder null für alle Städte
minAge, // Mindestalter in Jahren
maxAge, // Maximalalter in Jahren
minTitleId, // Minimale Title-ID
maxTitleId, // Maximale Title-ID
count // Anzahl der zu erstellenden NPCs
} = options;
// Berechne zuerst die Gesamtanzahl, um den Job richtig zu initialisieren
// WICHTIG: Nur Städte (city) verwenden, keine anderen Region-Typen
let targetRegions = [];
if (regionIds && regionIds.length > 0) {
targetRegions = await RegionData.findAll({
where: {
id: { [Op.in]: regionIds }
},
include: [{
model: RegionType,
as: 'regionType',
where: { labelTr: 'city' },
required: true // INNER JOIN - nur Regionen mit city-Type
}]
});
} else {
targetRegions = await RegionData.findAll({
include: [{
model: RegionType,
as: 'regionType',
where: { labelTr: 'city' },
required: true // INNER JOIN - nur Regionen mit city-Type
}]
});
}
// Zusätzliche Sicherheit: Filtere explizit nach city-Type
targetRegions = targetRegions.filter(region => {
return region.regionType && region.regionType.labelTr === 'city';
});
console.log(`[createNPCs] Found ${targetRegions.length} cities (filtered)`);
if (targetRegions.length > 0) {
console.log(`[createNPCs] City names: ${targetRegions.map(r => r.name).join(', ')}`);
}
if (targetRegions.length === 0) {
throw new Error('No cities found');
}
const titles = await TitleOfNobility.findAll({
where: {
id: {
[Op.between]: [minTitleId, maxTitleId]
}
},
order: [['id', 'ASC']]
});
if (titles.length === 0) {
throw new Error('No titles found in specified range');
}
const totalNPCs = targetRegions.length * titles.length * count;
// Erstelle Job-ID
const jobId = uuidv4();
npcCreationJobService.createJob(userId, jobId);
npcCreationJobService.updateProgress(jobId, 0, totalNPCs);
npcCreationJobService.setStatus(jobId, 'running');
// Starte asynchronen Prozess
this._createNPCsAsync(jobId, userId, {
regionIds,
minAge,
maxAge,
minTitleId,
maxTitleId,
count,
targetRegions,
titles
}).catch(error => {
console.error('Error in _createNPCsAsync:', error);
const errorMessage = error?.message || error?.toString() || 'Unknown error occurred';
npcCreationJobService.setError(jobId, errorMessage);
});
return { jobId };
}
async _createNPCsAsync(jobId, userId, options) {
try {
const {
regionIds,
minAge,
maxAge,
minTitleId,
maxTitleId,
count,
targetRegions,
titles
} = options;
const genders = ['male', 'female'];
const createdNPCs = [];
const totalNPCs = targetRegions.length * titles.length * count;
let currentNPC = 0;
console.log(`[NPC Creation Job ${jobId}] Starting creation of ${totalNPCs} NPCs`);
// Erstelle NPCs in einer Transaktion
// Für jede Stadt-Titel-Kombination wird die angegebene Anzahl erstellt
await sequelize.transaction(async (t) => {
for (const region of targetRegions) {
for (const title of titles) {
// Erstelle 'count' NPCs für diese Stadt-Titel-Kombination
for (let i = 0; i < count; i++) {
// Zufälliges Geschlecht
const gender = genders[Math.floor(Math.random() * genders.length)];
// Zufälliger Vorname
const firstName = await FalukantPredefineFirstname.findAll({
where: { gender },
order: sequelize.fn('RANDOM'),
limit: 1,
transaction: t
});
if (firstName.length === 0) {
throw new Error(`No first names found for gender: ${gender}`);
}
const fnObj = firstName[0];
// Zufälliger Nachname
const lastName = await FalukantPredefineLastname.findAll({
order: sequelize.fn('RANDOM'),
limit: 1,
transaction: t
});
if (lastName.length === 0) {
throw new Error('No last names found');
}
const lnObj = lastName[0];
// Zufälliges Alter (in Jahren, wird in Tage umgerechnet)
const randomAge = Math.floor(Math.random() * (maxAge - minAge + 1)) + minAge;
const birthdate = new Date();
birthdate.setDate(birthdate.getDate() - randomAge); // 5 Tage = 5 Jahre alt
// Erstelle den NPC-Charakter (ohne userId = NPC)
const npc = await FalukantCharacter.create({
userId: null, // Wichtig: null = NPC
regionId: region.id,
firstName: fnObj.id,
lastName: lnObj.id,
gender: gender,
birthdate: birthdate,
titleOfNobility: title.id,
health: 100,
moodId: 1
}, { transaction: t });
createdNPCs.push({
id: npc.id,
firstName: fnObj.name,
lastName: lnObj.name,
gender: gender,
age: randomAge,
region: region.name,
title: title.labelTr
});
// Update Progress
currentNPC++;
npcCreationJobService.updateProgress(jobId, currentNPC, totalNPCs);
}
}
}
});
console.log(`[NPC Creation Job ${jobId}] Completed: ${createdNPCs.length} NPCs created`);
// Job abschließen
npcCreationJobService.setResult(jobId, {
success: true,
count: createdNPCs.length,
countPerCombination: count,
totalCombinations: targetRegions.length * titles.length,
npcs: createdNPCs
});
} catch (error) {
console.error(`[NPC Creation Job ${jobId}] Error:`, error);
throw error; // Re-throw für den catch-Block in createNPCs
}
}
getNPCsCreationStatus(userId, jobId) {
const job = npcCreationJobService.getJob(jobId);
if (!job) {
throw new Error('Job not found');
}
if (job.userId !== userId) {
throw new Error('Access denied');
}
return job;
}
} }
export default new AdminService(); export default new AdminService();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
// In-Memory Job-Status-Service für NPC-Erstellung
// Für Produktion sollte man Redis oder eine Datenbank verwenden
const jobs = new Map();
class NPCCreationJobService {
createJob(userId, jobId) {
jobs.set(jobId, {
userId,
status: 'pending',
progress: 0,
total: 0,
current: 0,
startTime: Date.now(),
estimatedTimeRemaining: null,
error: null,
result: null
});
return jobId;
}
getJob(jobId) {
return jobs.get(jobId);
}
updateProgress(jobId, current, total) {
const job = jobs.get(jobId);
if (!job) return;
job.current = current;
job.total = total;
job.progress = total > 0 ? Math.round((current / total) * 100) : 0;
// Berechne verbleibende Zeit basierend auf bisheriger Geschwindigkeit
if (current > 0 && job.progress < 100) {
const elapsed = Date.now() - job.startTime;
const avgTimePerItem = elapsed / current;
const remaining = total - current;
job.estimatedTimeRemaining = Math.round(remaining * avgTimePerItem);
}
}
setStatus(jobId, status) {
const job = jobs.get(jobId);
if (!job) return;
job.status = status;
}
setError(jobId, error) {
const job = jobs.get(jobId);
if (!job) return;
job.status = 'error';
job.error = error;
}
setResult(jobId, result) {
const job = jobs.get(jobId);
if (!job) return;
job.status = 'completed';
job.result = result;
job.progress = 100;
job.estimatedTimeRemaining = 0;
}
deleteJob(jobId) {
jobs.delete(jobId);
}
// Cleanup alte Jobs (älter als 1 Stunde)
cleanupOldJobs() {
const oneHourAgo = Date.now() - (60 * 60 * 1000);
for (const [jobId, job] of jobs.entries()) {
if (job.startTime < oneHourAgo) {
jobs.delete(jobId);
}
}
}
}
// Cleanup alle 10 Minuten
setInterval(() => {
const service = new NPCCreationJobService();
service.cleanupOldJobs();
}, 10 * 60 * 1000);
export default new NPCCreationJobService();

View File

@@ -0,0 +1,532 @@
import crypto from 'crypto';
import User from '../models/community/user.js';
import { sequelize } from '../utils/sequelize.js';
import { notifyUser } from '../utils/socket.js';
export default class VocabService {
async _getUserByHashedId(hashedUserId) {
const user = await User.findOne({ where: { hashedId: hashedUserId } });
if (!user) {
const err = new Error('User not found');
err.status = 404;
throw err;
}
return user;
}
_normalizeLexeme(text) {
return String(text || '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
async _getLanguageAccess(userId, languageId) {
const id = Number.parseInt(languageId, 10);
if (!Number.isFinite(id)) {
const err = new Error('Invalid language id');
err.status = 400;
throw err;
}
const [row] = await sequelize.query(
`
SELECT
l.id,
(l.owner_user_id = :userId) AS "isOwner"
FROM community.vocab_language l
WHERE l.id = :languageId
AND (
l.owner_user_id = :userId
OR EXISTS (
SELECT 1
FROM community.vocab_language_subscription s
WHERE s.user_id = :userId AND s.language_id = l.id
)
)
LIMIT 1
`,
{
replacements: { userId, languageId: id },
type: sequelize.QueryTypes.SELECT,
}
);
if (!row) {
const err = new Error('Language not found or no access');
err.status = 404;
throw err;
}
return row;
}
async _getChapterAccess(userId, chapterId) {
const id = Number.parseInt(chapterId, 10);
if (!Number.isFinite(id)) {
const err = new Error('Invalid chapter id');
err.status = 400;
throw err;
}
const [row] = await sequelize.query(
`
SELECT
c.id,
c.language_id AS "languageId",
c.title,
(l.owner_user_id = :userId) AS "isOwner"
FROM community.vocab_chapter c
JOIN community.vocab_language l ON l.id = c.language_id
WHERE c.id = :chapterId
AND (
l.owner_user_id = :userId
OR EXISTS (
SELECT 1
FROM community.vocab_language_subscription s
WHERE s.user_id = :userId AND s.language_id = l.id
)
)
LIMIT 1
`,
{
replacements: { userId, chapterId: id },
type: sequelize.QueryTypes.SELECT,
}
);
if (!row) {
const err = new Error('Chapter not found or no access');
err.status = 404;
throw err;
}
return row;
}
async listLanguages(hashedUserId) {
const user = await this._getUserByHashedId(hashedUserId);
const rows = await sequelize.query(
`
SELECT
l.id,
l.name,
l.share_code AS "shareCode",
TRUE AS "isOwner"
FROM community.vocab_language l
WHERE l.owner_user_id = :userId
UNION ALL
SELECT
l.id,
l.name,
NULL::text AS "shareCode",
FALSE AS "isOwner"
FROM community.vocab_language_subscription s
JOIN community.vocab_language l ON l.id = s.language_id
WHERE s.user_id = :userId
ORDER BY name ASC
`,
{
replacements: { userId: user.id },
type: sequelize.QueryTypes.SELECT,
}
);
return { languages: rows };
}
async listLanguagesForMenu(userId) {
// userId ist die numerische community.user.id
const rows = await sequelize.query(
`
SELECT l.id, l.name
FROM community.vocab_language l
WHERE l.owner_user_id = :userId
UNION
SELECT l.id, l.name
FROM community.vocab_language_subscription s
JOIN community.vocab_language l ON l.id = s.language_id
WHERE s.user_id = :userId
ORDER BY name ASC
`,
{
replacements: { userId },
type: sequelize.QueryTypes.SELECT,
}
);
return rows;
}
async createLanguage(hashedUserId, { name }) {
const user = await this._getUserByHashedId(hashedUserId);
const cleanName = typeof name === 'string' ? name.trim() : '';
if (!cleanName || cleanName.length < 2 || cleanName.length > 60) {
const err = new Error('Invalid language name');
err.status = 400;
throw err;
}
// 16 hex chars => ausreichend kurz, gut teilbar
const shareCode = crypto.randomBytes(8).toString('hex');
const [created] = await sequelize.query(
`
INSERT INTO community.vocab_language (owner_user_id, name, share_code)
VALUES (:ownerUserId, :name, :shareCode)
RETURNING id, name, share_code AS "shareCode"
`,
{
replacements: { ownerUserId: user.id, name: cleanName, shareCode },
type: sequelize.QueryTypes.SELECT,
}
);
// Menü dynamisch nachladen (bei allen offenen Tabs/Clients)
try {
notifyUser(user.hashedId, 'reloadmenu', {});
} catch (_) {}
return created;
}
async subscribeByShareCode(hashedUserId, { shareCode }) {
const user = await this._getUserByHashedId(hashedUserId);
const code = typeof shareCode === 'string' ? shareCode.trim() : '';
if (!code || code.length < 6 || code.length > 128) {
const err = new Error('Invalid share code');
err.status = 400;
throw err;
}
const [lang] = await sequelize.query(
`
SELECT id, owner_user_id AS "ownerUserId", name
FROM community.vocab_language
WHERE share_code = :shareCode
LIMIT 1
`,
{
replacements: { shareCode: code },
type: sequelize.QueryTypes.SELECT,
}
);
if (!lang) {
const err = new Error('Language not found');
err.status = 404;
throw err;
}
// Owner braucht kein Abo
if (lang.ownerUserId === user.id) {
return { subscribed: false, message: 'Already owner', languageId: lang.id };
}
await sequelize.query(
`
INSERT INTO community.vocab_language_subscription (user_id, language_id)
VALUES (:userId, :languageId)
ON CONFLICT (user_id, language_id) DO NOTHING
`,
{
replacements: { userId: user.id, languageId: lang.id },
type: sequelize.QueryTypes.INSERT,
}
);
try {
notifyUser(user.hashedId, 'reloadmenu', {});
} catch (_) {}
return { subscribed: true, languageId: lang.id, name: lang.name };
}
async getLanguage(hashedUserId, languageId) {
const user = await this._getUserByHashedId(hashedUserId);
const id = Number.parseInt(languageId, 10);
if (!Number.isFinite(id)) {
const err = new Error('Invalid language id');
err.status = 400;
throw err;
}
const [row] = await sequelize.query(
`
SELECT
l.id,
l.name,
CASE WHEN l.owner_user_id = :userId THEN l.share_code ELSE NULL END AS "shareCode",
(l.owner_user_id = :userId) AS "isOwner"
FROM community.vocab_language l
WHERE l.id = :languageId
AND (
l.owner_user_id = :userId
OR EXISTS (
SELECT 1
FROM community.vocab_language_subscription s
WHERE s.user_id = :userId AND s.language_id = l.id
)
)
LIMIT 1
`,
{
replacements: { userId: user.id, languageId: id },
type: sequelize.QueryTypes.SELECT,
}
);
if (!row) {
const err = new Error('Language not found or no access');
err.status = 404;
throw err;
}
return row;
}
async listChapters(hashedUserId, languageId) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
const rows = await sequelize.query(
`
SELECT
c.id,
c.title,
c.created_at AS "createdAt",
(
SELECT COUNT(*)
FROM community.vocab_chapter_lexeme cl
WHERE cl.chapter_id = c.id
)::int AS "vocabCount"
FROM community.vocab_chapter c
WHERE c.language_id = :languageId
ORDER BY c.title ASC
`,
{
replacements: { languageId: access.id },
type: sequelize.QueryTypes.SELECT,
}
);
return { chapters: rows, isOwner: access.isOwner };
}
async createChapter(hashedUserId, languageId, { title }) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
if (!access.isOwner) {
const err = new Error('Only owner can create chapters');
err.status = 403;
throw err;
}
const cleanTitle = typeof title === 'string' ? title.trim() : '';
if (!cleanTitle || cleanTitle.length < 2 || cleanTitle.length > 80) {
const err = new Error('Invalid chapter title');
err.status = 400;
throw err;
}
const [created] = await sequelize.query(
`
INSERT INTO community.vocab_chapter (language_id, title, created_by_user_id)
VALUES (:languageId, :title, :userId)
RETURNING id, title, created_at AS "createdAt"
`,
{
replacements: { languageId: access.id, title: cleanTitle, userId: user.id },
type: sequelize.QueryTypes.SELECT,
}
);
return created;
}
async getChapter(hashedUserId, chapterId) {
const user = await this._getUserByHashedId(hashedUserId);
const ch = await this._getChapterAccess(user.id, chapterId);
return { id: ch.id, languageId: ch.languageId, title: ch.title, isOwner: ch.isOwner };
}
async listChapterVocabs(hashedUserId, chapterId) {
const user = await this._getUserByHashedId(hashedUserId);
const ch = await this._getChapterAccess(user.id, chapterId);
const rows = await sequelize.query(
`
SELECT
cl.id,
l1.text AS "learning",
l2.text AS "reference",
cl.created_at AS "createdAt"
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE cl.chapter_id = :chapterId
ORDER BY l1.text ASC, l2.text ASC
`,
{
replacements: { chapterId: ch.id },
type: sequelize.QueryTypes.SELECT,
}
);
return { chapter: { id: ch.id, title: ch.title, languageId: ch.languageId, isOwner: ch.isOwner }, vocabs: rows };
}
async listLanguageVocabs(hashedUserId, languageId) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
const rows = await sequelize.query(
`
SELECT
cl.id,
c.id AS "chapterId",
c.title AS "chapterTitle",
l1.text AS "learning",
l2.text AS "reference",
cl.created_at AS "createdAt"
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE c.language_id = :languageId
ORDER BY c.title ASC, l1.text ASC, l2.text ASC
`,
{
replacements: { languageId: access.id },
type: sequelize.QueryTypes.SELECT,
}
);
return { languageId: access.id, isOwner: access.isOwner, vocabs: rows };
}
async searchVocabs(hashedUserId, languageId, { q = '', learning = '', motherTongue = '' } = {}) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
const query = typeof q === 'string' ? q.trim() : '';
// Abwärtskompatibel: falls alte Parameter genutzt werden, zusammenfassen
const learningTerm = typeof learning === 'string' ? learning.trim() : '';
const motherTerm = typeof motherTongue === 'string' ? motherTongue.trim() : '';
const effective = query || learningTerm || motherTerm;
if (!effective) {
const err = new Error('Missing search term');
err.status = 400;
throw err;
}
const like = `%${effective}%`;
const rows = await sequelize.query(
`
SELECT
cl.id,
c.id AS "chapterId",
c.title AS "chapterTitle",
l1.text AS "learning",
l2.text AS "motherTongue"
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE c.language_id = :languageId
AND (l1.text ILIKE :like OR l2.text ILIKE :like)
ORDER BY l2.text ASC, l1.text ASC, c.title ASC
LIMIT 200
`,
{
replacements: {
languageId: access.id,
like,
},
type: sequelize.QueryTypes.SELECT,
}
);
return { languageId: access.id, results: rows };
}
async addVocabToChapter(hashedUserId, chapterId, { learning, reference }) {
const user = await this._getUserByHashedId(hashedUserId);
const ch = await this._getChapterAccess(user.id, chapterId);
if (!ch.isOwner) {
const err = new Error('Only owner can add vocab');
err.status = 403;
throw err;
}
const learningText = typeof learning === 'string' ? learning.trim() : '';
const referenceText = typeof reference === 'string' ? reference.trim() : '';
if (!learningText || !referenceText) {
const err = new Error('Invalid vocab');
err.status = 400;
throw err;
}
const learningNorm = this._normalizeLexeme(learningText);
const referenceNorm = this._normalizeLexeme(referenceText);
// Transaktion: Lexeme upserten + Zuordnung setzen
return await sequelize.transaction(async (t) => {
const [learningLex] = await sequelize.query(
`
INSERT INTO community.vocab_lexeme (language_id, text, normalized, created_by_user_id)
VALUES (:languageId, :text, :normalized, :userId)
ON CONFLICT (language_id, normalized) DO UPDATE SET text = EXCLUDED.text
RETURNING id
`,
{
replacements: { languageId: ch.languageId, text: learningText, normalized: learningNorm, userId: user.id },
type: sequelize.QueryTypes.SELECT,
transaction: t,
}
);
const [referenceLex] = await sequelize.query(
`
INSERT INTO community.vocab_lexeme (language_id, text, normalized, created_by_user_id)
VALUES (:languageId, :text, :normalized, :userId)
ON CONFLICT (language_id, normalized) DO UPDATE SET text = EXCLUDED.text
RETURNING id
`,
{
replacements: { languageId: ch.languageId, text: referenceText, normalized: referenceNorm, userId: user.id },
type: sequelize.QueryTypes.SELECT,
transaction: t,
}
);
const [mapping] = await sequelize.query(
`
INSERT INTO community.vocab_chapter_lexeme (chapter_id, learning_lexeme_id, reference_lexeme_id, created_by_user_id)
VALUES (:chapterId, :learningId, :referenceId, :userId)
ON CONFLICT (chapter_id, learning_lexeme_id, reference_lexeme_id) DO NOTHING
RETURNING id
`,
{
replacements: {
chapterId: ch.id,
learningId: learningLex.id,
referenceId: referenceLex.id,
userId: user.id,
},
type: sequelize.QueryTypes.SELECT,
transaction: t,
}
);
return { created: Boolean(mapping?.id) };
});
}
}

View File

@@ -12,6 +12,7 @@ import TitleOfNobility from "../../models/falukant/type/title_of_nobility.js";
import PartyType from "../../models/falukant/type/party.js"; import PartyType from "../../models/falukant/type/party.js";
import MusicType from "../../models/falukant/type/music.js"; import MusicType from "../../models/falukant/type/music.js";
import BanquetteType from "../../models/falukant/type/banquette.js"; import BanquetteType from "../../models/falukant/type/banquette.js";
import ReputationActionType from "../../models/falukant/type/reputation_action.js";
import VehicleType from "../../models/falukant/type/vehicle.js"; import VehicleType from "../../models/falukant/type/vehicle.js";
import LearnRecipient from "../../models/falukant/type/learn_recipient.js"; import LearnRecipient from "../../models/falukant/type/learn_recipient.js";
import PoliticalOfficeType from "../../models/falukant/type/political_office_type.js"; import PoliticalOfficeType from "../../models/falukant/type/political_office_type.js";
@@ -41,6 +42,7 @@ export const initializeFalukantTypes = async () => {
await initializeFalukantPartyTypes(); await initializeFalukantPartyTypes();
await initializeFalukantMusicTypes(); await initializeFalukantMusicTypes();
await initializeFalukantBanquetteTypes(); await initializeFalukantBanquetteTypes();
await initializeFalukantReputationActions();
await initializeLearnerTypes(); await initializeLearnerTypes();
await initializePoliticalOfficeBenefitTypes(); await initializePoliticalOfficeBenefitTypes();
await initializePoliticalOfficeTypes(); await initializePoliticalOfficeTypes();
@@ -52,6 +54,55 @@ export const initializeFalukantTypes = async () => {
await initializeFalukantProductWeatherEffects(); await initializeFalukantProductWeatherEffects();
}; };
const reputationActions = [
// Günstig / häufig: schnelle Abnutzung
{ tr: "soup_kitchen", cost: 500, baseGain: 2, decayFactor: 0.85, minGain: 0, decayWindowDays: 7 },
// Mittel: gesellschaftlich anerkannt
{ tr: "library_donation", cost: 5000, baseGain: 4, decayFactor: 0.88, minGain: 0, decayWindowDays: 7 },
{ tr: "scholarships", cost: 10000, baseGain: 5, decayFactor: 0.87, minGain: 0, decayWindowDays: 7 },
{ tr: "church_hospice", cost: 12000, baseGain: 5, decayFactor: 0.87, minGain: 0, decayWindowDays: 7 },
{ tr: "school_funding", cost: 15000, baseGain: 6, decayFactor: 0.88, minGain: 0, decayWindowDays: 7 },
// Großprojekte: teurer, langsamerer Rufverfall
{ tr: "orphanage_build", cost: 20000, baseGain: 7, decayFactor: 0.90, minGain: 0, decayWindowDays: 7 },
{ tr: "bridge_build", cost: 25000, baseGain: 7, decayFactor: 0.90, minGain: 0, decayWindowDays: 7 },
{ tr: "hospital_donation", cost: 30000, baseGain: 8, decayFactor: 0.90, minGain: 0, decayWindowDays: 7 },
{ tr: "patronage", cost: 40000, baseGain: 9, decayFactor: 0.91, minGain: 0, decayWindowDays: 7 },
{ tr: "statue_build", cost: 50000, baseGain: 10, decayFactor: 0.92, minGain: 0, decayWindowDays: 7 },
{ tr: "well_build", cost: 8000, baseGain: 4, decayFactor: 0.87, minGain: 0, decayWindowDays: 7 },
];
async function initializeFalukantReputationActions() {
// robustes Upsert ohne FK/Constraints-Ärger:
// - finde existierende nach tr
// - update bei Änderungen
// - create wenn fehlt
const existing = await ReputationActionType.findAll({ attributes: ['id', 'tr', 'cost', 'baseGain', 'decayFactor', 'minGain'] });
const existingByTr = new Map(existing.map(e => [e.tr, e]));
for (const a of reputationActions) {
const found = existingByTr.get(a.tr);
if (!found) {
await ReputationActionType.create(a);
continue;
}
const needsUpdate =
Number(found.cost) !== Number(a.cost) ||
Number(found.baseGain) !== Number(a.baseGain) ||
Number(found.decayFactor) !== Number(a.decayFactor) ||
Number(found.minGain) !== Number(a.minGain) ||
Number(found.decayWindowDays || 7) !== Number(a.decayWindowDays || 7);
if (needsUpdate) {
await found.update({
cost: a.cost,
baseGain: a.baseGain,
decayFactor: a.decayFactor,
minGain: a.minGain,
decayWindowDays: a.decayWindowDays ?? 7,
});
}
}
}
const regionTypes = []; const regionTypes = [];
const regionTypeTrs = [ const regionTypeTrs = [
"country", "country",

View File

@@ -3,6 +3,25 @@ import dotenv from 'dotenv';
dotenv.config(); dotenv.config();
// Optionales Performance-Logging (aktivierbar per ENV)
// - SQL_BENCHMARK=1: Sequelize liefert Query-Timing (ms) an logger
// - SQL_SLOW_MS=200: Logge nur Queries ab dieser Dauer (wenn SQL_LOG_ALL nicht gesetzt)
// - SQL_LOG_ALL=1: Logge alle Queries (auch ohne benchmark)
const SQL_BENCHMARK = process.env.SQL_BENCHMARK === '1';
const SQL_LOG_ALL = process.env.SQL_LOG_ALL === '1';
const SQL_SLOW_MS = Number.parseInt(process.env.SQL_SLOW_MS || '200', 10);
const sqlLogger = (sql, timing) => {
// Sequelize ruft logging(sql) oder logging(sql, timing) abhängig von benchmark auf.
if (!SQL_BENCHMARK) {
if (SQL_LOG_ALL) console.log(sql);
return;
}
const ms = typeof timing === 'number' ? timing : 0;
if (SQL_LOG_ALL || ms >= SQL_SLOW_MS) {
console.log(`🛢️ SQL ${ms}ms: ${sql}`);
}
};
// Validiere Umgebungsvariablen // Validiere Umgebungsvariablen
const dbName = process.env.DB_NAME; const dbName = process.env.DB_NAME;
const dbUser = process.env.DB_USER; const dbUser = process.env.DB_USER;
@@ -26,6 +45,8 @@ const sequelize = new Sequelize(dbName, dbUser, dbPass, {
timestamps: false, timestamps: false,
underscored: true // WICHTIG: Alle Datenbankfelder im snake_case Format underscored: true // WICHTIG: Alle Datenbankfelder im snake_case Format
}, },
benchmark: SQL_BENCHMARK,
logging: sqlLogger,
}); });
const createSchemas = async () => { const createSchemas = async () => {
@@ -380,7 +401,7 @@ const updateSchema = async (models) => {
console.log('✅ Datenbankschema aktualisiert'); console.log('✅ Datenbankschema aktualisiert');
}; };
async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, changedBy = null) { async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, changedBy = null, transaction = null) {
try { try {
const result = await sequelize.query( const result = await sequelize.query(
`SELECT falukant_data.update_money( `SELECT falukant_data.update_money(
@@ -397,6 +418,7 @@ async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, ch
changedBy, changedBy,
}, },
type: sequelize.QueryTypes.SELECT, type: sequelize.QueryTypes.SELECT,
transaction: transaction || undefined,
} }
); );
return { return {
@@ -475,12 +497,249 @@ const syncModelsAlways = async (models) => {
} }
} }
// constraints: false wird von Sequelize ignoriert wenn Associations vorhanden sind
// Wir müssen die Associations temporär entfernen, um Foreign Keys zu verhindern
const originalAssociations = model.associations ? { ...model.associations } : {};
const associationKeys = Object.keys(originalAssociations);
try { try {
// constraints: false verhindert, dass Sequelize Foreign Keys automatisch erstellt // Entferne temporär alle Associations, damit Sequelize keine Foreign Keys erstellt
// Foreign Keys sollten nur über Migrations verwaltet werden // Dies muss innerhalb des try Blocks sein, damit die Wiederherstellung im finally Block garantiert ist
if (associationKeys.length > 0) {
console.log(` ⚠️ Temporarily removing ${associationKeys.length} associations from ${model.name} to prevent FK creation`);
// Lösche alle Associations temporär
for (const key of associationKeys) {
delete model.associations[key];
}
}
// Entferne bestehende Foreign Keys vor dem Sync, damit Sequelize sie nicht aktualisiert
try {
const tableName = model.tableName;
// Schema kann eine Funktion sein, daher prüfen wir model.options.schema direkt
const schema = model.options?.schema || 'public';
console.log(` 🔍 Checking for foreign keys in ${schema}.${tableName}...`);
const foreignKeys = await sequelize.query(`
SELECT tc.constraint_name
FROM information_schema.table_constraints AS tc
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = :tableName
AND tc.table_schema = :schema
`, {
replacements: { tableName, schema },
type: sequelize.QueryTypes.SELECT
});
if (foreignKeys && foreignKeys.length > 0) {
console.log(` ⚠️ Found ${foreignKeys.length} existing foreign keys:`, foreignKeys.map(fk => fk.constraint_name).join(', '));
console.log(` ⚠️ Removing ${foreignKeys.length} existing foreign keys from ${model.name} (schema: ${schema}) before sync`);
for (const fk of foreignKeys) {
console.log(` 🗑️ Dropping constraint: ${fk.constraint_name}`);
await sequelize.query(`
ALTER TABLE "${schema}"."${tableName}"
DROP CONSTRAINT IF EXISTS "${fk.constraint_name}" CASCADE
`);
}
console.log(` ✅ All foreign keys removed for ${model.name}`);
} else {
console.log(` ✅ No foreign keys found for ${model.name}`);
}
} catch (fkError) {
console.warn(` ⚠️ Could not remove foreign keys for ${model.name}:`, fkError.message);
console.warn(` ⚠️ Error details:`, fkError);
}
console.log(` 🔄 Syncing model ${model.name} with constraints: false`); console.log(` 🔄 Syncing model ${model.name} with constraints: false`);
await model.sync({ alter: true, force: false, constraints: false }); try {
// Versuche doppelte pg_description Einträge vor dem Sync zu bereinigen
// Hinweis: Benötigt Superuser-Rechte oder spezielle Berechtigungen
try {
const tableName = model.tableName;
const schema = model.options?.schema || 'public';
// Verwende direkte Parameter-Einsetzung, da DO $$ keine Parameterbindung unterstützt
// Die Parameter sind sicher, da sie von Sequelize-Modell-Eigenschaften kommen
await sequelize.query(`
DELETE FROM pg_catalog.pg_description d1
WHERE d1.objoid IN (
SELECT c.oid
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = '${tableName.replace(/'/g, "''")}'
AND n.nspname = '${schema.replace(/'/g, "''")}'
)
AND EXISTS (
SELECT 1
FROM pg_catalog.pg_description d2
WHERE d2.objoid = d1.objoid
AND d2.objsubid = d1.objsubid
AND d2.ctid < d1.ctid
)
`);
} catch (descError) {
// Ignoriere Berechtigungsfehler - das ist normal, wenn der Benutzer keine Superuser-Rechte hat
if (descError.message && descError.message.includes('Berechtigung')) {
console.log(` Cannot clean up duplicate pg_description entries (requires superuser privileges): ${model.name}`);
} else {
console.warn(` ⚠️ Could not clean up duplicate pg_description entries for ${model.name}:`, descError.message);
}
}
await model.sync({ alter: true, force: false, constraints: false });
} catch (syncError) {
// Wenn Sequelize einen "mehr als eine Zeile" Fehler hat, überspringe das Model
// Dies kann durch doppelte pg_description Einträge oder mehrere Tabellen mit demselben Namen verursacht werden
if (syncError.message && (syncError.message.includes('mehr als eine Zeile') || syncError.message.includes('more than one row'))) {
const tableName = model.tableName;
const schema = model.options?.schema || 'public';
console.error(` ❌ Cannot sync ${model.name} (${schema}.${tableName}) due to Sequelize describeTable error`);
console.error(` ❌ This is likely caused by multiple tables with the same name in different schemas`);
console.error(` ❌ or duplicate pg_description entries (requires superuser to fix)`);
console.error(` ⚠️ Skipping sync for ${model.name} - Schema is likely already correct`);
// Überspringe dieses Model und fahre mit dem nächsten fort
continue;
}
// Wenn eine referenzierte Tabelle noch nicht existiert, erstelle die Tabelle ohne Foreign Key
else if (syncError.message && (syncError.message.includes('existiert nicht') || syncError.message.includes('does not exist') || syncError.message.includes('Relation'))) {
const tableName = model.tableName;
const schema = model.options?.schema || 'public';
console.warn(` ⚠️ Cannot create ${model.name} (${schema}.${tableName}) with Foreign Key - referenced table does not exist yet`);
console.warn(` ⚠️ Attempting to create table without Foreign Key constraint...`);
try {
// Prüfe, ob die Tabelle bereits existiert
const [tableExists] = await sequelize.query(`
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = :schema
AND table_name = :tableName
) as exists
`, {
replacements: { schema, tableName },
type: sequelize.QueryTypes.SELECT
});
if (tableExists && tableExists.exists) {
console.log(` Table ${schema}.${tableName} already exists, skipping creation`);
continue;
}
// Erstelle die Tabelle manuell ohne Foreign Key
// Verwende queryInterface.createTable mit den Attributen, aber ohne Foreign Keys
const queryInterface = sequelize.getQueryInterface();
const attributes = {};
// Kopiere alle Attribute aus dem Model, aber entferne references
for (const [key, attr] of Object.entries(model.rawAttributes)) {
attributes[key] = { ...attr };
// Entferne references, damit kein Foreign Key erstellt wird
if (attributes[key].references) {
delete attributes[key].references;
}
}
// Erstelle die Tabelle mit queryInterface.createTable ohne Foreign Keys
await queryInterface.createTable(tableName, attributes, {
schema,
// Stelle sicher, dass keine Foreign Keys erstellt werden
charset: model.options?.charset,
collate: model.options?.collate
});
console.log(` ✅ Table ${schema}.${tableName} created successfully without Foreign Key`);
} catch (createError) {
console.error(` ❌ Failed to create table ${schema}.${tableName} without Foreign Key:`, createError.message);
console.error(` ⚠️ Skipping ${model.name} - will retry after dependencies are created`);
// Überspringe dieses Model und fahre mit dem nächsten fort
continue;
}
}
// Wenn Sequelize einen Foreign Key Constraint Fehler hat, entferne verwaiste Einträge oder überspringe das Model
else if (syncError.name === 'SequelizeForeignKeyConstraintError' || (syncError.message && (syncError.message.includes('FOREIGN KEY') || syncError.message.includes('Fremdschlüssel')))) {
const tableName = model.tableName;
const schema = model.options?.schema || 'public';
console.error(` ❌ Cannot sync ${model.name} (${schema}.${tableName}) due to Foreign Key Constraint Error`);
console.error(` ❌ Detail: ${syncError.parent?.detail || syncError.message}`);
console.error(` ⚠️ This usually means there are orphaned records. Cleanup should have removed them.`);
console.error(` ⚠️ Skipping sync for ${model.name} - please check and fix orphaned records manually`);
// Überspringe dieses Model und fahre mit dem nächsten fort
continue;
}
// Wenn Sequelize versucht, Foreign Keys zu erstellen, entferne sie nach dem Fehler
else if (syncError.message && syncError.message.includes('REFERENCES')) {
console.log(` ⚠️ Sequelize tried to create FK despite constraints: false, removing any created FKs...`);
try {
const tableName = model.tableName;
const schema = model.options?.schema || 'public';
const foreignKeys = await sequelize.query(`
SELECT tc.constraint_name
FROM information_schema.table_constraints AS tc
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = :tableName
AND tc.table_schema = :schema
`, {
replacements: { tableName, schema },
type: sequelize.QueryTypes.SELECT
});
if (foreignKeys && foreignKeys.length > 0) {
for (const fk of foreignKeys) {
await sequelize.query(`
ALTER TABLE "${schema}"."${tableName}"
DROP CONSTRAINT IF EXISTS "${fk.constraint_name}" CASCADE
`);
}
}
// Versuche Sync erneut ohne Foreign Keys
console.log(` 🔄 Retrying sync without foreign keys...`);
await model.sync({ alter: true, force: false, constraints: false });
} catch (retryError) {
console.error(` ❌ Retry failed:`, retryError.message);
console.error(` ❌ Original sync error:`, syncError.message);
// Kombiniere beide Fehler für besseres Debugging
const combinedError = new Error(`Sync failed: ${syncError.message}. Retry also failed: ${retryError.message}`);
combinedError.originalError = syncError;
combinedError.retryError = retryError;
throw combinedError;
}
} else {
throw syncError;
}
}
// Entferne alle Foreign Keys, die Sequelize möglicherweise trotzdem erstellt hat
try {
const tableName = model.tableName;
const schema = model.options?.schema || 'public';
const foreignKeys = await sequelize.query(`
SELECT tc.constraint_name
FROM information_schema.table_constraints AS tc
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = :tableName
AND tc.table_schema = :schema
`, {
replacements: { tableName, schema },
type: sequelize.QueryTypes.SELECT
});
if (foreignKeys && foreignKeys.length > 0) {
console.log(` ⚠️ Sequelize created ${foreignKeys.length} foreign keys despite constraints: false, removing them...`);
for (const fk of foreignKeys) {
await sequelize.query(`
ALTER TABLE "${schema}"."${tableName}"
DROP CONSTRAINT IF EXISTS "${fk.constraint_name}" CASCADE
`);
}
}
} catch (fkError) {
console.warn(` ⚠️ Could not check/remove foreign keys after sync:`, fkError.message);
}
} finally { } finally {
// Stelle die Associations wieder her (IMMER, auch bei Fehlern)
if (associationKeys.length > 0) {
console.log(` ✅ Restoring ${associationKeys.length} associations for ${model.name}`);
model.associations = originalAssociations;
}
// Restore VIRTUAL fields after sync // Restore VIRTUAL fields after sync
for (const [key, attr] of Object.entries(virtualFields)) { for (const [key, attr] of Object.entries(virtualFields)) {
model.rawAttributes[key] = attr; model.rawAttributes[key] = attr;

View File

@@ -33,6 +33,123 @@ const syncDatabase = async () => {
console.log("Initializing database schemas..."); console.log("Initializing database schemas...");
await initializeDatabase(); await initializeDatabase();
// Vokabeltrainer: Tabellen sicherstellen (auch ohne manuell ausgeführte Migrations)
// Hintergrund: In Produktion sind Schema-Updates deaktiviert, und Migrations werden nicht automatisch ausgeführt.
// Damit API/Menu nicht mit "relation does not exist" (42P01) scheitert, legen wir die Tabellen idempotent an.
console.log("Ensuring Vocab-Trainer tables exist...");
try {
await sequelize.query(`
CREATE TABLE IF NOT EXISTS community.vocab_language (
id SERIAL PRIMARY KEY,
owner_user_id INTEGER NOT NULL,
name TEXT NOT NULL,
share_code TEXT NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_language_owner_fk
FOREIGN KEY (owner_user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_language_share_code_uniq UNIQUE (share_code)
);
CREATE TABLE IF NOT EXISTS community.vocab_language_subscription (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
language_id INTEGER NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_language_subscription_user_fk
FOREIGN KEY (user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_language_subscription_language_fk
FOREIGN KEY (language_id)
REFERENCES community.vocab_language(id)
ON DELETE CASCADE,
CONSTRAINT vocab_language_subscription_uniq UNIQUE (user_id, language_id)
);
CREATE INDEX IF NOT EXISTS vocab_language_owner_idx
ON community.vocab_language(owner_user_id);
CREATE INDEX IF NOT EXISTS vocab_language_subscription_user_idx
ON community.vocab_language_subscription(user_id);
CREATE INDEX IF NOT EXISTS vocab_language_subscription_language_idx
ON community.vocab_language_subscription(language_id);
CREATE TABLE IF NOT EXISTS community.vocab_chapter (
id SERIAL PRIMARY KEY,
language_id INTEGER NOT NULL,
title TEXT NOT NULL,
created_by_user_id INTEGER NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_chapter_language_fk
FOREIGN KEY (language_id)
REFERENCES community.vocab_language(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chapter_creator_fk
FOREIGN KEY (created_by_user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS vocab_chapter_language_idx
ON community.vocab_chapter(language_id);
CREATE TABLE IF NOT EXISTS community.vocab_lexeme (
id SERIAL PRIMARY KEY,
language_id INTEGER NOT NULL,
text TEXT NOT NULL,
normalized TEXT NOT NULL,
created_by_user_id INTEGER NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_lexeme_language_fk
FOREIGN KEY (language_id)
REFERENCES community.vocab_language(id)
ON DELETE CASCADE,
CONSTRAINT vocab_lexeme_creator_fk
FOREIGN KEY (created_by_user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_lexeme_unique_per_language UNIQUE (language_id, normalized)
);
CREATE INDEX IF NOT EXISTS vocab_lexeme_language_idx
ON community.vocab_lexeme(language_id);
CREATE TABLE IF NOT EXISTS community.vocab_chapter_lexeme (
id SERIAL PRIMARY KEY,
chapter_id INTEGER NOT NULL,
learning_lexeme_id INTEGER NOT NULL,
reference_lexeme_id INTEGER NOT NULL,
created_by_user_id INTEGER NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_chlex_chapter_fk
FOREIGN KEY (chapter_id)
REFERENCES community.vocab_chapter(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chlex_learning_fk
FOREIGN KEY (learning_lexeme_id)
REFERENCES community.vocab_lexeme(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chlex_reference_fk
FOREIGN KEY (reference_lexeme_id)
REFERENCES community.vocab_lexeme(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chlex_creator_fk
FOREIGN KEY (created_by_user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chlex_unique UNIQUE (chapter_id, learning_lexeme_id, reference_lexeme_id)
);
CREATE INDEX IF NOT EXISTS vocab_chlex_chapter_idx
ON community.vocab_chapter_lexeme(chapter_id);
CREATE INDEX IF NOT EXISTS vocab_chlex_learning_idx
ON community.vocab_chapter_lexeme(learning_lexeme_id);
CREATE INDEX IF NOT EXISTS vocab_chlex_reference_idx
ON community.vocab_chapter_lexeme(reference_lexeme_id);
`);
console.log("✅ Vocab-Trainer Tabellen sind vorhanden.");
} catch (e) {
console.warn('⚠️ Konnte Vocab-Trainer Tabellen nicht sicherstellen:', e?.message || e);
}
// Vorab: Stelle kritische Spalten sicher, damit Index-Erstellung nicht fehlschlägt // Vorab: Stelle kritische Spalten sicher, damit Index-Erstellung nicht fehlschlägt
console.log("Pre-ensure Taxi columns (traffic_light) ..."); console.log("Pre-ensure Taxi columns (traffic_light) ...");
try { try {
@@ -379,7 +496,43 @@ const syncDatabaseForDeployment = async () => {
console.log(`${deletedCount7} verwaiste child_relation Einträge entfernt`); console.log(`${deletedCount7} verwaiste child_relation Einträge entfernt`);
} }
if (deletedCount1 === 0 && deletedCount2 === 0 && deletedCount3 === 0 && deletedCount4 === 0 && deletedCount5 === 0 && deletedCount6 === 0 && deletedCount7 === 0) { // Cleanup political_office mit ungültigen character_id, office_type_id oder region_id
const result8 = await sequelize.query(`
DELETE FROM falukant_data.political_office
WHERE character_id NOT IN (
SELECT id FROM falukant_data.character
) OR office_type_id NOT IN (
SELECT id FROM falukant_type.political_office_type
) OR region_id NOT IN (
SELECT id FROM falukant_data.region
);
`);
const deletedCount8 = result8[1] || 0;
if (deletedCount8 > 0) {
console.log(`${deletedCount8} verwaiste political_office Einträge entfernt`);
}
// Cleanup vehicle.condition: Legacy-Nulls + Range clamp (UI zeigt sonst "Unbekannt")
const result9 = await sequelize.query(`
UPDATE falukant_data.vehicle
SET condition = 100
WHERE condition IS NULL;
`);
const updatedNullConditions = result9[1] || 0;
if (updatedNullConditions > 0) {
console.log(`${updatedNullConditions} vehicle.condition NULL → 100 gesetzt`);
}
const result10 = await sequelize.query(`
UPDATE falukant_data.vehicle
SET condition = GREATEST(0, LEAST(100, condition))
WHERE condition < 0 OR condition > 100;
`);
const clampedConditions = result10[1] || 0;
if (clampedConditions > 0) {
console.log(`${clampedConditions} vehicle.condition Werte auf 0..100 geklemmt`);
}
if (deletedCount1 === 0 && deletedCount2 === 0 && deletedCount3 === 0 && deletedCount4 === 0 && deletedCount5 === 0 && deletedCount6 === 0 && deletedCount7 === 0 && deletedCount8 === 0 && updatedNullConditions === 0 && clampedConditions === 0) {
console.log("✅ Keine verwaisten Einträge gefunden"); console.log("✅ Keine verwaisten Einträge gefunden");
} }
} catch (e) { } catch (e) {

View File

@@ -0,0 +1,143 @@
# 3D-Animationen im Falukant-Bereich
## Benötigte Dependencies
### Three.js (Empfohlen)
```bash
npm install three
npm install @types/three --save-dev # Für TypeScript-Support
```
**Alternative Optionen:**
- **Babylon.js**: Mächtiger, aber größer (~500KB vs ~600KB)
- **A-Frame**: WebVR-fokussiert, einfacher für VR/AR
- **React Three Fiber**: Falls React verwendet wird (hier Vue)
**Empfehlung: Three.js** - am weitesten verbreitet, beste Dokumentation, große Community
### Optional: Vue-Three.js Wrapper
```bash
npm install vue-threejs # Oder troika-three-text für Text-Rendering
```
## Sinnvolle Seiten für 3D-Animationen
### 1. **OverviewView** (Hauptübersicht)
**Sinnvoll:** ⭐⭐⭐⭐⭐
- **3D-Charakter-Modell**: Rotierendes 3D-Modell des eigenen Charakters
- **Statussymbole**: 3D-Icons für Geld, Gesundheit, Reputation (schwebend/rotierend)
- **Hintergrund**: Subtile 3D-Szene (z.B. mittelalterliche Stadt im Hintergrund)
### 2. **HouseView** (Haus)
**Sinnvoll:** ⭐⭐⭐⭐⭐
- **3D-Haus-Modell**: Interaktives 3D-Modell des eigenen Hauses
- **Upgrade-Visualisierung**: Animation beim Haus-Upgrade
- **Zustand-Anzeige**: 3D-Visualisierung von Dach, Wänden, Boden, Fenstern
### 3. **BranchView** (Niederlassungen)
**Sinnvoll:** ⭐⭐⭐⭐
- **3D-Fabrik/Gebäude**: 3D-Modell der Niederlassung
- **Produktions-Animation**: 3D-Animationen für laufende Produktionen
- **Transport-Visualisierung**: 3D-Wagen/Karren für Transporte
### 4. **FamilyView** (Familie)
**Sinnvoll:** ⭐⭐⭐⭐
- **3D-Charaktere**: 3D-Modelle von Partner und Kindern
- **Beziehungs-Visualisierung**: 3D-Animationen für Beziehungsstatus
- **Geschenk-Animation**: 3D-Animation beim Verschenken
### 5. **HealthView** (Gesundheit)
**Sinnvoll:** ⭐⭐⭐
- **3D-Körper-Modell**: 3D-Visualisierung des Gesundheitszustands
- **Aktivitäts-Animationen**: 3D-Animationen für Gesundheitsaktivitäten
### 6. **NobilityView** (Sozialstatus)
**Sinnvoll:** ⭐⭐⭐
- **3D-Wappen**: Rotierendes 3D-Wappen
- **Insignien**: 3D-Krone, Schwert, etc. je nach Titel
### 7. **ChurchView** (Kirche)
**Sinnvoll:** ⭐⭐⭐
- **3D-Kirche**: 3D-Modell der Kirche
- **Taufe-Animation**: 3D-Animation bei der Taufe
### 8. **BankView** (Bank)
**Sinnvoll:** ⭐⭐
- **3D-Bankgebäude**: 3D-Modell der Bank
- **Geld-Animation**: 3D-Münzen/Geldstapel
### 9. **UndergroundView** (Untergrund)
**Sinnvoll:** ⭐⭐⭐⭐
- **3D-Dungeon**: 3D-Untergrund-Visualisierung
- **Aktivitäts-Animationen**: 3D-Animationen für Untergrund-Aktivitäten
### 10. **ReputationView** (Reputation)
**Sinnvoll:** ⭐⭐⭐
- **3D-Party-Szene**: 3D-Visualisierung von Festen
- **Reputation-Visualisierung**: 3D-Effekte für Reputationsänderungen
## Implementierungs-Strategie
### Phase 1: Basis-Setup
1. Three.js installieren
2. Basis-Komponente `ThreeScene.vue` erstellen
3. Erste einfache Animation (z.B. rotierender Würfel) auf OverviewView
### Phase 2: Charakter-Modell
1. 3D-Charakter-Modell erstellen/laden (GLTF/GLB)
2. Auf OverviewView integrieren
3. Interaktionen (Klick, Hover)
### Phase 3: Gebäude-Modelle
1. Haus-Modell für HouseView
2. Fabrik-Modell für BranchView
3. Kirche-Modell für ChurchView
### Phase 4: Animationen
1. Upgrade-Animationen
2. Status-Änderungs-Animationen
3. Interaktive Elemente
## Technische Überlegungen
### Performance
- **Lazy Loading**: 3D-Szenen nur laden, wenn Seite aktiv ist
- **Level of Detail (LOD)**: Einfache Modelle für schwächere Geräte
- **WebGL-Detection**: Fallback auf 2D, wenn WebGL nicht unterstützt wird
### Asset-Management
- **GLTF/GLB**: Kompaktes Format für 3D-Modelle
- **Texturen**: Optimiert für Web (WebP, komprimiert)
- **CDN**: Assets über CDN laden für bessere Performance
### Browser-Kompatibilität
- **WebGL 1.0**: Mindestanforderung (95%+ Browser)
- **WebGL 2.0**: Optional für bessere Features
- **Fallback**: 2D-Versionen für ältere Browser
## Beispiel-Struktur
```
frontend/src/
components/
falukant/
ThreeScene.vue # Basis-3D-Szene-Komponente
CharacterModel.vue # 3D-Charakter-Komponente
BuildingModel.vue # 3D-Gebäude-Komponente
assets/
3d/
models/
character.glb
house.glb
factory.glb
textures/
...
```
## Nächste Schritte
1. **Three.js installieren**
2. **Basis-Komponente erstellen**
3. **Erste Animation auf OverviewView testen**
4. **3D-Modelle erstellen/beschaffen** (Blender, Sketchfab, etc.)
5. **Schrittweise auf weitere Seiten ausweiten**

171
docs/3D_ASSETS_STRUCTURE.md Normal file
View File

@@ -0,0 +1,171 @@
# 3D-Assets Struktur für Falukant
## Verzeichnisstruktur
```
frontend/public/
models/
3d/
falukant/
characters/
male.glb # Basis-Modell männlich
female.glb # Basis-Modell weiblich
male_child.glb # Männlich, Kind (0-9 Jahre)
male_teen.glb # Männlich, Teenager (10-17 Jahre)
male_adult.glb # Männlich, Erwachsen (18-39 Jahre)
male_middle.glb # Männlich, Mittelalter (40-59 Jahre)
male_elder.glb # Männlich, Älter (60+ Jahre)
female_child.glb # Weiblich, Kind
female_teen.glb # Weiblich, Teenager
female_adult.glb # Weiblich, Erwachsen
female_middle.glb # Weiblich, Mittelalter
female_elder.glb # Weiblich, Älter
buildings/
house/
house_small.glb # Kleines Haus
house_medium.glb # Mittleres Haus
house_large.glb # Großes Haus
factory/
factory_basic.glb # Basis-Fabrik
factory_advanced.glb # Erweiterte Fabrik
church/
church.glb # Kirche
bank/
bank.glb # Bank
objects/
weapons/
sword.glb
shield.glb
items/
coin.glb
gift.glb
effects/
particles/
money.glb # Geld-Effekt
health.glb # Gesundheits-Effekt
```
## Namenskonventionen
### Charaktere
- Format: `{gender}[_{ageRange}].glb`
- Beispiele:
- `male.glb` - Basis-Modell männlich (Fallback)
- `female.glb` - Basis-Modell weiblich (Fallback)
- `male_adult.glb` - Männlich, Erwachsen
- `female_teen.glb` - Weiblich, Teenager
### Gebäude
- Format: `{buildingType}_{variant}.glb`
- Beispiele:
- `house_small.glb`
- `factory_basic.glb`
- `church.glb`
### Objekte
- Format: `{category}/{item}.glb`
- Beispiele:
- `weapons/sword.glb`
- `items/coin.glb`
## Altersbereiche
Die Altersbereiche werden automatisch bestimmt:
```javascript
// In CharacterModel3D.vue
getAgeRange(age) {
if (age < 10) return 'child';
if (age < 18) return 'teen';
if (age < 40) return 'adult';
if (age < 60) return 'middle';
return 'elder';
}
```
**Fallback-Verhalten:**
- Wenn kein spezifisches Modell für den Altersbereich existiert, wird das Basis-Modell (`male.glb` / `female.glb`) verwendet
- Dies ermöglicht schrittweise Erweiterung ohne Breaking Changes
## Dateigrößen-Empfehlungen
- **Charaktere**: 100KB - 500KB (komprimiert)
- **Gebäude**: 200KB - 1MB (komprimiert)
- **Objekte**: 10KB - 100KB (komprimiert)
## Optimierung
### Vor dem Hochladen:
1. **Blender** öffnen
2. **Decimate Modifier** anwenden (falls nötig)
3. **Texturen komprimieren** (WebP, max 1024x1024)
4. **GLB Export** mit:
- Compression aktiviert
- Texturen eingebettet
- Unnötige Animationen entfernt
### Komprimierung:
- Verwende `gltf-pipeline` oder `gltf-transform` für weitere Komprimierung
- Ziel: < 500KB pro Modell
## Verwendung im Code
```vue
<!-- CharacterModel3D.vue -->
<CharacterModel3D
:gender="character.gender"
:age="character.age"
/>
<!-- Automatisch wird geladen: -->
<!-- /models/3d/falukant/characters/male_adult.glb -->
<!-- Falls nicht vorhanden: male.glb -->
```
## Erweiterte Struktur (Optional)
Für komplexere Szenarien:
```
frontend/public/
models/
3d/
falukant/
characters/
{gender}/
base/
{gender}.glb # Basis-Modell
ages/
{gender}_{ageRange}.glb
variants/
{gender}_{variant}.glb # Z.B. verschiedene Outfits
```
## Wartung
### Neue Modelle hinzufügen:
1. GLB-Datei in entsprechendes Verzeichnis kopieren
2. Namenskonvention beachten
3. Dateigröße prüfen (< 500KB empfohlen)
4. Im Browser testen
### Modelle aktualisieren:
1. Alte Datei ersetzen
2. Browser-Cache leeren (oder Versionierung verwenden)
3. Testen
### Versionierung (Optional):
```
characters/
v1/
male.glb
v2/
male.glb
```
## Performance-Tipps
1. **Lazy Loading**: Modelle nur laden, wenn benötigt
2. **Preloading**: Wichtige Modelle vorladen
3. **Caching**: Browser-Cache nutzen
4. **CDN**: Für Produktion CDN verwenden

View File

@@ -0,0 +1,159 @@
# 3D-Modell-Erstellung für Falukant
## KI-basierte Tools (Empfohlen)
### 1. **Rodin** ⭐⭐⭐⭐⭐
- **URL**: https://rodin.io/
- **Preis**: Kostenlos (mit Limits), Premium verfügbar
- **Features**:
- Text-zu-3D (z.B. "medieval character", "house")
- Sehr gute Qualität
- Export als GLB/GLTF
- **Gut für**: Charaktere, Gebäude, Objekte
### 2. **Meshy** ⭐⭐⭐⭐⭐
- **URL**: https://www.meshy.ai/
- **Preis**: Kostenlos (mit Limits), ab $9/monat
- **Features**:
- Text-zu-3D
- Bild-zu-3D
- Textur-Generierung
- Export als GLB/OBJ/FBX
- **Gut für**: Alle Arten von Modellen
### 3. **Luma AI Genie** ⭐⭐⭐⭐
- **URL**: https://lumalabs.ai/genie
- **Preis**: Kostenlos (Beta)
- **Features**:
- Text-zu-3D
- Sehr schnell
- Export als GLB
- **Gut für**: Schnelle Prototypen
### 4. **CSM (Common Sense Machines)** ⭐⭐⭐⭐
- **URL**: https://csm.ai/
- **Preis**: Kostenlos (mit Limits)
- **Features**:
- Text-zu-3D
- Bild-zu-3D
- Export als GLB/USD
- **Gut für**: Verschiedene Objekte
### 5. **Tripo AI** ⭐⭐⭐⭐
- **URL**: https://www.tripo3d.ai/
- **Preis**: Kostenlos (mit Limits), Premium verfügbar
- **Features**:
- Text-zu-3D
- Bild-zu-3D
- Export als GLB/FBX/OBJ
- **Gut für**: Charaktere und Objekte
### 6. **Masterpiece Studio** ⭐⭐⭐
- **URL**: https://masterpiecestudio.com/
- **Preis**: Ab $9/monat
- **Features**:
- Text-zu-3D
- VR-Unterstützung
- Export als GLB/FBX
- **Gut für**: Professionelle Modelle
## Traditionelle Tools (Für Nachbearbeitung)
### 1. **Blender** (Kostenlos) ⭐⭐⭐⭐⭐
- **URL**: https://www.blender.org/
- **Features**:
- Vollständige 3D-Suite
- GLB/GLTF Export
- Optimierung von KI-generierten Modellen
- **Gut für**: Nachbearbeitung, Optimierung, Animationen
### 2. **Sketchfab** (Modelle kaufen/laden)
- **URL**: https://sketchfab.com/
- **Preis**: Kostenlos (CC0 Modelle), Premium Modelle kostenpflichtig
- **Features**:
- Millionen von 3D-Modellen
- Viele kostenlose CC0 Modelle
- GLB/GLTF Download
- **Gut für**: Vorgefertigte Modelle, Inspiration
## Empfohlener Workflow
### Für Falukant-Charaktere:
1. **Rodin** oder **Meshy** verwenden
2. Prompt: "medieval character, male/female, simple style, low poly, game ready"
3. Export als GLB
4. In **Blender** optimieren (falls nötig)
5. Texturen anpassen
### Für Gebäude:
1. **Meshy** oder **Tripo AI** verwenden
2. Prompt: "medieval house, simple, low poly, game ready, front view"
3. Export als GLB
4. In **Blender** optimieren
5. Mehrere Varianten erstellen (Haus, Fabrik, Kirche)
### Für Objekte:
1. **Sketchfab** durchsuchen (kostenlose CC0 Modelle)
2. Oder **Meshy** für spezifische Objekte
3. Export als GLB
4. Optimieren falls nötig
## Prompt-Beispiele für Falukant
### Charakter:
```
"medieval character, [male/female], simple low poly style,
game ready, neutral pose, front view, no background,
GLB format, optimized for web"
```
### Haus:
```
"medieval house, simple low poly style, game ready,
front view, no background, GLB format, optimized for web"
```
### Fabrik:
```
"medieval factory building, simple low poly style,
game ready, front view, no background, GLB format"
```
### Wappen:
```
"medieval coat of arms shield, simple low poly style,
game ready, front view, no background, GLB format"
```
## Optimierung für Web
### Nach der Erstellung:
1. **Blender** öffnen
2. **Decimate Modifier** anwenden (weniger Polygone)
3. **Texture** komprimieren (WebP, 512x512 oder 1024x1024)
4. **GLB Export** mit:
- Compression aktiviert
- Texturen eingebettet
- Normals und Tangents berechnet
### Größen-Richtlinien:
- **Charaktere**: 2000-5000 Polygone
- **Gebäude**: 1000-3000 Polygone
- **Objekte**: 100-1000 Polygone
- **Texturen**: 512x512 oder 1024x1024 (nicht größer)
## Kostenlose Alternativen
### Wenn KI-Tools Limits haben:
1. **Sketchfab** durchsuchen (CC0 Modelle)
2. **Poly Haven** (https://polyhaven.com/) - kostenlose Assets
3. **Kenney.nl** - kostenlose Game Assets
4. **OpenGameArt.org** - kostenlose Game Assets
## Nächste Schritte
1. **Rodin** oder **Meshy** testen
2. Ersten Charakter erstellen
3. Als GLB exportieren
4. In Three.js testen
5. Bei Bedarf optimieren

View File

@@ -0,0 +1,334 @@
# Blender Rigging-Anleitung für Falukant-Charaktere
Diese Anleitung erklärt, wie du Bones/Gelenke zu deinen 3D-Modellen in Blender hinzufügst, damit sie animiert werden können.
## Voraussetzungen
- Blender (kostenlos, https://www.blender.org/)
- GLB-Modell von meshy.ai oder anderen Quellen
## Schritt-für-Schritt Anleitung
### 1. Modell in Blender importieren
1. Öffne Blender
2. Gehe zu `File``Import``glTF 2.0 (.glb/.gltf)`
3. Wähle dein Modell aus
4. Das Modell sollte jetzt in der Szene erscheinen
### 2. Modell vorbereiten
1. Stelle sicher, dass das Modell im **Object Mode** ist (Tab drücken, falls im Edit Mode)
2. Wähle das Modell aus (Linksklick)
3. Drücke `Alt + G` um die Position auf (0, 0, 0) zu setzen
4. Drücke `Alt + R` um die Rotation zurückzusetzen
5. Drücke `Alt + S` um die Skalierung auf 1 zu setzen
### 3. Rigging (Bones hinzufügen)
#### Option A: Automatisches Rigging mit Rigify (Empfohlen)
1. **Rigify aktivieren:**
- Gehe zu `Edit``Preferences` (oder `Blender``Preferences` auf Mac)
- Klicke auf den Tab **"Add-ons"** (links im Fenster)
- Im Suchfeld oben rechts tippe: **"rigify"** (ohne Anführungszeichen)
- Du solltest "Rigify: Auto-rigging system" sehen
- Aktiviere das **Häkchen** neben "Rigify"
- Das Add-on ist jetzt aktiviert
- Schließe das Preferences-Fenster
**Alternative Wege zu Preferences:**
- Windows/Linux: `Edit``Preferences`
- Mac: `Blender``Preferences`
- Oder: `Ctrl + ,` (Strg + Komma)
2. **Rigify-Rig hinzufügen:**
- Stelle sicher, dass du im **Object Mode** bist (Tab drücken, falls im Edit Mode)
- Wähle das Modell aus (oder nichts, das Rig wird separat erstellt)
- Drücke `Shift + A` (Add Menu)
- Wähle **`Armature`** aus
- In der Liste siehst du jetzt **`Human (Meta-Rig)`** - klicke darauf
- Ein Basis-Rig wird in der Szene erstellt
**Falls "Human (Meta-Rig)" nicht erscheint:**
- Stelle sicher, dass Rigify aktiviert ist (siehe Schritt 1)
- Starte Blender neu, falls nötig
- Prüfe, ob du die neueste Blender-Version hast (Rigify ist ab Version 2.8+ verfügbar)
3. **Rig positionieren und anpassen:**
**Schritt 1: Rig zum Modell bewegen**
- Stelle sicher, dass du im **Object Mode** bist (Tab drücken)
- Wähle das **Armature** aus (nicht das Modell)
- Drücke `G` (Grab/Move) und bewege das Rig zum Modell
- Oder: Drücke `Alt + G` um die Position zurückzusetzen, dann `G` + `X`, `Y` oder `Z` für eine Achse
**Schritt 2: Rig skalieren (falls zu groß/klein)**
- Wähle das Armature aus
- Drücke `S` (Scale) und skaliere das Rig
- Oder: `S` + `X`, `Y` oder `Z` für eine Achse
- Tipp: Drücke `Shift + X` (oder Y/Z) um diese Achse auszuschließen
**Schritt 3: Einzelne Bones anpassen**
- Wähle das Armature aus
- Wechsle in den **Edit Mode** (Tab)
- Wähle einen Bone aus (Linksklick)
- Drücke `G` um ihn zu bewegen
- Drücke `E` um einen neuen Bone zu extrudieren
- Drücke `R` um einen Bone zu rotieren
- Drücke `S` um einen Bone zu skalieren
**Wichtige Bones zum Anpassen:**
- **Root/Spine** - Sollte in der Mitte des Körpers sein (Hüfthöhe)
- **Spine1/Spine2** - Entlang der Wirbelsäule
- **Neck/Head** - Am Hals und Kopf
- **Shoulders** - An den Schultern
- **Arms** - Entlang der Arme
- **Legs** - Entlang der Beine
**Tipp:** Nutze die Zahlenansicht (Numpad) um die Positionen genau zu sehen
4. **Rig generieren:**
- Wechsle zurück in den **Object Mode** (Tab drücken)
- Wähle das **Meta-Rig (Armature)** aus (nicht das Modell!) - sollte im Outliner blau markiert sein
**Methode 1: Rigify-Button in der Toolbar (Einfachste Methode)**
- Oben in der Toolbar siehst du den Button **"Rigify"** (neben "Object")
- Klicke auf **"Rigify"** → **"Generate Rig"**
- Ein vollständiges Rig wird erstellt (dies kann einen Moment dauern)
**Methode 2: Properties-Panel (Alternative)**
- Im **Properties-Panel** (rechts):
- Klicke auf das **Wrench-Icon** (Modifier Properties) in der linken Toolbar
- Oder: Klicke auf das **Bone-Icon** (Armature Properties)
- Scrolle durch die Tabs, bis du **"Rigify"** oder **"Rigify Generation"** siehst
- In diesem Tab findest du den Button **"Generate Rig"**
- Klicke auf **"Generate Rig"**
**Wichtig:** Nach dem Generieren kannst du das Rig weiter anpassen, aber du musst es im **Pose Mode** tun (nicht Edit Mode)
**Die richtigen Tabs im Properties-Panel (von oben nach unten):**
- 📐 **Object Properties** (Würfel-Icon) - hier findest du Transform, etc.
- 🦴 **Armature Properties** (Bone-Icon) - hier findest du Armature-Einstellungen
- 🔧 **Modifier Properties** (Wrench-Icon) - hier sollte der **Rigify-Tab** sein!
- 🌍 **World Properties** (Globus-Icon) - NICHT hier suchen!
**Falls du den Rigify-Tab nicht siehst:**
- Stelle sicher, dass das **Meta-Rig** (nicht ein bereits generiertes Rig) ausgewählt ist
- Klicke auf das **Wrench-Icon** (Modifier Properties) in der linken Toolbar
- Der Rigify-Tab sollte dort erscheinen
#### Option B: Manuelles Rigging
1. **Armature erstellen:**
- Drücke `Shift + A``Armature`
- Ein Bone wird erstellt
2. **Bones hinzufügen:**
- Wechsle in den **Edit Mode** (Tab)
- Wähle den Root-Bone aus
- Drücke `E` um einen neuen Bone zu extrudieren
- Erstelle die wichtigsten Bones:
- **Spine/Spine1/Spine2** - Wirbelsäule
- **Neck/Head** - Hals und Kopf
- **LeftArm/LeftForeArm/LeftHand** - Linker Arm
- **RightArm/RightForeArm/RightHand** - Rechter Arm
- **LeftUpLeg/LeftLeg/LeftFoot** - Linkes Bein
- **RightUpLeg/RightLeg/RightFoot** - Rechtes Bein
3. **Bone-Namen vergeben:**
- Wähle jeden Bone aus
- Im Properties-Panel (rechts) unter "Bone" kannst du den Namen ändern
- **Wichtig:** Verwende diese Namen für die Animation:
- `LeftArm`, `RightArm`
- `LeftForeArm`, `RightForeArm`
- `LeftHand`, `RightHand`
- `LeftUpLeg`, `RightUpLeg`
- `LeftLeg`, `RightLeg`
- `LeftFoot`, `RightFoot`
- `Neck`, `Head`
- `Spine`, `Spine1`, `Spine2`
### 4. Modell an Bones binden (Skinning)
1. **Beide Objekte auswählen:**
- Wähle zuerst das **Mesh** aus
- Dann wähle das **Armature** aus (Shift + Linksklick)
- Drücke `Ctrl + P``With Automatic Weights`
- Blender berechnet automatisch, welche Vertices zu welchen Bones gehören
2. **Weights überprüfen:**
- Wähle das Mesh aus
- Wechsle in den **Weight Paint Mode** (Dropdown oben)
- Wähle einen Bone aus (rechts im Properties-Panel)
- Rot = vollständig gebunden, Blau = nicht gebunden
- Falls nötig, kannst du die Weights manuell anpassen
### 5. Test-Animation erstellen (Optional)
1. **Pose Mode aktivieren:**
- Wähle das **generierte Rig** aus (nicht das Meta-Rig!)
- Wechsle in den **Pose Mode** (Dropdown oben: "Object Mode" → "Pose Mode")
- Oder: `Ctrl + Tab` → "Pose Mode"
2. **Bone auswählen:**
- **Wichtig:** Arbeite im **3D-Viewport** (Hauptfenster), nicht nur im Outliner!
- **Rigify-Bone-Namen** (nach dem Generieren):
- Für **Knie beugen**: `Leg.L (IK)` oder `Leg.L (FK)` (nicht "Tweak"!)
- Für **Hand anheben**: `Arm.L (IK)` oder `Arm.L (FK)`
- Für **Fuß bewegen**: `Leg.L (IK)` (der Fuß-Controller)
- **IK** = Inverse Kinematics (einfacher, empfohlen für Anfänger)
- **FK** = Forward Kinematics (mehr Kontrolle)
- **Tweak** = Feinabstimmungen (für später, nicht für Hauptanimationen)
- Klicke auf einen **Bone** im **3D-Viewport** (nicht im Outliner!)
- Der Bone sollte orange/ausgewählt sein und im Viewport sichtbar sein
- **Tipp:** Nutze `X-Ray Mode` (Button oben im Viewport) um Bones besser zu sehen
- **Tipp:** Im Outliner kannst du Bones finden, aber die Animation machst du im Viewport
3. **Bone animieren:**
- Wähle z.B. `hand.L` (linke Hand) aus
- Drücke `R` (Rotate) und rotiere den Bone
- Oder: `R` + `Z` (um Z-Achse rotieren)
- Oder: `R` + `X` (um X-Achse rotieren)
- Bewege die Maus → Linksklick zum Bestätigen
- **Beispiel für Hand anheben:** `hand.L``R``Z` → nach oben bewegen
4. **Animation aufnehmen (Timeline):**
- Unten siehst du die **Timeline** (falls nicht sichtbar: `Shift + F12` oder `Window``Animation``Timeline`)
- Stelle den Frame auf **1** (Anfang)
- Wähle den Bone aus und positioniere ihn in der **Ausgangsposition**
- Drücke `I` (Insert Keyframe) → wähle **"Rotation"** (oder "Location" falls bewegt)
- Ein Keyframe wird erstellt (gelber Punkt in der Timeline)
- Stelle den Frame auf **30** (oder einen anderen Frame)
- Rotiere/Bewege den Bone in die **Zielposition** (z.B. Hand nach oben)
- Drücke wieder `I`**"Rotation"** (oder "Location")
- Stelle den Frame auf **60** (Rückkehr zur Ausgangsposition)
- Rotiere den Bone zurück zur Ausgangsposition
- Drücke `I`**"Rotation"**
- Drücke **Play** (Leertaste) um die Animation zu sehen
5. **Animation testen:**
- Die Animation sollte jetzt in einer Schleife abgespielt werden
- Du kannst weitere Keyframes hinzufügen (Frame 90, 120, etc.)
- **Tipp:** Nutze `Alt + A` um die Animation zu stoppen
### 6. Modell exportieren
1. **Beide Objekte auswählen:**
- Wähle das **Mesh** aus
- Shift + Linksklick auf das **generierte Rig** (nicht das Meta-Rig!)
2. **Exportieren:**
- Gehe zu `File``Export``glTF 2.0 (.glb/.gltf)`
- Wähle `.glb` Format
- Stelle sicher, dass folgende Optionen aktiviert sind:
-**Include****Selected Objects**
-**Transform****+Y Up**
-**Geometry****Apply Modifiers**
-**Animation****Bake Animation** (wichtig für Animationen!)
-**Animation****Always Sample Animations** (falls Animationen nicht korrekt exportiert werden)
- Klicke auf "Export glTF 2.0"
### 7. Modell testen
1. Kopiere die exportierte `.glb` Datei nach:
```
frontend/public/models/3d/falukant/characters/
```
2. Lade die Seite neu
3. Die Bones sollten jetzt automatisch erkannt und animiert werden
4. **Animationen testen:**
- Öffne die Browser-Konsole (F12)
- Du solltest sehen: `[ThreeScene] Found X animation(s)`
- Die Animationen sollten automatisch abgespielt werden
- Falls keine Animationen vorhanden sind, werden die Bones trotzdem mit Idle-Animationen bewegt
## Rig anpassen - Detaillierte Anleitung
### Rig nach dem Generieren anpassen
Wenn das Rigify-Rig generiert wurde, aber nicht perfekt passt:
1. **Pose Mode verwenden:**
- Wähle das generierte Armature aus
- Wechsle in den **Pose Mode** (Dropdown oben, oder Strg+Tab → Pose Mode)
- Hier kannst du die Bones bewegen, ohne die Struktur zu zerstören
2. **Rig neu generieren (falls nötig):**
- Falls das Rig komplett neu positioniert werden muss:
- Lösche das generierte Rig (X → Delete)
- Gehe zurück zum Meta-Rig
- Passe das Meta-Rig im Edit Mode an
- Generiere das Rig erneut
3. **Snap to Mesh (Hilfsmittel):**
- Im Edit Mode: `Shift + Tab` um Snap zu aktivieren
- Oder: Rechtsklick auf das Snap-Symbol (Magnet) oben
- Wähle "Face" oder "Vertex" als Snap-Target
- Jetzt werden Bones automatisch am Mesh ausgerichtet
### Häufige Probleme und Lösungen
**Problem: Rig ist zu groß/klein**
- Lösung: Im Object Mode das Armature auswählen und mit `S` skalieren
**Problem: Rig ist an falscher Position**
- Lösung: Im Object Mode mit `G` bewegen, oder `Alt + G` zurücksetzen
**Problem: Einzelne Bones passen nicht**
- Lösung: Im Edit Mode die Bones einzeln anpassen (`G` zum Bewegen)
**Problem: Nach dem Generieren passt es nicht mehr**
- Lösung: Passe das Meta-Rig an und generiere neu, oder verwende Pose Mode
## Tipps und Tricks
### Bone-Namen für automatische Erkennung
Die Komponente erkennt Bones anhand ihrer Namen. Verwende diese Keywords:
- `arm` - für Arme
- `hand` oder `wrist` - für Hände
- `leg` oder `knee` - für Beine
- `foot` oder `ankle` - für Füße
- `shoulder` - für Schultern
- `elbow` - für Ellbogen
### Einfacheres Rigging mit Mixamo
Alternativ kannst du:
1. Dein Modell auf [Mixamo](https://www.mixamo.com/) hochladen
2. Automatisches Rigging durchführen lassen
3. Das geriggte Modell herunterladen
4. In Blender importieren und anpassen
### Performance-Optimierung
- Verwende nicht zu viele Bones (max. 50-100 für Charaktere)
- Entferne unnötige Bones vor dem Export
- Teste die Animation im Browser, bevor du das finale Modell exportierst
## Troubleshooting
### Bones werden nicht erkannt
- Prüfe die Bone-Namen (müssen `arm`, `hand`, `leg`, etc. enthalten)
- Stelle sicher, dass das Modell korrekt an die Bones gebunden ist
- Öffne die Browser-Konsole und prüfe die Logs: `[ThreeScene] Found X bones for animation`
### Modell verformt sich falsch
- Überprüfe die Weights im Weight Paint Mode
- Passe die Bone-Positionen an
- Stelle sicher, dass alle Vertices korrekt zugewiesen sind
### Export schlägt fehl
- Stelle sicher, dass beide Objekte (Mesh + Armature) ausgewählt sind
- Prüfe, ob das Modell im Object Mode ist
- Versuche es mit einem anderen Export-Format (.gltf statt .glb)
## Weitere Ressourcen
- [Blender Rigging Tutorial](https://www.youtube.com/results?search_query=blender+rigging+tutorial)
- [Mixamo Auto-Rigging](https://www.mixamo.com/)
- [Three.js GLTF Animation Guide](https://threejs.org/docs/#manual/en/introduction/Animation-system)

View File

@@ -21,6 +21,7 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"three": "^0.182.0",
"vue": "~3.4.31", "vue": "~3.4.31",
"vue-i18n": "^10.0.0-beta.2", "vue-i18n": "^10.0.0-beta.2",
"vue-multiselect": "^3.1.0", "vue-multiselect": "^3.1.0",
@@ -2834,6 +2835,12 @@
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
}, },
"node_modules/three": {
"version": "0.182.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.14", "version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",

View File

@@ -21,6 +21,7 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"three": "^0.182.0",
"vue": "~3.4.31", "vue": "~3.4.31",
"vue-i18n": "^10.0.0-beta.2", "vue-i18n": "^10.0.0-beta.2",
"vue-multiselect": "^3.1.0", "vue-multiselect": "^3.1.0",

View File

@@ -0,0 +1,40 @@
# 3D-Charakter-Modelle
## Verzeichnisstruktur
Dieses Verzeichnis enthält die 3D-Modelle für Falukant-Charaktere.
## Dateinamen-Konvention
### Basis-Modelle (Fallback)
- `male.glb` - Basis-Modell männlich
- `female.glb` - Basis-Modell weiblich
### Altersspezifische Modelle
- `male_toddler.glb` - Männlich, Kleinkind (0-3 Jahre)
- `male_child.glb` - Männlich, Kind (4-7 Jahre)
- `male_preteen.glb` - Männlich, Vor-Teenager (8-12 Jahre)
- `male_teen.glb` - Männlich, Teenager (13-17 Jahre)
- `male_adult.glb` - Männlich, Erwachsen (18+ Jahre)
- `female_toddler.glb` - Weiblich, Kleinkind (0-3 Jahre)
- `female_child.glb` - Weiblich, Kind (4-7 Jahre)
- `female_preteen.glb` - Weiblich, Vor-Teenager (8-12 Jahre)
- `female_teen.glb` - Weiblich, Teenager (13-17 Jahre)
- `female_adult.glb` - Weiblich, Erwachsen (18+ Jahre)
## Fallback-Verhalten
Wenn kein spezifisches Modell für den Altersbereich existiert, wird automatisch das Basis-Modell (`male.glb` / `female.glb`) verwendet.
## Dateigröße
- Empfohlen: < 500KB pro Modell
- Maximal: 1MB pro Modell
## Optimierung
Vor dem Hochladen:
1. In Blender öffnen
2. Decimate Modifier anwenden (falls nötig)
3. Texturen komprimieren (WebP, max 1024x1024)
4. GLB Export mit Compression aktiviert

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +1,8 @@
<template> <template>
<main> <main class="contenthidden">
<router-view></router-view> <div class="contentscroll">
<router-view></router-view>
</div>
</main> </main>
</template> </template>
@@ -12,9 +14,13 @@
<style scoped> <style scoped>
main { main {
padding: 20px; padding: 0;
background-color: #ffffff; background-color: #ffffff;
flex: 1; flex: 1;
} }
.contentscroll {
padding: 20px;
}
</style> </style>

View File

@@ -27,7 +27,7 @@
:style="`background-image:url('/images/icons/${subitem.icon}')`" :style="`background-image:url('/images/icons/${subitem.icon}')`"
class="submenu-icon" class="submenu-icon"
>&nbsp;</span> >&nbsp;</span>
<span>{{ $t(`navigation.m-${key}.${subkey}`) }}</span> <span>{{ subitem?.label || $t(`navigation.m-${key}.${subkey}`) }}</span>
<span <span
v-if="subkey === 'forum' || subitem.children" v-if="subkey === 'forum' || subitem.children"
class="subsubmenu" class="subsubmenu"
@@ -62,7 +62,7 @@
:style="`background-image:url('/images/icons/${subsubitem.icon}')`" :style="`background-image:url('/images/icons/${subsubitem.icon}')`"
class="submenu-icon" class="submenu-icon"
>&nbsp;</span> >&nbsp;</span>
<span>{{ $t(`navigation.m-${key}.m-${subkey}.${subsubkey}`) }}</span> <span>{{ subsubitem?.label || $t(`navigation.m-${key}.m-${subkey}.${subsubkey}`) }}</span>
</li> </li>
</ul> </ul>
</li> </li>

View File

@@ -0,0 +1,225 @@
<template>
<div class="character-model-3d">
<ThreeScene
v-if="currentModelPath"
:key="currentModelPath"
:modelPath="currentModelPath"
:autoRotate="autoRotate"
:rotationSpeed="rotationSpeed"
:cameraPosition="cameraPosition"
:backgroundColor="backgroundColor"
@model-loaded="onModelLoaded"
@model-error="onModelError"
@loading-progress="onLoadingProgress"
/>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<p v-if="loadingProgress > 0">{{ Math.round(loadingProgress) }}%</p>
</div>
<div v-if="error" class="error-overlay">
<p>{{ error }}</p>
</div>
</div>
</template>
<script>
import ThreeScene from './ThreeScene.vue';
export default {
name: 'CharacterModel3D',
components: {
ThreeScene
},
props: {
gender: {
type: String,
required: true,
validator: (value) => ['male', 'female'].includes(value)
},
age: {
type: Number,
default: null
},
autoRotate: {
type: Boolean,
default: false
},
rotationSpeed: {
type: Number,
default: 0.5
},
cameraPosition: {
type: Object,
default: () => ({ x: 0, y: 1, z: 3 })
},
backgroundColor: {
type: String,
default: '#f0f0f0'
}
},
data() {
return {
loading: true,
loadingProgress: 0,
error: null,
currentModelPath: null
};
},
computed: {
baseModelPath() {
const basePath = '/models/3d/falukant/characters';
return `${basePath}/${this.gender}.glb`;
},
ageSpecificModelPath() {
const ageRange = this.getAgeRange(this.age);
if (!ageRange) return null;
const basePath = '/models/3d/falukant/characters';
return `${basePath}/${this.gender}_${ageRange}.glb`;
}
},
watch: {
gender() {
this.findAndLoadModel();
},
age() {
this.findAndLoadModel();
}
},
mounted() {
this.findAndLoadModel();
},
methods: {
getAgeRange(age) {
if (age === null || age === undefined) return null;
// Verfügbare Altersbereiche: toddler, child, preteen, teen, adult
// Alter ist in Tagen gespeichert (1 Tag = 1 Jahr)
if (age < 4) return 'toddler'; // 0-3 Jahre
if (age < 10) return 'child'; // 4-7 Jahre
if (age < 13) return 'preteen'; // 8-12 Jahre
if (age < 18) return 'teen'; // 13-17 Jahre
return 'adult'; // 18+ Jahre
},
async findAndLoadModel() {
this.loading = true;
this.error = null;
// Versuche zuerst altersspezifisches Modell, dann Basis-Modell
const pathsToTry = [];
if (this.ageSpecificModelPath) {
pathsToTry.push(this.ageSpecificModelPath);
}
pathsToTry.push(this.baseModelPath);
// Prüfe welche Datei existiert
for (const path of pathsToTry) {
const exists = await this.checkFileExists(path);
if (exists) {
this.currentModelPath = path;
console.log(`[CharacterModel3D] Using model: ${path}`);
return;
}
}
// Fallback: Verwende Basis-Modell auch wenn Prüfung fehlschlägt
this.currentModelPath = this.baseModelPath;
console.warn(`[CharacterModel3D] Using fallback model: ${this.baseModelPath}`);
},
async checkFileExists(path) {
try {
const response = await fetch(path, { method: 'HEAD' });
if (!response.ok) {
return false;
}
// Prüfe Content-Type - sollte nicht HTML sein
const contentType = response.headers.get('content-type') || '';
const isHTML = contentType.includes('text/html') || contentType.includes('text/plain');
if (isHTML) {
console.warn(`[CharacterModel3D] File ${path} returns HTML, probably doesn't exist`);
return false;
}
// GLB-Dateien können verschiedene Content-Types haben
return true;
} catch (error) {
console.warn(`[CharacterModel3D] Error checking file ${path}:`, error);
return false;
}
},
onModelLoaded(model) {
this.loading = false;
this.error = null;
this.$emit('model-loaded', model);
},
onModelError(error) {
// Wenn ein Fehler auftritt und wir noch nicht das Basis-Modell verwenden
if (this.currentModelPath !== this.baseModelPath) {
console.warn('[CharacterModel3D] Model failed, trying fallback...');
this.currentModelPath = this.baseModelPath;
// Der Watch-Handler wird das Modell neu laden
return;
}
this.loading = false;
this.error = 'Fehler beim Laden des 3D-Modells';
console.error('Character model error:', error);
this.$emit('model-error', error);
},
onLoadingProgress(progress) {
this.loadingProgress = progress;
}
}
};
</script>
<style scoped>
.character-model-3d {
width: 100%;
height: 100%;
position: relative;
min-height: 400px;
}
.loading-overlay,
.error-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.9);
z-index: 10;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #F9A22C;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-overlay p {
color: #d32f2f;
font-weight: bold;
}
</style>

View File

@@ -104,6 +104,14 @@
/> />
{{ $t('falukant.branch.director.starttransport') }} {{ $t('falukant.branch.director.starttransport') }}
</label> </label>
<label>
<input
type="checkbox"
v-model="director.mayRepairVehicles"
@change="saveSetting('mayRepairVehicles', director.mayRepairVehicles)"
/>
{{ $t('falukant.branch.director.repairVehicles') }}
</label>
</div> </div>
<div class="field"> <div class="field">

View File

@@ -16,7 +16,7 @@
<li v-for="n in messages" :key="n.id" :class="{ unread: !n.shown }"> <li v-for="n in messages" :key="n.id" :class="{ unread: !n.shown }">
<div class="body"> <div class="body">
<div v-if="formatBody(n).title" class="notification-title">{{ formatBody(n).title }}</div> <div v-if="formatBody(n).title" class="notification-title">{{ formatBody(n).title }}</div>
<div class="notification-description">{{ formatBody(n).description || formatBody(n) }}</div> <div class="notification-description" v-html="formatBody(n).description || formatBody(n)"></div>
</div> </div>
<div class="footer"> <div class="footer">
<span>{{ formatDate(n.createdAt) }}</span> <span>{{ formatDate(n.createdAt) }}</span>
@@ -133,54 +133,52 @@ export default {
} catch { return dt; } } catch { return dt; }
}, },
formatBody(n) { formatBody(n) {
// Wenn die Notification bereits title und description hat (z.B. von WebSocket Events)
if (n.title && n.description) {
// Parameter aus effects oder anderen Feldern extrahieren
const params = this.extractParams(n);
return {
title: this.interpolateString(n.title, params),
description: this.interpolateString(n.description, params)
};
}
let raw = n.tr || ''; let raw = n.tr || '';
let parsed = null;
let value = null;
let key = raw; let key = raw;
let params = {}; let params = {};
// 1) JSON-Format unterstützen: {"tr":"random_event.windfall","amount":1000,"characterName":"Max"} // 1) Parse JSON-Format: {"tr":"random_event.character_illness","value":{...}}
if (typeof raw === 'string') { if (typeof raw === 'string') {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) { if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
try { try {
const parsed = JSON.parse(trimmed); parsed = JSON.parse(trimmed);
if (parsed && parsed.tr) { if (parsed && parsed.tr) {
raw = parsed.tr;
key = parsed.tr; key = parsed.tr;
// Alle anderen Felder als Parameter verwenden und formatieren value = parsed.value || {};
params = this.formatParams({ ...parsed });
delete params.tr; // Extrahiere Parameter aus value und effects
// Merge in params extracted from nested structures (effects, character ids) params = this.extractParamsFromValue(value, n);
try {
const extracted = this.extractParams({ ...n, ...parsed, characterName: parsed.characterName || parsed.character_name || n.characterName || n.character_name });
for (const [k, v] of Object.entries(extracted || {})) {
if (!params.hasOwnProperty(k) || params[k] === undefined || params[k] === null || params[k] === '') {
params[k] = v;
}
}
} catch (e) {
// ignore extraction errors
}
} }
} catch (e) { } catch (e) {
// bei Parse-Fehler einfach weiter unten mit dem Rohwert arbeiten // Bei Parse-Fehler: Alte Struktur unterstützen
try {
parsed = JSON.parse(trimmed);
if (parsed && parsed.tr) {
key = parsed.tr;
params = this.formatParams({ ...parsed });
delete params.tr;
}
} catch (e2) {
// Ignore parse errors
}
} }
} }
} }
// 2) Schlüssel normalisieren: // 2) Wenn value.title und value.description vorhanden sind, verwende diese
// - wenn bereits ein voller i18n-Key wie "falukant.notifications.production.overproduction", if (value && value.title && value.description) {
// dann direkt verwenden // Parameter aus effects extrahieren und formatieren
// - sonst in den Namespace "falukant.notifications." hängen const formattedParams = this.formatParams(params);
return {
title: this.interpolateString(value.title, formattedParams),
description: this.formatDescriptionWithEffects(value.description, value.effects || [], formattedParams)
};
}
// 3) Schlüssel normalisieren
if (typeof key === 'string') { if (typeof key === 'string') {
const trimmedKey = key.trim(); const trimmedKey = key.trim();
if (trimmedKey.startsWith('falukant.')) { if (trimmedKey.startsWith('falukant.')) {
@@ -190,21 +188,35 @@ export default {
} }
} }
// 3) Prüfe, ob es sich um ein random_event handelt mit title/description Struktur // 4) Prüfe, ob es sich um ein random_event handelt mit title/description Struktur
if (key.startsWith('falukant.notifications.random_event.')) { if (key.startsWith('falukant.notifications.random_event.')) {
const eventId = key.replace('falukant.notifications.random_event.', ''); const eventId = key.replace('falukant.notifications.random_event.', '');
const eventKey = `falukant.notifications.random_event.${eventId}`; const eventKey = `falukant.notifications.random_event.${eventId}`;
try { try {
const titleKey = `${eventKey}.title`; const titleKey = `${eventKey}.title`;
const descKey = `${eventKey}.description`; const descKey = `${eventKey}.description`;
// If no params were parsed from JSON, try to extract them from the notification (effects, character_id, etc.)
// Wenn keine Parameter extrahiert wurden, versuche es aus der Notification
if ((!params || Object.keys(params).length === 0) && n) { if ((!params || Object.keys(params).length === 0) && n) {
params = this.extractParams(n) || {}; params = this.extractParams(n) || {};
} }
// Wenn value vorhanden ist, verwende effects für Details
if (value && value.effects) {
params = this.extractParamsFromValue(value, n);
}
const formattedParams = this.formatParams(params);
if (this.$te(titleKey) && this.$te(descKey)) { if (this.$te(titleKey) && this.$te(descKey)) {
const title = this.$t(titleKey, params); const title = this.$t(titleKey, formattedParams);
const description = this.$t(descKey, params); let description = this.$t(descKey, formattedParams);
// Füge Effect-Details hinzu, falls vorhanden
if (value && value.effects) {
description = this.formatDescriptionWithEffects(description, value.effects, formattedParams);
}
return { title, description }; return { title, description };
} }
} catch (e) { } catch (e) {
@@ -213,42 +225,64 @@ export default {
} }
// Fallback: Alte Methode für andere Notification-Typen // Fallback: Alte Methode für andere Notification-Typen
return this.$t(key, params); const formattedParams = this.formatParams(params);
return this.$t(key, formattedParams);
}, },
formatParams(params) { formatParams(params) {
const formatted = {}; const formatted = {};
// Geldbeträge formatieren // Geldbeträge formatieren
if (params.amount !== undefined) { if (params.amount !== undefined && params.amount !== null) {
formatted.amount = this.formatMoney(params.amount); formatted.amount = this.formatMoney(params.amount);
} }
if (params.absolute !== undefined) { if (params.absolute !== undefined && params.absolute !== null) {
formatted.amount = this.formatMoney(params.absolute); formatted.amount = this.formatMoney(params.absolute);
} }
if (params.percent !== undefined) { if (params.percent !== undefined && params.percent !== null) {
formatted.amount = `${params.percent > 0 ? '+' : ''}${params.percent.toFixed(1)}%`; formatted.percent = `${params.percent > 0 ? '+' : ''}${params.percent.toFixed(1)}%`;
} }
// Gesundheit formatieren // Gesundheit formatieren
if (params.change !== undefined) { if (params.change !== undefined && params.change !== null) {
formatted.healthChange = params.change > 0 ? `+${params.change}` : `${params.change}`; formatted.healthChange = params.change > 0 ? `+${params.change}` : `${params.change}`;
formatted.change = formatted.healthChange;
} }
if (params.healthChange !== undefined) { if (params.healthChange !== undefined && params.healthChange !== null && !formatted.healthChange) {
formatted.healthChange = params.healthChange > 0 ? `+${params.healthChange}` : `${params.healthChange}`; formatted.healthChange = typeof params.healthChange === 'string'
? params.healthChange
: (params.healthChange > 0 ? `+${params.healthChange}` : `${params.healthChange}`);
} }
// Schaden formatieren // Charakternamen
if (params.inventory_damage_percent !== undefined) { if (params.characterName) {
formatted.characterName = params.characterName;
}
if (params.character_first_name || params.character_last_name) {
const firstName = params.character_first_name || '';
const lastName = params.character_last_name || '';
formatted.characterName = `${firstName} ${lastName}`.trim() || formatted.characterName;
}
// Regions-Informationen
if (params.regionName) {
formatted.regionName = params.regionName;
}
if (params.region_id) {
formatted.region_id = params.region_id;
}
// Schaden formatieren (für Kompatibilität mit alter Struktur)
if (params.inventory_damage_percent !== undefined && params.inventory_damage_percent !== null) {
formatted.damagePercent = ` Lagerbestand beschädigt: ${params.inventory_damage_percent.toFixed(1)}%.`; formatted.damagePercent = ` Lagerbestand beschädigt: ${params.inventory_damage_percent.toFixed(1)}%.`;
} }
if (params.storage_destruction_percent !== undefined) { if (params.storage_destruction_percent !== undefined && params.storage_destruction_percent !== null) {
formatted.destructionPercent = ` Lager zerstört: ${params.storage_destruction_percent.toFixed(1)}%.`; formatted.destructionPercent = ` Lager zerstört: ${params.storage_destruction_percent.toFixed(1)}%.`;
} }
// Alle anderen Parameter übernehmen // Alle anderen Parameter übernehmen
for (const [key, value] of Object.entries(params)) { for (const [key, value] of Object.entries(params)) {
if (!formatted.hasOwnProperty(key) && key !== 'tr') { if (!formatted.hasOwnProperty(key) && key !== 'tr' && value !== undefined && value !== null) {
formatted[key] = value; formatted[key] = value;
} }
} }
@@ -256,10 +290,109 @@ export default {
return formatted; return formatted;
}, },
extractParamsFromValue(value, n) {
const params = {};
// Geldbeträge direkt aus value extrahieren (neue Struktur)
if (value.money_change_absolute !== undefined && value.money_change_absolute !== null) {
params.absolute = value.money_change_absolute;
params.amount = value.money_change_absolute;
}
if (value.money_change_percent !== undefined && value.money_change_percent !== null) {
params.percent = value.money_change_percent;
}
// Charakternamen aus value extrahieren
if (value.character_first_name || value.character_last_name) {
const firstName = value.character_first_name || '';
const lastName = value.character_last_name || '';
params.characterName = `${firstName} ${lastName}`.trim() || value.character_id ? `#${value.character_id}` : null;
}
if (value.character_id) {
params.character_id = value.character_id;
}
// Regions-Informationen
if (value.region_id) {
params.region_id = value.region_id;
}
if (value.regionName) {
params.regionName = value.regionName;
}
// Parameter aus effects extrahieren (falls nicht bereits aus value extrahiert)
if (value.effects && Array.isArray(value.effects)) {
for (const effect of value.effects) {
if (effect.type === 'money_change') {
// Nur verwenden, wenn nicht bereits aus value extrahiert
if (params.absolute === undefined && effect.absolute !== undefined) {
params.absolute = effect.absolute;
params.amount = effect.absolute;
}
if (params.percent === undefined && effect.percent !== undefined) {
params.percent = effect.percent;
}
} else if (effect.type === 'character_health_change') {
// Charakternamen aus Effect haben Vorrang
if (effect.character_first_name || effect.character_last_name) {
const firstName = effect.character_first_name || '';
const lastName = effect.character_last_name || '';
params.characterName = `${firstName} ${lastName}`.trim();
}
if (effect.character_id) {
params.character_id = effect.character_id;
}
if (effect.change !== undefined) {
params.change = effect.change;
params.healthChange = effect.change > 0 ? `+${effect.change}` : `${effect.change}`;
}
} else if (effect.type === 'character_death') {
if (effect.character_first_name || effect.character_last_name) {
const firstName = effect.character_first_name || '';
const lastName = effect.character_last_name || '';
params.characterName = `${firstName} ${lastName}`.trim();
}
if (effect.character_id) {
params.character_id = effect.character_id;
}
} else if (effect.type === 'storage_damage') {
if (effect.inventory_damage_percent !== undefined) {
params.inventory_damage_percent = effect.inventory_damage_percent;
}
if (effect.storage_destruction_percent !== undefined) {
params.storage_destruction_percent = effect.storage_destruction_percent;
}
} else if (effect.type === 'production_quality_change') {
if (effect.change !== undefined) {
params.productionQualityChange = effect.change;
}
} else if (effect.type === 'transport_speed_change') {
if (effect.percent !== undefined) {
params.transportSpeedChangePercent = effect.percent;
}
} else if (effect.type === 'storage_capacity_change') {
if (effect.percent !== undefined) {
params.storageCapacityChangePercent = effect.percent;
}
}
}
}
// Fallback: Charakternamen aus Notification-Spalte
if (!params.characterName && n.characterName) {
params.characterName = n.characterName;
}
if (!params.characterName && n.character_id) {
params.characterName = `#${n.character_id}`;
}
return params;
},
extractParams(n) { extractParams(n) {
const params = {}; const params = {};
// Parameter aus effects extrahieren // Parameter aus effects extrahieren (alte Struktur)
if (n.effects && Array.isArray(n.effects)) { if (n.effects && Array.isArray(n.effects)) {
for (const effect of n.effects) { for (const effect of n.effects) {
if (effect.type === 'money_change') { if (effect.type === 'money_change') {
@@ -270,7 +403,6 @@ export default {
} }
} else if (effect.type === 'character_health_change') { } else if (effect.type === 'character_health_change') {
if (effect.character_id) { if (effect.character_id) {
// Prefer explicit characterName from notification, otherwise fall back to provided name or use id placeholder
params.character_id = effect.character_id; params.character_id = effect.character_id;
params.characterName = params.characterName || n.characterName || `#${effect.character_id}`; params.characterName = params.characterName || n.characterName || `#${effect.character_id}`;
} }
@@ -304,7 +436,54 @@ export default {
params.amount = n.amount; params.amount = n.amount;
} }
return this.formatParams(params); return params;
},
formatDescriptionWithEffects(baseDescription, effects, params) {
if (!effects || !Array.isArray(effects) || effects.length === 0) {
return baseDescription;
}
let description = baseDescription;
const effectDetails = [];
for (const effect of effects) {
if (effect.type === 'character_health_change') {
const charName = effect.character_first_name && effect.character_last_name
? `${effect.character_first_name} ${effect.character_last_name}`.trim()
: params.characterName || `#${effect.character_id}`;
const change = effect.change > 0 ? `+${effect.change}` : `${effect.change}`;
effectDetails.push(`${charName} hat ${change} Gesundheit verloren.`);
} else if (effect.type === 'character_death') {
const charName = effect.character_first_name && effect.character_last_name
? `${effect.character_first_name} ${effect.character_last_name}`.trim()
: params.characterName || `#${effect.character_id}`;
effectDetails.push(`${charName} ist verstorben.`);
} else if (effect.type === 'production_quality_change') {
const change = effect.change > 0 ? `+${effect.change}` : `${effect.change}`;
effectDetails.push(`Produktionsqualität: ${change}.`);
} else if (effect.type === 'transport_speed_change') {
const percent = effect.percent > 0 ? `+${effect.percent.toFixed(1)}%` : `${effect.percent.toFixed(1)}%`;
effectDetails.push(`Transportgeschwindigkeit: ${percent}.`);
} else if (effect.type === 'storage_damage') {
const stockType = effect.stock_type || 'Lager';
const inventoryDamage = effect.inventory_damage_percent ? `${effect.inventory_damage_percent.toFixed(1)}%` : '0%';
const storageDestruction = effect.storage_destruction_percent ? `${effect.storage_destruction_percent.toFixed(1)}%` : '0%';
const affected = effect.affected_stocks ? effect.affected_stocks.length : 0;
const destroyed = effect.destroyed_stocks ? effect.destroyed_stocks.length : 0;
effectDetails.push(`${stockType}: ${inventoryDamage} Lagerbestand beschädigt, ${storageDestruction} Lager zerstört (${affected} betroffen, ${destroyed} zerstört).`);
} else if (effect.type === 'storage_capacity_change') {
const percent = effect.percent > 0 ? `+${effect.percent.toFixed(1)}%` : `${effect.percent.toFixed(1)}%`;
const affected = effect.affected_stocks ? effect.affected_stocks.length : 0;
effectDetails.push(`Lagerkapazität: ${percent} (${affected} betroffen).`);
}
}
if (effectDetails.length > 0) {
description += ' ' + effectDetails.join(' ');
}
return description;
}, },
interpolateString(str, params) { interpolateString(str, params) {

View File

@@ -20,8 +20,10 @@
<td>{{ item.quality }}</td> <td>{{ item.quality }}</td>
<td>{{ item.totalQuantity }}</td> <td>{{ item.totalQuantity }}</td>
<td> <td>
<input type="number" v-model.number="item.sellQuantity" :min="1" :max="item.totalQuantity" /> <input type="number" v-model.number="item.sellQuantity" :min="1" :max="item.totalQuantity" :disabled="sellingItemIndex === index" />
<button @click="sellItem(index)">{{ $t('falukant.branch.sale.sellButton') }}</button> <button @click="sellItem(index)" :disabled="sellingItemIndex === index || sellingAll">
{{ sellingItemIndex === index ? $t('falukant.branch.sale.selling') : $t('falukant.branch.sale.sellButton') }}
</button>
</td> </td>
<td> <td>
<div v-if="item.betterPrices && item.betterPrices.length > 0" class="price-cities"> <div v-if="item.betterPrices && item.betterPrices.length > 0" class="price-cities">
@@ -36,7 +38,12 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<button @click="sellAll">{{ $t('falukant.branch.sale.sellAllButton') }}</button> <button @click="sellAll" :disabled="sellingAll || sellingItemIndex !== null">
{{ sellingAll ? $t('falukant.branch.sale.selling') : $t('falukant.branch.sale.sellAllButton') }}
</button>
<div v-if="sellAllStatus" class="sell-all-status" :class="sellAllStatus.type">
{{ sellAllStatus.message }}
</div>
</div> </div>
<div v-else> <div v-else>
<p>{{ $t('falukant.branch.sale.noInventory') }}</p> <p>{{ $t('falukant.branch.sale.noInventory') }}</p>
@@ -183,6 +190,9 @@
data() { data() {
return { return {
inventory: [], inventory: [],
sellingItemIndex: null,
sellingAll: false,
sellAllStatus: null,
transportForm: { transportForm: {
sourceKey: null, sourceKey: null,
vehicleTypeId: null, vehicleTypeId: null,
@@ -251,13 +261,6 @@
return new Date(a.eta).getTime() - new Date(b.eta).getTime(); return new Date(a.eta).getTime() - new Date(b.eta).getTime();
}); });
}, },
speedLabel(value) {
const key = value == null ? 'unknown' : String(value);
const tKey = `falukant.branch.transport.speed.${key}`;
const translated = this.$t(tKey);
if (!translated || translated === tKey) return value;
return translated;
},
}, },
async mounted() { async mounted() {
await this.loadInventory(); await this.loadInventory();
@@ -274,12 +277,22 @@
} }
}, },
methods: { methods: {
speedLabel(value) {
// Muss in methods liegen (Vue3): in computed wäre es ein Getter und keine aufrufbare Funktion.
const key = value == null ? 'unknown' : String(value);
const tKey = `falukant.branch.transport.speed.${key}`;
const translated = this.$t(tKey);
if (!translated || translated === tKey) return value;
return translated;
},
async loadInventory() { async loadInventory() {
try { try {
const response = await apiClient.get(`/api/falukant/inventory/${this.branchId}`); const response = await apiClient.get(`/api/falukant/inventory/${this.branchId}`);
this.inventory = response.data.map(item => ({ this.inventory = response.data.map(item => ({
...item, ...item,
sellQuantity: item.totalQuantity, sellQuantity: item.totalQuantity,
// Vue3: besserPrices direkt als Property setzen (statt this.$set)
betterPrices: Array.isArray(item.betterPrices) ? item.betterPrices : [],
})); }));
await this.loadPricesForInventory(); await this.loadPricesForInventory();
} catch (error) { } catch (error) {
@@ -300,10 +313,11 @@
currentPrice: currentPrice currentPrice: currentPrice
} }
}); });
this.$set(item, 'betterPrices', data || []); // Vue3: direkte Zuweisung ist reaktiv
item.betterPrices = Array.isArray(data) ? data : [];
} catch (error) { } catch (error) {
console.error(`Error loading prices for item ${itemKey}:`, error); console.error(`Error loading prices for item ${itemKey}:`, error);
this.$set(item, 'betterPrices', []); item.betterPrices = [];
} finally { } finally {
this.loadingPrices.delete(itemKey); this.loadingPrices.delete(itemKey);
} }
@@ -320,23 +334,61 @@
maximumFractionDigits: 2, maximumFractionDigits: 2,
}).format(price); }).format(price);
}, },
sellItem(index) { async sellItem(index) {
if (this.sellingItemIndex !== null || this.sellingAll) return;
const item = this.inventory[index]; const item = this.inventory[index];
const quantityToSell = item.sellQuantity || item.totalQuantity; const quantityToSell = item.sellQuantity || item.totalQuantity;
apiClient.post(`/api/falukant/sell`, { this.sellingItemIndex = index;
branchId: this.branchId,
productId: item.product.id, try {
quantity: quantityToSell, await apiClient.post(`/api/falukant/sell`, {
quality: item.quality, branchId: this.branchId,
}).catch(() => { productId: item.product.id,
quantity: quantityToSell,
quality: item.quality,
});
// UI sofort freigeben (Label/Disabled zurücksetzen), dann Inventory refreshen
this.sellingItemIndex = null;
await this.loadInventory();
} catch (error) {
alert(this.$t('falukant.branch.sale.sellError')); alert(this.$t('falukant.branch.sale.sellError'));
}); } finally {
this.sellingItemIndex = null;
}
}, },
sellAll() { async sellAll() {
apiClient.post(`/api/falukant/sell/all`, { branchId: this.branchId }) if (this.sellingAll || this.sellingItemIndex !== null) return;
.catch(() => {
alert(this.$t('falukant.branch.sale.sellAllError')); this.sellingAll = true;
}); this.sellAllStatus = null;
try {
const response = await apiClient.post(`/api/falukant/sell/all`, { branchId: this.branchId });
const revenue = response.data?.revenue || 0;
// UI sofort freigeben + Status setzen, danach Inventory refreshen
this.sellingAll = false;
this.sellAllStatus = {
type: 'success',
message: this.$t('falukant.branch.sale.sellAllSuccess', { revenue: this.formatMoney(revenue) })
};
// Inventory neu laden nach erfolgreichem Verkauf
await this.loadInventory();
} catch (error) {
// UI sofort freigeben + Fehlerstatus setzen
this.sellingAll = false;
this.sellAllStatus = {
type: 'error',
message: this.$t('falukant.branch.sale.sellAllError')
};
} finally {
// Falls noch nicht freigegeben (z.B. wenn ein unerwarteter Fehler vor Response passiert)
this.sellingAll = false;
// Status nach 5 Sekunden löschen
setTimeout(() => {
this.sellAllStatus = null;
}, 5000);
}
}, },
inventoryOptions() { inventoryOptions() {
return this.inventory.map((item, index) => ({ return this.inventory.map((item, index) => ({
@@ -590,5 +642,19 @@
color: #999; color: #999;
font-style: italic; font-style: italic;
} }
.sell-all-status {
margin-top: 10px;
padding: 8px;
border-radius: 4px;
}
.sell-all-status.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.sell-all-status.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
</style> </style>

View File

@@ -0,0 +1,441 @@
<template>
<div ref="container" class="three-scene-container"></div>
</template>
<script>
import { markRaw } from 'vue';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
export default {
name: 'ThreeScene',
props: {
modelPath: {
type: String,
required: true
},
autoRotate: {
type: Boolean,
default: true
},
rotationSpeed: {
type: Number,
default: 0.5
},
cameraPosition: {
type: Object,
default: () => ({ x: 0, y: 1, z: 3 })
},
backgroundColor: {
type: String,
default: '#f0f0f0'
}
},
data() {
return {
scene: null,
camera: null,
renderer: null,
controls: null,
model: null,
animationId: null,
mixer: null,
clock: null,
animationStartTime: 0,
baseY: 0, // Basis-Y-Position für Bewegungsanimation
bones: [] // Gespeicherte Bones für manuelle Animation
};
},
mounted() {
this.initScene();
this.loadModel();
this.animate();
window.addEventListener('resize', this.onWindowResize);
},
beforeUnmount() {
window.removeEventListener('resize', this.onWindowResize);
if (this.animationId) {
cancelAnimationFrame(this.animationId);
}
if (this.mixer) {
this.mixer.stopAllAction();
}
if (this.renderer) {
this.renderer.dispose();
}
if (this.model) {
this.disposeModel(this.model);
}
},
watch: {
modelPath() {
if (this.model) {
this.disposeModel(this.model);
this.model = null;
}
this.loadModel();
},
autoRotate(newVal) {
if (this.controls) {
this.controls.autoRotate = newVal;
}
}
},
methods: {
initScene() {
// Szene erstellen - markRaw verhindert Vue-Reaktivität
this.scene = markRaw(new THREE.Scene());
this.scene.background = new THREE.Color(this.backgroundColor);
// Kamera erstellen - markRaw verhindert Vue-Reaktivität
this.camera = markRaw(new THREE.PerspectiveCamera(
50,
this.$refs.container.clientWidth / this.$refs.container.clientHeight,
0.1,
1000
));
this.camera.position.set(
this.cameraPosition.x,
this.cameraPosition.y,
this.cameraPosition.z
);
// Renderer erstellen - markRaw verhindert Vue-Reaktivität
this.renderer = markRaw(new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: 'high-performance'
}));
this.renderer.setSize(
this.$refs.container.clientWidth,
this.$refs.container.clientHeight
);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // Begrenzt für Performance
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.2; // Leicht erhöhte Helligkeit
this.$refs.container.appendChild(this.renderer.domElement);
// Controls erstellen - markRaw verhindert Vue-Reaktivität
this.controls = markRaw(new OrbitControls(this.camera, this.renderer.domElement));
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
this.controls.autoRotate = false; // Rotation deaktiviert
this.controls.enableRotate = false; // Manuelle Rotation deaktiviert
this.controls.enableZoom = true;
this.controls.enablePan = false;
this.controls.minDistance = 2;
this.controls.maxDistance = 5;
// Clock für Animationen
this.clock = markRaw(new THREE.Clock());
// Verbesserte Beleuchtung
// Umgebungslicht - heller für bessere Sichtbarkeit
const ambientLight = markRaw(new THREE.AmbientLight(0xffffff, 1.0));
this.scene.add(ambientLight);
// Hauptlicht von vorne oben (Key Light)
const mainLight = markRaw(new THREE.DirectionalLight(0xffffff, 1.2));
mainLight.position.set(3, 8, 4);
mainLight.castShadow = true;
mainLight.shadow.mapSize.width = 2048;
mainLight.shadow.mapSize.height = 2048;
mainLight.shadow.camera.near = 0.5;
mainLight.shadow.camera.far = 50;
this.scene.add(mainLight);
// Fülllicht von links (Fill Light)
const fillLight = markRaw(new THREE.DirectionalLight(0xffffff, 0.6));
fillLight.position.set(-4, 5, 3);
this.scene.add(fillLight);
// Zusätzliches Licht von rechts (Rim Light)
const rimLight = markRaw(new THREE.DirectionalLight(0xffffff, 0.5));
rimLight.position.set(4, 3, -3);
this.scene.add(rimLight);
// Punktlicht von oben für zusätzliche Helligkeit
const pointLight = markRaw(new THREE.PointLight(0xffffff, 0.8, 20));
pointLight.position.set(0, 6, 0);
this.scene.add(pointLight);
},
loadModel() {
const loader = new GLTFLoader();
// Optional: DRACO-Loader für komprimierte Modelle
// const dracoLoader = new DRACOLoader();
// dracoLoader.setDecoderPath('https://www.gstatic.com/draco/v1/decoders/');
// loader.setDRACOLoader(dracoLoader);
console.log('[ThreeScene] Loading model from:', this.modelPath);
console.log('[ThreeScene] Full URL:', window.location.origin + this.modelPath);
loader.load(
this.modelPath,
(gltf) => {
console.log('[ThreeScene] Model loaded successfully:', gltf);
// Altes Modell entfernen
if (this.model) {
this.scene.remove(this.model);
this.disposeModel(this.model);
}
// Modell als nicht-reaktiv markieren - verhindert Vue-Proxy-Konflikte
this.model = markRaw(gltf.scene);
// Modell zentrieren und skalieren
const box = new THREE.Box3().setFromObject(this.model);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
console.log('[ThreeScene] Model bounds:', { center, size });
// Modell zentrieren (X und Z)
this.model.position.x = -center.x;
this.model.position.z = -center.z;
// Modell skalieren (größer für bessere Sichtbarkeit)
const maxSize = Math.max(size.x, size.y, size.z);
const scale = maxSize > 0 ? 3.0 / maxSize : 1;
this.model.scale.multiplyScalar(scale);
// Modell auf Boden setzen und Basis-Y-Position speichern
this.baseY = -size.y * scale / 2;
this.model.position.y = this.baseY;
// Schatten aktivieren
this.model.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
this.scene.add(this.model);
// Kamera auf Modell ausrichten
this.centerCameraOnModel();
// Bones für manuelle Animation finden
this.findAndStoreBones(this.model);
// Falls keine Bones gefunden, Hinweis in der Konsole
if (this.bones.length === 0) {
console.warn('[ThreeScene] No bones found in model. To enable limb animations, add bones in Blender. See docs/BLENDER_RIGGING_GUIDE.md');
}
// Animationen aus GLTF laden (falls vorhanden)
if (gltf.animations && gltf.animations.length > 0) {
console.log(`[ThreeScene] Found ${gltf.animations.length} animation(s):`, gltf.animations.map(a => a.name));
this.mixer = markRaw(new THREE.AnimationMixer(this.model));
gltf.animations.forEach((clip) => {
const action = this.mixer.clipAction(clip);
action.play();
console.log(`[ThreeScene] Playing animation: "${clip.name}" (duration: ${clip.duration.toFixed(2)}s)`);
});
} else {
console.log('[ThreeScene] No animations found in model');
}
this.animationStartTime = this.clock.getElapsedTime();
this.$emit('model-loaded', this.model);
},
(progress) => {
// Loading-Progress
if (progress.lengthComputable) {
const percent = (progress.loaded / progress.total) * 100;
this.$emit('loading-progress', percent);
} else {
// Fallback für nicht-computable progress
this.$emit('loading-progress', 50);
}
},
(error) => {
console.error('[ThreeScene] Error loading model:', error);
console.error('[ThreeScene] Model path was:', this.modelPath);
console.error('[ThreeScene] Full URL:', window.location.origin + this.modelPath);
console.error('[ThreeScene] Error details:', {
message: error?.message,
stack: error?.stack,
type: error?.constructor?.name
});
// Prüfe ob es ein 404-Fehler ist (JSON-Parse-Fehler deutet auf HTML-Fehlerseite hin)
if (error?.message && (error.message.includes('JSON') || error.message.includes('Unexpected'))) {
console.error('[ThreeScene] Possible 404 error - file not found or wrong path');
console.error('[ThreeScene] Please check:');
console.error(' 1. File exists at:', this.modelPath);
console.error(' 2. Vite dev server is running');
console.error(' 3. File is in public/ directory');
// Versuche die Datei direkt zu fetchen um den Fehler zu sehen
fetch(this.modelPath)
.then(response => {
console.error('[ThreeScene] Fetch response:', {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries())
});
return response.text();
})
.then(text => {
console.error('[ThreeScene] Response preview:', text.substring(0, 200));
})
.catch(fetchError => {
console.error('[ThreeScene] Fetch error:', fetchError);
});
}
this.$emit('model-error', error);
}
);
},
disposeModel(model) {
model.traverse((child) => {
if (child.isMesh) {
if (child.geometry) child.geometry.dispose();
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach((mat) => mat.dispose());
} else {
child.material.dispose();
}
}
}
});
},
findAndStoreBones(object) {
this.bones = [];
object.traverse((child) => {
if (child.isBone || (child.type === 'Bone')) {
// Speichere Bones mit ihren Namen für einfachen Zugriff
const boneName = child.name.toLowerCase();
// Typische Bone-Namen für Gliedmaßen
if (boneName.includes('arm') ||
boneName.includes('hand') ||
boneName.includes('leg') ||
boneName.includes('foot') ||
boneName.includes('shoulder') ||
boneName.includes('elbow') ||
boneName.includes('knee') ||
boneName.includes('wrist') ||
boneName.includes('ankle')) {
this.bones.push({
bone: child,
name: boneName,
originalRotation: child.rotation.clone()
});
}
}
});
console.log(`[ThreeScene] Found ${this.bones.length} bones for animation`);
},
animateLimbs(time) {
// Sanfte Idle-Animation für Gliedmaßen
const animationSpeed = 1.5; // Geschwindigkeit
const maxRotation = 0.15; // Maximale Rotation in Radianten (ca. 8.6 Grad)
this.bones.forEach((boneData, index) => {
const bone = boneData.bone;
const boneName = boneData.name;
// Unterschiedliche Animationen basierend auf Bone-Typ
if (boneName.includes('arm') || boneName.includes('shoulder')) {
// Arme: Sanftes Vor- und Zurückschwingen
const phase = time * animationSpeed + (index * 0.5);
bone.rotation.x = boneData.originalRotation.x + Math.sin(phase) * maxRotation * 0.3;
bone.rotation.z = boneData.originalRotation.z + Math.cos(phase * 0.7) * maxRotation * 0.2;
} else if (boneName.includes('hand') || boneName.includes('wrist')) {
// Hände: Leichtes Wackeln
const phase = time * animationSpeed * 1.5 + (index * 0.3);
bone.rotation.y = boneData.originalRotation.y + Math.sin(phase) * maxRotation * 0.4;
} else if (boneName.includes('leg') || boneName.includes('knee')) {
// Beine: Leichtes Vor- und Zurückbewegen
const phase = time * animationSpeed * 0.8 + (index * 0.4);
bone.rotation.x = boneData.originalRotation.x + Math.sin(phase) * maxRotation * 0.2;
} else if (boneName.includes('foot') || boneName.includes('ankle')) {
// Füße: Minimales Wackeln
const phase = time * animationSpeed * 1.2 + (index * 0.2);
bone.rotation.x = boneData.originalRotation.x + Math.sin(phase) * maxRotation * 0.15;
}
});
},
centerCameraOnModel() {
if (!this.model || !this.camera) return;
// Kamera-Position für gute Ansicht des zentrierten Modells
this.camera.position.set(0, this.baseY + 1, 3);
this.camera.lookAt(0, this.baseY + 0.5, 0);
if (this.controls) {
this.controls.target.set(0, this.baseY + 0.5, 0);
this.controls.update();
}
},
animate() {
this.animationId = requestAnimationFrame(this.animate);
const delta = this.clock ? this.clock.getDelta() : 0;
// GLTF-Animationen aktualisieren (falls vorhanden)
if (this.mixer) {
this.mixer.update(delta);
}
// Gliedmaßen-Animationen
if (this.bones.length > 0) {
const time = this.clock ? this.clock.getElapsedTime() : 0;
this.animateLimbs(time);
}
if (this.controls) {
this.controls.update();
}
if (this.renderer && this.scene && this.camera) {
this.renderer.render(this.scene, this.camera);
}
},
onWindowResize() {
if (!this.$refs.container || !this.camera || !this.renderer) return;
const width = this.$refs.container.clientWidth;
const height = this.$refs.container.clientHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
}
};
</script>
<style scoped>
.three-scene-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.three-scene-container canvas {
display: block;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,534 @@
<template>
<DialogWidget
ref="dialog"
:title="$t('socialnetwork.vocab.practice.title')"
:show-close="false"
:buttons="buttons"
:modal="true"
:isTitleTranslated="false"
width="55em"
height="32em"
name="VocabPracticeDialog"
display="flex"
>
<div class="layout">
<div class="left">
<div class="opts">
<label class="chk">
<input type="checkbox" v-model="allVocabs" @change="reloadPool" />
{{ $t('socialnetwork.vocab.practice.allVocabs') }}
</label>
<label class="chk">
<input type="checkbox" v-model="simpleMode" @change="onSimpleModeChanged" />
{{ $t('socialnetwork.vocab.practice.simple') }}
</label>
</div>
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-else-if="pool.length === 0">
{{ $t('socialnetwork.vocab.practice.noPool') }}
</div>
<div v-else>
<div class="prompt">
<div class="dir">{{ directionLabel }}</div>
<div class="word">{{ currentPrompt }}</div>
</div>
<div v-if="answered" class="feedback" :class="{ ok: lastCorrect, bad: !lastCorrect }">
<div v-if="lastCorrect">{{ $t('socialnetwork.vocab.practice.correct') }}</div>
<div v-else>
{{ $t('socialnetwork.vocab.practice.wrong') }}
<div class="answers">
<div class="answersTitle">{{ $t('socialnetwork.vocab.practice.acceptable') }}</div>
<ul>
<li v-for="a in acceptableAnswers" :key="a">{{ a }}</li>
</ul>
</div>
</div>
</div>
<div v-if="!answered" class="answerArea">
<div v-if="simpleMode" class="choices">
<button
v-for="opt in choiceOptions"
:key="opt"
class="choiceBtn"
:disabled="locked"
@click="submitChoice(opt)"
>
{{ opt }}
</button>
</div>
<div v-else class="typing">
<input
ref="answerInput"
v-model="typedAnswer"
type="text"
:disabled="locked"
@keydown.enter.prevent="submitTyped"
/>
<button :disabled="locked || typedAnswer.trim().length === 0" @click="submitTyped">
{{ $t('socialnetwork.vocab.practice.check') }}
</button>
</div>
</div>
<div class="controls">
<button v-if="showNextButton" @click="next">
{{ $t('socialnetwork.vocab.practice.next') }}
</button>
<button v-else-if="showSkipButton" @click="skip">
{{ $t('socialnetwork.vocab.practice.skip') }}
</button>
</div>
</div>
</div>
<div class="right">
<div class="stat">
<div class="statTitle">{{ $t('socialnetwork.vocab.practice.stats') }}</div>
<div class="statRow">
<span class="k">{{ $t('socialnetwork.vocab.practice.success') }}</span>
<span class="v">{{ correctCount }} ({{ successPercent }}%)</span>
</div>
<div class="statRow">
<span class="k">{{ $t('socialnetwork.vocab.practice.fail') }}</span>
<span class="v">{{ wrongCount }} ({{ failPercent }}%)</span>
</div>
</div>
</div>
</div>
</DialogWidget>
</template>
<script>
import DialogWidget from '@/components/DialogWidget.vue';
import apiClient from '@/utils/axios.js';
export default {
name: 'VocabPracticeDialog',
components: { DialogWidget },
data() {
return {
openParams: null, // { languageId, chapterId }
onClose: null,
loading: false,
allVocabs: false,
simpleMode: false,
pool: [],
// session stats
correctCount: 0,
wrongCount: 0,
perId: {}, // { [id]: { c, w, streak, lastAsked } }
lastIds: [],
// current question
current: null, // { id, learning, reference }
direction: 'L2R', // L2R: learning->reference, R2L: reference->learning
acceptableAnswers: [],
choiceOptions: [],
typedAnswer: '',
answered: false,
lastCorrect: false,
locked: false,
autoAdvanceTimer: null,
};
},
computed: {
buttons() {
return [{ text: this.$t('message.close'), action: this.close }];
},
totalCount() {
return this.correctCount + this.wrongCount;
},
successPercent() {
if (this.totalCount === 0) return 0;
return Math.round((this.correctCount / this.totalCount) * 100);
},
failPercent() {
if (this.totalCount === 0) return 0;
return Math.round((this.wrongCount / this.totalCount) * 100);
},
currentPrompt() {
if (!this.current) return '';
return this.direction === 'L2R' ? this.current.learning : this.current.reference;
},
directionLabel() {
return this.direction === 'L2R'
? this.$t('socialnetwork.vocab.practice.dirLearningToRef')
: this.$t('socialnetwork.vocab.practice.dirRefToLearning');
},
showNextButton() {
// Nur bei falscher Antwort auf "Weiter" warten
return this.answered && !this.lastCorrect;
},
showSkipButton() {
return !this.answered;
},
},
methods: {
open({ languageId, chapterId, onClose = null }) {
if (this.autoAdvanceTimer) {
clearTimeout(this.autoAdvanceTimer);
this.autoAdvanceTimer = null;
}
this.openParams = { languageId, chapterId };
this.onClose = typeof onClose === 'function' ? onClose : null;
this.allVocabs = false;
this.simpleMode = false;
this.correctCount = 0;
this.wrongCount = 0;
this.perId = {};
this.lastIds = [];
this.pool = [];
this.locked = false;
this.resetQuestion();
this.$refs.dialog.open();
this.$nextTick(() => {
document.addEventListener('keydown', this.handleKeyDown);
});
this.reloadPool();
},
close() {
if (this.autoAdvanceTimer) {
clearTimeout(this.autoAdvanceTimer);
this.autoAdvanceTimer = null;
}
const cb = this.onClose;
this.onClose = null;
document.removeEventListener('keydown', this.handleKeyDown);
this.$refs.dialog.close();
try {
if (cb) cb();
} catch (_) {}
},
handleKeyDown(event) {
// Enter soll bei "Weiter" (falsch beantwortet) funktionieren.
// Im Tippmodus soll Enter weiterhin "Prüfen" auslösen (Input hat eigenen handler).
if (event.key !== 'Enter' && event.keyCode !== 13) return;
if (this.showNextButton) {
event.preventDefault();
this.next();
return;
}
// Falls man im Tippmodus ist und der Fokus NICHT im Input liegt, erlauben wir Enter als "Prüfen".
if (!this.answered && !this.simpleMode && !this.locked) {
const tag = event.target?.tagName?.toLowerCase?.();
if (tag !== 'input' && tag !== 'textarea') {
event.preventDefault();
this.submitTyped();
}
}
},
normalize(s) {
return String(s || '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
},
resetQuestion() {
this.current = null;
this.direction = Math.random() < 0.5 ? 'L2R' : 'R2L';
this.acceptableAnswers = [];
this.choiceOptions = [];
this.typedAnswer = '';
this.answered = false;
this.lastCorrect = false;
this.locked = false;
},
onSimpleModeChanged() {
if (this.autoAdvanceTimer) {
clearTimeout(this.autoAdvanceTimer);
this.autoAdvanceTimer = null;
}
this.locked = false;
this.answered = false;
this.lastCorrect = false;
this.typedAnswer = '';
if (!this.pool || this.pool.length === 0) return;
// Wenn wir aktuell keine Frage haben, sofort eine neue ziehen.
if (!this.current) {
this.next();
return;
}
// Aktuelle Frage behalten, nur UI/Antwortmodus neu aufbauen
const prompt = this.currentPrompt;
this.acceptableAnswers = this.getAnswersForPrompt(prompt, this.direction);
if (this.simpleMode) {
this.buildChoices();
} else {
this.choiceOptions = [];
this.$nextTick(() => this.$refs.answerInput?.focus?.());
}
},
async reloadPool() {
if (!this.openParams) return;
this.loading = true;
try {
let res;
if (this.allVocabs) {
res = await apiClient.get(`/api/vocab/languages/${this.openParams.languageId}/vocabs`);
this.pool = res.data?.vocabs || [];
} else {
res = await apiClient.get(`/api/vocab/chapters/${this.openParams.chapterId}/vocabs`);
this.pool = res.data?.vocabs || [];
}
} catch (e) {
console.error('Reload pool failed:', e);
this.pool = [];
} finally {
this.loading = false;
this.next();
}
},
getAnswersForPrompt(prompt, direction) {
const p = this.normalize(prompt);
const answers = new Set();
for (const item of this.pool) {
const itemPrompt = direction === 'L2R' ? item.learning : item.reference;
if (this.normalize(itemPrompt) === p) {
const a = direction === 'L2R' ? item.reference : item.learning;
answers.add(a);
}
}
return Array.from(answers);
},
computeWeight(item) {
const st = this.perId[item.id] || { c: 0, w: 0, streak: 0, lastAsked: 0 };
let w = 1;
w += st.w * 2.5;
w *= Math.pow(0.7, st.c);
if (st.streak > 0) {
w *= Math.pow(0.8, st.streak);
} else if (st.streak < 0) {
w *= 1 + Math.min(5, Math.abs(st.streak));
}
if (this.lastIds.includes(item.id)) w *= 0.1;
return Math.max(0.05, Math.min(50, w));
},
pickNextItem() {
const items = this.pool;
if (!items || items.length === 0) return null;
const weights = items.map((it) => this.computeWeight(it));
const sum = weights.reduce((a, b) => a + b, 0);
let r = Math.random() * sum;
for (let i = 0; i < items.length; i++) {
r -= weights[i];
if (r <= 0) return items[i];
}
return items[items.length - 1];
},
buildChoices() {
const prompt = this.currentPrompt;
const acceptable = this.getAnswersForPrompt(prompt, this.direction);
this.acceptableAnswers = acceptable;
const options = new Set();
// 1) mindestens eine richtige Übersetzung
options.add(acceptable[0] || (this.direction === 'L2R' ? this.current.reference : this.current.learning));
// 2) weitere Übersetzungen (Mehrdeutigkeiten) fürs gleiche Wort
for (const a of acceptable) {
if (options.size >= 3) break;
options.add(a);
}
// 3) Distraktoren aus anderen Wörtern
const allAnswers = this.pool.map((it) => (this.direction === 'L2R' ? it.reference : it.learning));
for (let i = 0; i < 50 && options.size < 4; i++) {
const cand = allAnswers[Math.floor(Math.random() * allAnswers.length)];
if (!acceptable.map(this.normalize).includes(this.normalize(cand))) {
options.add(cand);
}
}
const arr = Array.from(options);
// shuffle
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
this.choiceOptions = arr;
},
async playSound(ok) {
try {
const audio = new Audio(ok ? '/sounds/success.mp3' : '/sounds/fail.mp3');
await audio.play();
} catch (_) {
// ignore autoplay issues
}
},
markResult(isCorrect) {
this.answered = true;
this.lastCorrect = isCorrect;
if (isCorrect) this.correctCount += 1;
else this.wrongCount += 1;
const id = this.current?.id;
if (!id) return;
const st = this.perId[id] || { c: 0, w: 0, streak: 0, lastAsked: 0 };
if (isCorrect) {
st.c += 1;
st.streak = st.streak >= 0 ? st.streak + 1 : 1;
} else {
st.w += 1;
st.streak = st.streak <= 0 ? st.streak - 1 : -1;
}
st.lastAsked = Date.now();
this.perId[id] = st;
this.lastIds.unshift(id);
this.lastIds = this.lastIds.slice(0, 3);
},
submitChoice(opt) {
if (this.locked) return;
const ok = this.acceptableAnswers.map(this.normalize).includes(this.normalize(opt));
this.markResult(ok);
this.playSound(ok);
if (ok) {
// Direkt weiter zur nächsten Frage (kein Klick nötig)
this.locked = true;
this.autoAdvanceTimer = setTimeout(() => {
this.autoAdvanceTimer = null;
this.next();
}, 350);
}
},
submitTyped() {
if (this.locked) return;
const ans = this.normalize(this.typedAnswer);
const ok = this.acceptableAnswers.map(this.normalize).includes(ans);
this.markResult(ok);
this.playSound(ok);
if (ok) {
this.locked = true;
this.autoAdvanceTimer = setTimeout(() => {
this.autoAdvanceTimer = null;
this.next();
}, 350);
}
},
skip() {
this.next();
},
next() {
if (this.autoAdvanceTimer) {
clearTimeout(this.autoAdvanceTimer);
this.autoAdvanceTimer = null;
}
if (!this.pool || this.pool.length === 0) {
this.resetQuestion();
return;
}
this.resetQuestion();
this.current = this.pickNextItem();
if (!this.current) return;
const prompt = this.currentPrompt;
this.acceptableAnswers = this.getAnswersForPrompt(prompt, this.direction);
if (this.simpleMode) this.buildChoices();
this.$nextTick(() => {
if (!this.simpleMode) this.$refs.answerInput?.focus?.();
});
},
},
};
</script>
<style scoped>
.layout {
display: flex;
gap: 16px;
height: 100%;
}
.left {
flex: 1;
min-width: 0;
}
.right {
width: 16em;
border-left: 1px solid #ddd;
padding-left: 12px;
}
.opts {
display: flex;
gap: 16px;
margin-bottom: 10px;
}
.chk {
display: inline-flex;
gap: 6px;
align-items: center;
}
.prompt {
padding: 10px;
background: #fff;
border: 1px solid #ccc;
margin-bottom: 10px;
}
.dir {
color: #555;
font-size: 0.9em;
}
.word {
font-size: 1.8em;
font-weight: bold;
}
.typing {
display: flex;
gap: 8px;
align-items: center;
}
.typing input {
flex: 1;
padding: 6px;
}
.choices {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.choiceBtn {
padding: 8px;
}
.controls {
margin-top: 12px;
}
.feedback {
padding: 10px;
border: 1px solid #ccc;
margin-bottom: 10px;
}
.feedback.ok {
background: #e8ffe8;
border-color: #7bbe55;
}
.feedback.bad {
background: #ffecec;
border-color: #d33;
}
.answersTitle {
margin-top: 6px;
font-weight: bold;
}
.statTitle {
font-weight: bold;
margin-bottom: 8px;
}
.statRow {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
}
.k {
color: #333;
}
.v {
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,162 @@
<template>
<DialogWidget
ref="dialog"
:title="$t('socialnetwork.vocab.search.title')"
:show-close="true"
:buttons="buttons"
:modal="true"
:isTitleTranslated="false"
width="60em"
height="34em"
name="VocabSearchDialog"
display="flex"
@close="close"
>
<div class="layout">
<div class="top">
<div class="row">
<label class="field">
{{ $t('socialnetwork.vocab.search.term') }}
<input v-model="term" type="text" @keydown.enter.prevent="runSearch" />
</label>
<button class="btn" :disabled="loading || !term.trim()" @click="runSearch">
{{ loading ? $t('general.loading') : $t('socialnetwork.vocab.search.search') }}
</button>
</div>
</div>
<div class="body">
<div v-if="error" class="error">{{ error }}</div>
<div v-else-if="results.length === 0">
{{ $t('socialnetwork.vocab.search.noResults') }}
</div>
<table v-else class="tbl">
<thead>
<tr>
<th>{{ $t('socialnetwork.vocab.search.motherTongue') }}</th>
<th>{{ learningLabel }}</th>
<th>{{ $t('socialnetwork.vocab.search.lesson') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="r in results" :key="r.id">
<td>{{ r.motherTongue }}</td>
<td>{{ r.learning }}</td>
<td>{{ r.chapterTitle }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</DialogWidget>
</template>
<script>
import DialogWidget from '@/components/DialogWidget.vue';
import apiClient from '@/utils/axios.js';
export default {
name: 'VocabSearchDialog',
components: { DialogWidget },
data() {
return {
languageId: null,
languageName: '',
term: '',
loading: false,
results: [],
error: '',
};
},
computed: {
buttons() {
return [{ text: this.$t('message.close'), action: this.close }];
},
learningLabel() {
return this.languageName || this.$t('socialnetwork.vocab.search.learningLanguage');
},
},
methods: {
open({ languageId, languageName = '' } = {}) {
this.languageId = languageId;
this.languageName = languageName || '';
this.term = '';
this.results = [];
this.error = '';
this.loading = false;
this.$refs.dialog.open();
this.$nextTick(() => {});
},
close() {
this.$refs.dialog.close();
},
async runSearch() {
if (!this.languageId) return;
const q = this.term.trim();
if (!q) return;
this.loading = true;
this.error = '';
try {
const res = await apiClient.get(`/api/vocab/languages/${this.languageId}/search`, {
params: {
q,
},
});
this.results = res.data?.results || [];
} catch (e) {
console.error('Search failed:', e);
this.results = [];
this.error = this.$t('socialnetwork.vocab.search.error');
} finally {
this.loading = false;
}
},
},
};
</script>
<style scoped>
.layout {
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
}
.top .row {
display: flex;
gap: 10px;
align-items: flex-end;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.field input {
padding: 6px;
}
.btn {
padding: 8px 12px;
}
.body {
flex: 1;
overflow: auto;
}
.tbl {
width: 100%;
border-collapse: collapse;
}
.tbl th,
.tbl td {
border: 1px solid #ccc;
padding: 6px;
}
.error {
color: #b00020;
margin-bottom: 8px;
}
</style>

View File

@@ -113,6 +113,35 @@
"errorSaveConnection": "Die Verbindung konnte nicht gespeichert werden.", "errorSaveConnection": "Die Verbindung konnte nicht gespeichert werden.",
"errorDeleteConnection": "Die Verbindung konnte nicht gelöscht werden.", "errorDeleteConnection": "Die Verbindung konnte nicht gelöscht werden.",
"confirmDeleteConnection": "Verbindung wirklich löschen?" "confirmDeleteConnection": "Verbindung wirklich löschen?"
},
"createNPC": {
"title": "NPCs erstellen",
"region": "Stadt",
"allRegions": "Alle Städte",
"ageRange": "Altersbereich",
"to": "bis",
"years": "Jahre",
"titleRange": "Titel-Bereich",
"count": "Anzahl pro Stadt-Titel-Kombination",
"countHelp": "Diese Anzahl wird für jede Kombination aus gewählter Stadt und Titel erstellt.",
"create": "NPCs erstellen",
"creating": "Erstelle...",
"result": "Ergebnis",
"createdCount": "{count} NPCs wurden erstellt.",
"combinationInfo": "{perCombination} NPCs pro Kombination × {combinations} Kombinationen = {count} NPCs insgesamt",
"age": "Alter",
"errorLoadingRegions": "Fehler beim Laden der Städte.",
"errorLoadingTitles": "Fehler beim Laden der Titel.",
"errorCreating": "Fehler beim Erstellen der NPCs.",
"invalidAgeRange": "Ungültiger Altersbereich.",
"invalidTitleRange": "Ungültiger Titel-Bereich.",
"invalidCount": "Ungültige Anzahl (1-500).",
"progress": "Fortschritt",
"progressDetails": "{current} von {total} NPCs erstellt",
"timeRemainingSeconds": "Verbleibende Zeit: {seconds} Sekunden",
"timeRemainingMinutes": "Verbleibende Zeit: {minutes} Minuten {seconds} Sekunden",
"almostDone": "Fast fertig...",
"jobNotFound": "Job nicht gefunden oder abgelaufen."
} }
}, },
"chatrooms": { "chatrooms": {

View File

@@ -135,6 +135,14 @@
"store": "Verkauf", "store": "Verkauf",
"fullstack": "Produktion mit Verkauf" "fullstack": "Produktion mit Verkauf"
} }
},
"heirSelection": {
"title": "Charakter verloren - Erben auswählen",
"description": "Dein Charakter wurde durch einen Fehler verloren. Bitte wähle einen Erben aus deiner Hauptregion aus, um fortzufahren.",
"loading": "Lade mögliche Erben...",
"noHeirs": "Es wurden keine passenden Erben gefunden.",
"select": "Als Erben wählen",
"error": "Fehler beim Auswählen des Erben."
} }
}, },
"titles": { "titles": {
@@ -232,6 +240,7 @@
"produce": "Darf produzieren", "produce": "Darf produzieren",
"sell": "Darf verkaufen", "sell": "Darf verkaufen",
"starttransport": "Darf Transporte veranlassen", "starttransport": "Darf Transporte veranlassen",
"repairVehicles": "Darf Fahrzeuge reparieren",
"emptyTransport": { "emptyTransport": {
"title": "Transport ohne Produkte", "title": "Transport ohne Produkte",
"description": "Bewege Transportmittel von dieser Niederlassung zu einer anderen, um sie besser zu nutzen.", "description": "Bewege Transportmittel von dieser Niederlassung zu einer anderen, um sie besser zu nutzen.",
@@ -260,6 +269,10 @@
"sell": "Verkauf", "sell": "Verkauf",
"sellButton": "Verkaufen", "sellButton": "Verkaufen",
"sellAllButton": "Alles verkaufen", "sellAllButton": "Alles verkaufen",
"selling": "Verkauf läuft...",
"sellError": "Fehler beim Verkauf des Produkts.",
"sellAllError": "Fehler beim Verkauf aller Produkte.",
"sellAllSuccess": "Alle Produkte wurden erfolgreich verkauft. Einnahmen: {revenue}",
"transportTitle": "Transport anlegen", "transportTitle": "Transport anlegen",
"transportSource": "Artikel", "transportSource": "Artikel",
"transportSourcePlaceholder": "Artikel wählen", "transportSourcePlaceholder": "Artikel wählen",
@@ -578,6 +591,7 @@
"Production cost": "Produktionskosten", "Production cost": "Produktionskosten",
"Sell all products": "Alle Produkte verkauft", "Sell all products": "Alle Produkte verkauft",
"sell products": "Produkte verkauft", "sell products": "Produkte verkauft",
"taxFromSaleProduct": "Steuer aus Verkauf: {product}",
"director starts production": "Direktor beginnt Produktion", "director starts production": "Direktor beginnt Produktion",
"director payed out": "Direktorgehalt ausgezahlt", "director payed out": "Direktorgehalt ausgezahlt",
"Buy storage (type: field)": "Lagerplatz gekauft (Typ: Feld)", "Buy storage (type: field)": "Lagerplatz gekauft (Typ: Feld)",
@@ -596,6 +610,9 @@
"new nobility title": "Neuer Adelstitel", "new nobility title": "Neuer Adelstitel",
"partyOrder": "Fest bestellt", "partyOrder": "Fest bestellt",
"renovation_all": "Haus komplett renoviert", "renovation_all": "Haus komplett renoviert",
"reputationAction": {
"school_funding": "Sozialstatus: Schule/Lehrstuhl finanziert"
},
"health": { "health": {
"pill": "Gesundheitsmaßnahme: Tablette", "pill": "Gesundheitsmaßnahme: Tablette",
"doctor": "Gesundheitsmaßnahme: Arztbesuch", "doctor": "Gesundheitsmaßnahme: Arztbesuch",
@@ -738,7 +755,8 @@
"reputation": { "reputation": {
"title": "Reputation", "title": "Reputation",
"overview": { "overview": {
"title": "Übersicht" "title": "Übersicht",
"current": "Deine aktuelle Reputation"
}, },
"party": { "party": {
"title": "Feste", "title": "Feste",
@@ -777,6 +795,34 @@
"type": "Festart", "type": "Festart",
"cost": "Kosten", "cost": "Kosten",
"date": "Datum" "date": "Datum"
},
"actions": {
"title": "Aktionen",
"description": "Mit diesen Aktionen kannst du Reputation gewinnen. Je öfter du dieselbe Aktion ausführst, desto weniger Reputation bringt sie (unabhängig von den Kosten).",
"action": "Aktion",
"cost": "Kosten",
"gain": "Reputation",
"timesUsed": "Bereits genutzt",
"execute": "Ausführen",
"running": "Läuft...",
"none": "Keine Aktionen verfügbar.",
"dailyLimit": "Heute noch verfügbar: {remaining} / {cap} Reputation (durch Aktionen).",
"cooldown": "Nächste Sozialstatus-Aktion in ca. {minutes} Minuten möglich.",
"success": "Aktion erfolgreich! Reputation +{gain}, Kosten {cost}.",
"successSimple": "Aktion erfolgreich!",
"type": {
"library_donation": "Spende für eine Bibliothek",
"orphanage_build": "Waisenhaus aufbauen",
"statue_build": "Statue errichten",
"hospital_donation": "Krankenhaus/Heilhaus stiften",
"school_funding": "Schule/Lehrstuhl finanzieren",
"well_build": "Brunnen/Wasserwerk bauen",
"bridge_build": "Straßen-/Brückenbau finanzieren",
"soup_kitchen": "Armenspeisung organisieren",
"patronage": "Kunst & Mäzenatentum",
"church_hospice": "Hospiz-/Kirchenspende",
"scholarships": "Stipendienfonds finanzieren"
}
} }
}, },
"party": { "party": {
@@ -883,6 +929,9 @@
"success": "Erfolg", "success": "Erfolg",
"selectMeasure": "Maßnahme", "selectMeasure": "Maßnahme",
"perform": "Durchführen", "perform": "Durchführen",
"errors": {
"tooClose": "Aktionen zu dicht hintereinander (maximal 1× pro 24 Stunden)."
},
"measures": { "measures": {
"pill": "Tablette", "pill": "Tablette",
"doctor": "Arztbesuch", "doctor": "Arztbesuch",

View File

@@ -26,7 +26,10 @@
} }
}, },
"general": { "general": {
"datetimelong": "dd.MM.yyyy HH:mm:ss" "datetimelong": "dd.MM.yyyy HH:mm:ss",
"loading": "Lädt...",
"back": "Zurück",
"cancel": "Abbrechen"
}, },
"OK": "Ok", "OK": "Ok",
"Cancel": "Abbrechen", "Cancel": "Abbrechen",

View File

@@ -20,6 +20,7 @@
"usersearch": "Benutzersuche", "usersearch": "Benutzersuche",
"forum": "Forum", "forum": "Forum",
"gallery": "Galerie", "gallery": "Galerie",
"vocabtrainer": "Vokabeltrainer",
"blockedUsers": "Blockierte Benutzer", "blockedUsers": "Blockierte Benutzer",
"oneTimeInvitation": "Einmal-Einladungen", "oneTimeInvitation": "Einmal-Einladungen",
"diary": "Tagebuch", "diary": "Tagebuch",
@@ -27,6 +28,9 @@
"m-erotic": { "m-erotic": {
"pictures": "Bilder", "pictures": "Bilder",
"videos": "Videos" "videos": "Videos"
},
"m-vocabtrainer": {
"newLanguage": "Neue Sprache"
} }
}, },
"m-minigames": { "m-minigames": {
@@ -46,6 +50,7 @@
"m-administration": { "m-administration": {
"contactrequests": "Kontaktanfragen", "contactrequests": "Kontaktanfragen",
"users": "Benutzer", "users": "Benutzer",
"userrights": "Benutzerrechte",
"m-users": { "m-users": {
"userlist": "Benutzerliste", "userlist": "Benutzerliste",
"userstatistics": "Benutzerstatistiken", "userstatistics": "Benutzerstatistiken",
@@ -58,7 +63,8 @@
"logentries": "Log-Einträge", "logentries": "Log-Einträge",
"edituser": "Benutzer bearbeiten", "edituser": "Benutzer bearbeiten",
"database": "Datenbank", "database": "Datenbank",
"mapEditor": "Karteneditor" "mapEditor": "Karteneditor",
"createNPC": "NPCs erstellen"
}, },
"minigames": "Minispiele", "minigames": "Minispiele",
"m-minigames": { "m-minigames": {

View File

@@ -249,5 +249,77 @@
"denied": "Du hast die Freundschaftsanfrage abgelehnt.", "denied": "Du hast die Freundschaftsanfrage abgelehnt.",
"accepted": "Die Freundschaft wurde geschlossen." "accepted": "Die Freundschaft wurde geschlossen."
} }
,
"vocab": {
"title": "Vokabeltrainer",
"description": "Lege Sprachen an (oder abonniere sie) und teile sie mit Freunden.",
"newLanguage": "Neue Sprache",
"newLanguageTitle": "Neue Sprache anlegen",
"languageName": "Name der Sprache",
"create": "Anlegen",
"saving": "Speichere...",
"created": "Sprache wurde angelegt.",
"createdTitle": "Vokabeltrainer",
"createdMessage": "Sprache wurde angelegt. Das Menü wird aktualisiert.",
"createError": "Konnte die Sprache nicht anlegen.",
"openLanguage": "Öffnen",
"none": "Du hast noch keine Sprachen angelegt oder abonniert.",
"owner": "Eigen",
"subscribed": "Abonniert",
"languageTitle": "Vokabeltrainer: {name}",
"notFound": "Sprache nicht gefunden oder kein Zugriff.",
"shareCode": "Teilen-Code",
"shareHint": "Diesen Code kannst du an Freunde weitergeben, damit sie die Sprache abonnieren können.",
"subscribeByCode": "Per Code abonnieren",
"subscribeTitle": "Sprache abonnieren",
"subscribeHint": "Gib den Teilen-Code ein, den du von einem Freund bekommen hast.",
"subscribe": "Abonnieren",
"subscribeSuccess": "Abo erfolgreich. Menü wird aktualisiert.",
"subscribeError": "Abo fehlgeschlagen. Code ungültig oder kein Zugriff.",
"trainerPlaceholder": "Trainer-Funktionen (Vokabeln/Abfragen) kommen als nächster Schritt."
,
"chapters": "Kapitel",
"newChapter": "Neues Kapitel",
"createChapter": "Kapitel anlegen",
"createChapterError": "Konnte Kapitel nicht anlegen.",
"noChapters": "Noch keine Kapitel vorhanden.",
"chapterTitle": "Kapitel: {title}",
"addVocab": "Vokabel hinzufügen",
"learningWord": "Lernsprache",
"referenceWord": "Referenz",
"add": "Hinzufügen",
"addVocabError": "Konnte Vokabel nicht hinzufügen.",
"noVocabs": "In diesem Kapitel sind noch keine Vokabeln."
,
"practice": {
"open": "Üben",
"title": "Vokabeln üben",
"allVocabs": "Alle Vokabeln",
"simple": "Einfaches Üben",
"noPool": "Keine Vokabeln zum Üben vorhanden.",
"dirLearningToRef": "Lernsprache → Referenz",
"dirRefToLearning": "Referenz → Lernsprache",
"check": "Prüfen",
"next": "Weiter",
"skip": "Überspringen",
"correct": "Richtig!",
"wrong": "Falsch.",
"acceptable": "Mögliche richtige Übersetzungen:",
"stats": "Statistik",
"success": "Erfolg",
"fail": "Misserfolg"
},
"search": {
"open": "Suche",
"title": "Vokabeln suchen",
"term": "Suchbegriff",
"motherTongue": "Muttersprache",
"learningLanguage": "Lernsprache",
"lesson": "Lektion",
"search": "Suchen",
"noResults": "Keine Treffer.",
"error": "Suche fehlgeschlagen."
}
}
} }
} }

View File

@@ -140,6 +140,35 @@
"errorAddingStock": "Error adding warehouse.", "errorAddingStock": "Error adding warehouse.",
"stockAdded": "Warehouse successfully added.", "stockAdded": "Warehouse successfully added.",
"invalidStockData": "Please enter valid warehouse type and quantity." "invalidStockData": "Please enter valid warehouse type and quantity."
},
"createNPC": {
"title": "Create NPCs",
"region": "City",
"allRegions": "All Cities",
"ageRange": "Age Range",
"to": "to",
"years": "years",
"titleRange": "Title Range",
"count": "Count per City-Title Combination",
"countHelp": "This count will be created for each combination of selected city and title.",
"create": "Create NPCs",
"creating": "Creating...",
"result": "Result",
"createdCount": "{count} NPCs have been created.",
"combinationInfo": "{perCombination} NPCs per combination × {combinations} combinations = {count} NPCs total",
"age": "Age",
"errorLoadingRegions": "Error loading cities.",
"errorLoadingTitles": "Error loading titles.",
"errorCreating": "Error creating NPCs.",
"invalidAgeRange": "Invalid age range.",
"invalidTitleRange": "Invalid title range.",
"invalidCount": "Invalid count (1-500).",
"progress": "Progress",
"progressDetails": "{current} of {total} NPCs created",
"timeRemainingSeconds": "Time remaining: {seconds} seconds",
"timeRemainingMinutes": "Time remaining: {minutes} minutes {seconds} seconds",
"almostDone": "Almost done...",
"jobNotFound": "Job not found or expired."
} }
}, },
"chatrooms": { "chatrooms": {

View File

@@ -100,6 +100,12 @@
"bad": "Bad", "bad": "Bad",
"very_bad": "Very bad" "very_bad": "Very bad"
}, },
"healthview": {
"title": "Health",
"errors": {
"tooClose": "Actions too close together (max once per 24 hours)."
}
},
"moneyHistory": { "moneyHistory": {
"title": "Money history", "title": "Money history",
"filter": "Filter", "filter": "Filter",
@@ -116,6 +122,7 @@
"Production cost": "Production cost", "Production cost": "Production cost",
"Sell all products": "Sell all products", "Sell all products": "Sell all products",
"sell products": "Sell products", "sell products": "Sell products",
"taxFromSaleProduct": "Tax from product sale: {product}",
"director starts production": "Director starts production", "director starts production": "Director starts production",
"director payed out": "Director salary paid out", "director payed out": "Director salary paid out",
"Buy storage (type: field)": "Bought storage (type: field)", "Buy storage (type: field)": "Bought storage (type: field)",
@@ -134,6 +141,9 @@
"new nobility title": "New title of nobility", "new nobility title": "New title of nobility",
"partyOrder": "Party ordered", "partyOrder": "Party ordered",
"renovation_all": "House fully renovated", "renovation_all": "House fully renovated",
"reputationAction": {
"school_funding": "Social status: funded a school/chair"
},
"health": { "health": {
"pill": "Health measure: pill", "pill": "Health measure: pill",
"doctor": "Health measure: doctor", "doctor": "Health measure: doctor",
@@ -163,7 +173,8 @@
}, },
"director": { "director": {
"income": "Income", "income": "Income",
"incomeUpdated": "Salary has been successfully updated." "incomeUpdated": "Salary has been successfully updated.",
"repairVehicles": "May repair vehicles"
}, },
"vehicles": { "vehicles": {
"cargo_cart": "Cargo cart", "cargo_cart": "Cargo cart",
@@ -195,9 +206,80 @@
} }
} }
}, },
"overview": {
"title": "Falukant - Overview",
"metadata": {
"title": "Personal",
"name": "Name",
"money": "Wealth",
"age": "Age",
"mainbranch": "Home City",
"nobleTitle": "Status"
},
"productions": {
"title": "Productions"
},
"stock": {
"title": "Stock"
},
"branches": {
"title": "Branches",
"level": {
"production": "Production",
"store": "Store",
"fullstack": "Production with Store"
}
},
"heirSelection": {
"title": "Character Lost - Select Heir",
"description": "Your character was lost due to an error. Please select an heir from your main region to continue.",
"loading": "Loading potential heirs...",
"noHeirs": "No suitable heirs were found.",
"select": "Select as Heir",
"error": "Error selecting heir."
}
},
"nobility": { "nobility": {
"cooldown": "You can only advance again on {date}." "cooldown": "You can only advance again on {date}."
}, },
"reputation": {
"title": "Reputation",
"overview": {
"title": "Overview",
"current": "Your current reputation"
},
"party": {
"title": "Parties"
},
"actions": {
"title": "Actions",
"description": "These actions let you gain reputation. The more often you repeat the same action, the less reputation it yields (independent of cost).",
"action": "Action",
"cost": "Cost",
"gain": "Reputation",
"timesUsed": "Times used",
"execute": "Execute",
"running": "Running...",
"none": "No actions available.",
"dailyLimit": "Available today: {remaining} / {cap} reputation (from actions).",
"cooldown": "Next social status action available in about {minutes} minutes.",
"success": "Action successful! Reputation +{gain}, cost {cost}.",
"successSimple": "Action successful!",
"type": {
"library_donation": "Donate to a library",
"orphanage_build": "Build an orphanage",
"statue_build": "Erect a statue",
"hospital_donation": "Found a hospital/infirmary",
"school_funding": "Fund a school/chair",
"well_build": "Build a well/waterworks",
"bridge_build": "Fund roads/bridges",
"soup_kitchen": "Organize a soup kitchen",
"patronage": "Arts & patronage",
"church_hospice": "Hospice/church donation",
"scholarships": "Fund scholarships"
}
}
},
"branchProduction": { "branchProduction": {
"storageAvailable": "Free storage" "storageAvailable": "Free storage"
}, },

View File

@@ -6,6 +6,12 @@
"dataPrivacy": { "dataPrivacy": {
"title": "Data Privacy Policy" "title": "Data Privacy Policy"
}, },
"general": {
"loading": "Loading...",
"back": "Back",
"cancel": "Cancel",
"datetimelong": "dd.MM.yyyy HH:mm:ss"
},
"message": { "message": {
"close": "Close" "close": "Close"
}, },

View File

@@ -20,6 +20,7 @@
"usersearch": "User search", "usersearch": "User search",
"forum": "Forum", "forum": "Forum",
"gallery": "Gallery", "gallery": "Gallery",
"vocabtrainer": "Vocabulary trainer",
"blockedUsers": "Blocked users", "blockedUsers": "Blocked users",
"oneTimeInvitation": "One-time invitations", "oneTimeInvitation": "One-time invitations",
"diary": "Diary", "diary": "Diary",
@@ -27,6 +28,9 @@
"m-erotic": { "m-erotic": {
"pictures": "Pictures", "pictures": "Pictures",
"videos": "Videos" "videos": "Videos"
},
"m-vocabtrainer": {
"newLanguage": "New language"
} }
}, },
"m-minigames": { "m-minigames": {
@@ -46,6 +50,7 @@
"m-administration": { "m-administration": {
"contactrequests": "Contact requests", "contactrequests": "Contact requests",
"users": "Users", "users": "Users",
"userrights": "User rights",
"m-users": { "m-users": {
"userlist": "User list", "userlist": "User list",
"userstatistics": "User statistics", "userstatistics": "User statistics",
@@ -58,7 +63,8 @@
"logentries": "Log entries", "logentries": "Log entries",
"edituser": "Edit user", "edituser": "Edit user",
"database": "Database", "database": "Database",
"mapEditor": "Map editor" "mapEditor": "Map editor",
"createNPC": "Create NPCs"
}, },
"minigames": "Mini games", "minigames": "Mini games",
"m-minigames": { "m-minigames": {

View File

@@ -249,5 +249,77 @@
"denied": "You have denied the friendship request.", "denied": "You have denied the friendship request.",
"accepted": "The friendship has been established." "accepted": "The friendship has been established."
} }
,
"vocab": {
"title": "Vocabulary trainer",
"description": "Create languages (or subscribe to them) and share them with friends.",
"newLanguage": "New language",
"newLanguageTitle": "Create new language",
"languageName": "Language name",
"create": "Create",
"saving": "Saving...",
"created": "Language created.",
"createdTitle": "Vocabulary trainer",
"createdMessage": "Language created. The menu will refresh.",
"createError": "Could not create language.",
"openLanguage": "Open",
"none": "You have no languages yet (created or subscribed).",
"owner": "Owned",
"subscribed": "Subscribed",
"languageTitle": "Vocabulary trainer: {name}",
"notFound": "Language not found or no access.",
"shareCode": "Share code",
"shareHint": "Send this code to friends so they can subscribe to this language.",
"subscribeByCode": "Subscribe by code",
"subscribeTitle": "Subscribe to language",
"subscribeHint": "Enter a share code you received from a friend.",
"subscribe": "Subscribe",
"subscribeSuccess": "Subscribed. The menu will refresh.",
"subscribeError": "Subscribe failed. Invalid code or no access.",
"trainerPlaceholder": "Trainer features (words/quizzes) will be the next step."
,
"chapters": "Chapters",
"newChapter": "New chapter",
"createChapter": "Create chapter",
"createChapterError": "Could not create chapter.",
"noChapters": "No chapters yet.",
"chapterTitle": "Chapter: {title}",
"addVocab": "Add vocabulary",
"learningWord": "To learn",
"referenceWord": "Reference",
"add": "Add",
"addVocabError": "Could not add vocabulary.",
"noVocabs": "No vocabulary in this chapter yet."
,
"practice": {
"open": "Practice",
"title": "Practice vocabulary",
"allVocabs": "All vocabulary",
"simple": "Simple practice",
"noPool": "No vocabulary to practice.",
"dirLearningToRef": "To learn → Reference",
"dirRefToLearning": "Reference → To learn",
"check": "Check",
"next": "Next",
"skip": "Skip",
"correct": "Correct!",
"wrong": "Wrong.",
"acceptable": "Acceptable answers:",
"stats": "Stats",
"success": "Success",
"fail": "Fail"
},
"search": {
"open": "Search",
"title": "Search vocabulary",
"term": "Search term",
"motherTongue": "Mother tongue",
"learningLanguage": "Learning language",
"lesson": "Lesson",
"search": "Search",
"noResults": "No results.",
"error": "Search failed."
}
}
} }
} }

View File

@@ -5,6 +5,7 @@ import UserRightsView from '../views/admin/UserRightsView.vue';
import ForumAdminView from '../dialogues/admin/ForumAdminView.vue'; import ForumAdminView from '../dialogues/admin/ForumAdminView.vue';
import AdminFalukantEditUserView from '../views/admin/falukant/EditUserView.vue'; import AdminFalukantEditUserView from '../views/admin/falukant/EditUserView.vue';
import AdminFalukantMapRegionsView from '../views/admin/falukant/MapRegionsView.vue'; import AdminFalukantMapRegionsView from '../views/admin/falukant/MapRegionsView.vue';
import AdminFalukantCreateNPCView from '../views/admin/falukant/CreateNPCView.vue';
import AdminMinigamesView from '../views/admin/MinigamesView.vue'; import AdminMinigamesView from '../views/admin/MinigamesView.vue';
import AdminTaxiToolsView from '../views/admin/TaxiToolsView.vue'; import AdminTaxiToolsView from '../views/admin/TaxiToolsView.vue';
import AdminUsersView from '../views/admin/UsersView.vue'; import AdminUsersView from '../views/admin/UsersView.vue';
@@ -66,6 +67,12 @@ const adminRoutes = [
component: AdminFalukantMapRegionsView, component: AdminFalukantMapRegionsView,
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{
path: '/admin/falukant/create-npc',
name: 'AdminFalukantCreateNPCView',
component: AdminFalukantCreateNPCView,
meta: { requiresAuth: true }
},
{ {
path: '/admin/minigames/match3', path: '/admin/minigames/match3',
name: 'AdminMinigames', name: 'AdminMinigames',

View File

@@ -5,6 +5,11 @@ import GuestbookView from '../views/social/GuestbookView.vue';
import DiaryView from '../views/social/DiaryView.vue'; import DiaryView from '../views/social/DiaryView.vue';
import ForumView from '../views/social/ForumView.vue'; import ForumView from '../views/social/ForumView.vue';
import ForumTopicView from '../views/social/ForumTopicView.vue'; import ForumTopicView from '../views/social/ForumTopicView.vue';
import VocabTrainerView from '../views/social/VocabTrainerView.vue';
import VocabNewLanguageView from '../views/social/VocabNewLanguageView.vue';
import VocabLanguageView from '../views/social/VocabLanguageView.vue';
import VocabSubscribeView from '../views/social/VocabSubscribeView.vue';
import VocabChapterView from '../views/social/VocabChapterView.vue';
const socialRoutes = [ const socialRoutes = [
{ {
@@ -49,6 +54,36 @@ const socialRoutes = [
component: DiaryView, component: DiaryView,
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{
path: '/socialnetwork/vocab',
name: 'VocabTrainer',
component: VocabTrainerView,
meta: { requiresAuth: true }
},
{
path: '/socialnetwork/vocab/new',
name: 'VocabNewLanguage',
component: VocabNewLanguageView,
meta: { requiresAuth: true }
},
{
path: '/socialnetwork/vocab/subscribe',
name: 'VocabSubscribe',
component: VocabSubscribeView,
meta: { requiresAuth: true }
},
{
path: '/socialnetwork/vocab/:languageId',
name: 'VocabLanguage',
component: VocabLanguageView,
meta: { requiresAuth: true }
},
{
path: '/socialnetwork/vocab/:languageId/chapters/:chapterId',
name: 'VocabChapter',
component: VocabChapterView,
meta: { requiresAuth: true }
},
]; ];
export default socialRoutes; export default socialRoutes;

View File

@@ -187,6 +187,20 @@ const store = createStore({
if (!socketIoUrl && (import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) { if (!socketIoUrl && (import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) {
socketIoUrl = 'http://localhost:3001'; socketIoUrl = 'http://localhost:3001';
} }
// Normalisiere URL (Env-Variablen enthalten teils Pfade wie /api; Port kann absichtlich gesetzt sein, z.B. :4443)
try {
if (socketIoUrl) {
const parsed = new URL(socketIoUrl, window.location.origin);
// Falls /api oder ähnliche Pfade enthalten sind → auf Origin reduzieren (inkl. Port!)
socketIoUrl = parsed.origin;
}
} catch (e) {
// Wenn Parsing fehlschlägt: letzte Rettung ist der aktuelle Origin
try {
socketIoUrl = window.location.origin;
} catch (_) {}
}
const socket = io(socketIoUrl, { const socket = io(socketIoUrl, {
secure: true, secure: true,

View File

@@ -0,0 +1,447 @@
<template>
<div class="contenthidden">
<div class="contentscroll">
<div class="create-npc-view">
<h1>{{ $t('admin.falukant.createNPC.title') }}</h1>
<div class="form-section">
<div class="form-group">
<label>{{ $t('admin.falukant.createNPC.region') }}:</label>
<div class="region-selection">
<label>
<input type="checkbox" v-model="allRegions" @change="onAllRegionsChange" />
{{ $t('admin.falukant.createNPC.allRegions') }}
</label>
<select v-model="selectedRegionIds" multiple :disabled="allRegions" class="form-select" size="10">
<option v-for="region in regions" :key="region.id" :value="region.id">
{{ region.name }}
</option>
</select>
</div>
</div>
<div class="form-group">
<label>{{ $t('admin.falukant.createNPC.ageRange') }}:</label>
<div class="age-range">
<input type="number" v-model.number="minAge" min="0" max="100" class="form-input" />
<span>{{ $t('admin.falukant.createNPC.to') }}</span>
<input type="number" v-model.number="maxAge" min="0" max="100" class="form-input" />
<span>{{ $t('admin.falukant.createNPC.years') }}</span>
</div>
</div>
<div class="form-group">
<label>{{ $t('admin.falukant.createNPC.titleRange') }}:</label>
<div class="title-range">
<select v-model.number="minTitleId" class="form-select">
<option v-for="title in titles" :key="title.id" :value="title.id">
{{ $t(`falukant.titles.male.${title.labelTr}`) }} (ID: {{ title.id }})
</option>
</select>
<span>{{ $t('admin.falukant.createNPC.to') }}</span>
<select v-model.number="maxTitleId" class="form-select">
<option v-for="title in titles" :key="title.id" :value="title.id">
{{ $t(`falukant.titles.male.${title.labelTr}`) }} (ID: {{ title.id }})
</option>
</select>
</div>
</div>
<div class="form-group">
<label>{{ $t('admin.falukant.createNPC.count') }}:</label>
<input type="number" v-model.number="count" min="1" max="500" class="form-input" />
<div class="help-text">{{ $t('admin.falukant.createNPC.countHelp') }}</div>
</div>
<div class="action-buttons">
<button @click="createNPCs" :disabled="creating" class="btn btn-primary">
{{ creating ? $t('admin.falukant.createNPC.creating') : $t('admin.falukant.createNPC.create') }}
</button>
</div>
<!-- Fortschrittsanzeige -->
<div v-if="creating && jobStatus" class="progress-section">
<div class="progress-header">
<h3>{{ $t('admin.falukant.createNPC.progress') }}</h3>
<span class="progress-percentage">{{ jobStatus.progress }}%</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar" :style="{ width: jobStatus.progress + '%' }"></div>
</div>
<div class="progress-details">
<div>{{ $t('admin.falukant.createNPC.progressDetails', {
current: jobStatus.current || 0,
total: jobStatus.total || 0
}) }}</div>
<div v-if="jobStatus.estimatedTimeRemaining" class="time-remaining">
{{ formatTimeRemaining(jobStatus.estimatedTimeRemaining) }}
</div>
</div>
</div>
</div>
<!-- Ergebnis-Anzeige -->
<div v-if="result" class="result-section">
<h2>{{ $t('admin.falukant.createNPC.result') }}</h2>
<p>{{ $t('admin.falukant.createNPC.createdCount', { count: result.count }) }}</p>
<p v-if="result.totalCombinations" class="info-text">
{{ $t('admin.falukant.createNPC.combinationInfo', {
perCombination: result.countPerCombination,
combinations: result.totalCombinations,
total: result.count
}) }}
</p>
<div v-if="result.npcs && result.npcs.length > 0" class="npcs-list">
<div v-for="npc in result.npcs" :key="npc.id" class="npc-item">
{{ $t(`falukant.titles.${npc.gender}.${npc.title}`) }} {{ npc.firstName }} {{ npc.lastName }}
({{ $t('admin.falukant.createNPC.age') }}: {{ npc.age }}, {{ $t('admin.falukant.createNPC.region') }}: {{ npc.region }})
</div>
</div>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
</div>
</div>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
export default {
name: 'AdminFalukantCreateNPCView',
data() {
return {
regions: [],
titles: [],
selectedRegionIds: [],
allRegions: true,
minAge: 0,
maxAge: 100,
minTitleId: 1,
maxTitleId: 19,
count: 1,
creating: false,
result: null,
error: null,
jobId: null,
jobStatus: null,
statusPollInterval: null
};
},
beforeUnmount() {
if (this.statusPollInterval) {
clearInterval(this.statusPollInterval);
}
},
async mounted() {
await this.loadRegions();
await this.loadTitles();
},
methods: {
async loadRegions() {
try {
const response = await apiClient.get('/api/admin/falukant/regions');
this.regions = response.data || [];
} catch (error) {
console.error('Error loading regions:', error);
this.error = this.$t('admin.falukant.createNPC.errorLoadingRegions');
}
},
async loadTitles() {
try {
const response = await apiClient.get('/api/admin/falukant/titles');
this.titles = response.data || [];
if (this.titles.length > 0) {
this.minTitleId = this.titles[0].id;
this.maxTitleId = this.titles[this.titles.length - 1].id;
}
} catch (error) {
console.error('Error loading titles:', error);
this.error = this.$t('admin.falukant.createNPC.errorLoadingTitles');
}
},
onAllRegionsChange() {
if (this.allRegions) {
this.selectedRegionIds = [];
}
},
async createNPCs() {
if (this.creating) return;
// Validierung
if (this.minAge < 0 || this.maxAge < 0 || this.minAge > this.maxAge) {
this.error = this.$t('admin.falukant.createNPC.invalidAgeRange');
return;
}
if (this.minTitleId > this.maxTitleId) {
this.error = this.$t('admin.falukant.createNPC.invalidTitleRange');
return;
}
if (this.count < 1 || this.count > 500) {
this.error = this.$t('admin.falukant.createNPC.invalidCount');
return;
}
this.creating = true;
this.error = null;
this.result = null;
this.jobStatus = null;
this.jobId = null;
try {
const response = await apiClient.post('/api/admin/falukant/npcs/create', {
regionIds: this.allRegions ? null : this.selectedRegionIds,
minAge: this.minAge,
maxAge: this.maxAge,
minTitleId: this.minTitleId,
maxTitleId: this.maxTitleId,
count: this.count
});
this.jobId = response.data.jobId;
this.startStatusPolling();
} catch (error) {
console.error('Error creating NPCs:', error);
this.error = error.response?.data?.error || this.$t('admin.falukant.createNPC.errorCreating');
this.creating = false;
}
},
startStatusPolling() {
if (this.statusPollInterval) {
clearInterval(this.statusPollInterval);
}
this.statusPollInterval = setInterval(async () => {
if (!this.jobId) return;
try {
const response = await apiClient.get(`/api/admin/falukant/npcs/status/${this.jobId}`);
this.jobStatus = response.data;
if (this.jobStatus.status === 'completed') {
this.result = this.jobStatus.result;
this.creating = false;
clearInterval(this.statusPollInterval);
this.statusPollInterval = null;
} else if (this.jobStatus.status === 'error') {
this.error = this.jobStatus.error || this.$t('admin.falukant.createNPC.errorCreating');
this.creating = false;
clearInterval(this.statusPollInterval);
this.statusPollInterval = null;
}
} catch (error) {
console.error('Error polling status:', error);
if (error.response?.status === 404) {
// Job nicht gefunden - möglicherweise abgelaufen
this.error = this.$t('admin.falukant.createNPC.jobNotFound');
this.creating = false;
clearInterval(this.statusPollInterval);
this.statusPollInterval = null;
}
}
}, 1000); // Poll alle Sekunde
},
formatTimeRemaining(ms) {
if (!ms || ms <= 0) return this.$t('admin.falukant.createNPC.almostDone');
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes > 0) {
return this.$t('admin.falukant.createNPC.timeRemainingMinutes', {
minutes,
seconds: remainingSeconds
});
}
return this.$t('admin.falukant.createNPC.timeRemainingSeconds', { seconds });
}
}
};
</script>
<style scoped>
.create-npc-view {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.form-section {
background: #f5f5f5;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: bold;
}
.region-selection {
display: flex;
flex-direction: column;
gap: 10px;
}
.region-selection label {
display: flex;
align-items: center;
gap: 8px;
font-weight: normal;
}
.form-select {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
.form-select[multiple] {
min-height: 200px;
}
.age-range,
.title-range {
display: flex;
align-items: center;
gap: 10px;
}
.form-input {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
width: 100px;
}
.action-buttons {
margin-top: 20px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.btn-primary {
background-color: #28a745;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #218838;
}
.btn-primary:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
.result-section {
background: #d4edda;
padding: 20px;
border-radius: 8px;
margin-top: 20px;
}
.npcs-list {
margin-top: 15px;
}
.npc-item {
padding: 8px;
background: white;
margin-bottom: 5px;
border-radius: 4px;
}
.error-message {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 4px;
margin-top: 20px;
}
.help-text {
font-size: 0.9em;
color: #666;
margin-top: 5px;
font-style: italic;
}
.info-text {
font-size: 0.9em;
color: #155724;
margin-top: 5px;
}
.progress-section {
background: #e7f3ff;
padding: 20px;
border-radius: 8px;
margin-top: 20px;
border: 1px solid #b3d9ff;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.progress-header h3 {
margin: 0;
font-size: 1.2em;
}
.progress-percentage {
font-size: 1.5em;
font-weight: bold;
color: #0066cc;
}
.progress-bar-container {
width: 100%;
height: 30px;
background-color: #e0e0e0;
border-radius: 15px;
overflow: hidden;
margin-bottom: 10px;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #28a745, #20c997);
transition: width 0.3s ease;
border-radius: 15px;
}
.progress-details {
display: flex;
justify-content: space-between;
font-size: 0.9em;
color: #666;
}
.time-remaining {
font-weight: bold;
color: #0066cc;
}
</style>

View File

@@ -468,10 +468,14 @@ export default {
}, },
watch: { watch: {
activeTab(newVal) { activeTab(newVal, oldVal) {
if (newVal === 'taxes') { // Nur neu laden, wenn der Tab wirklich gewechselt wurde und ein Branch ausgewählt ist
this.loadBranchTaxes(); if (!this.selectedBranch || newVal === oldVal) return;
}
// Alle Tabs neu laden, wenn gewechselt wird
this.$nextTick(() => {
this.refreshActiveTab();
});
}, },
selectedBranch: { selectedBranch: {
handler(newBranch) { handler(newBranch) {
@@ -537,6 +541,33 @@ export default {
} }
}, },
refreshActiveTab() {
// Lade die Daten für den aktiven Tab neu
switch (this.activeTab) {
case 'director':
this.$refs.directorInfo?.refresh();
break;
case 'inventory':
this.$refs.saleSection?.loadInventory();
this.$refs.saleSection?.loadTransports();
break;
case 'production':
this.$refs.productionSection?.loadProductions();
this.$refs.productionSection?.loadStorage();
this.$refs.revenueSection?.refresh && this.$refs.revenueSection.refresh();
break;
case 'taxes':
this.loadBranchTaxes();
break;
case 'storage':
this.$refs.storageSection?.loadStorageData();
break;
case 'transport':
this.loadVehicles();
break;
}
},
async onBranchSelected(newBranch) { async onBranchSelected(newBranch) {
this.selectedBranch = newBranch; this.selectedBranch = newBranch;
// Branches neu laden, um das Wetter zu aktualisieren // Branches neu laden, um das Wetter zu aktualisieren
@@ -549,13 +580,8 @@ export default {
await this.loadVehicles(); await this.loadVehicles();
await this.loadProductPricesForCurrentBranch(); await this.loadProductPricesForCurrentBranch();
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.directorInfo?.refresh(); // Alle Tabs neu laden
this.$refs.saleSection?.loadInventory(); this.refreshActiveTab();
this.$refs.saleSection?.loadTransports();
this.$refs.productionSection?.loadProductions();
this.$refs.productionSection?.loadStorage();
this.$refs.storageSection?.loadStorageData();
this.$refs.revenueSection?.refresh && this.$refs.revenueSection.refresh();
}); });
// load tax info for this branch // load tax info for this branch
@@ -692,7 +718,10 @@ export default {
}, },
conditionLabel(value) { conditionLabel(value) {
const v = Number(value) || 0; // 0 ist ein gültiger Zustand (z.B. komplett kaputt) und darf nicht als "Unbekannt" enden.
if (value === null || value === undefined) return 'Unbekannt';
const v = Number(value);
if (!Number.isFinite(v)) return 'Unbekannt';
if (v >= 95) return 'Ausgezeichnet'; // 95100 if (v >= 95) return 'Ausgezeichnet'; // 95100
if (v >= 72) return 'Sehr gut'; // 7294 if (v >= 72) return 'Sehr gut'; // 7294
if (v >= 54) return 'Gut'; // 5471 if (v >= 54) return 'Gut'; // 5471
@@ -700,7 +729,7 @@ export default {
if (v >= 22) return 'Schlecht'; // 2238 if (v >= 22) return 'Schlecht'; // 2238
if (v >= 6) return 'Sehr schlecht'; // 621 if (v >= 6) return 'Sehr schlecht'; // 621
if (v >= 1) return 'Katastrophal'; // 15 if (v >= 1) return 'Katastrophal'; // 15
return 'Unbekannt'; return 'Katastrophal'; // 0 oder kleiner
}, },
speedLabel(value) { speedLabel(value) {
@@ -1014,12 +1043,15 @@ export default {
}); });
await this.loadVehicles(); await this.loadVehicles();
this.closeRepairAllVehiclesDialog(); this.closeRepairAllVehiclesDialog();
alert(this.$t('falukant.branch.transport.repairAllSuccess')); // Statt JS-alert: Dialog schließen und MessageDialog anzeigen
this.$root.$refs.messageDialog?.open('tr:falukant.branch.transport.repairAllSuccess');
this.$refs.statusBar?.fetchStatus(); this.$refs.statusBar?.fetchStatus();
} catch (error) { } catch (error) {
console.error('Error repairing all vehicles:', error); console.error('Error repairing all vehicles:', error);
const errorMessage = error.response?.data?.message || this.$t('falukant.branch.transport.repairAllError'); const errorMessage = error.response?.data?.message || this.$t('falukant.branch.transport.repairAllError');
alert(errorMessage); // Bestätigungsdialog ebenfalls schließen und Fehler im MessageDialog anzeigen
this.closeRepairAllVehiclesDialog();
this.$root.$refs.messageDialog?.open(String(errorMessage), this.$t('error.title'));
} }
}, },
@@ -1076,12 +1108,15 @@ export default {
await apiClient.post(`/api/falukant/vehicles/${this.repairVehicleDialog.vehicle.id}/repair`); await apiClient.post(`/api/falukant/vehicles/${this.repairVehicleDialog.vehicle.id}/repair`);
await this.loadVehicles(); await this.loadVehicles();
this.closeRepairVehicleDialog(); this.closeRepairVehicleDialog();
alert(this.$t('falukant.branch.transport.repairSuccess')); // Statt JS-alert: Dialog schließen und MessageDialog anzeigen
this.$root.$refs.messageDialog?.open('tr:falukant.branch.transport.repairSuccess');
this.$refs.statusBar?.fetchStatus(); this.$refs.statusBar?.fetchStatus();
} catch (error) { } catch (error) {
console.error('Error repairing vehicle:', error); console.error('Error repairing vehicle:', error);
const errorMessage = error.response?.data?.message || this.$t('falukant.branch.transport.repairError'); const errorMessage = error.response?.data?.message || this.$t('falukant.branch.transport.repairError');
alert(errorMessage); // Bestätigungsdialog ebenfalls schließen und Fehler im MessageDialog anzeigen
this.closeRepairVehicleDialog();
this.$root.$refs.messageDialog?.open(String(errorMessage), this.$t('error.title'));
} }
}, },
}, },

View File

@@ -295,8 +295,13 @@ export default {
}, },
async loadGifts() { async loadGifts() {
const response = await apiClient.get('/api/falukant/family/gifts'); try {
this.gifts = response.data; const response = await apiClient.get('/api/falukant/family/gifts');
this.gifts = response.data || [];
} catch (error) {
console.error('Error loading gifts:', error);
this.gifts = []; // Leeres Array bei Fehler
}
}, },
async sendGift() { async sendGift() {

View File

@@ -129,13 +129,22 @@ export default {
async performMeasure() { async performMeasure() {
if (!this.selectedMeasure) return; if (!this.selectedMeasure) return;
try { try {
await apiClient.post('/api/falukant/health', { const { data } = await apiClient.post('/api/falukant/health', {
measureTr: this.selectedTr measureTr: this.selectedTr
}); });
// Feedback via global message dialog
const title = this.$t('falukant.healthview.title');
const body = data?.delta != null
? `${this.$t(`falukant.healthview.measures.${this.selectedTr}`)}: ${data.delta > 0 ? '+' : ''}${data.delta}`
: this.$t('message.success');
this.$root.$refs.messageDialog?.open(body, title);
await this.loadHealthData(); await this.loadHealthData();
this.selectedTr = ''; this.selectedTr = '';
} catch (err) { } catch (err) {
console.error('Error performing measure', err); console.error('Error performing measure', err);
const title = this.$t('falukant.healthview.title');
const remoteMsg = err?.response?.data?.error || err?.message || String(err);
this.$root.$refs.messageDialog?.open(remoteMsg, title);
} }
}, },
handleDaemonMessage(evt) { handleDaemonMessage(evt) {

View File

@@ -67,12 +67,28 @@ export default {
currentPage: 1, currentPage: 1,
totalPages: 1, totalPages: 1,
}, },
productsById: {},
}; };
}, },
async mounted() { async mounted() {
await this.fetchMoneyHistory(1); await Promise.all([this.loadProducts(), this.fetchMoneyHistory(1)]);
}, },
methods: { methods: {
async loadProducts() {
try {
const { data } = await apiClient.get('/api/falukant/products');
const map = {};
for (const p of (data || [])) {
if (p && p.id != null && p.labelTr) {
map[String(p.id)] = p.labelTr;
}
}
this.productsById = map;
} catch (e) {
console.error('Error loading products for money history', e);
this.productsById = {};
}
},
async fetchMoneyHistory(page) { async fetchMoneyHistory(page) {
try { try {
const response = await apiClient.post('/api/falukant/moneyhistory', { const response = await apiClient.post('/api/falukant/moneyhistory', {
@@ -85,6 +101,25 @@ export default {
} }
}, },
translateActivity(activity) { translateActivity(activity) {
try {
const raw = String(activity ?? '');
// Handle legacy format: "tax from sale product 3"
const m = raw.match(/^tax\s+from\s+sale\s+product\s+(\d+)$/i);
if (m && m[1]) {
const id = m[1];
const labelTr = this.productsById[String(id)];
const productName = labelTr ? this.$t(`falukant.product.${labelTr}`) : `#${id}`;
return this.$t('falukant.moneyHistory.activities.taxFromSaleProduct', { product: productName });
}
// New/structured format: "taxFromSaleProduct.<labelTr>"
if (raw.startsWith('taxFromSaleProduct.')) {
const labelTr = raw.substring('taxFromSaleProduct.'.length);
const productName = labelTr ? this.$t(`falukant.product.${labelTr}`) : labelTr;
return this.$t('falukant.moneyHistory.activities.taxFromSaleProduct', { product: productName });
}
} catch (_) {
// ignore and fall back
}
// Handle nested keys like "health.pill" -> "health.pill" // Handle nested keys like "health.pill" -> "health.pill"
const key = `falukant.moneyHistory.activities.${activity}`; const key = `falukant.moneyHistory.activities.${activity}`;
const translation = this.$t(key); const translation = this.$t(key);

View File

@@ -2,19 +2,45 @@
<div> <div>
<StatusBar /> <StatusBar />
<h2>{{ $t('falukant.overview.title') }}</h2> <h2>{{ $t('falukant.overview.title') }}</h2>
<div class="overviewcontainer">
<!-- Erben-Auswahl wenn kein Charakter vorhanden -->
<div v-if="!falukantUser?.character" class="heir-selection-container">
<h3>{{ $t('falukant.overview.heirSelection.title') }}</h3>
<p>{{ $t('falukant.overview.heirSelection.description') }}</p>
<div v-if="loadingHeirs" class="loading">{{ $t('falukant.overview.heirSelection.loading') }}</div>
<div v-else-if="potentialHeirs.length === 0" class="no-heirs">
{{ $t('falukant.overview.heirSelection.noHeirs') }}
</div>
<div v-else class="heirs-list">
<div v-for="heir in potentialHeirs" :key="heir.id" class="heir-card">
<div class="heir-info">
<div class="heir-name">
{{ $t(`falukant.titles.${heir.gender}.noncivil`) }}
{{ heir.definedFirstName.name }} {{ heir.definedLastName.name }}
</div>
<div class="heir-age">{{ $t('falukant.overview.metadata.age') }}: {{ heir.age }}</div>
</div>
<button @click="selectHeir(heir.id)" class="select-heir-button">
{{ $t('falukant.overview.heirSelection.select') }}
</button>
</div>
</div>
</div>
<!-- Normale Übersicht wenn Charakter vorhanden -->
<div v-else class="overviewcontainer">
<div> <div>
<h3>{{ $t('falukant.overview.metadata.title') }}</h3> <h3>{{ $t('falukant.overview.metadata.title') }}</h3>
<table> <table>
<tr> <tr>
<td>{{ $t('falukant.overview.metadata.name') }}</td> <td>{{ $t('falukant.overview.metadata.name') }}</td>
<td>{{ falukantUser?.character.definedFirstName.name }} {{ <td>{{ falukantUser?.character?.definedFirstName?.name }} {{
falukantUser?.character.definedLastName.name }}</td> falukantUser?.character?.definedLastName?.name }}</td>
</tr> </tr>
<tr> <tr>
<td>{{ $t('falukant.overview.metadata.nobleTitle') }}</td> <td>{{ $t('falukant.overview.metadata.nobleTitle') }}</td>
<td>{{ $t('falukant.titles.' + falukantUser?.character.gender + '.' + <td>{{ $t('falukant.titles.' + falukantUser?.character?.gender + '.' +
falukantUser?.character.nobleTitle.labelTr) }}</td> falukantUser?.character?.nobleTitle?.labelTr) }}</td>
</tr> </tr>
<tr> <tr>
<td>{{ $t('falukant.overview.metadata.money') }}</td> <td>{{ $t('falukant.overview.metadata.money') }}</td>
@@ -26,11 +52,11 @@
</tr> </tr>
<tr> <tr>
<td>{{ $t('falukant.overview.metadata.age') }}</td> <td>{{ $t('falukant.overview.metadata.age') }}</td>
<td>{{ falukantUser?.character.age }}</td> <td>{{ falukantUser?.character?.age }}</td>
</tr> </tr>
<tr> <tr>
<td>{{ $t('falukant.overview.metadata.mainbranch') }}</td> <td>{{ $t('falukant.overview.metadata.mainbranch') }}</td>
<td>{{ falukantUser?.mainBranchRegion.name }}</td> <td>{{ falukantUser?.mainBranchRegion?.name }}</td>
</tr> </tr>
</table> </table>
</div> </div>
@@ -90,15 +116,26 @@
</table> </table>
</div> </div>
</div> </div>
<div class="imagecontainer"> <div v-if="falukantUser?.character" class="overview-visualization">
<div :style="getAvatarStyle" class="avatar"></div> <div class="character-3d-container">
<div :style="getHouseStyle" class="house"></div> <CharacterModel3D
:gender="falukantUser.character.gender"
:age="falukantUser.character.age"
:autoRotate="true"
:rotationSpeed="0.5"
/>
</div>
<div class="imagecontainer">
<div :style="getAvatarStyle" class="avatar"></div>
<div :style="getHouseStyle" class="house"></div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import StatusBar from '@/components/falukant/StatusBar.vue'; import StatusBar from '@/components/falukant/StatusBar.vue';
import CharacterModel3D from '@/components/falukant/CharacterModel3D.vue';
import apiClient from '@/utils/axios.js'; import apiClient from '@/utils/axios.js';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
@@ -143,18 +180,21 @@ export default {
name: 'FalukantOverviewView', name: 'FalukantOverviewView',
components: { components: {
StatusBar, StatusBar,
CharacterModel3D,
}, },
data() { data() {
return { return {
falukantUser: null, falukantUser: null,
allStock: [], allStock: [],
productions: [], productions: [],
potentialHeirs: [],
loadingHeirs: false,
}; };
}, },
computed: { computed: {
...mapState(['socket']), ...mapState(['socket']),
getAvatarStyle() { getAvatarStyle() {
if (!this.falukantUser) return {}; if (!this.falukantUser || !this.falukantUser.character) return {};
const { gender, age } = this.falukantUser.character; const { gender, age } = this.falukantUser.character;
const imageUrl = `/images/falukant/avatar/${gender}.png`; const imageUrl = `/images/falukant/avatar/${gender}.png`;
const ageGroup = this.getAgeGroup(age); const ageGroup = this.getAgeGroup(age);
@@ -212,8 +252,12 @@ export default {
}, },
async mounted() { async mounted() {
await this.fetchFalukantUser(); await this.fetchFalukantUser();
await this.fetchAllStock(); if (!this.falukantUser?.character) {
await this.fetchProductions(); await this.fetchPotentialHeirs();
} else {
await this.fetchAllStock();
await this.fetchProductions();
}
// Daemon WebSocket deaktiviert - verwende Socket.io für alle Events // Daemon WebSocket deaktiviert - verwende Socket.io für alle Events
this.setupSocketEvents(); this.setupSocketEvents();
}, },
@@ -306,6 +350,43 @@ export default {
formatDate(timestamp) { formatDate(timestamp) {
return new Date(timestamp).toLocaleString(); return new Date(timestamp).toLocaleString();
}, },
async fetchPotentialHeirs() {
// Prüfe sowohl mainBranchRegion.id als auch mainBranchRegionId
const regionId = this.falukantUser?.mainBranchRegion?.id || this.falukantUser?.mainBranchRegionId;
if (!regionId) {
console.error('No main branch region found', this.falukantUser);
this.potentialHeirs = [];
return;
}
this.loadingHeirs = true;
try {
const response = await apiClient.get('/api/falukant/heirs/potential');
this.potentialHeirs = response.data || [];
if (this.potentialHeirs.length === 0) {
console.warn('No potential heirs returned from API');
}
} catch (error) {
console.error('Error fetching potential heirs:', error);
console.error('Error details:', error.response?.data || error.message);
this.potentialHeirs = [];
} finally {
this.loadingHeirs = false;
}
},
async selectHeir(heirId) {
try {
await apiClient.post('/api/falukant/heirs/select', { heirId });
// Lade User-Daten neu
await this.fetchFalukantUser();
if (this.falukantUser?.character) {
await this.fetchAllStock();
await this.fetchProductions();
}
} catch (error) {
console.error('Error selecting heir:', error);
alert(this.$t('falukant.overview.heirSelection.error'));
}
},
}, },
}; };
</script> </script>
@@ -348,4 +429,91 @@ export default {
h2 { h2 {
padding-top: 20px; padding-top: 20px;
} }
.heir-selection-container {
border: 2px solid #dc3545;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
background-color: #fff3cd;
}
.heir-selection-container h3 {
margin-top: 0;
color: #856404;
}
.heirs-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 15px;
margin-top: 20px;
}
.heir-card {
border: 1px solid #ccc;
border-radius: 4px;
padding: 15px;
background-color: white;
display: flex;
justify-content: space-between;
align-items: center;
}
.heir-info {
flex: 1;
}
.heir-name {
font-weight: bold;
margin-bottom: 5px;
}
.heir-age {
color: #666;
font-size: 0.9em;
}
.select-heir-button {
background-color: #28a745;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.select-heir-button:hover {
background-color: #218838;
}
.loading, .no-heirs {
text-align: center;
padding: 20px;
color: #666;
}
.overview-visualization {
display: flex;
gap: 20px;
margin-top: 20px;
flex-wrap: wrap;
}
.character-3d-container {
flex: 1;
min-width: 300px;
max-width: 500px;
height: 400px;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
background: #f9f9f9;
}
.imagecontainer {
flex: 1;
min-width: 300px;
}
</style> </style>

View File

@@ -12,7 +12,52 @@
<div class="tab-content"> <div class="tab-content">
<div v-if="activeTab === 'overview'"> <div v-if="activeTab === 'overview'">
<p>Deine aktuelle Reputation: </p> <p>
{{ $t('falukant.reputation.overview.current') }}:
<strong>{{ reputationDisplay }}</strong>
</p>
<div class="separator-class">
<h3>{{ $t('falukant.reputation.actions.title') }}</h3>
<p>
{{ $t('falukant.reputation.actions.description') }}
</p>
<p v-if="reputationActionsDailyCap != null" class="reputation-actions-daily">
{{ $t('falukant.reputation.actions.dailyLimit', { remaining: reputationActionsDailyRemaining, cap: reputationActionsDailyCap }) }}
</p>
<p v-if="reputationActionsCooldownRemainingSec > 0" class="reputation-actions-cooldown">
{{ $t('falukant.reputation.actions.cooldown', { minutes: Math.ceil(reputationActionsCooldownRemainingSec / 60) }) }}
</p>
<table v-if="reputationActions.length">
<thead>
<tr>
<th>{{ $t('falukant.reputation.actions.action') }}</th>
<th>{{ $t('falukant.reputation.actions.cost') }}</th>
<th>{{ $t('falukant.reputation.actions.gain') }}</th>
<th>{{ $t('falukant.reputation.actions.timesUsed') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="a in reputationActions" :key="a.id">
<td>{{ $t('falukant.reputation.actions.type.' + a.tr) }}</td>
<td>{{ Number(a.cost || 0).toLocaleString($i18n.locale) }}</td>
<td>+{{ Number(a.currentGain || 0) }}</td>
<td>{{ Number(a.timesUsed || 0) }}</td>
<td>
<button type="button" :disabled="runningActionId === a.id || reputationActionsCooldownRemainingSec > 0"
@click.prevent="executeReputationAction(a)">
{{ runningActionId === a.id ? $t('falukant.reputation.actions.running') : $t('falukant.reputation.actions.execute') }}
</button>
</td>
</tr>
</tbody>
</table>
<p v-else>
{{ $t('falukant.reputation.actions.none') }}
</p>
</div>
</div> </div>
<div v-else-if="activeTab === 'party'"> <div v-else-if="activeTab === 'party'">
@@ -139,6 +184,7 @@
</table> </table>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
@@ -169,7 +215,15 @@ export default {
selectedNobilityIds: [], selectedNobilityIds: [],
servantRatio: 50, servantRatio: 50,
inProgressParties: [], inProgressParties: [],
completedParties: [] completedParties: [],
reputation: null,
reputationActions: [],
reputationActionsDailyCap: null,
reputationActionsDailyUsed: null,
reputationActionsDailyRemaining: null,
reputationActionsCooldownMinutes: null,
reputationActionsCooldownRemainingSec: 0,
runningActionId: null,
} }
}, },
methods: { methods: {
@@ -198,6 +252,55 @@ export default {
return partyDate <= twentyFourHoursAgo; return partyDate <= twentyFourHoursAgo;
}); });
}, },
async loadReputation() {
try {
const { data } = await apiClient.get('/api/falukant/info');
this.reputation = data?.character?.reputation ?? null;
} catch (e) {
console.error('Failed to load reputation', e);
this.reputation = null;
}
},
async loadReputationActions() {
try {
const { data } = await apiClient.get('/api/falukant/reputation/actions');
this.reputationActionsDailyCap = data?.dailyCap ?? null;
this.reputationActionsDailyUsed = data?.dailyUsed ?? null;
this.reputationActionsDailyRemaining = data?.dailyRemaining ?? null;
this.reputationActionsCooldownMinutes = data?.cooldownMinutes ?? null;
this.reputationActionsCooldownRemainingSec = Number(data?.cooldownRemainingSec ?? 0) || 0;
this.reputationActions = Array.isArray(data?.actions) ? data.actions : [];
} catch (e) {
console.error('Failed to load reputation actions', e);
this.reputationActions = [];
this.reputationActionsDailyCap = null;
this.reputationActionsDailyUsed = null;
this.reputationActionsDailyRemaining = null;
this.reputationActionsCooldownMinutes = null;
this.reputationActionsCooldownRemainingSec = 0;
}
},
async executeReputationAction(action) {
if (!action?.id) return;
if (this.runningActionId) return;
if (this.reputationActionsCooldownRemainingSec > 0) return;
this.runningActionId = action.id;
try {
const { data } = await apiClient.post('/api/falukant/reputation/actions', { actionTypeId: action.id });
const gain = data?.gain ?? null;
const cost = data?.cost ?? null;
const msg = gain != null
? this.$t('falukant.reputation.actions.success', { gain, cost })
: this.$t('falukant.reputation.actions.successSimple');
this.$root.$refs.messageDialog?.open(msg, this.$t('falukant.reputation.actions.title'));
await Promise.all([this.loadReputation(), this.loadReputationActions()]);
} catch (e) {
const errText = e?.response?.data?.error || e?.message || String(e);
this.$root.$refs.messageDialog?.open(errText, this.$t('falukant.reputation.actions.title'));
} finally {
this.runningActionId = null;
}
},
async loadNobilityTitles() { async loadNobilityTitles() {
this.nobilityTitles = await apiClient.get('/api/falukant/nobility/titels').then(r => r.data) this.nobilityTitles = await apiClient.get('/api/falukant/nobility/titels').then(r => r.data)
}, },
@@ -219,6 +322,10 @@ export default {
} }
}, },
computed: { computed: {
reputationDisplay() {
if (this.reputation == null) return '—';
return String(this.reputation);
},
formattedCost() { formattedCost() {
const type = this.partyTypes.find(t => t.id === this.newPartyTypeId) || {}; const type = this.partyTypes.find(t => t.id === this.newPartyTypeId) || {};
const music = this.musicTypes.find(m => m.id === this.musicId) || {}; const music = this.musicTypes.find(m => m.id === this.musicId) || {};
@@ -245,6 +352,8 @@ export default {
await this.loadPartyTypes(); await this.loadPartyTypes();
await this.loadNobilityTitles(); await this.loadNobilityTitles();
await this.loadParties(); await this.loadParties();
await this.loadReputation();
await this.loadReputationActions();
} }
} }
</script> </script>
@@ -305,4 +414,14 @@ table th {
border-top: 1px solid #ccc; border-top: 1px solid #ccc;
margin-top: 1em; margin-top: 1em;
} }
.reputation-actions-daily {
margin: 0.5rem 0 1rem;
font-weight: bold;
}
.reputation-actions-cooldown {
margin: -0.5rem 0 1rem;
font-weight: bold;
}
</style> </style>

View File

@@ -0,0 +1,176 @@
<template>
<h2>{{ $t('socialnetwork.vocab.chapterTitle', { title: chapter?.title || '' }) }}</h2>
<div class="box">
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-else-if="!chapter">{{ $t('socialnetwork.vocab.notFound') }}</div>
<div v-else>
<div v-show="!practiceOpen">
<div class="row">
<button @click="back">{{ $t('general.back') }}</button>
<button v-if="vocabs.length" @click="openPractice">{{ $t('socialnetwork.vocab.practice.open') }}</button>
<button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button>
</div>
<div class="row" v-if="chapter.isOwner">
<h3>{{ $t('socialnetwork.vocab.addVocab') }}</h3>
<div class="grid">
<label>
{{ $t('socialnetwork.vocab.learningWord') }}
<input v-model="learning" type="text" />
</label>
<label>
{{ $t('socialnetwork.vocab.referenceWord') }}
<input v-model="reference" type="text" />
</label>
</div>
<button :disabled="saving || !canSave" @click="add">
{{ saving ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.add') }}
</button>
</div>
<hr />
<div v-if="vocabs.length === 0">{{ $t('socialnetwork.vocab.noVocabs') }}</div>
<table v-else class="tbl">
<thead>
<tr>
<th>{{ $t('socialnetwork.vocab.learningWord') }}</th>
<th>{{ $t('socialnetwork.vocab.referenceWord') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="v in vocabs" :key="v.id">
<td>{{ v.learning }}</td>
<td>{{ v.reference }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<VocabPracticeDialog ref="practiceDialog" />
<VocabSearchDialog ref="searchDialog" />
</template>
<script>
import apiClient from '@/utils/axios.js';
import VocabPracticeDialog from '@/dialogues/socialnetwork/VocabPracticeDialog.vue';
import VocabSearchDialog from '@/dialogues/socialnetwork/VocabSearchDialog.vue';
export default {
name: 'VocabChapterView',
components: { VocabPracticeDialog, VocabSearchDialog },
data() {
return {
loading: false,
saving: false,
practiceOpen: false,
chapter: null,
languageName: '',
vocabs: [],
learning: '',
reference: '',
};
},
computed: {
canSave() {
return this.learning.trim().length > 0 && this.reference.trim().length > 0;
},
},
methods: {
back() {
this.$router.push(`/socialnetwork/vocab/${this.$route.params.languageId}`);
},
openPractice() {
this.practiceOpen = true;
this.$refs.practiceDialog?.open?.({
languageId: this.$route.params.languageId,
chapterId: this.$route.params.chapterId,
onClose: () => {
this.practiceOpen = false;
},
});
},
openSearch() {
this.$refs.searchDialog?.open?.({
languageId: this.$route.params.languageId,
languageName: this.languageName || '',
});
},
async load() {
this.loading = true;
try {
const res = await apiClient.get(`/api/vocab/chapters/${this.$route.params.chapterId}/vocabs`);
this.chapter = res.data?.chapter || null;
this.vocabs = res.data?.vocabs || [];
try {
const langRes = await apiClient.get(`/api/vocab/languages/${this.$route.params.languageId}`);
this.languageName = langRes.data?.name || '';
} catch (_) {
this.languageName = '';
}
} catch (e) {
console.error('Load chapter vocabs failed:', e);
this.chapter = null;
this.vocabs = [];
} finally {
this.loading = false;
}
},
async add() {
this.saving = true;
try {
await apiClient.post(`/api/vocab/chapters/${this.$route.params.chapterId}/vocabs`, {
learning: this.learning,
reference: this.reference,
});
this.learning = '';
this.reference = '';
await this.load();
} catch (e) {
console.error('Add vocab failed:', e);
this.$root.$refs.messageDialog?.open(
this.$t('socialnetwork.vocab.addVocabError'),
this.$t('error.title')
);
} finally {
this.saving = false;
}
},
},
mounted() {
this.load();
},
};
</script>
<style scoped>
.box {
background: #f6f6f6;
padding: 12px;
border: 1px solid #ccc;
display: inline-block;
}
.row {
margin-bottom: 10px;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 8px;
}
.tbl {
width: 100%;
border-collapse: collapse;
}
.tbl th,
.tbl td {
border: 1px solid #ccc;
padding: 6px;
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<h2>{{ $t('socialnetwork.vocab.languageTitle', { name: language?.name || '' }) }}</h2>
<div class="box">
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-else-if="!language">{{ $t('socialnetwork.vocab.notFound') }}</div>
<div v-else>
<div class="row">
<strong>{{ $t('socialnetwork.vocab.languageName') }}:</strong>
<span>{{ language.name }}</span>
</div>
<div class="row" v-if="language.isOwner && language.shareCode">
<strong>{{ $t('socialnetwork.vocab.shareCode') }}:</strong>
<code>{{ language.shareCode }}</code>
</div>
<div class="row">
<button @click="goSubscribe">{{ $t('socialnetwork.vocab.subscribeByCode') }}</button>
<button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button>
</div>
<hr />
<div class="row">
<h3>{{ $t('socialnetwork.vocab.chapters') }}</h3>
</div>
<div class="row" v-if="language.isOwner">
<label>
{{ $t('socialnetwork.vocab.newChapter') }}
<input v-model="newChapterTitle" type="text" />
</label>
<button :disabled="creatingChapter || newChapterTitle.trim().length < 2" @click="createChapter">
{{ creatingChapter ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.createChapter') }}
</button>
</div>
<div v-if="chaptersLoading">{{ $t('general.loading') }}</div>
<div v-else>
<div v-if="chapters.length === 0">{{ $t('socialnetwork.vocab.noChapters') }}</div>
<ul v-else>
<li v-for="c in chapters" :key="c.id">
<span class="click" @click="openChapter(c.id)">
{{ c.title }} <span class="count">({{ c.vocabCount }})</span>
</span>
</li>
</ul>
</div>
</div>
</div>
<VocabSearchDialog ref="searchDialog" />
</template>
<script>
import apiClient from '@/utils/axios.js';
import VocabSearchDialog from '@/dialogues/socialnetwork/VocabSearchDialog.vue';
export default {
name: 'VocabLanguageView',
components: { VocabSearchDialog },
data() {
return {
loading: false,
language: null,
chaptersLoading: false,
chapters: [],
newChapterTitle: '',
creatingChapter: false,
};
},
methods: {
goSubscribe() {
this.$router.push('/socialnetwork/vocab/subscribe');
},
openSearch() {
this.$refs.searchDialog?.open?.({
languageId: this.$route.params.languageId,
languageName: this.language?.name || '',
});
},
openChapter(chapterId) {
this.$router.push(`/socialnetwork/vocab/${this.$route.params.languageId}/chapters/${chapterId}`);
},
async load() {
this.loading = true;
try {
const res = await apiClient.get(`/api/vocab/languages/${this.$route.params.languageId}`);
this.language = res.data;
await this.loadChapters();
} catch (e) {
console.error('Load vocab language failed:', e);
this.language = null;
} finally {
this.loading = false;
}
},
async loadChapters() {
this.chaptersLoading = true;
try {
const res = await apiClient.get(`/api/vocab/languages/${this.$route.params.languageId}/chapters`);
this.chapters = res.data?.chapters || [];
} catch (e) {
console.error('Load chapters failed:', e);
this.chapters = [];
} finally {
this.chaptersLoading = false;
}
},
async createChapter() {
this.creatingChapter = true;
try {
await apiClient.post(`/api/vocab/languages/${this.$route.params.languageId}/chapters`, {
title: this.newChapterTitle,
});
this.newChapterTitle = '';
await this.loadChapters();
} catch (e) {
console.error('Create chapter failed:', e);
this.$root.$refs.messageDialog?.open(
this.$t('socialnetwork.vocab.createChapterError'),
this.$t('error.title')
);
} finally {
this.creatingChapter = false;
}
},
},
watch: {
'$route.params.languageId'() {
this.load();
},
},
mounted() {
this.load();
},
};
</script>
<style scoped>
.box {
background: #f6f6f6;
padding: 12px;
border: 1px solid #ccc;
}
.row {
margin-bottom: 8px;
}
.click {
cursor: pointer;
text-decoration: underline;
}
.count {
color: #666;
font-size: 0.9em;
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<h2>{{ $t('socialnetwork.vocab.newLanguageTitle') }}</h2>
<div class="box">
<label class="label">
{{ $t('socialnetwork.vocab.languageName') }}
<input v-model="name" type="text" />
</label>
<div class="actions">
<button :disabled="saving || !canSave" @click="create">
{{ saving ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.create') }}
</button>
<button :disabled="saving" @click="cancel">{{ $t('Cancel') }}</button>
</div>
<div v-if="created" class="created">
<div><strong>{{ $t('socialnetwork.vocab.created') }}</strong></div>
<div>
{{ $t('socialnetwork.vocab.shareCode') }}:
<code>{{ created.shareCode }}</code>
</div>
<div class="hint">{{ $t('socialnetwork.vocab.shareHint') }}</div>
<button @click="openLanguage(created.id)">{{ $t('socialnetwork.vocab.openLanguage') }}</button>
</div>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import apiClient from '@/utils/axios.js';
export default {
name: 'VocabNewLanguageView',
data() {
return {
name: '',
saving: false,
created: null,
};
},
computed: {
canSave() {
return this.name.trim().length >= 2;
},
},
methods: {
...mapActions(['loadMenu']),
cancel() {
this.$router.push('/socialnetwork/vocab');
},
openLanguage(id) {
this.$router.push(`/socialnetwork/vocab/${id}`);
},
async create() {
this.saving = true;
try {
const res = await apiClient.post('/api/vocab/languages', { name: this.name });
this.created = res.data;
// Menü sofort lokal aktualisieren (zusätzlich zum serverseitigen reloadmenu event)
try { await this.loadMenu(); } catch (_) {}
this.$root.$refs.messageDialog?.open(
this.$t('socialnetwork.vocab.createdMessage'),
this.$t('socialnetwork.vocab.createdTitle')
);
} catch (e) {
console.error('Create vocab language failed:', e);
this.$root.$refs.messageDialog?.open(
this.$t('socialnetwork.vocab.createError'),
this.$t('error.title')
);
} finally {
this.saving = false;
}
},
},
};
</script>
<style scoped>
.box {
background: #f6f6f6;
padding: 12px;
border: 1px solid #ccc;
}
.label {
display: block;
margin-bottom: 10px;
}
.actions {
display: flex;
gap: 8px;
}
.created {
margin-top: 12px;
padding: 10px;
background: #fff;
border: 1px solid #bbb;
}
.hint {
margin-top: 6px;
color: #555;
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<h2>{{ $t('socialnetwork.vocab.subscribeTitle') }}</h2>
<div class="box">
<p>{{ $t('socialnetwork.vocab.subscribeHint') }}</p>
<label class="label">
{{ $t('socialnetwork.vocab.shareCode') }}
<input v-model="shareCode" type="text" />
</label>
<div class="actions">
<button :disabled="saving || !canSave" @click="subscribe">
{{ saving ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.subscribe') }}
</button>
<button :disabled="saving" @click="back">{{ $t('general.back') }}</button>
</div>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import apiClient from '@/utils/axios.js';
export default {
name: 'VocabSubscribeView',
data() {
return {
shareCode: '',
saving: false,
};
},
computed: {
canSave() {
return this.shareCode.trim().length >= 6;
},
},
methods: {
...mapActions(['loadMenu']),
back() {
this.$router.push('/socialnetwork/vocab');
},
async subscribe() {
this.saving = true;
try {
const res = await apiClient.post('/api/vocab/subscribe', { shareCode: this.shareCode });
try { await this.loadMenu(); } catch (_) {}
const langId = res.data?.languageId;
this.$root.$refs.messageDialog?.open(
this.$t('socialnetwork.vocab.subscribeSuccess'),
this.$t('socialnetwork.vocab.subscribeTitle')
);
if (langId) {
this.$router.push(`/socialnetwork/vocab/${langId}`);
}
} catch (e) {
console.error('Subscribe failed:', e);
this.$root.$refs.messageDialog?.open(
this.$t('socialnetwork.vocab.subscribeError'),
this.$t('error.title')
);
} finally {
this.saving = false;
}
},
},
mounted() {
// optional: ?code=... unterstützt
const code = this.$route?.query?.code;
if (typeof code === 'string' && code.trim()) {
this.shareCode = code.trim();
}
},
};
</script>
<style scoped>
.box {
background: #f6f6f6;
padding: 12px;
border: 1px solid #ccc;
}
.label {
display: block;
margin-bottom: 10px;
}
.actions {
display: flex;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<h2>{{ $t('socialnetwork.vocab.title') }}</h2>
<div class="box">
<p>{{ $t('socialnetwork.vocab.description') }}</p>
<div class="actions">
<button @click="goNewLanguage">{{ $t('socialnetwork.vocab.newLanguage') }}</button>
</div>
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-else>
<div v-if="languages.length === 0">
{{ $t('socialnetwork.vocab.none') }}
</div>
<ul v-else>
<li v-for="l in languages" :key="l.id">
<span class="langname" @click="openLanguage(l.id)">{{ l.name }}</span>
<span class="role" v-if="l.isOwner">({{ $t('socialnetwork.vocab.owner') }})</span>
<span class="role" v-else>({{ $t('socialnetwork.vocab.subscribed') }})</span>
</li>
</ul>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import apiClient from '@/utils/axios.js';
export default {
name: 'VocabTrainerView',
data() {
return {
loading: false,
languages: [],
};
},
computed: {
...mapGetters(['user']),
},
methods: {
goNewLanguage() {
this.$router.push('/socialnetwork/vocab/new');
},
openLanguage(id) {
this.$router.push(`/socialnetwork/vocab/${id}`);
},
async load() {
this.loading = true;
try {
const res = await apiClient.get('/api/vocab/languages');
this.languages = res.data?.languages || [];
} catch (e) {
console.error('Konnte Vokabel-Sprachen nicht laden:', e);
} finally {
this.loading = false;
}
},
},
mounted() {
this.load();
},
};
</script>
<style scoped>
.box {
background: #f6f6f6;
padding: 12px;
border: 1px solid #ccc;
}
.actions {
margin: 10px 0;
}
.langname {
cursor: pointer;
text-decoration: underline;
}
.role {
margin-left: 6px;
color: #666;
}
</style>

View File

@@ -50,5 +50,13 @@ export default defineConfig(({ mode }) => {
assert: 'assert', assert: 'assert',
} }
}, },
server: {
fs: {
// Erlaube Zugriff auf Dateien außerhalb des Projektverzeichnisses
strict: false
}
},
// Stelle sicher, dass GLB/GLTF-Dateien als Assets behandelt werden
assetsInclude: ['**/*.glb', '**/*.gltf']
}; };
}); });

1457
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
"devDependencies": { "devDependencies": {
"concurrently": "^7.0.0", "concurrently": "^7.0.0",
"nodemon": "^3.1.10", "nodemon": "^3.1.10",
"npm-run-all": "^4.1.5" "npm-run-all2": "^5.0.0"
}, },
"dependencies": { "dependencies": {
"cors": "^2.8.5", "cors": "^2.8.5",

6
renovate.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}