Compare commits
29 Commits
a0aa678e7d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | |||
| 2a4928c1b6 | |||
| efe2bd57ab |
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 express from 'express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
import crypto from 'crypto';
|
||||||
import chatRouter from './routers/chatRouter.js';
|
import chatRouter from './routers/chatRouter.js';
|
||||||
import authRouter from './routers/authRouter.js';
|
import authRouter from './routers/authRouter.js';
|
||||||
import navigationRouter from './routers/navigationRouter.js';
|
import navigationRouter from './routers/navigationRouter.js';
|
||||||
@@ -25,6 +26,25 @@ const __dirname = path.dirname(__filename);
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// Request-Timing (aktivierbar per ENV)
|
||||||
|
// - LOG_SLOW_REQ_MS=200: Logge Requests, die länger dauern als X ms (Default 500)
|
||||||
|
// - LOG_ALL_REQ=1: Logge alle Requests
|
||||||
|
const LOG_ALL_REQ = process.env.LOG_ALL_REQ === '1';
|
||||||
|
const LOG_SLOW_REQ_MS = Number.parseInt(process.env.LOG_SLOW_REQ_MS || '500', 10);
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const reqId = req.headers['x-request-id'] || (crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(8).toString('hex'));
|
||||||
|
req.reqId = reqId;
|
||||||
|
res.setHeader('x-request-id', reqId);
|
||||||
|
const t0 = Date.now();
|
||||||
|
res.on('finish', () => {
|
||||||
|
const ms = Date.now() - t0;
|
||||||
|
if (LOG_ALL_REQ || ms >= LOG_SLOW_REQ_MS) {
|
||||||
|
console.log(`⏱️ REQ ${ms}ms ${res.statusCode} ${req.method} ${req.originalUrl} rid=${reqId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
const corsOptions = {
|
const corsOptions = {
|
||||||
origin: ['http://localhost:3000', 'http://localhost:5173', 'http://127.0.0.1:3000', 'http://127.0.0.1:5173'],
|
origin: ['http://localhost:3000', 'http://localhost:5173', 'http://127.0.0.1:3000', 'http://127.0.0.1:5173'],
|
||||||
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
|
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
|
||||||
|
|||||||
@@ -116,6 +116,12 @@ class FalukantController {
|
|||||||
}, { successStatus: 201 });
|
}, { successStatus: 201 });
|
||||||
this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId));
|
this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId));
|
||||||
|
|
||||||
|
this.getReputationActions = this._wrapWithUser((userId) => this.service.getReputationActions(userId));
|
||||||
|
this.executeReputationAction = this._wrapWithUser((userId, req) => {
|
||||||
|
const { actionTypeId } = req.body;
|
||||||
|
return this.service.executeReputationAction(userId, actionTypeId);
|
||||||
|
}, { successStatus: 201 });
|
||||||
|
|
||||||
this.getNotBaptisedChildren = this._wrapWithUser((userId) => this.service.getNotBaptisedChildren(userId));
|
this.getNotBaptisedChildren = this._wrapWithUser((userId) => this.service.getNotBaptisedChildren(userId));
|
||||||
this.baptise = this._wrapWithUser((userId, req) => {
|
this.baptise = this._wrapWithUser((userId, req) => {
|
||||||
const { characterId: childId, firstName } = req.body;
|
const { characterId: childId, firstName } = req.body;
|
||||||
|
|||||||
@@ -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'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +34,18 @@ FalukantCharacter.init(
|
|||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: 1}
|
defaultValue: 1}
|
||||||
|
,
|
||||||
|
reputation: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
// Initialisierung: zufällig 20..80 (Prozent)
|
||||||
|
// DB-seitig per DEFAULT umgesetzt, damit es auch ohne App-Logic gilt.
|
||||||
|
defaultValue: sequelize.literal('(floor(random()*61)+20)'),
|
||||||
|
validate: {
|
||||||
|
min: 0,
|
||||||
|
max: 100
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sequelize,
|
sequelize,
|
||||||
|
|||||||
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 MusicType from './falukant/type/music.js';
|
||||||
import BanquetteType from './falukant/type/banquette.js';
|
import BanquetteType from './falukant/type/banquette.js';
|
||||||
import PartyInvitedNobility from './falukant/data/partyInvitedNobility.js';
|
import PartyInvitedNobility from './falukant/data/partyInvitedNobility.js';
|
||||||
|
import ReputationActionType from './falukant/type/reputation_action.js';
|
||||||
|
import ReputationActionLog from './falukant/log/reputation_action.js';
|
||||||
import ChildRelation from './falukant/data/child_relation.js';
|
import ChildRelation from './falukant/data/child_relation.js';
|
||||||
import LearnRecipient from './falukant/type/learn_recipient.js';
|
import LearnRecipient from './falukant/type/learn_recipient.js';
|
||||||
import Learning from './falukant/data/learning.js';
|
import Learning from './falukant/data/learning.js';
|
||||||
@@ -208,6 +210,8 @@ const models = {
|
|||||||
BanquetteType,
|
BanquetteType,
|
||||||
Party,
|
Party,
|
||||||
PartyInvitedNobility,
|
PartyInvitedNobility,
|
||||||
|
ReputationActionType,
|
||||||
|
ReputationActionLog,
|
||||||
ChildRelation,
|
ChildRelation,
|
||||||
LearnRecipient,
|
LearnRecipient,
|
||||||
Learning,
|
Learning,
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ router.post('/houses', falukantController.buyUserHouse);
|
|||||||
router.get('/party/types', falukantController.getPartyTypes);
|
router.get('/party/types', falukantController.getPartyTypes);
|
||||||
router.post('/party', falukantController.createParty);
|
router.post('/party', falukantController.createParty);
|
||||||
router.get('/party', falukantController.getParties);
|
router.get('/party', falukantController.getParties);
|
||||||
|
router.get('/reputation/actions', falukantController.getReputationActions);
|
||||||
|
router.post('/reputation/actions', falukantController.executeReputationAction);
|
||||||
router.get('/family/notbaptised', falukantController.getNotBaptisedChildren);
|
router.get('/family/notbaptised', falukantController.getNotBaptisedChildren);
|
||||||
router.post('/church/baptise', falukantController.baptise);
|
router.post('/church/baptise', falukantController.baptise);
|
||||||
router.get('/education', falukantController.getEducation);
|
router.get('/education', falukantController.getEducation);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import TitleOfNobility from "../../models/falukant/type/title_of_nobility.js";
|
|||||||
import PartyType from "../../models/falukant/type/party.js";
|
import PartyType from "../../models/falukant/type/party.js";
|
||||||
import MusicType from "../../models/falukant/type/music.js";
|
import MusicType from "../../models/falukant/type/music.js";
|
||||||
import BanquetteType from "../../models/falukant/type/banquette.js";
|
import BanquetteType from "../../models/falukant/type/banquette.js";
|
||||||
|
import ReputationActionType from "../../models/falukant/type/reputation_action.js";
|
||||||
import VehicleType from "../../models/falukant/type/vehicle.js";
|
import VehicleType from "../../models/falukant/type/vehicle.js";
|
||||||
import LearnRecipient from "../../models/falukant/type/learn_recipient.js";
|
import LearnRecipient from "../../models/falukant/type/learn_recipient.js";
|
||||||
import PoliticalOfficeType from "../../models/falukant/type/political_office_type.js";
|
import PoliticalOfficeType from "../../models/falukant/type/political_office_type.js";
|
||||||
@@ -41,6 +42,7 @@ export const initializeFalukantTypes = async () => {
|
|||||||
await initializeFalukantPartyTypes();
|
await initializeFalukantPartyTypes();
|
||||||
await initializeFalukantMusicTypes();
|
await initializeFalukantMusicTypes();
|
||||||
await initializeFalukantBanquetteTypes();
|
await initializeFalukantBanquetteTypes();
|
||||||
|
await initializeFalukantReputationActions();
|
||||||
await initializeLearnerTypes();
|
await initializeLearnerTypes();
|
||||||
await initializePoliticalOfficeBenefitTypes();
|
await initializePoliticalOfficeBenefitTypes();
|
||||||
await initializePoliticalOfficeTypes();
|
await initializePoliticalOfficeTypes();
|
||||||
@@ -52,6 +54,55 @@ export const initializeFalukantTypes = async () => {
|
|||||||
await initializeFalukantProductWeatherEffects();
|
await initializeFalukantProductWeatherEffects();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const reputationActions = [
|
||||||
|
// Günstig / häufig: schnelle Abnutzung
|
||||||
|
{ tr: "soup_kitchen", cost: 500, baseGain: 2, decayFactor: 0.85, minGain: 0, decayWindowDays: 7 },
|
||||||
|
// Mittel: gesellschaftlich anerkannt
|
||||||
|
{ tr: "library_donation", cost: 5000, baseGain: 4, decayFactor: 0.88, minGain: 0, decayWindowDays: 7 },
|
||||||
|
{ tr: "scholarships", cost: 10000, baseGain: 5, decayFactor: 0.87, minGain: 0, decayWindowDays: 7 },
|
||||||
|
{ tr: "church_hospice", cost: 12000, baseGain: 5, decayFactor: 0.87, minGain: 0, decayWindowDays: 7 },
|
||||||
|
{ tr: "school_funding", cost: 15000, baseGain: 6, decayFactor: 0.88, minGain: 0, decayWindowDays: 7 },
|
||||||
|
// Großprojekte: teurer, langsamerer Rufverfall
|
||||||
|
{ tr: "orphanage_build", cost: 20000, baseGain: 7, decayFactor: 0.90, minGain: 0, decayWindowDays: 7 },
|
||||||
|
{ tr: "bridge_build", cost: 25000, baseGain: 7, decayFactor: 0.90, minGain: 0, decayWindowDays: 7 },
|
||||||
|
{ tr: "hospital_donation", cost: 30000, baseGain: 8, decayFactor: 0.90, minGain: 0, decayWindowDays: 7 },
|
||||||
|
{ tr: "patronage", cost: 40000, baseGain: 9, decayFactor: 0.91, minGain: 0, decayWindowDays: 7 },
|
||||||
|
{ tr: "statue_build", cost: 50000, baseGain: 10, decayFactor: 0.92, minGain: 0, decayWindowDays: 7 },
|
||||||
|
{ tr: "well_build", cost: 8000, baseGain: 4, decayFactor: 0.87, minGain: 0, decayWindowDays: 7 },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function initializeFalukantReputationActions() {
|
||||||
|
// robustes Upsert ohne FK/Constraints-Ärger:
|
||||||
|
// - finde existierende nach tr
|
||||||
|
// - update bei Änderungen
|
||||||
|
// - create wenn fehlt
|
||||||
|
const existing = await ReputationActionType.findAll({ attributes: ['id', 'tr', 'cost', 'baseGain', 'decayFactor', 'minGain'] });
|
||||||
|
const existingByTr = new Map(existing.map(e => [e.tr, e]));
|
||||||
|
|
||||||
|
for (const a of reputationActions) {
|
||||||
|
const found = existingByTr.get(a.tr);
|
||||||
|
if (!found) {
|
||||||
|
await ReputationActionType.create(a);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const needsUpdate =
|
||||||
|
Number(found.cost) !== Number(a.cost) ||
|
||||||
|
Number(found.baseGain) !== Number(a.baseGain) ||
|
||||||
|
Number(found.decayFactor) !== Number(a.decayFactor) ||
|
||||||
|
Number(found.minGain) !== Number(a.minGain) ||
|
||||||
|
Number(found.decayWindowDays || 7) !== Number(a.decayWindowDays || 7);
|
||||||
|
if (needsUpdate) {
|
||||||
|
await found.update({
|
||||||
|
cost: a.cost,
|
||||||
|
baseGain: a.baseGain,
|
||||||
|
decayFactor: a.decayFactor,
|
||||||
|
minGain: a.minGain,
|
||||||
|
decayWindowDays: a.decayWindowDays ?? 7,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const regionTypes = [];
|
const regionTypes = [];
|
||||||
const regionTypeTrs = [
|
const regionTypeTrs = [
|
||||||
"country",
|
"country",
|
||||||
|
|||||||
@@ -3,6 +3,25 @@ import dotenv from 'dotenv';
|
|||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
// Optionales Performance-Logging (aktivierbar per ENV)
|
||||||
|
// - SQL_BENCHMARK=1: Sequelize liefert Query-Timing (ms) an logger
|
||||||
|
// - SQL_SLOW_MS=200: Logge nur Queries ab dieser Dauer (wenn SQL_LOG_ALL nicht gesetzt)
|
||||||
|
// - SQL_LOG_ALL=1: Logge alle Queries (auch ohne benchmark)
|
||||||
|
const SQL_BENCHMARK = process.env.SQL_BENCHMARK === '1';
|
||||||
|
const SQL_LOG_ALL = process.env.SQL_LOG_ALL === '1';
|
||||||
|
const SQL_SLOW_MS = Number.parseInt(process.env.SQL_SLOW_MS || '200', 10);
|
||||||
|
const sqlLogger = (sql, timing) => {
|
||||||
|
// Sequelize ruft logging(sql) oder logging(sql, timing) abhängig von benchmark auf.
|
||||||
|
if (!SQL_BENCHMARK) {
|
||||||
|
if (SQL_LOG_ALL) console.log(sql);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ms = typeof timing === 'number' ? timing : 0;
|
||||||
|
if (SQL_LOG_ALL || ms >= SQL_SLOW_MS) {
|
||||||
|
console.log(`🛢️ SQL ${ms}ms: ${sql}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Validiere Umgebungsvariablen
|
// Validiere Umgebungsvariablen
|
||||||
const dbName = process.env.DB_NAME;
|
const dbName = process.env.DB_NAME;
|
||||||
const dbUser = process.env.DB_USER;
|
const dbUser = process.env.DB_USER;
|
||||||
@@ -26,6 +45,8 @@ const sequelize = new Sequelize(dbName, dbUser, dbPass, {
|
|||||||
timestamps: false,
|
timestamps: false,
|
||||||
underscored: true // WICHTIG: Alle Datenbankfelder im snake_case Format
|
underscored: true // WICHTIG: Alle Datenbankfelder im snake_case Format
|
||||||
},
|
},
|
||||||
|
benchmark: SQL_BENCHMARK,
|
||||||
|
logging: sqlLogger,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createSchemas = async () => {
|
const createSchemas = async () => {
|
||||||
@@ -380,7 +401,7 @@ const updateSchema = async (models) => {
|
|||||||
console.log('✅ Datenbankschema aktualisiert');
|
console.log('✅ Datenbankschema aktualisiert');
|
||||||
};
|
};
|
||||||
|
|
||||||
async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, changedBy = null) {
|
async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, changedBy = null, transaction = null) {
|
||||||
try {
|
try {
|
||||||
const result = await sequelize.query(
|
const result = await sequelize.query(
|
||||||
`SELECT falukant_data.update_money(
|
`SELECT falukant_data.update_money(
|
||||||
@@ -397,6 +418,7 @@ async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, ch
|
|||||||
changedBy,
|
changedBy,
|
||||||
},
|
},
|
||||||
type: sequelize.QueryTypes.SELECT,
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
transaction: transaction || undefined,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -20,8 +20,10 @@
|
|||||||
<td>{{ item.quality }}</td>
|
<td>{{ item.quality }}</td>
|
||||||
<td>{{ item.totalQuantity }}</td>
|
<td>{{ item.totalQuantity }}</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="number" v-model.number="item.sellQuantity" :min="1" :max="item.totalQuantity" />
|
<input type="number" v-model.number="item.sellQuantity" :min="1" :max="item.totalQuantity" :disabled="sellingItemIndex === index" />
|
||||||
<button @click="sellItem(index)">{{ $t('falukant.branch.sale.sellButton') }}</button>
|
<button @click="sellItem(index)" :disabled="sellingItemIndex === index || sellingAll">
|
||||||
|
{{ sellingItemIndex === index ? $t('falukant.branch.sale.selling') : $t('falukant.branch.sale.sellButton') }}
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div v-if="item.betterPrices && item.betterPrices.length > 0" class="price-cities">
|
<div v-if="item.betterPrices && item.betterPrices.length > 0" class="price-cities">
|
||||||
@@ -36,7 +38,12 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<button @click="sellAll">{{ $t('falukant.branch.sale.sellAllButton') }}</button>
|
<button @click="sellAll" :disabled="sellingAll || sellingItemIndex !== null">
|
||||||
|
{{ sellingAll ? $t('falukant.branch.sale.selling') : $t('falukant.branch.sale.sellAllButton') }}
|
||||||
|
</button>
|
||||||
|
<div v-if="sellAllStatus" class="sell-all-status" :class="sellAllStatus.type">
|
||||||
|
{{ sellAllStatus.message }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<p>{{ $t('falukant.branch.sale.noInventory') }}</p>
|
<p>{{ $t('falukant.branch.sale.noInventory') }}</p>
|
||||||
@@ -183,6 +190,9 @@
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
inventory: [],
|
inventory: [],
|
||||||
|
sellingItemIndex: null,
|
||||||
|
sellingAll: false,
|
||||||
|
sellAllStatus: null,
|
||||||
transportForm: {
|
transportForm: {
|
||||||
sourceKey: null,
|
sourceKey: null,
|
||||||
vehicleTypeId: null,
|
vehicleTypeId: null,
|
||||||
@@ -251,13 +261,6 @@
|
|||||||
return new Date(a.eta).getTime() - new Date(b.eta).getTime();
|
return new Date(a.eta).getTime() - new Date(b.eta).getTime();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
speedLabel(value) {
|
|
||||||
const key = value == null ? 'unknown' : String(value);
|
|
||||||
const tKey = `falukant.branch.transport.speed.${key}`;
|
|
||||||
const translated = this.$t(tKey);
|
|
||||||
if (!translated || translated === tKey) return value;
|
|
||||||
return translated;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
await this.loadInventory();
|
await this.loadInventory();
|
||||||
@@ -274,12 +277,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
speedLabel(value) {
|
||||||
|
// Muss in methods liegen (Vue3): in computed wäre es ein Getter und keine aufrufbare Funktion.
|
||||||
|
const key = value == null ? 'unknown' : String(value);
|
||||||
|
const tKey = `falukant.branch.transport.speed.${key}`;
|
||||||
|
const translated = this.$t(tKey);
|
||||||
|
if (!translated || translated === tKey) return value;
|
||||||
|
return translated;
|
||||||
|
},
|
||||||
async loadInventory() {
|
async loadInventory() {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/api/falukant/inventory/${this.branchId}`);
|
const response = await apiClient.get(`/api/falukant/inventory/${this.branchId}`);
|
||||||
this.inventory = response.data.map(item => ({
|
this.inventory = response.data.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
sellQuantity: item.totalQuantity,
|
sellQuantity: item.totalQuantity,
|
||||||
|
// Vue3: besserPrices direkt als Property setzen (statt this.$set)
|
||||||
|
betterPrices: Array.isArray(item.betterPrices) ? item.betterPrices : [],
|
||||||
}));
|
}));
|
||||||
await this.loadPricesForInventory();
|
await this.loadPricesForInventory();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -300,10 +313,11 @@
|
|||||||
currentPrice: currentPrice
|
currentPrice: currentPrice
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.$set(item, 'betterPrices', data || []);
|
// Vue3: direkte Zuweisung ist reaktiv
|
||||||
|
item.betterPrices = Array.isArray(data) ? data : [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error loading prices for item ${itemKey}:`, error);
|
console.error(`Error loading prices for item ${itemKey}:`, error);
|
||||||
this.$set(item, 'betterPrices', []);
|
item.betterPrices = [];
|
||||||
} finally {
|
} finally {
|
||||||
this.loadingPrices.delete(itemKey);
|
this.loadingPrices.delete(itemKey);
|
||||||
}
|
}
|
||||||
@@ -320,23 +334,61 @@
|
|||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
}).format(price);
|
}).format(price);
|
||||||
},
|
},
|
||||||
sellItem(index) {
|
async sellItem(index) {
|
||||||
|
if (this.sellingItemIndex !== null || this.sellingAll) return;
|
||||||
|
|
||||||
const item = this.inventory[index];
|
const item = this.inventory[index];
|
||||||
const quantityToSell = item.sellQuantity || item.totalQuantity;
|
const quantityToSell = item.sellQuantity || item.totalQuantity;
|
||||||
apiClient.post(`/api/falukant/sell`, {
|
this.sellingItemIndex = index;
|
||||||
branchId: this.branchId,
|
|
||||||
productId: item.product.id,
|
try {
|
||||||
quantity: quantityToSell,
|
await apiClient.post(`/api/falukant/sell`, {
|
||||||
quality: item.quality,
|
branchId: this.branchId,
|
||||||
}).catch(() => {
|
productId: item.product.id,
|
||||||
|
quantity: quantityToSell,
|
||||||
|
quality: item.quality,
|
||||||
|
});
|
||||||
|
// UI sofort freigeben (Label/Disabled zurücksetzen), dann Inventory refreshen
|
||||||
|
this.sellingItemIndex = null;
|
||||||
|
await this.loadInventory();
|
||||||
|
} catch (error) {
|
||||||
alert(this.$t('falukant.branch.sale.sellError'));
|
alert(this.$t('falukant.branch.sale.sellError'));
|
||||||
});
|
} finally {
|
||||||
|
this.sellingItemIndex = null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
sellAll() {
|
async sellAll() {
|
||||||
apiClient.post(`/api/falukant/sell/all`, { branchId: this.branchId })
|
if (this.sellingAll || this.sellingItemIndex !== null) return;
|
||||||
.catch(() => {
|
|
||||||
alert(this.$t('falukant.branch.sale.sellAllError'));
|
this.sellingAll = true;
|
||||||
});
|
this.sellAllStatus = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/api/falukant/sell/all`, { branchId: this.branchId });
|
||||||
|
const revenue = response.data?.revenue || 0;
|
||||||
|
// UI sofort freigeben + Status setzen, danach Inventory refreshen
|
||||||
|
this.sellingAll = false;
|
||||||
|
this.sellAllStatus = {
|
||||||
|
type: 'success',
|
||||||
|
message: this.$t('falukant.branch.sale.sellAllSuccess', { revenue: this.formatMoney(revenue) })
|
||||||
|
};
|
||||||
|
// Inventory neu laden nach erfolgreichem Verkauf
|
||||||
|
await this.loadInventory();
|
||||||
|
} catch (error) {
|
||||||
|
// UI sofort freigeben + Fehlerstatus setzen
|
||||||
|
this.sellingAll = false;
|
||||||
|
this.sellAllStatus = {
|
||||||
|
type: 'error',
|
||||||
|
message: this.$t('falukant.branch.sale.sellAllError')
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
// Falls noch nicht freigegeben (z.B. wenn ein unerwarteter Fehler vor Response passiert)
|
||||||
|
this.sellingAll = false;
|
||||||
|
// Status nach 5 Sekunden löschen
|
||||||
|
setTimeout(() => {
|
||||||
|
this.sellAllStatus = null;
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
inventoryOptions() {
|
inventoryOptions() {
|
||||||
return this.inventory.map((item, index) => ({
|
return this.inventory.map((item, index) => ({
|
||||||
@@ -590,5 +642,19 @@
|
|||||||
color: #999;
|
color: #999;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
.sell-all-status {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.sell-all-status.success {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
.sell-all-status.error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -260,6 +260,10 @@
|
|||||||
"sell": "Verkauf",
|
"sell": "Verkauf",
|
||||||
"sellButton": "Verkaufen",
|
"sellButton": "Verkaufen",
|
||||||
"sellAllButton": "Alles verkaufen",
|
"sellAllButton": "Alles verkaufen",
|
||||||
|
"selling": "Verkauf läuft...",
|
||||||
|
"sellError": "Fehler beim Verkauf des Produkts.",
|
||||||
|
"sellAllError": "Fehler beim Verkauf aller Produkte.",
|
||||||
|
"sellAllSuccess": "Alle Produkte wurden erfolgreich verkauft. Einnahmen: {revenue}",
|
||||||
"transportTitle": "Transport anlegen",
|
"transportTitle": "Transport anlegen",
|
||||||
"transportSource": "Artikel",
|
"transportSource": "Artikel",
|
||||||
"transportSourcePlaceholder": "Artikel wählen",
|
"transportSourcePlaceholder": "Artikel wählen",
|
||||||
@@ -738,7 +742,8 @@
|
|||||||
"reputation": {
|
"reputation": {
|
||||||
"title": "Reputation",
|
"title": "Reputation",
|
||||||
"overview": {
|
"overview": {
|
||||||
"title": "Übersicht"
|
"title": "Übersicht",
|
||||||
|
"current": "Deine aktuelle Reputation"
|
||||||
},
|
},
|
||||||
"party": {
|
"party": {
|
||||||
"title": "Feste",
|
"title": "Feste",
|
||||||
@@ -777,6 +782,34 @@
|
|||||||
"type": "Festart",
|
"type": "Festart",
|
||||||
"cost": "Kosten",
|
"cost": "Kosten",
|
||||||
"date": "Datum"
|
"date": "Datum"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"title": "Aktionen",
|
||||||
|
"description": "Mit diesen Aktionen kannst du Reputation gewinnen. Je öfter du dieselbe Aktion ausführst, desto weniger Reputation bringt sie (unabhängig von den Kosten).",
|
||||||
|
"action": "Aktion",
|
||||||
|
"cost": "Kosten",
|
||||||
|
"gain": "Reputation",
|
||||||
|
"timesUsed": "Bereits genutzt",
|
||||||
|
"execute": "Ausführen",
|
||||||
|
"running": "Läuft...",
|
||||||
|
"none": "Keine Aktionen verfügbar.",
|
||||||
|
"dailyLimit": "Heute noch verfügbar: {remaining} / {cap} Reputation (durch Aktionen).",
|
||||||
|
"cooldown": "Nächste Sozialstatus-Aktion in ca. {minutes} Minuten möglich.",
|
||||||
|
"success": "Aktion erfolgreich! Reputation +{gain}, Kosten {cost}.",
|
||||||
|
"successSimple": "Aktion erfolgreich!",
|
||||||
|
"type": {
|
||||||
|
"library_donation": "Spende für eine Bibliothek",
|
||||||
|
"orphanage_build": "Waisenhaus aufbauen",
|
||||||
|
"statue_build": "Statue errichten",
|
||||||
|
"hospital_donation": "Krankenhaus/Heilhaus stiften",
|
||||||
|
"school_funding": "Schule/Lehrstuhl finanzieren",
|
||||||
|
"well_build": "Brunnen/Wasserwerk bauen",
|
||||||
|
"bridge_build": "Straßen-/Brückenbau finanzieren",
|
||||||
|
"soup_kitchen": "Armenspeisung organisieren",
|
||||||
|
"patronage": "Kunst & Mäzenatentum",
|
||||||
|
"church_hospice": "Hospiz-/Kirchenspende",
|
||||||
|
"scholarships": "Stipendienfonds finanzieren"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"party": {
|
"party": {
|
||||||
|
|||||||
@@ -198,6 +198,44 @@
|
|||||||
"nobility": {
|
"nobility": {
|
||||||
"cooldown": "You can only advance again on {date}."
|
"cooldown": "You can only advance again on {date}."
|
||||||
},
|
},
|
||||||
|
"reputation": {
|
||||||
|
"title": "Reputation",
|
||||||
|
"overview": {
|
||||||
|
"title": "Overview",
|
||||||
|
"current": "Your current reputation"
|
||||||
|
},
|
||||||
|
"party": {
|
||||||
|
"title": "Parties"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"title": "Actions",
|
||||||
|
"description": "These actions let you gain reputation. The more often you repeat the same action, the less reputation it yields (independent of cost).",
|
||||||
|
"action": "Action",
|
||||||
|
"cost": "Cost",
|
||||||
|
"gain": "Reputation",
|
||||||
|
"timesUsed": "Times used",
|
||||||
|
"execute": "Execute",
|
||||||
|
"running": "Running...",
|
||||||
|
"none": "No actions available.",
|
||||||
|
"dailyLimit": "Available today: {remaining} / {cap} reputation (from actions).",
|
||||||
|
"cooldown": "Next social status action available in about {minutes} minutes.",
|
||||||
|
"success": "Action successful! Reputation +{gain}, cost {cost}.",
|
||||||
|
"successSimple": "Action successful!",
|
||||||
|
"type": {
|
||||||
|
"library_donation": "Donate to a library",
|
||||||
|
"orphanage_build": "Build an orphanage",
|
||||||
|
"statue_build": "Erect a statue",
|
||||||
|
"hospital_donation": "Found a hospital/infirmary",
|
||||||
|
"school_funding": "Fund a school/chair",
|
||||||
|
"well_build": "Build a well/waterworks",
|
||||||
|
"bridge_build": "Fund roads/bridges",
|
||||||
|
"soup_kitchen": "Organize a soup kitchen",
|
||||||
|
"patronage": "Arts & patronage",
|
||||||
|
"church_hospice": "Hospice/church donation",
|
||||||
|
"scholarships": "Fund scholarships"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"branchProduction": {
|
"branchProduction": {
|
||||||
"storageAvailable": "Free storage"
|
"storageAvailable": "Free storage"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1014,12 +1014,15 @@ export default {
|
|||||||
});
|
});
|
||||||
await this.loadVehicles();
|
await this.loadVehicles();
|
||||||
this.closeRepairAllVehiclesDialog();
|
this.closeRepairAllVehiclesDialog();
|
||||||
alert(this.$t('falukant.branch.transport.repairAllSuccess'));
|
// Statt JS-alert: Dialog schließen und MessageDialog anzeigen
|
||||||
|
this.$root.$refs.messageDialog?.open('tr:falukant.branch.transport.repairAllSuccess');
|
||||||
this.$refs.statusBar?.fetchStatus();
|
this.$refs.statusBar?.fetchStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error repairing all vehicles:', error);
|
console.error('Error repairing all vehicles:', error);
|
||||||
const errorMessage = error.response?.data?.message || this.$t('falukant.branch.transport.repairAllError');
|
const errorMessage = error.response?.data?.message || this.$t('falukant.branch.transport.repairAllError');
|
||||||
alert(errorMessage);
|
// Bestätigungsdialog ebenfalls schließen und Fehler im MessageDialog anzeigen
|
||||||
|
this.closeRepairAllVehiclesDialog();
|
||||||
|
this.$root.$refs.messageDialog?.open(String(errorMessage), this.$t('error.title'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1076,12 +1079,15 @@ export default {
|
|||||||
await apiClient.post(`/api/falukant/vehicles/${this.repairVehicleDialog.vehicle.id}/repair`);
|
await apiClient.post(`/api/falukant/vehicles/${this.repairVehicleDialog.vehicle.id}/repair`);
|
||||||
await this.loadVehicles();
|
await this.loadVehicles();
|
||||||
this.closeRepairVehicleDialog();
|
this.closeRepairVehicleDialog();
|
||||||
alert(this.$t('falukant.branch.transport.repairSuccess'));
|
// Statt JS-alert: Dialog schließen und MessageDialog anzeigen
|
||||||
|
this.$root.$refs.messageDialog?.open('tr:falukant.branch.transport.repairSuccess');
|
||||||
this.$refs.statusBar?.fetchStatus();
|
this.$refs.statusBar?.fetchStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error repairing vehicle:', error);
|
console.error('Error repairing vehicle:', error);
|
||||||
const errorMessage = error.response?.data?.message || this.$t('falukant.branch.transport.repairError');
|
const errorMessage = error.response?.data?.message || this.$t('falukant.branch.transport.repairError');
|
||||||
alert(errorMessage);
|
// Bestätigungsdialog ebenfalls schließen und Fehler im MessageDialog anzeigen
|
||||||
|
this.closeRepairVehicleDialog();
|
||||||
|
this.$root.$refs.messageDialog?.open(String(errorMessage), this.$t('error.title'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,52 @@
|
|||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<div v-if="activeTab === 'overview'">
|
<div v-if="activeTab === 'overview'">
|
||||||
<p>Deine aktuelle Reputation: …</p>
|
<p>
|
||||||
|
{{ $t('falukant.reputation.overview.current') }}:
|
||||||
|
<strong>{{ reputationDisplay }}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="separator-class">
|
||||||
|
<h3>{{ $t('falukant.reputation.actions.title') }}</h3>
|
||||||
|
<p>
|
||||||
|
{{ $t('falukant.reputation.actions.description') }}
|
||||||
|
</p>
|
||||||
|
<p v-if="reputationActionsDailyCap != null" class="reputation-actions-daily">
|
||||||
|
{{ $t('falukant.reputation.actions.dailyLimit', { remaining: reputationActionsDailyRemaining, cap: reputationActionsDailyCap }) }}
|
||||||
|
</p>
|
||||||
|
<p v-if="reputationActionsCooldownRemainingSec > 0" class="reputation-actions-cooldown">
|
||||||
|
{{ $t('falukant.reputation.actions.cooldown', { minutes: Math.ceil(reputationActionsCooldownRemainingSec / 60) }) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table v-if="reputationActions.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ $t('falukant.reputation.actions.action') }}</th>
|
||||||
|
<th>{{ $t('falukant.reputation.actions.cost') }}</th>
|
||||||
|
<th>{{ $t('falukant.reputation.actions.gain') }}</th>
|
||||||
|
<th>{{ $t('falukant.reputation.actions.timesUsed') }}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="a in reputationActions" :key="a.id">
|
||||||
|
<td>{{ $t('falukant.reputation.actions.type.' + a.tr) }}</td>
|
||||||
|
<td>{{ Number(a.cost || 0).toLocaleString($i18n.locale) }}</td>
|
||||||
|
<td>+{{ Number(a.currentGain || 0) }}</td>
|
||||||
|
<td>{{ Number(a.timesUsed || 0) }}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" :disabled="runningActionId === a.id || reputationActionsCooldownRemainingSec > 0"
|
||||||
|
@click.prevent="executeReputationAction(a)">
|
||||||
|
{{ runningActionId === a.id ? $t('falukant.reputation.actions.running') : $t('falukant.reputation.actions.execute') }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-else>
|
||||||
|
{{ $t('falukant.reputation.actions.none') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="activeTab === 'party'">
|
<div v-else-if="activeTab === 'party'">
|
||||||
@@ -139,6 +184,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -169,7 +215,15 @@ export default {
|
|||||||
selectedNobilityIds: [],
|
selectedNobilityIds: [],
|
||||||
servantRatio: 50,
|
servantRatio: 50,
|
||||||
inProgressParties: [],
|
inProgressParties: [],
|
||||||
completedParties: []
|
completedParties: [],
|
||||||
|
reputation: null,
|
||||||
|
reputationActions: [],
|
||||||
|
reputationActionsDailyCap: null,
|
||||||
|
reputationActionsDailyUsed: null,
|
||||||
|
reputationActionsDailyRemaining: null,
|
||||||
|
reputationActionsCooldownMinutes: null,
|
||||||
|
reputationActionsCooldownRemainingSec: 0,
|
||||||
|
runningActionId: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -198,6 +252,55 @@ export default {
|
|||||||
return partyDate <= twentyFourHoursAgo;
|
return partyDate <= twentyFourHoursAgo;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
async loadReputation() {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get('/api/falukant/info');
|
||||||
|
this.reputation = data?.character?.reputation ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load reputation', e);
|
||||||
|
this.reputation = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadReputationActions() {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get('/api/falukant/reputation/actions');
|
||||||
|
this.reputationActionsDailyCap = data?.dailyCap ?? null;
|
||||||
|
this.reputationActionsDailyUsed = data?.dailyUsed ?? null;
|
||||||
|
this.reputationActionsDailyRemaining = data?.dailyRemaining ?? null;
|
||||||
|
this.reputationActionsCooldownMinutes = data?.cooldownMinutes ?? null;
|
||||||
|
this.reputationActionsCooldownRemainingSec = Number(data?.cooldownRemainingSec ?? 0) || 0;
|
||||||
|
this.reputationActions = Array.isArray(data?.actions) ? data.actions : [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load reputation actions', e);
|
||||||
|
this.reputationActions = [];
|
||||||
|
this.reputationActionsDailyCap = null;
|
||||||
|
this.reputationActionsDailyUsed = null;
|
||||||
|
this.reputationActionsDailyRemaining = null;
|
||||||
|
this.reputationActionsCooldownMinutes = null;
|
||||||
|
this.reputationActionsCooldownRemainingSec = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async executeReputationAction(action) {
|
||||||
|
if (!action?.id) return;
|
||||||
|
if (this.runningActionId) return;
|
||||||
|
if (this.reputationActionsCooldownRemainingSec > 0) return;
|
||||||
|
this.runningActionId = action.id;
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.post('/api/falukant/reputation/actions', { actionTypeId: action.id });
|
||||||
|
const gain = data?.gain ?? null;
|
||||||
|
const cost = data?.cost ?? null;
|
||||||
|
const msg = gain != null
|
||||||
|
? this.$t('falukant.reputation.actions.success', { gain, cost })
|
||||||
|
: this.$t('falukant.reputation.actions.successSimple');
|
||||||
|
this.$root.$refs.messageDialog?.open(msg, this.$t('falukant.reputation.actions.title'));
|
||||||
|
await Promise.all([this.loadReputation(), this.loadReputationActions()]);
|
||||||
|
} catch (e) {
|
||||||
|
const errText = e?.response?.data?.error || e?.message || String(e);
|
||||||
|
this.$root.$refs.messageDialog?.open(errText, this.$t('falukant.reputation.actions.title'));
|
||||||
|
} finally {
|
||||||
|
this.runningActionId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
async loadNobilityTitles() {
|
async loadNobilityTitles() {
|
||||||
this.nobilityTitles = await apiClient.get('/api/falukant/nobility/titels').then(r => r.data)
|
this.nobilityTitles = await apiClient.get('/api/falukant/nobility/titels').then(r => r.data)
|
||||||
},
|
},
|
||||||
@@ -219,6 +322,10 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
reputationDisplay() {
|
||||||
|
if (this.reputation == null) return '—';
|
||||||
|
return String(this.reputation);
|
||||||
|
},
|
||||||
formattedCost() {
|
formattedCost() {
|
||||||
const type = this.partyTypes.find(t => t.id === this.newPartyTypeId) || {};
|
const type = this.partyTypes.find(t => t.id === this.newPartyTypeId) || {};
|
||||||
const music = this.musicTypes.find(m => m.id === this.musicId) || {};
|
const music = this.musicTypes.find(m => m.id === this.musicId) || {};
|
||||||
@@ -245,6 +352,8 @@ export default {
|
|||||||
await this.loadPartyTypes();
|
await this.loadPartyTypes();
|
||||||
await this.loadNobilityTitles();
|
await this.loadNobilityTitles();
|
||||||
await this.loadParties();
|
await this.loadParties();
|
||||||
|
await this.loadReputation();
|
||||||
|
await this.loadReputationActions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -305,4 +414,14 @@ table th {
|
|||||||
border-top: 1px solid #ccc;
|
border-top: 1px solid #ccc;
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reputation-actions-daily {
|
||||||
|
margin: 0.5rem 0 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reputation-actions-cooldown {
|
||||||
|
margin: -0.5rem 0 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
1451
package-lock.json
generated
1451
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^7.0.0",
|
"concurrently": "^7.0.0",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"npm-run-all": "^4.1.5"
|
"npm-run-all2": "^5.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
6
renovate.json
Normal file
6
renovate.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user