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 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';
|
||||
@@ -25,6 +26,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'],
|
||||
|
||||
@@ -116,6 +116,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;
|
||||
|
||||
@@ -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,
|
||||
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,
|
||||
|
||||
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,
|
||||
|
||||
@@ -53,6 +53,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);
|
||||
|
||||
@@ -65,6 +65,8 @@ import Weather from '../models/falukant/data/weather.js';
|
||||
import TownProductWorth from '../models/falukant/data/town_product_worth.js';
|
||||
import ProductWeatherEffect from '../models/falukant/type/product_weather_effect.js';
|
||||
import WeatherType from '../models/falukant/type/weather.js';
|
||||
import ReputationActionType from '../models/falukant/type/reputation_action.js';
|
||||
import ReputationActionLog from '../models/falukant/log/reputation_action.js';
|
||||
|
||||
function calcAge(birthdate) {
|
||||
const b = new Date(birthdate); b.setHours(0, 0);
|
||||
@@ -72,9 +74,10 @@ function calcAge(birthdate) {
|
||||
return differenceInDays(now, b);
|
||||
}
|
||||
|
||||
async function getFalukantUserOrFail(hashedId) {
|
||||
async function getFalukantUserOrFail(hashedId, options = {}) {
|
||||
const user = await FalukantUser.findOne({
|
||||
include: [{ model: User, as: 'user', attributes: ['username', 'hashedId'], where: { hashedId } }]
|
||||
include: [{ model: User, as: 'user', attributes: ['username', 'hashedId'], where: { hashedId } }],
|
||||
transaction: options.transaction
|
||||
});
|
||||
if (!user) throw new Error('User not found');
|
||||
return user;
|
||||
@@ -92,6 +95,14 @@ function calcSellPrice(product, knowledgeFactor = 0) {
|
||||
return min + (max - min) * (knowledgeFactor / 100);
|
||||
}
|
||||
|
||||
// Synchron version für Batch-Operationen (ohne DB-Query)
|
||||
function calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent = 50) {
|
||||
const basePrice = product.sellCost * (worthPercent / 100);
|
||||
const min = basePrice * 0.6;
|
||||
const max = basePrice;
|
||||
return min + (max - min) * (knowledgeFactor / 100);
|
||||
}
|
||||
|
||||
async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null) {
|
||||
// Wenn worthPercent nicht übergeben wurde, hole es aus der Datenbank
|
||||
if (worthPercent === null) {
|
||||
@@ -147,9 +158,34 @@ async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPe
|
||||
async function getCumulativeTaxPercentWithExemptions(userId, regionId) {
|
||||
if (!regionId) return 0;
|
||||
// fetch user's political offices (active) and their region types
|
||||
const offices = await PoliticalOffice.findAll({
|
||||
// PoliticalOffice hat keine userId Spalte, daher müssen wir zuerst den Character finden
|
||||
const character = await FalukantCharacter.findOne({
|
||||
where: { userId },
|
||||
include: [{ model: PoliticalOfficeType, as: 'type', attributes: ['name'] }, { model: RegionData, as: 'region', include: [{ model: RegionType, as: 'regionType', attributes: ['labelTr'] }] }]
|
||||
attributes: ['id']
|
||||
});
|
||||
if (!character) {
|
||||
// Wenn kein Character existiert, gibt es keine politischen Ämter
|
||||
return 0;
|
||||
}
|
||||
|
||||
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']
|
||||
}]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// build set of exempt region type labels from user's offices
|
||||
@@ -169,21 +205,27 @@ async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPe
|
||||
if (hasChancellor) return 0;
|
||||
|
||||
// Now compute cumulative tax but exclude regions whose regionType.labelTr is in exemptTypes
|
||||
// Konvertiere exemptTypes Set zu einem PostgreSQL-Array-String
|
||||
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_type rt ON rt.id = r.region_type_id
|
||||
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_type rt2 ON rt2.id = reg.region_type_id
|
||||
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 :exempt_types::text[] && ARRAY[region_type] THEN 0 ELSE tax_percent END),0) AS total FROM ancestors;`,
|
||||
SELECT COALESCE(SUM(CASE WHEN ${exemptTypesString} && ARRAY[region_type]::text[] THEN 0 ELSE tax_percent END),0) AS total FROM ancestors;`,
|
||||
{
|
||||
replacements: { id: regionId, exempt_types: Array.from(exemptTypes) },
|
||||
replacements: { id: regionId },
|
||||
type: sequelize.QueryTypes.SELECT
|
||||
}
|
||||
);
|
||||
@@ -287,6 +329,9 @@ class PreconditionError extends Error {
|
||||
|
||||
class FalukantService extends BaseService {
|
||||
static KNOWLEDGE_MAX = 99;
|
||||
static REPUTATION_ACTION_DAILY_CAP = Number(process.env.FALUKANT_REPUTATION_ACTION_DAILY_CAP ?? 10);
|
||||
static REPUTATION_ACTION_COOLDOWN_MINUTES = Number(process.env.FALUKANT_REPUTATION_ACTION_COOLDOWN_MINUTES ?? 60);
|
||||
static RANDOM_EVENT_DAILY_ENABLED = String(process.env.FALUKANT_RANDOM_EVENT_DAILY_ENABLED ?? '1') === '1';
|
||||
static COST_CONFIG = {
|
||||
one: { min: 50, max: 5000 },
|
||||
all: { min: 400, max: 40000 }
|
||||
@@ -333,7 +378,7 @@ class FalukantService extends BaseService {
|
||||
{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr', 'id'] },
|
||||
{ model: CharacterTrait, as: 'traits', attributes: ['id', 'tr'] }
|
||||
],
|
||||
attributes: ['id', 'birthdate', 'gender', 'moodId', 'health']
|
||||
attributes: ['id', 'birthdate', 'gender', 'moodId', 'health', 'reputation']
|
||||
},
|
||||
{
|
||||
model: UserHouse,
|
||||
@@ -457,7 +502,7 @@ class FalukantService extends BaseService {
|
||||
{
|
||||
model: FalukantCharacter,
|
||||
as: 'character',
|
||||
attributes: ['birthdate', 'health'],
|
||||
attributes: ['id', 'regionId', 'birthdate', 'health', 'reputation'],
|
||||
include: [
|
||||
{
|
||||
model: Relationship,
|
||||
@@ -510,6 +555,16 @@ class FalukantService extends BaseService {
|
||||
const userCharacterIds = userCharacterIdsRows.map(r => r.id);
|
||||
bm('aggregate.userCharacters', { count: userCharacterIds.length, ids: userCharacterIds.slice(0, 5) });
|
||||
|
||||
// Daily random event (once per calendar day per user) -> stored as Notification random_event.*
|
||||
// Frontend already supports JSON-encoded tr: {"tr":"random_event.windfall","amount":123}
|
||||
try {
|
||||
if (FalukantService.RANDOM_EVENT_DAILY_ENABLED) {
|
||||
await this._maybeCreateDailyRandomEvent(falukantUser, user);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Falukant] daily random event failed (non-fatal):', e?.message || e);
|
||||
}
|
||||
|
||||
// Count distinct children for any of the user's characters (as father or mother)
|
||||
let childrenCount = 0;
|
||||
let unbaptisedChildrenCount = 0;
|
||||
@@ -562,6 +617,99 @@ class FalukantService extends BaseService {
|
||||
return falukantUser;
|
||||
}
|
||||
|
||||
async _maybeCreateDailyRandomEvent(falukantUser, user) {
|
||||
if (!falukantUser?.id) return null;
|
||||
|
||||
// Already created today?
|
||||
const since = new Date();
|
||||
since.setHours(0, 0, 0, 0);
|
||||
const already = await Notification.count({
|
||||
where: {
|
||||
userId: falukantUser.id,
|
||||
createdAt: { [Op.gte]: since },
|
||||
[Op.or]: [
|
||||
{ tr: { [Op.like]: 'random_event.%' } },
|
||||
{ tr: { [Op.like]: '%\"tr\":\"random_event.%' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (already > 0) return null;
|
||||
|
||||
// Choose an event (guaranteed once/day, random type)
|
||||
const events = [
|
||||
{ id: 'windfall', weight: 25 },
|
||||
{ id: 'theft', weight: 20 },
|
||||
{ id: 'character_illness', weight: 20 },
|
||||
{ id: 'character_recovery', weight: 15 },
|
||||
{ id: 'character_accident', weight: 10 },
|
||||
{ id: 'regional_festival', weight: 10 },
|
||||
];
|
||||
const total = events.reduce((s, e) => s + e.weight, 0);
|
||||
let r = Math.random() * total;
|
||||
let chosen = events[0].id;
|
||||
for (const e of events) {
|
||||
r -= e.weight;
|
||||
if (r <= 0) { chosen = e.id; break; }
|
||||
}
|
||||
|
||||
const payload = { tr: `random_event.${chosen}` };
|
||||
|
||||
return await sequelize.transaction(async (t) => {
|
||||
// Reload current values inside tx
|
||||
const freshUser = await FalukantUser.findByPk(falukantUser.id, { transaction: t });
|
||||
const character = await FalukantCharacter.findOne({
|
||||
where: { userId: falukantUser.id },
|
||||
include: [
|
||||
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'], required: false },
|
||||
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'], required: false },
|
||||
],
|
||||
transaction: t,
|
||||
});
|
||||
|
||||
// Effects (keine harten Datenlöschungen)
|
||||
if (chosen === 'windfall') {
|
||||
const amount = Math.floor(Math.random() * 901) + 100; // 100..1000
|
||||
payload.amount = amount;
|
||||
await updateFalukantUserMoney(falukantUser.id, amount, 'random_event.windfall', falukantUser.id, t);
|
||||
} else if (chosen === 'theft') {
|
||||
const maxLoss = Math.max(0, Math.min(500, Math.floor(Number(freshUser?.money || 0))));
|
||||
const amount = maxLoss > 0 ? (Math.floor(Math.random() * maxLoss) + 1) : 0;
|
||||
payload.amount = amount;
|
||||
if (amount > 0) {
|
||||
await updateFalukantUserMoney(falukantUser.id, -amount, 'random_event.theft', falukantUser.id, t);
|
||||
}
|
||||
} else if (chosen === 'character_illness' || chosen === 'character_recovery' || chosen === 'character_accident') {
|
||||
const name = [character?.definedFirstName?.name, character?.definedLastName?.name].filter(Boolean).join(' ').trim();
|
||||
payload.characterName = name || null;
|
||||
let delta = 0;
|
||||
if (chosen === 'character_illness') delta = -(Math.floor(Math.random() * 11) + 5); // -5..-15
|
||||
if (chosen === 'character_recovery') delta = (Math.floor(Math.random() * 11) + 5); // +5..+15
|
||||
if (chosen === 'character_accident') delta = -(Math.floor(Math.random() * 16) + 10); // -10..-25
|
||||
payload.healthChange = delta > 0 ? `+${delta}` : `${delta}`;
|
||||
if (character) {
|
||||
const next = Math.min(100, Math.max(0, Number(character.health || 0) + delta));
|
||||
await character.update({ health: next }, { transaction: t });
|
||||
}
|
||||
} else if (chosen === 'regional_festival') {
|
||||
const regionId = character?.regionId || falukantUser?.mainBranchRegionId || null;
|
||||
if (regionId) {
|
||||
const region = await RegionData.findByPk(regionId, { attributes: ['name'], transaction: t });
|
||||
payload.regionName = region?.name || null;
|
||||
}
|
||||
}
|
||||
|
||||
// Store notification as JSON string so frontend can interpolate params
|
||||
await Notification.create(
|
||||
{ userId: falukantUser.id, tr: JSON.stringify(payload), shown: false },
|
||||
{ transaction: t }
|
||||
);
|
||||
|
||||
// Make statusbar update (unread count, etc.)
|
||||
try { notifyUser(user.hashedId, 'falukantUpdateStatus', {}); } catch (_) {}
|
||||
return payload;
|
||||
});
|
||||
}
|
||||
|
||||
async getBranches(hashedUserId) {
|
||||
const u = await getFalukantUserOrFail(hashedUserId);
|
||||
const bs = await Branch.findAll({
|
||||
@@ -1508,57 +1656,87 @@ class FalukantService extends BaseService {
|
||||
}
|
||||
|
||||
async getInventory(hashedUserId, branchId) {
|
||||
// PERFORMANCE: Diese Route war langsam wegen doppelter/verschachtelter Includes (Branch->Stocks->Region + Inventory->Stock->Branch->Region).
|
||||
// Wir holen stattdessen genau die benötigten Felder in EINER aggregierenden SQL-Query.
|
||||
const u = await getFalukantUserOrFail(hashedUserId);
|
||||
const f = branchId ? { id: branchId, falukantUserId: u.id } : { falukantUserId: u.id };
|
||||
const br = await Branch.findAll({
|
||||
where: f,
|
||||
include: [
|
||||
{ model: FalukantStock, as: 'stocks', include: [{ model: FalukantStockType, as: 'stockType' }] },
|
||||
{ model: RegionData, as: 'region', include: [{ model: RegionType, as: 'regionType' }] }
|
||||
]
|
||||
});
|
||||
const stockIds = br.flatMap(b => b.stocks.map(s => s.id));
|
||||
const inv = await Inventory.findAll({
|
||||
where: { stockId: stockIds },
|
||||
include: [
|
||||
const branchIdInt = branchId == null ? null : parseInt(branchId, 10);
|
||||
if (branchId != null && Number.isNaN(branchIdInt)) {
|
||||
throw new Error('Invalid branchId');
|
||||
}
|
||||
|
||||
const rows = await sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
r.id AS region_id,
|
||||
r.name AS region_name,
|
||||
rt.id AS region_type_id,
|
||||
rt.label_tr AS region_type_label_tr,
|
||||
p.id AS product_id,
|
||||
p.label_tr AS product_label_tr,
|
||||
p.sell_cost AS product_sell_cost,
|
||||
i.quality AS quality,
|
||||
SUM(i.quantity)::int AS total_quantity
|
||||
FROM falukant_data.inventory i
|
||||
JOIN falukant_data.stock s ON s.id = i.stock_id
|
||||
JOIN falukant_data.branch b ON b.id = s.branch_id
|
||||
JOIN falukant_data.region r ON r.id = b.region_id
|
||||
LEFT JOIN falukant_type.region rt ON rt.id = r.region_type_id
|
||||
JOIN falukant_type.product p ON p.id = i.product_id
|
||||
WHERE b.falukant_user_id = :falukantUserId
|
||||
AND (:branchId::int IS NULL OR b.id = :branchId::int)
|
||||
GROUP BY
|
||||
r.id, r.name, rt.id, rt.label_tr,
|
||||
p.id, p.label_tr, p.sell_cost,
|
||||
i.quality
|
||||
ORDER BY r.id, p.id, i.quality
|
||||
`,
|
||||
{
|
||||
model: FalukantStock,
|
||||
as: 'stock',
|
||||
include: [
|
||||
{
|
||||
model: Branch,
|
||||
as: 'branch',
|
||||
include: [{ model: RegionData, as: 'region', include: [{ model: RegionType, as: 'regionType' }] }]
|
||||
replacements: { falukantUserId: u.id, branchId: branchIdInt },
|
||||
type: sequelize.QueryTypes.SELECT
|
||||
}
|
||||
);
|
||||
|
||||
return (rows || []).map(r => ({
|
||||
region: {
|
||||
id: r.region_id,
|
||||
name: r.region_name,
|
||||
regionType: r.region_type_id
|
||||
? { id: r.region_type_id, labelTr: r.region_type_label_tr }
|
||||
: null
|
||||
},
|
||||
{ model: FalukantStockType, as: 'stockType' }
|
||||
]
|
||||
product: {
|
||||
id: r.product_id,
|
||||
labelTr: r.product_label_tr,
|
||||
sellCost: r.product_sell_cost
|
||||
},
|
||||
{ model: ProductType, as: 'productType' }
|
||||
]
|
||||
});
|
||||
const grouped = inv.reduce((acc, i) => {
|
||||
const r = i.stock.branch.region;
|
||||
const k = `${r.id}-${i.productType.id}-${i.quality}`;
|
||||
acc[k] = acc[k] || { region: r, product: i.productType, quality: i.quality, totalQuantity: 0 };
|
||||
acc[k].totalQuantity += i.quantity;
|
||||
return acc;
|
||||
}, {});
|
||||
return Object.values(grouped).sort((a, b) => {
|
||||
if (a.region.id !== b.region.id) return a.region.id - b.region.id;
|
||||
if (a.product.id !== b.product.id) return a.product.id - b.product.id;
|
||||
return a.quality - b.quality;
|
||||
});
|
||||
quality: r.quality,
|
||||
totalQuantity: r.total_quantity
|
||||
}));
|
||||
}
|
||||
|
||||
async sellProduct(hashedUserId, branchId, productId, quality, quantity) {
|
||||
const user = await getFalukantUserOrFail(hashedUserId);
|
||||
// Konsistenz wie sellAll: nur aus Stocks dieses Branches verkaufen und alles atomar ausführen
|
||||
return await sequelize.transaction(async (t) => {
|
||||
const user = await getFalukantUserOrFail(hashedUserId, { transaction: t });
|
||||
const branch = await getBranchOrFail(user.id, branchId);
|
||||
const character = await FalukantCharacter.findOne({ where: { userId: user.id } });
|
||||
|
||||
const character = await FalukantCharacter.findOne({ where: { userId: user.id }, transaction: t });
|
||||
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');
|
||||
|
||||
const stocks = await FalukantStock.findAll({
|
||||
where: { branchId: branch.id },
|
||||
attributes: ['id'],
|
||||
transaction: t
|
||||
});
|
||||
const stockIds = stocks.map(s => s.id);
|
||||
if (!stockIds.length) throw new Error('Stock not found');
|
||||
|
||||
const inventory = await Inventory.findAll({
|
||||
where: { quality },
|
||||
where: {
|
||||
stockId: { [Op.in]: stockIds },
|
||||
productId,
|
||||
quality
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: ProductType,
|
||||
@@ -1574,13 +1752,18 @@ class FalukantService extends BaseService {
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
order: [['producedAt', 'ASC'], ['id', 'ASC']],
|
||||
transaction: t
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -1595,43 +1778,55 @@ class FalukantService extends BaseService {
|
||||
const taxValue = Math.round((revenue * cumulativeTax / 100) * 100) / 100;
|
||||
const net = Math.round((revenue - taxValue) * 100) / 100;
|
||||
|
||||
// Book net to seller
|
||||
const moneyResult = await updateFalukantUserMoney(user.id, net, `Product sale (net)`, user.id);
|
||||
// Book net to seller (in tx)
|
||||
const moneyResult = await updateFalukantUserMoney(user.id, net, `Product sale (net)`, user.id, t);
|
||||
if (!moneyResult.success) throw new Error('Failed to update money for seller');
|
||||
|
||||
// Book tax to treasury (if configured)
|
||||
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);
|
||||
const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), taxValue, `Sales tax (${cumulativeTax}%)`, user.id, t);
|
||||
if (!taxResult.success) throw new Error('Failed to update money for treasury');
|
||||
}
|
||||
|
||||
let remaining = quantity;
|
||||
for (const inv of inventory) {
|
||||
if (remaining <= 0) break;
|
||||
if (inv.quantity <= remaining) {
|
||||
remaining -= inv.quantity;
|
||||
await inv.destroy();
|
||||
await inv.destroy({ transaction: t });
|
||||
} else {
|
||||
await inv.update({ quantity: inv.quantity - remaining });
|
||||
await inv.update({ quantity: inv.quantity - remaining }, { transaction: t });
|
||||
remaining = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
await this.addSellItem(branchId, user.id, productId, quantity);
|
||||
console.log('[FalukantService.sellProduct] emitting events for user', user.user.hashedId, 'branch', branch?.id);
|
||||
if (remaining !== 0) {
|
||||
throw new Error(`Inventory deduction mismatch (remaining=${remaining})`);
|
||||
}
|
||||
|
||||
await this.addSellItem(branchId, user.id, productId, quantity, t);
|
||||
|
||||
// notify after successful commit (we can still emit here; worst-case it's slightly early)
|
||||
notifyUser(user.user.hashedId, 'falukantUpdateStatus', {});
|
||||
notifyUser(user.user.hashedId, 'falukantBranchUpdate', { branchId: branch.id });
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
async sellAllProducts(hashedUserId, branchId) {
|
||||
const falukantUser = await getFalukantUserOrFail(hashedUserId);
|
||||
// Konsistenz-Garantie: Verkauf, DaySell-Log, Geldbuchung und Inventory-Löschung müssen atomar sein.
|
||||
// Sonst kann es (wie beobachtet) zu "teilweise verkauft/gelöscht" kommen.
|
||||
return await sequelize.transaction(async (t) => {
|
||||
const falukantUser = await getFalukantUserOrFail(hashedUserId, { transaction: t });
|
||||
const branch = await Branch.findOne({
|
||||
where: { id: branchId, falukantUserId: falukantUser.id },
|
||||
include: [{ model: FalukantStock, as: 'stocks' }]
|
||||
include: [{ model: FalukantStock, as: 'stocks' }],
|
||||
transaction: t
|
||||
});
|
||||
if (!branch) throw new Error('Branch not found');
|
||||
const stockIds = branch.stocks.map(s => s.id);
|
||||
const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id } });
|
||||
const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, transaction: t });
|
||||
if (!character) throw new Error('No character for user');
|
||||
const inventory = await Inventory.findAll({
|
||||
where: { stockId: stockIds },
|
||||
@@ -1664,81 +1859,191 @@ class FalukantService extends BaseService {
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
transaction: t
|
||||
});
|
||||
if (!inventory.length) return { success: true, revenue: 0 };
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Batch-Load alle benötigten Daten
|
||||
const regionIds = [...new Set(inventory.map(item => item.stock.branch.regionId))];
|
||||
const productIds = [...new Set(inventory.map(item => item.productType.id))];
|
||||
|
||||
// 1. Batch-Load alle TownProductWorth Einträge
|
||||
const townWorths = await TownProductWorth.findAll({
|
||||
where: {
|
||||
productId: { [Op.in]: productIds },
|
||||
regionId: { [Op.in]: regionIds }
|
||||
},
|
||||
transaction: t
|
||||
});
|
||||
const worthMap = new Map();
|
||||
townWorths.forEach(tw => {
|
||||
worthMap.set(`${tw.productId}-${tw.regionId}`, tw.worthPercent);
|
||||
});
|
||||
|
||||
// 2/3. Batch-Berechne Steuern für alle Regionen
|
||||
const taxMap = new Map();
|
||||
const uniqueRegionIds = [...new Set(regionIds)];
|
||||
for (const regionId of uniqueRegionIds) {
|
||||
const tax = await getCumulativeTaxPercentWithExemptions(falukantUser.id, regionId);
|
||||
taxMap.set(regionId, tax);
|
||||
}
|
||||
|
||||
// 4. Berechne Preise, Steuern und Einnahmen in EINER Schleife
|
||||
let total = 0;
|
||||
let totalTax = 0;
|
||||
const sellItems = [];
|
||||
|
||||
for (const item of inventory) {
|
||||
const knowledgeVal = item.productType.knowledges[0]?.knowledge || 0;
|
||||
const regionId = item.stock.branch.regionId;
|
||||
const pricePerUnit = await calcRegionalSellPrice(item.productType, knowledgeVal, regionId);
|
||||
const cumulativeTax = await getCumulativeTaxPercentWithExemptions(user.id, regionId);
|
||||
const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100));
|
||||
const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100;
|
||||
total += item.quantity * adjustedPricePerUnit;
|
||||
await this.addSellItem(item.stock.branch.id, falukantUser.id, item.productType.id, item.quantity);
|
||||
}
|
||||
// compute tax per region (using cumulative tax per region) and aggregate
|
||||
let totalTax = 0;
|
||||
for (const item of inventory) {
|
||||
const regionId = item.stock.branch.regionId;
|
||||
const region = await RegionData.findOne({ where: { id: regionId } });
|
||||
const cumulativeTax = await getCumulativeTaxPercent(regionId);
|
||||
const pricePerUnit = await calcRegionalSellPrice(item.productType, item.productType.knowledges?.[0]?.knowledge || 0, regionId);
|
||||
const worthPercent = worthMap.get(`${item.productType.id}-${regionId}`) || 50;
|
||||
|
||||
const pricePerUnit = calcRegionalSellPriceSync(item.productType, knowledgeVal, worthPercent);
|
||||
|
||||
const cumulativeTax = taxMap.get(regionId);
|
||||
const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100));
|
||||
const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100;
|
||||
const itemRevenue = item.quantity * adjustedPricePerUnit;
|
||||
const itemTax = Math.round((itemRevenue * cumulativeTax / 100) * 100) / 100;
|
||||
|
||||
total += itemRevenue;
|
||||
totalTax += itemTax;
|
||||
|
||||
sellItems.push({
|
||||
branchId: item.stock.branch.id,
|
||||
productId: item.productType.id,
|
||||
quantity: item.quantity
|
||||
});
|
||||
}
|
||||
|
||||
const totalNet = Math.round((total - totalTax) * 100) / 100;
|
||||
|
||||
// 5. Batch-Update DaySell Einträge (innerhalb Transaktion)
|
||||
await this.addSellItemsBatch(sellItems, falukantUser.id, t);
|
||||
|
||||
// 6. Inventory löschen (innerhalb Transaktion) und sicherstellen, dass alles weg ist
|
||||
const inventoryIds = inventory.map(item => item.id).filter(Boolean);
|
||||
const expected = inventoryIds.length;
|
||||
const deleted = await Inventory.destroy({ where: { id: { [Op.in]: inventoryIds } }, transaction: t });
|
||||
if (deleted !== expected) {
|
||||
throw new Error(`Inventory delete mismatch: expected ${expected}, deleted ${deleted}`);
|
||||
}
|
||||
|
||||
// 7. Geld buchen (innerhalb Transaktion)
|
||||
const moneyResult = await updateFalukantUserMoney(
|
||||
falukantUser.id,
|
||||
totalNet,
|
||||
'Sell all products (net)',
|
||||
falukantUser.id
|
||||
falukantUser.id,
|
||||
t
|
||||
);
|
||||
if (!moneyResult.success) throw new Error('Failed to update money for seller');
|
||||
|
||||
const treasuryId = process.env.TREASURY_FALUKANT_USER_ID;
|
||||
if (treasuryId && totalTax > 0) {
|
||||
const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), Math.round(totalTax * 100) / 100, `Sales tax (aggregate)`, falukantUser.id);
|
||||
const taxResult = await updateFalukantUserMoney(
|
||||
parseInt(treasuryId, 10),
|
||||
Math.round(totalTax * 100) / 100,
|
||||
`Sales tax (aggregate)`,
|
||||
falukantUser.id,
|
||||
t
|
||||
);
|
||||
if (!taxResult.success) throw new Error('Failed to update money for treasury');
|
||||
}
|
||||
for (const item of inventory) {
|
||||
await Inventory.destroy({ where: { id: item.id } });
|
||||
}
|
||||
console.log('[FalukantService.sellAllProducts] emitting events for user', falukantUser.user.hashedId, 'branch', branchId, 'revenue', total, 'items', inventory.length);
|
||||
|
||||
console.log('[FalukantService.sellAllProducts] sold items', expected, 'deleted', deleted, 'revenue', total);
|
||||
notifyUser(falukantUser.user.hashedId, 'falukantUpdateStatus', {});
|
||||
notifyUser(falukantUser.user.hashedId, 'falukantBranchUpdate', { branchId });
|
||||
return { success: true, revenue: total };
|
||||
});
|
||||
}
|
||||
|
||||
async addSellItem(branchId, userId, productId, quantity) {
|
||||
async addSellItem(branchId, userId, productId, quantity, transaction = null) {
|
||||
const branch = await Branch.findOne({
|
||||
where: { id: branchId },
|
||||
})
|
||||
;
|
||||
const daySell = await DaySell.findOne({
|
||||
attributes: ['id', 'regionId'],
|
||||
transaction: transaction || undefined
|
||||
});
|
||||
if (!branch) throw new Error(`Branch not found (branchId: ${branchId})`);
|
||||
|
||||
const [daySell, created] = await DaySell.findOrCreate({
|
||||
where: {
|
||||
regionId: branch.regionId,
|
||||
productId: productId,
|
||||
sellerId: userId,
|
||||
}
|
||||
},
|
||||
defaults: { quantity: quantity },
|
||||
transaction: transaction || undefined
|
||||
});
|
||||
if (daySell) {
|
||||
if (!created) {
|
||||
daySell.quantity += quantity;
|
||||
await daySell.save();
|
||||
} else {
|
||||
await DaySell.create({
|
||||
await daySell.save({ transaction: transaction || undefined });
|
||||
}
|
||||
}
|
||||
|
||||
async addSellItemsBatch(sellItems, userId, transaction = null) {
|
||||
if (!sellItems || sellItems.length === 0) return;
|
||||
|
||||
// Lade alle benötigten Branches auf einmal
|
||||
const branchIds = [...new Set(sellItems.map(item => item.branchId))];
|
||||
const branches = await Branch.findAll({
|
||||
where: { id: { [Op.in]: branchIds } },
|
||||
attributes: ['id', 'regionId'],
|
||||
transaction: transaction || undefined
|
||||
});
|
||||
const branchMap = new Map(branches.map(b => [b.id, b]));
|
||||
// WICHTIG: Wie bei addSellItem muss ein fehlender Branch ein harter Fehler sein,
|
||||
// sonst entsteht ein Accounting-/Audit-Mismatch (Geld/Steuern werden gebucht, aber Sell-Logs fehlen).
|
||||
const missingBranchIds = branchIds.filter(id => !branchMap.has(id));
|
||||
if (missingBranchIds.length > 0) {
|
||||
throw new Error(
|
||||
`Branch not found for sell batch (missing branchIds: ${missingBranchIds.join(', ')})`
|
||||
);
|
||||
}
|
||||
|
||||
// Gruppiere nach (regionId, productId, sellerId)
|
||||
const grouped = new Map();
|
||||
for (const item of sellItems) {
|
||||
const branch = branchMap.get(item.branchId);
|
||||
// sollte durch missingBranchIds Check oben nie passieren, aber defensiv:
|
||||
if (!branch) {
|
||||
throw new Error(`Branch not found for sell batch (branchId: ${item.branchId})`);
|
||||
}
|
||||
|
||||
const key = `${branch.regionId}-${item.productId}-${userId}`;
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, {
|
||||
regionId: branch.regionId,
|
||||
productId: productId,
|
||||
productId: item.productId,
|
||||
sellerId: userId,
|
||||
quantity: quantity,
|
||||
quantity: 0
|
||||
});
|
||||
}
|
||||
grouped.get(key).quantity += item.quantity;
|
||||
}
|
||||
|
||||
// Batch-Update oder Create für alle gruppierten Einträge
|
||||
const promises = [];
|
||||
for (const [key, data] of grouped) {
|
||||
promises.push(
|
||||
DaySell.findOrCreate({
|
||||
where: {
|
||||
regionId: data.regionId,
|
||||
productId: data.productId,
|
||||
sellerId: data.sellerId
|
||||
},
|
||||
defaults: { quantity: data.quantity },
|
||||
transaction: transaction || undefined
|
||||
}).then(([daySell, created]) => {
|
||||
if (!created) {
|
||||
daySell.quantity += data.quantity;
|
||||
return daySell.save({ transaction: transaction || undefined });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
// Return tax summary for a branch: total cumulative tax and breakdown per region (region -> parent chain)
|
||||
@@ -3109,7 +3414,198 @@ class FalukantService extends BaseService {
|
||||
return { partyTypes, musicTypes, banquetteTypes };
|
||||
}
|
||||
|
||||
async getReputationActions(hashedUserId) {
|
||||
const falukantUser = await getFalukantUserOrFail(hashedUserId);
|
||||
const actionTypes = await ReputationActionType.findAll({ order: [['cost', 'ASC']] });
|
||||
|
||||
// Tageslimit (global, aus Aktionen) – Anzeige im UI
|
||||
const dailyCap = FalukantService.REPUTATION_ACTION_DAILY_CAP;
|
||||
const [{ dailyUsed }] = await sequelize.query(
|
||||
`
|
||||
SELECT COALESCE(SUM(gain), 0)::int AS "dailyUsed"
|
||||
FROM falukant_log.reputation_action
|
||||
WHERE falukant_user_id = :uid
|
||||
AND action_timestamp >= date_trunc('day', now())
|
||||
`,
|
||||
{ replacements: { uid: falukantUser.id }, type: sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
const dailyRemaining = Math.max(0, Number(dailyCap) - Number(dailyUsed || 0));
|
||||
|
||||
// Globaler Cooldown: max. 1 Aktion pro Stunde (oder konfigurierbar) unabhängig vom Typ
|
||||
const cooldownMinutes = FalukantService.REPUTATION_ACTION_COOLDOWN_MINUTES;
|
||||
const [{ lastTs }] = await sequelize.query(
|
||||
`
|
||||
SELECT MAX(action_timestamp) AS "lastTs"
|
||||
FROM falukant_log.reputation_action
|
||||
WHERE falukant_user_id = :uid
|
||||
`,
|
||||
{ replacements: { uid: falukantUser.id }, type: sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
let cooldownRemainingSec = 0;
|
||||
if (lastTs) {
|
||||
const last = new Date(lastTs).getTime();
|
||||
const nextAllowed = last + cooldownMinutes * 60 * 1000;
|
||||
cooldownRemainingSec = Math.max(0, Math.ceil((nextAllowed - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
if (!actionTypes.length) {
|
||||
return {
|
||||
dailyCap,
|
||||
dailyUsed: Number(dailyUsed || 0),
|
||||
dailyRemaining,
|
||||
cooldownMinutes,
|
||||
cooldownRemainingSec,
|
||||
actions: []
|
||||
};
|
||||
}
|
||||
|
||||
// counts in einem Query – aber pro Typ in seinem "Decay-Fenster" (default 7 Tage)
|
||||
const now = Date.now();
|
||||
const actions = [];
|
||||
for (const t of actionTypes) {
|
||||
const windowDays = Number(t.decayWindowDays || 7);
|
||||
const since = new Date(now - windowDays * 24 * 3600 * 1000);
|
||||
const timesUsed = await ReputationActionLog.count({
|
||||
where: {
|
||||
falukantUserId: falukantUser.id,
|
||||
actionTypeId: t.id,
|
||||
actionTimestamp: { [Op.gte]: since },
|
||||
}
|
||||
});
|
||||
const raw = Number(t.baseGain) * Math.pow(Number(t.decayFactor), Number(timesUsed));
|
||||
const gain = Math.max(Number(t.minGain || 0), Math.ceil(raw));
|
||||
actions.push({
|
||||
id: t.id,
|
||||
tr: t.tr,
|
||||
cost: t.cost,
|
||||
baseGain: t.baseGain,
|
||||
decayFactor: t.decayFactor,
|
||||
minGain: t.minGain,
|
||||
decayWindowDays: t.decayWindowDays,
|
||||
timesUsed,
|
||||
currentGain: gain,
|
||||
});
|
||||
}
|
||||
return {
|
||||
dailyCap,
|
||||
dailyUsed: Number(dailyUsed || 0),
|
||||
dailyRemaining,
|
||||
cooldownMinutes,
|
||||
cooldownRemainingSec,
|
||||
actions
|
||||
};
|
||||
}
|
||||
|
||||
async executeReputationAction(hashedUserId, actionTypeId) {
|
||||
return await sequelize.transaction(async (t) => {
|
||||
const falukantUser = await getFalukantUserOrFail(hashedUserId, { transaction: t });
|
||||
const actionType = await ReputationActionType.findByPk(actionTypeId, { transaction: t });
|
||||
if (!actionType) throw new Error('Unbekannte Aktion');
|
||||
|
||||
// Globaler Cooldown (unabhängig vom Aktionstyp): max. 1 pro Stunde
|
||||
const cooldownMinutes = FalukantService.REPUTATION_ACTION_COOLDOWN_MINUTES;
|
||||
const [{ lastTs }] = await sequelize.query(
|
||||
`
|
||||
SELECT MAX(action_timestamp) AS "lastTs"
|
||||
FROM falukant_log.reputation_action
|
||||
WHERE falukant_user_id = :uid
|
||||
`,
|
||||
{ replacements: { uid: falukantUser.id }, type: sequelize.QueryTypes.SELECT, transaction: t }
|
||||
);
|
||||
if (lastTs) {
|
||||
const last = new Date(lastTs).getTime();
|
||||
const nextAllowed = last + cooldownMinutes * 60 * 1000;
|
||||
const remainingSec = Math.max(0, Math.ceil((nextAllowed - Date.now()) / 1000));
|
||||
if (remainingSec > 0) {
|
||||
const remainingMin = Math.ceil(remainingSec / 60);
|
||||
throw new Error(`Sozialstatus-Aktionen sind nur ${cooldownMinutes} Minutenweise möglich. Bitte warte noch ca. ${remainingMin} Minuten.`);
|
||||
}
|
||||
}
|
||||
|
||||
const character = await FalukantCharacter.findOne({
|
||||
where: { userId: falukantUser.id },
|
||||
attributes: ['id', 'reputation'],
|
||||
transaction: t
|
||||
});
|
||||
if (!character) throw new Error('No character for user');
|
||||
|
||||
// Abnutzung nur innerhalb des Fensters (default 7 Tage)
|
||||
const windowDays = Number(actionType.decayWindowDays || 7);
|
||||
const since = new Date(Date.now() - windowDays * 24 * 3600 * 1000);
|
||||
const timesUsedBefore = await ReputationActionLog.count({
|
||||
where: {
|
||||
falukantUserId: falukantUser.id,
|
||||
actionTypeId: actionType.id,
|
||||
actionTimestamp: { [Op.gte]: since },
|
||||
},
|
||||
transaction: t
|
||||
});
|
||||
|
||||
const raw = Number(actionType.baseGain) * Math.pow(Number(actionType.decayFactor), Number(timesUsedBefore));
|
||||
const plannedGain = Math.max(Number(actionType.minGain || 0), Math.ceil(raw));
|
||||
|
||||
// Tageslimit aus Aktionen (global)
|
||||
const dailyCap = FalukantService.REPUTATION_ACTION_DAILY_CAP;
|
||||
const [{ dailyUsed }] = await sequelize.query(
|
||||
`
|
||||
SELECT COALESCE(SUM(gain), 0)::int AS "dailyUsed"
|
||||
FROM falukant_log.reputation_action
|
||||
WHERE falukant_user_id = :uid
|
||||
AND action_timestamp >= date_trunc('day', now())
|
||||
`,
|
||||
{ replacements: { uid: falukantUser.id }, type: sequelize.QueryTypes.SELECT, transaction: t }
|
||||
);
|
||||
const dailyRemaining = Math.max(0, Number(dailyCap) - Number(dailyUsed || 0));
|
||||
if (dailyRemaining <= 0) {
|
||||
throw new Error(`Tageslimit erreicht (max. ${dailyCap} Reputation pro Tag durch Aktionen)`);
|
||||
}
|
||||
const gain = Math.min(plannedGain, dailyRemaining);
|
||||
|
||||
if (gain <= 0) {
|
||||
throw new Error('Diese Aktion bringt aktuell keine Reputation mehr');
|
||||
}
|
||||
|
||||
const cost = Number(actionType.cost || 0);
|
||||
if (Number(falukantUser.money) < cost) {
|
||||
throw new Error('Nicht genügend Guthaben');
|
||||
}
|
||||
|
||||
const moneyResult = await updateFalukantUserMoney(
|
||||
falukantUser.id,
|
||||
-cost,
|
||||
`reputationAction.${actionType.tr}`,
|
||||
falukantUser.id,
|
||||
t
|
||||
);
|
||||
if (!moneyResult.success) throw new Error('Geld konnte nicht abgezogen werden');
|
||||
|
||||
await ReputationActionLog.create({
|
||||
falukantUserId: falukantUser.id,
|
||||
actionTypeId: actionType.id,
|
||||
cost,
|
||||
baseGain: actionType.baseGain,
|
||||
gain,
|
||||
timesUsedBefore: Number(timesUsedBefore),
|
||||
}, { transaction: t });
|
||||
|
||||
await character.update(
|
||||
{ reputation: Sequelize.literal(`LEAST(100, COALESCE(reputation,0) + ${gain})`) },
|
||||
{ transaction: t }
|
||||
);
|
||||
|
||||
const user = await User.findByPk(falukantUser.userId, { transaction: t });
|
||||
notifyUser(user.hashedId, 'falukantUpdateStatus', {});
|
||||
notifyUser(user.hashedId, 'falukantReputationUpdate', { gain, actionTr: actionType.tr });
|
||||
|
||||
return { success: true, gain, plannedGain, dailyCap, dailyRemainingBefore: dailyRemaining, cost, actionTr: actionType.tr };
|
||||
});
|
||||
}
|
||||
|
||||
async createParty(hashedUserId, partyTypeId, musicId, banquetteId, nobilityIds = [], servantRatio) {
|
||||
// Reputation-Logik: Party steigert Reputation um 1..5 (bestes Fest 5, kleinstes 1).
|
||||
// Wir leiten "Ausstattung" aus den Party-Kosten ab (linear zwischen min/max möglicher Konfiguration),
|
||||
// und deckeln Reputation bei 100.
|
||||
return await sequelize.transaction(async (t) => {
|
||||
const falukantUser = await getFalukantUserOrFail(hashedUserId);
|
||||
const since = new Date(Date.now() - 24 * 3600 * 1000);
|
||||
const already = await Party.findOne({
|
||||
@@ -3118,21 +3614,24 @@ class FalukantService extends BaseService {
|
||||
partyTypeId,
|
||||
createdAt: { [Op.gte]: since },
|
||||
},
|
||||
attributes: ['id']
|
||||
attributes: ['id'],
|
||||
transaction: t
|
||||
});
|
||||
if (already) {
|
||||
throw new Error('Diese Party wurde bereits innerhalb der letzten 24 Stunden bestellt');
|
||||
}
|
||||
|
||||
const [ptype, music, banquette] = await Promise.all([
|
||||
PartyType.findByPk(partyTypeId),
|
||||
MusicType.findByPk(musicId),
|
||||
BanquetteType.findByPk(banquetteId),
|
||||
PartyType.findByPk(partyTypeId, { transaction: t }),
|
||||
MusicType.findByPk(musicId, { transaction: t }),
|
||||
BanquetteType.findByPk(banquetteId, { transaction: t }),
|
||||
]);
|
||||
if (!ptype || !music || !banquette) {
|
||||
throw new Error('Ungültige Party-, Musik- oder Bankett-Auswahl');
|
||||
}
|
||||
|
||||
const nobilities = nobilityIds && nobilityIds.length
|
||||
? await TitleOfNobility.findAll({ where: { id: { [Op.in]: nobilityIds } } })
|
||||
? await TitleOfNobility.findAll({ where: { id: { [Op.in]: nobilityIds } }, transaction: t })
|
||||
: [];
|
||||
|
||||
// Prüfe, ob alle angegebenen IDs gefunden wurden
|
||||
@@ -3144,18 +3643,53 @@ class FalukantService extends BaseService {
|
||||
cost += (50 / servantRatio - 1) * 1000;
|
||||
const nobilityCost = nobilities.reduce((sum, n) => sum + ((n.id ^ 5) * 1000), 0);
|
||||
cost += nobilityCost;
|
||||
|
||||
if (Number(falukantUser.money) < cost) {
|
||||
throw new Error('Nicht genügend Guthaben für diese Party');
|
||||
}
|
||||
|
||||
// min/max mögliche Kosten für die Skalierung (nur für Reputation; Party-Preis bleibt wie berechnet)
|
||||
const [allPartyTypes, allMusicTypes, allBanquetteTypes, allNobilityTitles] = await Promise.all([
|
||||
PartyType.findAll({ attributes: ['cost'], transaction: t }),
|
||||
MusicType.findAll({ attributes: ['cost'], transaction: t }),
|
||||
BanquetteType.findAll({ attributes: ['cost'], transaction: t }),
|
||||
TitleOfNobility.findAll({ attributes: ['id'], transaction: t }),
|
||||
]);
|
||||
const minParty = allPartyTypes.length ? Math.min(...allPartyTypes.map(x => Number(x.cost || 0))) : 0;
|
||||
const maxParty = allPartyTypes.length ? Math.max(...allPartyTypes.map(x => Number(x.cost || 0))) : 0;
|
||||
const minMusic = allMusicTypes.length ? Math.min(...allMusicTypes.map(x => Number(x.cost || 0))) : 0;
|
||||
const maxMusic = allMusicTypes.length ? Math.max(...allMusicTypes.map(x => Number(x.cost || 0))) : 0;
|
||||
const minBanq = allBanquetteTypes.length ? Math.min(...allBanquetteTypes.map(x => Number(x.cost || 0))) : 0;
|
||||
const maxBanq = allBanquetteTypes.length ? Math.max(...allBanquetteTypes.map(x => Number(x.cost || 0))) : 0;
|
||||
const servantsMin = 0; // servantRatio=50 => (50/50 - 1)*1000 = 0
|
||||
const servantsMax = (50 / 1 - 1) * 1000; // servantRatio=1 => 49k
|
||||
const nobilityMax = (allNobilityTitles || []).reduce((sum, n) => sum + ((Number(n.id) ^ 5) * 1000), 0);
|
||||
|
||||
const minCostPossible = (minParty || 0) + (minMusic || 0) + (minBanq || 0) + servantsMin;
|
||||
const maxCostPossible = (maxParty || 0) + (maxMusic || 0) + (maxBanq || 0) + servantsMax + (nobilityMax || 0);
|
||||
const denom = Math.max(1, (maxCostPossible - minCostPossible));
|
||||
const score = Math.min(1, Math.max(0, (cost - minCostPossible) / denom));
|
||||
const reputationGain = 1 + Math.round(score * 4); // 1..5
|
||||
|
||||
const character = await FalukantCharacter.findOne({
|
||||
where: { userId: falukantUser.id },
|
||||
attributes: ['id', 'reputation'],
|
||||
transaction: t
|
||||
});
|
||||
if (!character) throw new Error('No character for user');
|
||||
|
||||
// Geld abziehen
|
||||
const moneyResult = await updateFalukantUserMoney(
|
||||
falukantUser.id,
|
||||
-cost,
|
||||
'partyOrder',
|
||||
falukantUser.id
|
||||
falukantUser.id,
|
||||
t
|
||||
);
|
||||
if (!moneyResult.success) {
|
||||
throw new Error('Geld konnte nicht abgezogen werden');
|
||||
}
|
||||
|
||||
const party = await Party.create({
|
||||
partyTypeId,
|
||||
falukantUserId: falukantUser.id,
|
||||
@@ -3163,17 +3697,29 @@ class FalukantService extends BaseService {
|
||||
banquetteTypeId: banquetteId,
|
||||
servantRatio,
|
||||
cost: cost
|
||||
});
|
||||
}, { transaction: t });
|
||||
|
||||
if (nobilities.length > 0) {
|
||||
// Verwende die bereits geladenen Objekte
|
||||
await party.addInvitedNobilities(nobilities);
|
||||
await party.addInvitedNobilities(nobilities, { transaction: t });
|
||||
}
|
||||
const user = await User.findByPk(falukantUser.userId);
|
||||
|
||||
// Reputation erhöhen (0..100)
|
||||
await character.update(
|
||||
{ reputation: Sequelize.literal(`LEAST(100, COALESCE(reputation,0) + ${reputationGain})`) },
|
||||
{ transaction: t }
|
||||
);
|
||||
|
||||
const user = await User.findByPk(falukantUser.userId, { transaction: t });
|
||||
notifyUser(user.hashedId, 'falukantPartyUpdate', {
|
||||
partyId: party.id,
|
||||
cost,
|
||||
reputationGain,
|
||||
});
|
||||
// Statusbar kann sich damit ebenfalls aktualisieren
|
||||
notifyUser(user.hashedId, 'falukantUpdateStatus', {});
|
||||
|
||||
return { success: true, reputationGain };
|
||||
});
|
||||
return { 'success': true };
|
||||
}
|
||||
|
||||
async getParties(hashedUserId) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`, {
|
||||
this.sellingItemIndex = index;
|
||||
|
||||
try {
|
||||
await apiClient.post(`/api/falukant/sell`, {
|
||||
branchId: this.branchId,
|
||||
productId: item.product.id,
|
||||
quantity: quantityToSell,
|
||||
quality: item.quality,
|
||||
}).catch(() => {
|
||||
});
|
||||
// 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>
|
||||
|
||||
@@ -260,6 +260,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",
|
||||
@@ -738,7 +742,8 @@
|
||||
"reputation": {
|
||||
"title": "Reputation",
|
||||
"overview": {
|
||||
"title": "Übersicht"
|
||||
"title": "Übersicht",
|
||||
"current": "Deine aktuelle Reputation"
|
||||
},
|
||||
"party": {
|
||||
"title": "Feste",
|
||||
@@ -777,6 +782,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": {
|
||||
|
||||
@@ -198,6 +198,44 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -1014,12 +1014,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 +1079,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'));
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
1449
package-lock.json
generated
1449
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",
|
||||
|
||||
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