Compare commits
58 Commits
2a4928c1b6
...
falukant-3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d74f7b852b | ||
|
|
92d6b15c3f | ||
|
|
91f59062f5 | ||
|
|
1674086c73 | ||
|
|
5ddb099f5a | ||
|
|
3722bcf8c8 | ||
|
|
0372d213c0 | ||
|
|
c322eb1e5a | ||
|
|
b34dcac685 | ||
|
|
4850f50c66 | ||
|
|
5996f819e8 | ||
|
|
4d967fe7a2 | ||
|
|
bb91c2bbe5 | ||
|
|
511df52c3c | ||
|
|
d42e1da14b | ||
|
|
75dbd78da1 | ||
|
|
c90b7785c0 | ||
|
|
c17af04cbf | ||
|
|
f5e3a9a4a2 | ||
|
|
dab3391aa2 | ||
|
|
0336c55560 | ||
|
|
8e618ab443 | ||
|
|
352d672bdd | ||
|
|
df64c0a4b5 | ||
|
|
83597d9e02 | ||
|
|
a09220b881 | ||
|
|
5623f3af09 | ||
|
|
820b5e8570 | ||
|
|
dc72ed2feb | ||
|
|
ea468c9878 | ||
|
|
d1b683344e | ||
|
|
a82ec7de3f | ||
|
|
560a9efc69 | ||
|
|
4f8b1e33fa | ||
|
|
38dd51f757 | ||
|
|
38f23cc6ae | ||
|
|
6cf8fa8a9c | ||
|
|
f9ea4715d7 | ||
|
|
b34b374f76 | ||
|
|
83d1168f26 | ||
|
|
91009f52cd | ||
|
|
c6dfca7052 | ||
|
|
aaeaeeed24 | ||
|
|
c5804f764c | ||
|
|
fbe0d1bcd1 | ||
|
|
2fb440f033 | ||
|
|
a8a136a9ce | ||
|
|
fcbb903839 | ||
|
|
ac45a2ba26 | ||
|
|
afe15dd4f5 | ||
|
|
e3df88bea0 | ||
|
|
c69a414f78 | ||
|
|
d08022ab94 | ||
|
|
66e6fab663 | ||
| 4da572822e | |||
| ee23bb3ba3 | |||
| d002e340dd | |||
| 0e1d87ddab |
230
PERFORMANCE_ANALYSIS.md
Normal file
230
PERFORMANCE_ANALYSIS.md
Normal 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
601
SELL_OVERVIEW.md
Normal 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`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import crypto from 'crypto';
|
||||
import chatRouter from './routers/chatRouter.js';
|
||||
import authRouter from './routers/authRouter.js';
|
||||
import navigationRouter from './routers/navigationRouter.js';
|
||||
@@ -17,6 +18,7 @@ import taxiRouter from './routers/taxiRouter.js';
|
||||
import taxiMapRouter from './routers/taxiMapRouter.js';
|
||||
import taxiHighscoreRouter from './routers/taxiHighscoreRouter.js';
|
||||
import termineRouter from './routers/termineRouter.js';
|
||||
import vocabRouter from './routers/vocabRouter.js';
|
||||
import cors from 'cors';
|
||||
import './jobs/sessionCleanup.js';
|
||||
|
||||
@@ -25,6 +27,25 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
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 = {
|
||||
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'],
|
||||
@@ -49,6 +70,7 @@ app.use('/api/taxi/highscores', taxiHighscoreRouter);
|
||||
app.use('/images', express.static(path.join(__dirname, '../frontend/public/images')));
|
||||
app.use('/api/contact', contactRouter);
|
||||
app.use('/api/socialnetwork', socialnetworkRouter);
|
||||
app.use('/api/vocab', vocabRouter);
|
||||
app.use('/api/forum', forumRouter);
|
||||
app.use('/api/falukant', falukantRouter);
|
||||
app.use('/api/friendships', friendshipRouter);
|
||||
|
||||
@@ -43,6 +43,9 @@ class AdminController {
|
||||
this.getRegionDistances = this.getRegionDistances.bind(this);
|
||||
this.upsertRegionDistance = this.upsertRegionDistance.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) {
|
||||
@@ -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) {
|
||||
try {
|
||||
const userId = req.headers.userid;
|
||||
|
||||
@@ -93,6 +93,8 @@ class FalukantController {
|
||||
return result;
|
||||
});
|
||||
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.getGifts = this._wrapWithUser((userId) => {
|
||||
console.log('🔍 getGifts called with userId:', userId);
|
||||
@@ -116,6 +118,12 @@ class FalukantController {
|
||||
}, { successStatus: 201 });
|
||||
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.baptise = this._wrapWithUser((userId, req) => {
|
||||
const { characterId: childId, firstName } = req.body;
|
||||
|
||||
@@ -4,6 +4,7 @@ import UserRight from '../models/community/user_right.js';
|
||||
import UserRightType from '../models/type/user_right.js';
|
||||
import UserParamType from '../models/type/user_param.js';
|
||||
import FalukantUser from '../models/falukant/data/user.js';
|
||||
import VocabService from '../services/vocabService.js';
|
||||
|
||||
const menuStructure = {
|
||||
home: {
|
||||
@@ -49,6 +50,11 @@ const menuStructure = {
|
||||
visible: ["all"],
|
||||
path: "/socialnetwork/gallery"
|
||||
},
|
||||
vocabtrainer: {
|
||||
visible: ["all"],
|
||||
path: "/socialnetwork/vocab",
|
||||
children: {}
|
||||
},
|
||||
blockedUsers: {
|
||||
visible: ["all"],
|
||||
path: "/socialnetwork/blocked"
|
||||
@@ -274,6 +280,10 @@ const menuStructure = {
|
||||
visible: ["mainadmin", "falukant"],
|
||||
path: "/admin/falukant/map"
|
||||
},
|
||||
createNPC: {
|
||||
visible: ["mainadmin", "falukant"],
|
||||
path: "/admin/falukant/create-npc"
|
||||
},
|
||||
}
|
||||
},
|
||||
minigames: {
|
||||
@@ -296,6 +306,7 @@ const menuStructure = {
|
||||
class NavigationController {
|
||||
constructor() {
|
||||
this.menu = this.menu.bind(this);
|
||||
this.vocabService = new VocabService();
|
||||
}
|
||||
|
||||
calculateAge(birthDate) {
|
||||
@@ -365,6 +376,24 @@ class NavigationController {
|
||||
const age = this.calculateAge(birthDate);
|
||||
const rights = userRights.map(ur => ur.rightType?.title).filter(Boolean);
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error('Error fetching menu:', error);
|
||||
|
||||
46
backend/controllers/vocabController.js
Normal file
46
backend/controllers/vocabController.js
Normal 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;
|
||||
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
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)
|
||||
const connections = new Set();
|
||||
|
||||
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) => {
|
||||
const peer = req.socket.remoteAddress + ':' + req.socket.remotePort;
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
47
backend/migrations/20251220001000-add-reputation-actions.cjs
Normal file
47
backend/migrations/20251220001000-add-reputation-actions.cjs
Normal 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;`);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -34,6 +34,18 @@ FalukantCharacter.init(
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
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,
|
||||
|
||||
@@ -29,6 +29,10 @@ Director.init({
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true},
|
||||
mayRepairVehicles: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true},
|
||||
lastSalaryPayout: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
|
||||
59
backend/models/falukant/log/reputation_action.js
Normal file
59
backend/models/falukant/log/reputation_action.js
Normal 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;
|
||||
|
||||
|
||||
51
backend/models/falukant/type/reputation_action.js
Normal file
51
backend/models/falukant/type/reputation_action.js
Normal 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;
|
||||
|
||||
|
||||
@@ -79,6 +79,8 @@ import Party from './falukant/data/party.js';
|
||||
import MusicType from './falukant/type/music.js';
|
||||
import BanquetteType from './falukant/type/banquette.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 LearnRecipient from './falukant/type/learn_recipient.js';
|
||||
import Learning from './falukant/data/learning.js';
|
||||
@@ -208,6 +210,8 @@ const models = {
|
||||
BanquetteType,
|
||||
Party,
|
||||
PartyInvitedNobility,
|
||||
ReputationActionType,
|
||||
ReputationActionLog,
|
||||
ChildRelation,
|
||||
LearnRecipient,
|
||||
Learning,
|
||||
|
||||
134
backend/package-lock.json
generated
134
backend/package-lock.json
generated
@@ -994,29 +994,58 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"on-finished": "2.4.1",
|
||||
"qs": "6.13.0",
|
||||
"raw-body": "2.5.2",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.14.0",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "1.0.0"
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
@@ -1647,39 +1676,39 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"body-parser": "~1.20.3",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "1.3.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.12",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"qs": "~6.14.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "0.19.0",
|
||||
"serve-static": "1.16.2",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
@@ -2910,12 +2939,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.0.6"
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@@ -2949,20 +2978,49 @@
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
|
||||
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"unpipe": "1.0.0"
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"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": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
|
||||
@@ -46,6 +46,9 @@ router.put('/falukant/regions/:id/map', authenticate, adminController.updateFalu
|
||||
router.get('/falukant/region-distances', authenticate, adminController.getRegionDistances);
|
||||
router.post('/falukant/region-distances', authenticate, adminController.upsertRegionDistance);
|
||||
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 ---
|
||||
router.get('/minigames/match3/campaigns', authenticate, adminController.getMatch3Campaigns);
|
||||
|
||||
@@ -39,6 +39,8 @@ router.get('/directors', falukantController.getAllDirectors);
|
||||
router.post('/directors', falukantController.updateDirector);
|
||||
router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal);
|
||||
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/children', falukantController.getChildren);
|
||||
router.post('/family/gift', falukantController.sendGift);
|
||||
@@ -53,6 +55,8 @@ router.post('/houses', falukantController.buyUserHouse);
|
||||
router.get('/party/types', falukantController.getPartyTypes);
|
||||
router.post('/party', falukantController.createParty);
|
||||
router.get('/party', falukantController.getParties);
|
||||
router.get('/reputation/actions', falukantController.getReputationActions);
|
||||
router.post('/reputation/actions', falukantController.executeReputationAction);
|
||||
router.get('/family/notbaptised', falukantController.getNotBaptisedChildren);
|
||||
router.post('/church/baptise', falukantController.baptise);
|
||||
router.get('/education', falukantController.getEducation);
|
||||
|
||||
27
backend/routers/vocabRouter.js
Normal file
27
backend/routers/vocabRouter.js
Normal 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;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import UserParamType from "../models/type/user_param.js";
|
||||
import ContactMessage from "../models/service/contactmessage.js";
|
||||
import ContactService from "./ContactService.js";
|
||||
import { sendAnswerEmail } from './emailService.js';
|
||||
import { Op } from 'sequelize';
|
||||
import { Op, Sequelize } from 'sequelize';
|
||||
import FalukantUser from "../models/falukant/data/user.js";
|
||||
import FalukantCharacter from "../models/falukant/data/character.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 Room from '../models/chat/room.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 {
|
||||
async hasUserAccess(userId, section) {
|
||||
@@ -321,6 +325,17 @@ class AdminService {
|
||||
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) {
|
||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||
throw new Error('noaccess');
|
||||
@@ -1085,6 +1100,216 @@ class AdminService {
|
||||
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();
|
||||
File diff suppressed because it is too large
Load Diff
86
backend/services/npcCreationJobService.js
Normal file
86
backend/services/npcCreationJobService.js
Normal 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();
|
||||
532
backend/services/vocabService.js
Normal file
532
backend/services/vocabService.js
Normal 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) };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import TitleOfNobility from "../../models/falukant/type/title_of_nobility.js";
|
||||
import PartyType from "../../models/falukant/type/party.js";
|
||||
import MusicType from "../../models/falukant/type/music.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 LearnRecipient from "../../models/falukant/type/learn_recipient.js";
|
||||
import PoliticalOfficeType from "../../models/falukant/type/political_office_type.js";
|
||||
@@ -41,6 +42,7 @@ export const initializeFalukantTypes = async () => {
|
||||
await initializeFalukantPartyTypes();
|
||||
await initializeFalukantMusicTypes();
|
||||
await initializeFalukantBanquetteTypes();
|
||||
await initializeFalukantReputationActions();
|
||||
await initializeLearnerTypes();
|
||||
await initializePoliticalOfficeBenefitTypes();
|
||||
await initializePoliticalOfficeTypes();
|
||||
@@ -52,6 +54,55 @@ export const initializeFalukantTypes = async () => {
|
||||
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 regionTypeTrs = [
|
||||
"country",
|
||||
|
||||
@@ -3,6 +3,25 @@ import dotenv from 'dotenv';
|
||||
|
||||
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
|
||||
const dbName = process.env.DB_NAME;
|
||||
const dbUser = process.env.DB_USER;
|
||||
@@ -26,6 +45,8 @@ const sequelize = new Sequelize(dbName, dbUser, dbPass, {
|
||||
timestamps: false,
|
||||
underscored: true // WICHTIG: Alle Datenbankfelder im snake_case Format
|
||||
},
|
||||
benchmark: SQL_BENCHMARK,
|
||||
logging: sqlLogger,
|
||||
});
|
||||
|
||||
const createSchemas = async () => {
|
||||
@@ -380,7 +401,7 @@ const updateSchema = async (models) => {
|
||||
console.log('✅ Datenbankschema aktualisiert');
|
||||
};
|
||||
|
||||
async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, changedBy = null) {
|
||||
async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, changedBy = null, transaction = null) {
|
||||
try {
|
||||
const result = await sequelize.query(
|
||||
`SELECT falukant_data.update_money(
|
||||
@@ -397,6 +418,7 @@ async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, ch
|
||||
changedBy,
|
||||
},
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
transaction: transaction || undefined,
|
||||
}
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -33,6 +33,123 @@ const syncDatabase = async () => {
|
||||
console.log("Initializing database schemas...");
|
||||
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
|
||||
console.log("Pre-ensure Taxi columns (traffic_light) ...");
|
||||
try {
|
||||
@@ -395,7 +512,27 @@ const syncDatabaseForDeployment = async () => {
|
||||
console.log(`✅ ${deletedCount8} verwaiste political_office Einträge entfernt`);
|
||||
}
|
||||
|
||||
if (deletedCount1 === 0 && deletedCount2 === 0 && deletedCount3 === 0 && deletedCount4 === 0 && deletedCount5 === 0 && deletedCount6 === 0 && deletedCount7 === 0 && deletedCount8 === 0) {
|
||||
// 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");
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
143
docs/3D_ANIMATIONS_FALUKANT.md
Normal file
143
docs/3D_ANIMATIONS_FALUKANT.md
Normal 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
171
docs/3D_ASSETS_STRUCTURE.md
Normal 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
|
||||
159
docs/3D_MODEL_CREATION_TOOLS.md
Normal file
159
docs/3D_MODEL_CREATION_TOOLS.md
Normal 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
|
||||
334
docs/BLENDER_RIGGING_GUIDE.md
Normal file
334
docs/BLENDER_RIGGING_GUIDE.md
Normal 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)
|
||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -21,6 +21,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"mitt": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"three": "^0.182.0",
|
||||
"vue": "~3.4.31",
|
||||
"vue-i18n": "^10.0.0-beta.2",
|
||||
"vue-multiselect": "^3.1.0",
|
||||
@@ -2834,6 +2835,12 @@
|
||||
"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": {
|
||||
"version": "0.2.14",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"mitt": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"three": "^0.182.0",
|
||||
"vue": "~3.4.31",
|
||||
"vue-i18n": "^10.0.0-beta.2",
|
||||
"vue-multiselect": "^3.1.0",
|
||||
|
||||
40
frontend/public/models/3d/falukant/characters/README.md
Normal file
40
frontend/public/models/3d/falukant/characters/README.md
Normal 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
|
||||
BIN
frontend/public/models/3d/falukant/characters/female.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/female.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/female_adult.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/female_adult.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/female_child.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/female_child.glb
Normal file
Binary file not shown.
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/female_preteen.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/female_preteen.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/female_teen.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/female_teen.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/female_toddler.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/female_toddler.glb
Normal file
Binary file not shown.
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/male.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/male.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/male_adult.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/male_adult.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/male_child.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/male_child.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/male_preteen.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/male_preteen.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/male_teen.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/male_teen.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/male_toddler.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/male_toddler.glb
Normal file
Binary file not shown.
BIN
frontend/public/sounds/fail.mp3
Normal file
BIN
frontend/public/sounds/fail.mp3
Normal file
Binary file not shown.
BIN
frontend/public/sounds/success.mp3
Normal file
BIN
frontend/public/sounds/success.mp3
Normal file
Binary file not shown.
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<main>
|
||||
<router-view></router-view>
|
||||
<main class="contenthidden">
|
||||
<div class="contentscroll">
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -12,9 +14,13 @@
|
||||
|
||||
<style scoped>
|
||||
main {
|
||||
padding: 20px;
|
||||
padding: 0;
|
||||
background-color: #ffffff;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.contentscroll {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
:style="`background-image:url('/images/icons/${subitem.icon}')`"
|
||||
class="submenu-icon"
|
||||
> </span>
|
||||
<span>{{ $t(`navigation.m-${key}.${subkey}`) }}</span>
|
||||
<span>{{ subitem?.label || $t(`navigation.m-${key}.${subkey}`) }}</span>
|
||||
<span
|
||||
v-if="subkey === 'forum' || subitem.children"
|
||||
class="subsubmenu"
|
||||
@@ -62,7 +62,7 @@
|
||||
:style="`background-image:url('/images/icons/${subsubitem.icon}')`"
|
||||
class="submenu-icon"
|
||||
> </span>
|
||||
<span>{{ $t(`navigation.m-${key}.m-${subkey}.${subsubkey}`) }}</span>
|
||||
<span>{{ subsubitem?.label || $t(`navigation.m-${key}.m-${subkey}.${subsubkey}`) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
225
frontend/src/components/falukant/CharacterModel3D.vue
Normal file
225
frontend/src/components/falukant/CharacterModel3D.vue
Normal 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>
|
||||
@@ -104,6 +104,14 @@
|
||||
/>
|
||||
{{ $t('falukant.branch.director.starttransport') }}
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="director.mayRepairVehicles"
|
||||
@change="saveSetting('mayRepairVehicles', director.mayRepairVehicles)"
|
||||
/>
|
||||
{{ $t('falukant.branch.director.repairVehicles') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<li v-for="n in messages" :key="n.id" :class="{ unread: !n.shown }">
|
||||
<div class="body">
|
||||
<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 class="footer">
|
||||
<span>{{ formatDate(n.createdAt) }}</span>
|
||||
@@ -133,54 +133,52 @@ export default {
|
||||
} catch { return dt; }
|
||||
},
|
||||
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 parsed = null;
|
||||
let value = null;
|
||||
let key = raw;
|
||||
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') {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
parsed = JSON.parse(trimmed);
|
||||
if (parsed && parsed.tr) {
|
||||
raw = parsed.tr;
|
||||
key = parsed.tr;
|
||||
// Alle anderen Felder als Parameter verwenden und formatieren
|
||||
params = this.formatParams({ ...parsed });
|
||||
delete params.tr;
|
||||
// Merge in params extracted from nested structures (effects, character ids)
|
||||
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
|
||||
}
|
||||
value = parsed.value || {};
|
||||
|
||||
// Extrahiere Parameter aus value und effects
|
||||
params = this.extractParamsFromValue(value, n);
|
||||
}
|
||||
} 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:
|
||||
// - wenn bereits ein voller i18n-Key wie "falukant.notifications.production.overproduction",
|
||||
// dann direkt verwenden
|
||||
// - sonst in den Namespace "falukant.notifications." hängen
|
||||
// 2) Wenn value.title und value.description vorhanden sind, verwende diese
|
||||
if (value && value.title && value.description) {
|
||||
// Parameter aus effects extrahieren und formatieren
|
||||
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') {
|
||||
const trimmedKey = key.trim();
|
||||
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.')) {
|
||||
const eventId = key.replace('falukant.notifications.random_event.', '');
|
||||
const eventKey = `falukant.notifications.random_event.${eventId}`;
|
||||
try {
|
||||
const titleKey = `${eventKey}.title`;
|
||||
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) {
|
||||
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)) {
|
||||
const title = this.$t(titleKey, params);
|
||||
const description = this.$t(descKey, params);
|
||||
const title = this.$t(titleKey, formattedParams);
|
||||
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 };
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -213,42 +225,64 @@ export default {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const formatted = {};
|
||||
|
||||
// Geldbeträge formatieren
|
||||
if (params.amount !== undefined) {
|
||||
if (params.amount !== undefined && params.amount !== null) {
|
||||
formatted.amount = this.formatMoney(params.amount);
|
||||
}
|
||||
if (params.absolute !== undefined) {
|
||||
if (params.absolute !== undefined && params.absolute !== null) {
|
||||
formatted.amount = this.formatMoney(params.absolute);
|
||||
}
|
||||
if (params.percent !== undefined) {
|
||||
formatted.amount = `${params.percent > 0 ? '+' : ''}${params.percent.toFixed(1)}%`;
|
||||
if (params.percent !== undefined && params.percent !== null) {
|
||||
formatted.percent = `${params.percent > 0 ? '+' : ''}${params.percent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
// Gesundheit formatieren
|
||||
if (params.change !== undefined) {
|
||||
if (params.change !== undefined && params.change !== null) {
|
||||
formatted.healthChange = params.change > 0 ? `+${params.change}` : `${params.change}`;
|
||||
formatted.change = formatted.healthChange;
|
||||
}
|
||||
if (params.healthChange !== undefined) {
|
||||
formatted.healthChange = params.healthChange > 0 ? `+${params.healthChange}` : `${params.healthChange}`;
|
||||
if (params.healthChange !== undefined && params.healthChange !== null && !formatted.healthChange) {
|
||||
formatted.healthChange = typeof params.healthChange === 'string'
|
||||
? params.healthChange
|
||||
: (params.healthChange > 0 ? `+${params.healthChange}` : `${params.healthChange}`);
|
||||
}
|
||||
|
||||
// Schaden formatieren
|
||||
if (params.inventory_damage_percent !== undefined) {
|
||||
// Charakternamen
|
||||
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)}%.`;
|
||||
}
|
||||
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)}%.`;
|
||||
}
|
||||
|
||||
// Alle anderen Parameter übernehmen
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -256,10 +290,109 @@ export default {
|
||||
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) {
|
||||
const params = {};
|
||||
|
||||
// Parameter aus effects extrahieren
|
||||
// Parameter aus effects extrahieren (alte Struktur)
|
||||
if (n.effects && Array.isArray(n.effects)) {
|
||||
for (const effect of n.effects) {
|
||||
if (effect.type === 'money_change') {
|
||||
@@ -270,7 +403,6 @@ export default {
|
||||
}
|
||||
} else if (effect.type === 'character_health_change') {
|
||||
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.characterName = params.characterName || n.characterName || `#${effect.character_id}`;
|
||||
}
|
||||
@@ -304,7 +436,54 @@ export default {
|
||||
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) {
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
<td>{{ item.quality }}</td>
|
||||
<td>{{ item.totalQuantity }}</td>
|
||||
<td>
|
||||
<input type="number" v-model.number="item.sellQuantity" :min="1" :max="item.totalQuantity" />
|
||||
<button @click="sellItem(index)">{{ $t('falukant.branch.sale.sellButton') }}</button>
|
||||
<input type="number" v-model.number="item.sellQuantity" :min="1" :max="item.totalQuantity" :disabled="sellingItemIndex === index" />
|
||||
<button @click="sellItem(index)" :disabled="sellingItemIndex === index || sellingAll">
|
||||
{{ sellingItemIndex === index ? $t('falukant.branch.sale.selling') : $t('falukant.branch.sale.sellButton') }}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<div v-if="item.betterPrices && item.betterPrices.length > 0" class="price-cities">
|
||||
@@ -36,7 +38,12 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</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 v-else>
|
||||
<p>{{ $t('falukant.branch.sale.noInventory') }}</p>
|
||||
@@ -183,6 +190,9 @@
|
||||
data() {
|
||||
return {
|
||||
inventory: [],
|
||||
sellingItemIndex: null,
|
||||
sellingAll: false,
|
||||
sellAllStatus: null,
|
||||
transportForm: {
|
||||
sourceKey: null,
|
||||
vehicleTypeId: null,
|
||||
@@ -251,13 +261,6 @@
|
||||
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() {
|
||||
await this.loadInventory();
|
||||
@@ -274,12 +277,22 @@
|
||||
}
|
||||
},
|
||||
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() {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/falukant/inventory/${this.branchId}`);
|
||||
this.inventory = response.data.map(item => ({
|
||||
...item,
|
||||
sellQuantity: item.totalQuantity,
|
||||
// Vue3: besserPrices direkt als Property setzen (statt this.$set)
|
||||
betterPrices: Array.isArray(item.betterPrices) ? item.betterPrices : [],
|
||||
}));
|
||||
await this.loadPricesForInventory();
|
||||
} catch (error) {
|
||||
@@ -300,10 +313,11 @@
|
||||
currentPrice: currentPrice
|
||||
}
|
||||
});
|
||||
this.$set(item, 'betterPrices', data || []);
|
||||
// Vue3: direkte Zuweisung ist reaktiv
|
||||
item.betterPrices = Array.isArray(data) ? data : [];
|
||||
} catch (error) {
|
||||
console.error(`Error loading prices for item ${itemKey}:`, error);
|
||||
this.$set(item, 'betterPrices', []);
|
||||
item.betterPrices = [];
|
||||
} finally {
|
||||
this.loadingPrices.delete(itemKey);
|
||||
}
|
||||
@@ -320,23 +334,61 @@
|
||||
maximumFractionDigits: 2,
|
||||
}).format(price);
|
||||
},
|
||||
sellItem(index) {
|
||||
async sellItem(index) {
|
||||
if (this.sellingItemIndex !== null || this.sellingAll) return;
|
||||
|
||||
const item = this.inventory[index];
|
||||
const quantityToSell = item.sellQuantity || item.totalQuantity;
|
||||
apiClient.post(`/api/falukant/sell`, {
|
||||
branchId: this.branchId,
|
||||
productId: item.product.id,
|
||||
quantity: quantityToSell,
|
||||
quality: item.quality,
|
||||
}).catch(() => {
|
||||
this.sellingItemIndex = index;
|
||||
|
||||
try {
|
||||
await apiClient.post(`/api/falukant/sell`, {
|
||||
branchId: this.branchId,
|
||||
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'));
|
||||
});
|
||||
} finally {
|
||||
this.sellingItemIndex = null;
|
||||
}
|
||||
},
|
||||
sellAll() {
|
||||
apiClient.post(`/api/falukant/sell/all`, { branchId: this.branchId })
|
||||
.catch(() => {
|
||||
alert(this.$t('falukant.branch.sale.sellAllError'));
|
||||
});
|
||||
async sellAll() {
|
||||
if (this.sellingAll || this.sellingItemIndex !== null) return;
|
||||
|
||||
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() {
|
||||
return this.inventory.map((item, index) => ({
|
||||
@@ -590,5 +642,19 @@
|
||||
color: #999;
|
||||
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>
|
||||
|
||||
441
frontend/src/components/falukant/ThreeScene.vue
Normal file
441
frontend/src/components/falukant/ThreeScene.vue
Normal 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>
|
||||
534
frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue
Normal file
534
frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue
Normal 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>
|
||||
|
||||
|
||||
162
frontend/src/dialogues/socialnetwork/VocabSearchDialog.vue
Normal file
162
frontend/src/dialogues/socialnetwork/VocabSearchDialog.vue
Normal 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>
|
||||
|
||||
|
||||
@@ -113,6 +113,35 @@
|
||||
"errorSaveConnection": "Die Verbindung konnte nicht gespeichert werden.",
|
||||
"errorDeleteConnection": "Die Verbindung konnte nicht gelöscht werden.",
|
||||
"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": {
|
||||
|
||||
@@ -135,6 +135,14 @@
|
||||
"store": "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": {
|
||||
@@ -232,6 +240,7 @@
|
||||
"produce": "Darf produzieren",
|
||||
"sell": "Darf verkaufen",
|
||||
"starttransport": "Darf Transporte veranlassen",
|
||||
"repairVehicles": "Darf Fahrzeuge reparieren",
|
||||
"emptyTransport": {
|
||||
"title": "Transport ohne Produkte",
|
||||
"description": "Bewege Transportmittel von dieser Niederlassung zu einer anderen, um sie besser zu nutzen.",
|
||||
@@ -260,6 +269,10 @@
|
||||
"sell": "Verkauf",
|
||||
"sellButton": "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",
|
||||
"transportSource": "Artikel",
|
||||
"transportSourcePlaceholder": "Artikel wählen",
|
||||
@@ -578,6 +591,7 @@
|
||||
"Production cost": "Produktionskosten",
|
||||
"Sell all products": "Alle Produkte verkauft",
|
||||
"sell products": "Produkte verkauft",
|
||||
"taxFromSaleProduct": "Steuer aus Verkauf: {product}",
|
||||
"director starts production": "Direktor beginnt Produktion",
|
||||
"director payed out": "Direktorgehalt ausgezahlt",
|
||||
"Buy storage (type: field)": "Lagerplatz gekauft (Typ: Feld)",
|
||||
@@ -596,6 +610,9 @@
|
||||
"new nobility title": "Neuer Adelstitel",
|
||||
"partyOrder": "Fest bestellt",
|
||||
"renovation_all": "Haus komplett renoviert",
|
||||
"reputationAction": {
|
||||
"school_funding": "Sozialstatus: Schule/Lehrstuhl finanziert"
|
||||
},
|
||||
"health": {
|
||||
"pill": "Gesundheitsmaßnahme: Tablette",
|
||||
"doctor": "Gesundheitsmaßnahme: Arztbesuch",
|
||||
@@ -738,7 +755,8 @@
|
||||
"reputation": {
|
||||
"title": "Reputation",
|
||||
"overview": {
|
||||
"title": "Übersicht"
|
||||
"title": "Übersicht",
|
||||
"current": "Deine aktuelle Reputation"
|
||||
},
|
||||
"party": {
|
||||
"title": "Feste",
|
||||
@@ -777,6 +795,34 @@
|
||||
"type": "Festart",
|
||||
"cost": "Kosten",
|
||||
"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": {
|
||||
@@ -883,6 +929,9 @@
|
||||
"success": "Erfolg",
|
||||
"selectMeasure": "Maßnahme",
|
||||
"perform": "Durchführen",
|
||||
"errors": {
|
||||
"tooClose": "Aktionen zu dicht hintereinander (maximal 1× pro 24 Stunden)."
|
||||
},
|
||||
"measures": {
|
||||
"pill": "Tablette",
|
||||
"doctor": "Arztbesuch",
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"Cancel": "Abbrechen",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"usersearch": "Benutzersuche",
|
||||
"forum": "Forum",
|
||||
"gallery": "Galerie",
|
||||
"vocabtrainer": "Vokabeltrainer",
|
||||
"blockedUsers": "Blockierte Benutzer",
|
||||
"oneTimeInvitation": "Einmal-Einladungen",
|
||||
"diary": "Tagebuch",
|
||||
@@ -27,6 +28,9 @@
|
||||
"m-erotic": {
|
||||
"pictures": "Bilder",
|
||||
"videos": "Videos"
|
||||
},
|
||||
"m-vocabtrainer": {
|
||||
"newLanguage": "Neue Sprache"
|
||||
}
|
||||
},
|
||||
"m-minigames": {
|
||||
@@ -46,6 +50,7 @@
|
||||
"m-administration": {
|
||||
"contactrequests": "Kontaktanfragen",
|
||||
"users": "Benutzer",
|
||||
"userrights": "Benutzerrechte",
|
||||
"m-users": {
|
||||
"userlist": "Benutzerliste",
|
||||
"userstatistics": "Benutzerstatistiken",
|
||||
@@ -58,7 +63,8 @@
|
||||
"logentries": "Log-Einträge",
|
||||
"edituser": "Benutzer bearbeiten",
|
||||
"database": "Datenbank",
|
||||
"mapEditor": "Karteneditor"
|
||||
"mapEditor": "Karteneditor",
|
||||
"createNPC": "NPCs erstellen"
|
||||
},
|
||||
"minigames": "Minispiele",
|
||||
"m-minigames": {
|
||||
|
||||
@@ -249,5 +249,77 @@
|
||||
"denied": "Du hast die Freundschaftsanfrage abgelehnt.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,35 @@
|
||||
"errorAddingStock": "Error adding warehouse.",
|
||||
"stockAdded": "Warehouse successfully added.",
|
||||
"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": {
|
||||
|
||||
@@ -100,6 +100,12 @@
|
||||
"bad": "Bad",
|
||||
"very_bad": "Very bad"
|
||||
},
|
||||
"healthview": {
|
||||
"title": "Health",
|
||||
"errors": {
|
||||
"tooClose": "Actions too close together (max once per 24 hours)."
|
||||
}
|
||||
},
|
||||
"moneyHistory": {
|
||||
"title": "Money history",
|
||||
"filter": "Filter",
|
||||
@@ -116,6 +122,7 @@
|
||||
"Production cost": "Production cost",
|
||||
"Sell all products": "Sell all products",
|
||||
"sell products": "Sell products",
|
||||
"taxFromSaleProduct": "Tax from product sale: {product}",
|
||||
"director starts production": "Director starts production",
|
||||
"director payed out": "Director salary paid out",
|
||||
"Buy storage (type: field)": "Bought storage (type: field)",
|
||||
@@ -134,6 +141,9 @@
|
||||
"new nobility title": "New title of nobility",
|
||||
"partyOrder": "Party ordered",
|
||||
"renovation_all": "House fully renovated",
|
||||
"reputationAction": {
|
||||
"school_funding": "Social status: funded a school/chair"
|
||||
},
|
||||
"health": {
|
||||
"pill": "Health measure: pill",
|
||||
"doctor": "Health measure: doctor",
|
||||
@@ -163,7 +173,8 @@
|
||||
},
|
||||
"director": {
|
||||
"income": "Income",
|
||||
"incomeUpdated": "Salary has been successfully updated."
|
||||
"incomeUpdated": "Salary has been successfully updated.",
|
||||
"repairVehicles": "May repair vehicles"
|
||||
},
|
||||
"vehicles": {
|
||||
"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": {
|
||||
"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": {
|
||||
"storageAvailable": "Free storage"
|
||||
},
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
"dataPrivacy": {
|
||||
"title": "Data Privacy Policy"
|
||||
},
|
||||
"general": {
|
||||
"loading": "Loading...",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"datetimelong": "dd.MM.yyyy HH:mm:ss"
|
||||
},
|
||||
"message": {
|
||||
"close": "Close"
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"usersearch": "User search",
|
||||
"forum": "Forum",
|
||||
"gallery": "Gallery",
|
||||
"vocabtrainer": "Vocabulary trainer",
|
||||
"blockedUsers": "Blocked users",
|
||||
"oneTimeInvitation": "One-time invitations",
|
||||
"diary": "Diary",
|
||||
@@ -27,6 +28,9 @@
|
||||
"m-erotic": {
|
||||
"pictures": "Pictures",
|
||||
"videos": "Videos"
|
||||
},
|
||||
"m-vocabtrainer": {
|
||||
"newLanguage": "New language"
|
||||
}
|
||||
},
|
||||
"m-minigames": {
|
||||
@@ -46,6 +50,7 @@
|
||||
"m-administration": {
|
||||
"contactrequests": "Contact requests",
|
||||
"users": "Users",
|
||||
"userrights": "User rights",
|
||||
"m-users": {
|
||||
"userlist": "User list",
|
||||
"userstatistics": "User statistics",
|
||||
@@ -58,7 +63,8 @@
|
||||
"logentries": "Log entries",
|
||||
"edituser": "Edit user",
|
||||
"database": "Database",
|
||||
"mapEditor": "Map editor"
|
||||
"mapEditor": "Map editor",
|
||||
"createNPC": "Create NPCs"
|
||||
},
|
||||
"minigames": "Mini games",
|
||||
"m-minigames": {
|
||||
|
||||
@@ -249,5 +249,77 @@
|
||||
"denied": "You have denied the friendship request.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import UserRightsView from '../views/admin/UserRightsView.vue';
|
||||
import ForumAdminView from '../dialogues/admin/ForumAdminView.vue';
|
||||
import AdminFalukantEditUserView from '../views/admin/falukant/EditUserView.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 AdminTaxiToolsView from '../views/admin/TaxiToolsView.vue';
|
||||
import AdminUsersView from '../views/admin/UsersView.vue';
|
||||
@@ -66,6 +67,12 @@ const adminRoutes = [
|
||||
component: AdminFalukantMapRegionsView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/falukant/create-npc',
|
||||
name: 'AdminFalukantCreateNPCView',
|
||||
component: AdminFalukantCreateNPCView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/minigames/match3',
|
||||
name: 'AdminMinigames',
|
||||
|
||||
@@ -5,6 +5,11 @@ import GuestbookView from '../views/social/GuestbookView.vue';
|
||||
import DiaryView from '../views/social/DiaryView.vue';
|
||||
import ForumView from '../views/social/ForumView.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 = [
|
||||
{
|
||||
@@ -49,6 +54,36 @@ const socialRoutes = [
|
||||
component: DiaryView,
|
||||
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;
|
||||
|
||||
@@ -187,6 +187,20 @@ const store = createStore({
|
||||
if (!socketIoUrl && (import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) {
|
||||
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, {
|
||||
secure: true,
|
||||
|
||||
447
frontend/src/views/admin/falukant/CreateNPCView.vue
Normal file
447
frontend/src/views/admin/falukant/CreateNPCView.vue
Normal 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>
|
||||
@@ -468,10 +468,14 @@ export default {
|
||||
},
|
||||
|
||||
watch: {
|
||||
activeTab(newVal) {
|
||||
if (newVal === 'taxes') {
|
||||
this.loadBranchTaxes();
|
||||
}
|
||||
activeTab(newVal, oldVal) {
|
||||
// Nur neu laden, wenn der Tab wirklich gewechselt wurde und ein Branch ausgewählt ist
|
||||
if (!this.selectedBranch || newVal === oldVal) return;
|
||||
|
||||
// Alle Tabs neu laden, wenn gewechselt wird
|
||||
this.$nextTick(() => {
|
||||
this.refreshActiveTab();
|
||||
});
|
||||
},
|
||||
selectedBranch: {
|
||||
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) {
|
||||
this.selectedBranch = newBranch;
|
||||
// Branches neu laden, um das Wetter zu aktualisieren
|
||||
@@ -549,13 +580,8 @@ export default {
|
||||
await this.loadVehicles();
|
||||
await this.loadProductPricesForCurrentBranch();
|
||||
this.$nextTick(() => {
|
||||
this.$refs.directorInfo?.refresh();
|
||||
this.$refs.saleSection?.loadInventory();
|
||||
this.$refs.saleSection?.loadTransports();
|
||||
this.$refs.productionSection?.loadProductions();
|
||||
this.$refs.productionSection?.loadStorage();
|
||||
this.$refs.storageSection?.loadStorageData();
|
||||
this.$refs.revenueSection?.refresh && this.$refs.revenueSection.refresh();
|
||||
// Alle Tabs neu laden
|
||||
this.refreshActiveTab();
|
||||
});
|
||||
|
||||
// load tax info for this branch
|
||||
@@ -692,7 +718,10 @@ export default {
|
||||
},
|
||||
|
||||
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'; // 95–100
|
||||
if (v >= 72) return 'Sehr gut'; // 72–94
|
||||
if (v >= 54) return 'Gut'; // 54–71
|
||||
@@ -700,7 +729,7 @@ export default {
|
||||
if (v >= 22) return 'Schlecht'; // 22–38
|
||||
if (v >= 6) return 'Sehr schlecht'; // 6–21
|
||||
if (v >= 1) return 'Katastrophal'; // 1–5
|
||||
return 'Unbekannt';
|
||||
return 'Katastrophal'; // 0 oder kleiner
|
||||
},
|
||||
|
||||
speedLabel(value) {
|
||||
@@ -1014,12 +1043,15 @@ export default {
|
||||
});
|
||||
await this.loadVehicles();
|
||||
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();
|
||||
} catch (error) {
|
||||
console.error('Error repairing all vehicles:', error);
|
||||
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 this.loadVehicles();
|
||||
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();
|
||||
} catch (error) {
|
||||
console.error('Error repairing vehicle:', error);
|
||||
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'));
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -295,8 +295,13 @@ export default {
|
||||
},
|
||||
|
||||
async loadGifts() {
|
||||
const response = await apiClient.get('/api/falukant/family/gifts');
|
||||
this.gifts = response.data;
|
||||
try {
|
||||
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() {
|
||||
|
||||
@@ -129,13 +129,22 @@ export default {
|
||||
async performMeasure() {
|
||||
if (!this.selectedMeasure) return;
|
||||
try {
|
||||
await apiClient.post('/api/falukant/health', {
|
||||
const { data } = await apiClient.post('/api/falukant/health', {
|
||||
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();
|
||||
this.selectedTr = '';
|
||||
} catch (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) {
|
||||
|
||||
@@ -67,12 +67,28 @@ export default {
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
},
|
||||
productsById: {},
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchMoneyHistory(1);
|
||||
await Promise.all([this.loadProducts(), this.fetchMoneyHistory(1)]);
|
||||
},
|
||||
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) {
|
||||
try {
|
||||
const response = await apiClient.post('/api/falukant/moneyhistory', {
|
||||
@@ -85,6 +101,25 @@ export default {
|
||||
}
|
||||
},
|
||||
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"
|
||||
const key = `falukant.moneyHistory.activities.${activity}`;
|
||||
const translation = this.$t(key);
|
||||
|
||||
@@ -2,19 +2,45 @@
|
||||
<div>
|
||||
<StatusBar />
|
||||
<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>
|
||||
<h3>{{ $t('falukant.overview.metadata.title') }}</h3>
|
||||
<table>
|
||||
<tr>
|
||||
<td>{{ $t('falukant.overview.metadata.name') }}</td>
|
||||
<td>{{ falukantUser?.character.definedFirstName.name }} {{
|
||||
falukantUser?.character.definedLastName.name }}</td>
|
||||
<td>{{ falukantUser?.character?.definedFirstName?.name }} {{
|
||||
falukantUser?.character?.definedLastName?.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ $t('falukant.overview.metadata.nobleTitle') }}</td>
|
||||
<td>{{ $t('falukant.titles.' + falukantUser?.character.gender + '.' +
|
||||
falukantUser?.character.nobleTitle.labelTr) }}</td>
|
||||
<td>{{ $t('falukant.titles.' + falukantUser?.character?.gender + '.' +
|
||||
falukantUser?.character?.nobleTitle?.labelTr) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ $t('falukant.overview.metadata.money') }}</td>
|
||||
@@ -26,11 +52,11 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ $t('falukant.overview.metadata.age') }}</td>
|
||||
<td>{{ falukantUser?.character.age }}</td>
|
||||
<td>{{ falukantUser?.character?.age }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ $t('falukant.overview.metadata.mainbranch') }}</td>
|
||||
<td>{{ falukantUser?.mainBranchRegion.name }}</td>
|
||||
<td>{{ falukantUser?.mainBranchRegion?.name }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -90,15 +116,26 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="imagecontainer">
|
||||
<div :style="getAvatarStyle" class="avatar"></div>
|
||||
<div :style="getHouseStyle" class="house"></div>
|
||||
<div v-if="falukantUser?.character" class="overview-visualization">
|
||||
<div class="character-3d-container">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import StatusBar from '@/components/falukant/StatusBar.vue';
|
||||
import CharacterModel3D from '@/components/falukant/CharacterModel3D.vue';
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import { mapState } from 'vuex';
|
||||
|
||||
@@ -143,18 +180,21 @@ export default {
|
||||
name: 'FalukantOverviewView',
|
||||
components: {
|
||||
StatusBar,
|
||||
CharacterModel3D,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
falukantUser: null,
|
||||
allStock: [],
|
||||
productions: [],
|
||||
potentialHeirs: [],
|
||||
loadingHeirs: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(['socket']),
|
||||
getAvatarStyle() {
|
||||
if (!this.falukantUser) return {};
|
||||
if (!this.falukantUser || !this.falukantUser.character) return {};
|
||||
const { gender, age } = this.falukantUser.character;
|
||||
const imageUrl = `/images/falukant/avatar/${gender}.png`;
|
||||
const ageGroup = this.getAgeGroup(age);
|
||||
@@ -212,8 +252,12 @@ export default {
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchFalukantUser();
|
||||
await this.fetchAllStock();
|
||||
await this.fetchProductions();
|
||||
if (!this.falukantUser?.character) {
|
||||
await this.fetchPotentialHeirs();
|
||||
} else {
|
||||
await this.fetchAllStock();
|
||||
await this.fetchProductions();
|
||||
}
|
||||
// Daemon WebSocket deaktiviert - verwende Socket.io für alle Events
|
||||
this.setupSocketEvents();
|
||||
},
|
||||
@@ -306,6 +350,43 @@ export default {
|
||||
formatDate(timestamp) {
|
||||
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>
|
||||
@@ -348,4 +429,91 @@ export default {
|
||||
h2 {
|
||||
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>
|
||||
|
||||
@@ -12,7 +12,52 @@
|
||||
|
||||
<div class="tab-content">
|
||||
<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 v-else-if="activeTab === 'party'">
|
||||
@@ -139,6 +184,7 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -169,7 +215,15 @@ export default {
|
||||
selectedNobilityIds: [],
|
||||
servantRatio: 50,
|
||||
inProgressParties: [],
|
||||
completedParties: []
|
||||
completedParties: [],
|
||||
reputation: null,
|
||||
reputationActions: [],
|
||||
reputationActionsDailyCap: null,
|
||||
reputationActionsDailyUsed: null,
|
||||
reputationActionsDailyRemaining: null,
|
||||
reputationActionsCooldownMinutes: null,
|
||||
reputationActionsCooldownRemainingSec: 0,
|
||||
runningActionId: null,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -198,6 +252,55 @@ export default {
|
||||
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() {
|
||||
this.nobilityTitles = await apiClient.get('/api/falukant/nobility/titels').then(r => r.data)
|
||||
},
|
||||
@@ -219,6 +322,10 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
reputationDisplay() {
|
||||
if (this.reputation == null) return '—';
|
||||
return String(this.reputation);
|
||||
},
|
||||
formattedCost() {
|
||||
const type = this.partyTypes.find(t => t.id === this.newPartyTypeId) || {};
|
||||
const music = this.musicTypes.find(m => m.id === this.musicId) || {};
|
||||
@@ -245,6 +352,8 @@ export default {
|
||||
await this.loadPartyTypes();
|
||||
await this.loadNobilityTitles();
|
||||
await this.loadParties();
|
||||
await this.loadReputation();
|
||||
await this.loadReputationActions();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -305,4 +414,14 @@ table th {
|
||||
border-top: 1px solid #ccc;
|
||||
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>
|
||||
176
frontend/src/views/social/VocabChapterView.vue
Normal file
176
frontend/src/views/social/VocabChapterView.vue
Normal 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>
|
||||
|
||||
|
||||
160
frontend/src/views/social/VocabLanguageView.vue
Normal file
160
frontend/src/views/social/VocabLanguageView.vue
Normal 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>
|
||||
|
||||
|
||||
106
frontend/src/views/social/VocabNewLanguageView.vue
Normal file
106
frontend/src/views/social/VocabNewLanguageView.vue
Normal 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>
|
||||
|
||||
|
||||
93
frontend/src/views/social/VocabSubscribeView.vue
Normal file
93
frontend/src/views/social/VocabSubscribeView.vue
Normal 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>
|
||||
|
||||
|
||||
86
frontend/src/views/social/VocabTrainerView.vue
Normal file
86
frontend/src/views/social/VocabTrainerView.vue
Normal 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>
|
||||
|
||||
|
||||
@@ -50,5 +50,13 @@ export default defineConfig(({ mode }) => {
|
||||
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
1457
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@
|
||||
"devDependencies": {
|
||||
"concurrently": "^7.0.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"npm-run-all": "^4.1.5"
|
||||
"npm-run-all2": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
|
||||
Reference in New Issue
Block a user