diff --git a/backend/controllers/falukantController.js b/backend/controllers/falukantController.js index 81ab7da..848b726 100644 --- a/backend/controllers/falukantController.js +++ b/backend/controllers/falukantController.js @@ -93,8 +93,6 @@ class FalukantController { return result; }); this.setHeir = this._wrapWithUser((userId, req) => this.service.setHeir(userId, req.body.childCharacterId)); - this.getPotentialHeirs = this._wrapWithUser((userId) => this.service.getPotentialHeirs(userId)); - this.selectHeir = this._wrapWithUser((userId, req) => this.service.selectHeir(userId, req.body.heirId)); this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId)); this.getGifts = this._wrapWithUser((userId) => { console.log('🔍 getGifts called with userId:', userId); @@ -118,12 +116,6 @@ 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; @@ -148,20 +140,6 @@ class FalukantController { this.getPoliticsOverview = this._wrapWithUser((userId) => this.service.getPoliticsOverview(userId)); this.getOpenPolitics = this._wrapWithUser((userId) => this.service.getOpenPolitics(userId)); - - // Church career endpoints - this.getChurchOverview = this._wrapWithUser((userId) => this.service.getChurchOverview(userId)); - this.getAvailableChurchPositions = this._wrapWithUser((userId) => this.service.getAvailableChurchPositions(userId)); - this.applyForChurchPosition = this._wrapWithUser((userId, req) => { - const { officeTypeId, regionId } = req.body; - return this.service.applyForChurchPosition(userId, officeTypeId, regionId); - }, { successStatus: 201 }); - this.getSupervisedApplications = this._wrapWithUser((userId) => this.service.getSupervisedApplications(userId)); - this.decideOnChurchApplication = this._wrapWithUser((userId, req) => { - const { applicationId, decision } = req.body; - return this.service.decideOnChurchApplication(userId, applicationId, decision); - }); - this.hasChurchCareer = this._wrapWithUser((userId) => this.service.hasChurchCareer(userId)); this.getElections = this._wrapWithUser((userId) => this.service.getElections(userId)); this.vote = this._wrapWithUser((userId, req) => this.service.vote(userId, req.body.votes)); this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds)); @@ -176,18 +154,6 @@ class FalukantController { } return this.service.getProductPriceInRegion(userId, productId, regionId); }); - this.getProductPricesInRegionBatch = this._wrapWithUser((userId, req) => { - const productIds = req.query.productIds; - const regionId = parseInt(req.query.regionId, 10); - if (!productIds || Number.isNaN(regionId)) { - throw new Error('productIds (comma-separated) and regionId are required'); - } - const productIdArray = productIds.split(',').map(id => parseInt(id.trim(), 10)).filter(id => !Number.isNaN(id)); - if (productIdArray.length === 0) { - throw new Error('At least one valid productId is required'); - } - return this.service.getProductPricesInRegionBatch(userId, productIdArray, regionId); - }); this.getProductPricesInCities = this._wrapWithUser((userId, req) => { const productId = parseInt(req.query.productId, 10); const currentPrice = parseFloat(req.query.currentPrice); diff --git a/backend/models/falukant/data/region.js b/backend/models/falukant/data/region.js index 2047167..48842ed 100644 --- a/backend/models/falukant/data/region.js +++ b/backend/models/falukant/data/region.js @@ -10,11 +10,20 @@ RegionData.init({ allowNull: false}, regionTypeId: { type: DataTypes.INTEGER, - allowNull: false + allowNull: false, + references: { + model: RegionType, + key: 'id', + schema: 'falukant_type' + } }, parentId: { type: DataTypes.INTEGER, - allowNull: true + allowNull: true, + references: { + model: 'region', + key: 'id', + schema: 'falukant_data'} }, map: { type: DataTypes.JSONB, diff --git a/backend/models/falukant/data/stock.js b/backend/models/falukant/data/stock.js index e38d884..65dcf73 100644 --- a/backend/models/falukant/data/stock.js +++ b/backend/models/falukant/data/stock.js @@ -6,7 +6,8 @@ class FalukantStock extends Model { } FalukantStock.init({ branchId: { type: DataTypes.INTEGER, - allowNull: false + allowNull: false, + defaultValue: 0 }, stockTypeId: { type: DataTypes.INTEGER, diff --git a/backend/models/falukant/type/product.js b/backend/models/falukant/type/product.js index 6ba2f1b..7393139 100644 --- a/backend/models/falukant/type/product.js +++ b/backend/models/falukant/type/product.js @@ -16,6 +16,17 @@ ProductType.init({ sellCost: { type: DataTypes.INTEGER, allowNull: false} + , + sellCostMinNeutral: { + type: DataTypes.DECIMAL, + allowNull: true, + field: 'sell_cost_min_neutral' + }, + sellCostMaxNeutral: { + type: DataTypes.DECIMAL, + allowNull: true, + field: 'sell_cost_max_neutral' + } }, { sequelize, modelName: 'ProductType', diff --git a/backend/routers/falukantRouter.js b/backend/routers/falukantRouter.js index e3b02bf..1d36092 100644 --- a/backend/routers/falukantRouter.js +++ b/backend/routers/falukantRouter.js @@ -39,8 +39,6 @@ router.get('/directors', falukantController.getAllDirectors); router.post('/directors', falukantController.updateDirector); router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal); router.post('/family/set-heir', falukantController.setHeir); -router.get('/heirs/potential', falukantController.getPotentialHeirs); -router.post('/heirs/select', falukantController.selectHeir); router.get('/family/gifts', falukantController.getGifts); router.get('/family/children', falukantController.getChildren); router.post('/family/gift', falukantController.sendGift); @@ -55,16 +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('/church/overview', falukantController.getChurchOverview); -router.get('/church/positions/available', falukantController.getAvailableChurchPositions); -router.post('/church/positions/apply', falukantController.applyForChurchPosition); -router.get('/church/applications/supervised', falukantController.getSupervisedApplications); -router.post('/church/applications/decide', falukantController.decideOnChurchApplication); -router.get('/church/career/check', falukantController.hasChurchCareer); router.get('/education', falukantController.getEducation); router.post('/education', falukantController.sendToSchool); router.get('/bank/overview', falukantController.getBankOverview); @@ -82,7 +72,6 @@ router.get('/politics/open', falukantController.getOpenPolitics); router.post('/politics/open', falukantController.applyForElections); router.get('/cities', falukantController.getRegions); router.get('/products/price-in-region', falukantController.getProductPriceInRegion); -router.get('/products/prices-in-region-batch', falukantController.getProductPricesInRegionBatch); router.get('/products/prices-in-cities', falukantController.getProductPricesInCities); router.get('/branches/:branchId/taxes', falukantController.getBranchTaxes); router.get('/vehicles/types', falukantController.getVehicleTypes); diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index ee41149..5c9351b 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -33,7 +33,6 @@ import PromotionalGiftCharacterTrait from '../models/falukant/predefine/promotio import PromotionalGiftMood from '../models/falukant/predefine/promotional_gift_mood.js'; import PromotionalGiftLog from '../models/falukant/log/promotional_gift.js'; import CharacterTrait from '../models/falukant/type/character_trait.js'; -import FalukantCharacterTrait from '../models/falukant/data/falukant_character_trait.js'; import Mood from '../models/falukant/type/mood.js'; import UserHouse from '../models/falukant/data/user_house.js'; import HouseType from '../models/falukant/type/house.js'; @@ -57,10 +56,6 @@ import PoliticalOfficeHistory from '../models/falukant/log/political_office_hist import UndergroundType from '../models/falukant/type/underground.js'; import Notification from '../models/falukant/log/notification.js'; import PoliticalOffice from '../models/falukant/data/political_office.js'; -import ChurchOfficeType from '../models/falukant/type/church_office_type.js'; -import ChurchOfficeRequirement from '../models/falukant/predefine/church_office_requirement.js'; -import ChurchOffice from '../models/falukant/data/church_office.js'; -import ChurchApplication from '../models/falukant/data/church_application.js'; import Underground from '../models/falukant/data/underground.js'; import VehicleType from '../models/falukant/type/vehicle.js'; import Vehicle from '../models/falukant/data/vehicle.js'; @@ -70,8 +65,6 @@ 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); @@ -79,10 +72,9 @@ function calcAge(birthdate) { return differenceInDays(now, b); } -async function getFalukantUserOrFail(hashedId, options = {}) { +async function getFalukantUserOrFail(hashedId) { const user = await FalukantUser.findOne({ - include: [{ model: User, as: 'user', attributes: ['username', 'hashedId'], where: { hashedId } }], - transaction: options.transaction + include: [{ model: User, as: 'user', attributes: ['username', 'hashedId'], where: { hashedId } }] }); if (!user) throw new Error('User not found'); return user; @@ -100,14 +92,6 @@ 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) { @@ -163,34 +147,9 @@ 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 - // PoliticalOffice hat keine userId Spalte, daher müssen wir zuerst den Character finden - const character = await FalukantCharacter.findOne({ - where: { userId }, - 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'] - }] - } - ] + where: { userId }, + 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 @@ -210,27 +169,21 @@ 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 rt ON rt.id = r.region_type_id + JOIN falukant_type.region_type 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 falukant_type.region_type 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;`, + SELECT COALESCE(SUM(CASE WHEN :exempt_types::text[] && ARRAY[region_type] THEN 0 ELSE tax_percent END),0) AS total FROM ancestors;`, { - replacements: { id: regionId }, + replacements: { id: regionId, exempt_types: Array.from(exemptTypes) }, type: sequelize.QueryTypes.SELECT } ); @@ -334,9 +287,6 @@ 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 } @@ -383,7 +333,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', 'reputation'] + attributes: ['id', 'birthdate', 'gender', 'moodId', 'health'] }, { model: UserHouse, @@ -422,7 +372,7 @@ class FalukantService extends BaseService { model: RegionData, as: 'mainBranchRegion', include: [{ model: RegionType, as: 'regionType' }], - attributes: ['id', 'name'] + attributes: ['name'] }, { model: Branch, @@ -507,7 +457,7 @@ class FalukantService extends BaseService { { model: FalukantCharacter, as: 'character', - attributes: ['id', 'regionId', 'birthdate', 'health', 'reputation'], + attributes: ['birthdate', 'health'], include: [ { model: Relationship, @@ -560,16 +510,6 @@ 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; @@ -622,132 +562,6 @@ 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 (reduced frequency - not guaranteed every day) - // Total weight: 50 (50% chance per day, or adjust as needed) - const events = [ - { id: 'windfall', weight: 10 }, - { id: 'theft', weight: 8 }, - { id: 'character_illness', weight: 8 }, - { id: 'character_recovery', weight: 6 }, - { id: 'character_accident', weight: 4 }, - { id: 'regional_festival', weight: 4 }, - // Regionale Events sind sehr selten - { id: 'regional_storm', weight: 1 }, - { id: 'regional_epidemic', weight: 1 }, - { id: 'earthquake', weight: 1 }, - ]; - const total = events.reduce((s, e) => s + e.weight, 0); - // Reduzierte Wahrscheinlichkeit: Nur 30% Chance pro Tag, dass ein Event auftritt - const eventChance = 0.3; - if (Math.random() > eventChance) { - return null; // Kein Event heute - } - - 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() * 10) + 1); // -1..-10 - if (chosen === 'character_recovery') delta = (Math.floor(Math.random() * 11) + 5); // +5..+15 - if (chosen === 'character_accident') delta = -(Math.floor(Math.random() * 24) + 2); // -2..-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' || chosen === 'regional_storm' || chosen === 'regional_epidemic' || chosen === 'earthquake') { - const regionId = character?.regionId || falukantUser?.mainBranchRegionId || null; - if (regionId) { - const region = await RegionData.findByPk(regionId, { attributes: ['name'], transaction: t }); - payload.regionName = region?.name || null; - } - - // Regionale Events sollten nur einen moderaten Health-Verlust verursachen - // NICHT alle Charaktere töten! - if (chosen === 'regional_epidemic' && character) { - // Moderate Health-Reduktion: -3 bis -7 (reduziert) - const delta = -(Math.floor(Math.random() * 5) + 3); // -3..-7 - payload.healthChange = `${delta}`; - const next = Math.min(100, Math.max(0, Number(character.health || 0) + delta)); - await character.update({ health: next }, { transaction: t }); - } else if (chosen === 'regional_storm' && character) { - // Sehr geringer Health-Verlust: -2 bis -5 - const delta = -(Math.floor(Math.random() * 4) + 2); // -2..-5 - payload.healthChange = `${delta}`; - const next = Math.min(100, Math.max(0, Number(character.health || 0) + delta)); - await character.update({ health: next }, { transaction: t }); - } else if (chosen === 'earthquake' && character) { - // Moderate Health-Reduktion: -5 bis -10 (reduziert) - const delta = -(Math.floor(Math.random() * 6) + 5); // -5..-10 - payload.healthChange = `${delta}`; - const next = Math.min(100, Math.max(0, Number(character.health || 0) + delta)); - await character.update({ health: next }, { transaction: t }); - } - } - - // 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({ @@ -842,7 +656,7 @@ class FalukantService extends BaseService { { model: Production, as: 'productions', - attributes: ['quantity', 'startTimestamp', 'sleep'], + attributes: ['quantity', 'startTimestamp'], include: [{ model: ProductType, as: 'productType', attributes: ['id', 'category', 'labelTr', 'sellCost', 'productionTime'] }] } ], @@ -960,8 +774,7 @@ class FalukantService extends BaseService { return { id: plain.id, - // Defensive: legacy DB rows can have NULL -> UI would display "Unbekannt" - condition: Math.max(0, Math.min(100, Number.isFinite(Number(plain.condition)) ? Number(plain.condition) : 100)), + condition: plain.condition, availableFrom: plain.availableFrom, status, type: { @@ -1021,11 +834,6 @@ class FalukantService extends BaseService { async createTransport(hashedUserId, { branchId, vehicleTypeId, vehicleIds, productId, quantity, targetBranchId }) { const user = await getFalukantUserOrFail(hashedUserId); - - // Prüfe, ob User eine kirchliche Karriere hat (dann nur Direktoren können transportieren) - if (await this.hasChurchCareer(hashedUserId)) { - throw new Error('churchCareerNoDirectTransactions'); - } const sourceBranch = await Branch.findOne({ where: { id: branchId, falukantUserId: user.id }, @@ -1613,12 +1421,6 @@ class FalukantService extends BaseService { async createProduction(hashedUserId, branchId, productId, quantity) { const u = await getFalukantUserOrFail(hashedUserId); - - // Prüfe, ob User eine kirchliche Karriere hat (dann nur Direktoren können produzieren) - if (await this.hasChurchCareer(hashedUserId)) { - throw new Error('churchCareerNoDirectTransactions'); - } - const b = await getBranchOrFail(u.id, branchId); const p = await ProductType.findOne({ where: { id: productId } }); const runningProductions = await Production.findAll({ where: { branchId: b.id } }); @@ -1706,411 +1508,239 @@ 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 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 - `, - { - 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 - }, - product: { - id: r.product_id, - labelTr: r.product_label_tr, - sellCost: r.product_sell_cost - }, - quality: r.quality, - totalQuantity: r.total_quantity - })); + 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: [ + { + model: FalukantStock, + as: 'stock', + include: [ + { + model: Branch, + as: 'branch', + include: [{ model: RegionData, as: 'region', include: [{ model: RegionType, as: 'regionType' }] }] + }, + { model: FalukantStockType, as: 'stockType' } + ] + }, + { 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; + }); } async sellProduct(hashedUserId, branchId, productId, quality, quantity) { - // 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); - - // Prüfe, ob User eine kirchliche Karriere hat (dann nur Direktoren können verkaufen) - const character = await FalukantCharacter.findOne({ where: { userId: user.id }, transaction: t }); - if (!character) throw new Error('No character found for user'); - - const churchOffice = await ChurchOffice.findOne({ where: { characterId: character.id }, transaction: t }); - if (churchOffice) { - throw new Error('churchCareerNoDirectTransactions'); - } - - 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: { - stockId: { [Op.in]: stockIds }, - productId, - quality - }, - include: [ - { - model: ProductType, - as: 'productType', - required: true, - where: { id: productId }, - include: [ - { - model: Knowledge, - as: 'knowledges', - required: false, - where: { characterId: character.id } - } - ] - } - ], - 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); - - // compute cumulative tax (region + ancestors) with political exemptions and inflate price so seller net is unchanged - 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; - - // compute tax and net - const taxValue = Math.round((revenue * cumulativeTax / 100) * 100) / 100; - const net = Math.round((revenue - taxValue) * 100) / 100; - - // 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, 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({ transaction: t }); - } else { - await inv.update({ quantity: inv.quantity - remaining }, { transaction: t }); - remaining = 0; - break; + 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'); + 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 (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 }; + ] }); + 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); + + // compute cumulative tax (region + ancestors) with political exemptions and inflate price so seller net is unchanged + 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; + + // compute tax and net + 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); + 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); + if (!taxResult.success) throw new Error('Failed to update money for treasury'); + } + 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; + } + } + await this.addSellItem(branchId, user.id, productId, quantity); + console.log('[FalukantService.sellProduct] emitting events for user', user.user.hashedId, 'branch', branch?.id); + notifyUser(user.user.hashedId, 'falukantUpdateStatus', {}); + notifyUser(user.user.hashedId, 'falukantBranchUpdate', { branchId: branch.id }); + return { success: true }; } async sellAllProducts(hashedUserId, branchId) { - // 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' }], - transaction: t - }); - if (!branch) throw new Error('Branch not found'); - const stockIds = branch.stocks.map(s => s.id); - - // Prüfe, ob User eine kirchliche Karriere hat (dann nur Direktoren können verkaufen) - const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, transaction: t }); - if (!character) throw new Error('No character for user'); - - const churchOffice = await ChurchOffice.findOne({ where: { characterId: character.id }, transaction: t }); - if (churchOffice) { - throw new Error('churchCareerNoDirectTransactions'); - } - const inventory = await Inventory.findAll({ - where: { stockId: stockIds }, - include: [ - { - model: ProductType, - as: 'productType', - include: [ - { - model: Knowledge, - as: 'knowledges', - required: false, - where: { - characterId: character.id - } - } - ] - }, - { - model: FalukantStock, - as: 'stock', - include: [ - { - model: Branch, - as: 'branch' - }, - { - model: FalukantStockType, - as: 'stockType' - } - ] - } - ], - 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 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, - 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, - t - ); - if (!taxResult.success) throw new Error('Failed to update money for treasury'); - } - - 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 }; + const falukantUser = await getFalukantUserOrFail(hashedUserId); + const branch = await Branch.findOne({ + where: { id: branchId, falukantUserId: falukantUser.id }, + include: [{ model: FalukantStock, as: 'stocks' }] }); + 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 } }); + if (!character) throw new Error('No character for user'); + const inventory = await Inventory.findAll({ + where: { stockId: stockIds }, + include: [ + { + model: ProductType, + as: 'productType', + include: [ + { + model: Knowledge, + as: 'knowledges', + required: false, + where: { + characterId: character.id + } + } + ] + }, + { + model: FalukantStock, + as: 'stock', + include: [ + { + model: Branch, + as: 'branch' + }, + { + model: FalukantStockType, + as: 'stockType' + } + ] + } + ] + }); + if (!inventory.length) return { success: true, revenue: 0 }; + let total = 0; + 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 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; + totalTax += itemTax; + } + + const totalNet = Math.round((total - totalTax) * 100) / 100; + + const moneyResult = await updateFalukantUserMoney( + falukantUser.id, + totalNet, + 'Sell all products (net)', + falukantUser.id + ); + 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); + 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); + notifyUser(falukantUser.user.hashedId, 'falukantUpdateStatus', {}); + notifyUser(falukantUser.user.hashedId, 'falukantBranchUpdate', { branchId }); + return { success: true, revenue: total }; } - async addSellItem(branchId, userId, productId, quantity, transaction = null) { + async addSellItem(branchId, userId, productId, quantity) { const branch = await Branch.findOne({ where: { id: branchId }, - attributes: ['id', 'regionId'], - transaction: transaction || undefined - }); - if (!branch) throw new Error(`Branch not found (branchId: ${branchId})`); - - const [daySell, created] = await DaySell.findOrCreate({ + }) + ; + const daySell = await DaySell.findOne({ where: { regionId: branch.regionId, productId: productId, sellerId: userId, - }, - defaults: { quantity: quantity }, - transaction: transaction || undefined + } }); - if (!created) { + if (daySell) { daySell.quantity += quantity; - await daySell.save({ transaction: transaction || undefined }); + await daySell.save(); + } else { + await DaySell.create({ + regionId: branch.regionId, + productId: productId, + sellerId: userId, + quantity: quantity, + }); } } - 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: item.productId, - sellerId: userId, - 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) async getBranchTaxes(hashedUserId, branchId) { const user = await getFalukantUserOrFail(hashedUserId); @@ -2221,12 +1851,6 @@ class FalukantService extends BaseService { async buyStorage(hashedUserId, branchId, amount, stockTypeId) { const user = await getFalukantUserOrFail(hashedUserId); - - // Prüfe, ob User eine kirchliche Karriere hat (dann nur Direktoren können Lager kaufen) - if (await this.hasChurchCareer(hashedUserId)) { - throw new Error('churchCareerNoDirectTransactions'); - } - const branch = await getBranchOrFail(user.id, branchId); const buyableStocks = await BuyableStock.findAll({ where: { regionId: branch.regionId, stockTypeId }, @@ -2296,12 +1920,6 @@ class FalukantService extends BaseService { async sellStorage(hashedUserId, branchId, amount, stockTypeId) { const user = await getFalukantUserOrFail(hashedUserId); - - // Prüfe, ob User eine kirchliche Karriere hat (dann nur Direktoren können Lager verkaufen) - if (await this.hasChurchCareer(hashedUserId)) { - throw new Error('churchCareerNoDirectTransactions'); - } - const branch = await getBranchOrFail(user.id, branchId); const stock = await FalukantStock.findOne({ where: { branchId: branch.id, stockTypeId }, @@ -2454,20 +2072,11 @@ class FalukantService extends BaseService { throw new Error('Branch not found or does not belong to the user'); } const { falukantUserId, regionId } = branch; - - // OPTIMIERUNG: Erst Proposals laden, dann nur bei Bedarf bereinigen - // Dies vermeidet unnötige DELETE-Queries bei jedem Aufruf + await this.deleteExpiredProposals(); const existingProposals = await this.fetchProposals(falukantUserId, regionId); if (existingProposals.length > 0) { - // Nur bereinigen, wenn wir Proposals haben (im Hintergrund, nicht blockierend) - this.deleteExpiredProposals().catch(err => - console.error('[getDirectorProposals] Error cleaning expired proposals:', err) - ); return this.formatProposals(existingProposals); } - - // Wenn keine Proposals vorhanden sind, bereinigen wir vor der Generierung - await this.deleteExpiredProposals(); await this.generateProposals(falukantUserId, regionId); const newProposals = await this.fetchProposals(falukantUserId, regionId); return this.formatProposals(newProposals); @@ -2485,8 +2094,6 @@ class FalukantService extends BaseService { } async fetchProposals(falukantUserId, regionId) { - // OPTIMIERUNG: Query mit expliziten Joins und required: true für bessere Performance - // required: true sorgt für INNER JOIN statt LEFT JOIN, was schneller ist return DirectorProposal.findAll({ where: { employerUserId: falukantUserId }, include: [ @@ -2495,40 +2102,20 @@ class FalukantService extends BaseService { as: 'character', attributes: ['firstName', 'lastName', 'birthdate', 'titleOfNobility', 'gender'], where: { regionId }, - required: true, // INNER JOIN für bessere Performance include: [ - { - model: FalukantPredefineFirstname, - as: 'definedFirstName', - required: false // LEFT JOIN, da optional - }, - { - model: FalukantPredefineLastname, - as: 'definedLastName', - required: false // LEFT JOIN, da optional - }, - { - model: TitleOfNobility, - as: 'nobleTitle', - required: false // LEFT JOIN, da optional - }, + { model: FalukantPredefineFirstname, as: 'definedFirstName' }, + { model: FalukantPredefineLastname, as: 'definedLastName' }, + { model: TitleOfNobility, as: 'nobleTitle' }, { model: Knowledge, as: 'knowledges', - required: false, // LEFT JOIN, da optional include: [ - { - model: ProductType, - as: 'productType', - required: false // LEFT JOIN, da optional - }, + { model: ProductType, as: 'productType' }, ] }, ], }, ], - // OPTIMIERUNG: Limit setzen, um unnötige Daten zu vermeiden - limit: 10, }); } @@ -2536,95 +2123,36 @@ class FalukantService extends BaseService { try { const threeWeeksAgo = new Date(Date.now() - 21 * 24 * 60 * 60 * 1000); const proposalCount = Math.floor(Math.random() * 3) + 3; - - // OPTIMIERUNG: Verwende eine einzige SQL-Query mit CTEs statt mehrerer separater Queries - // Dies ist viel schneller, da PostgreSQL die Query optimieren kann - // Die Knowledge-Berechnung wird direkt in SQL gemacht (AVG) - const sqlQuery = ` - WITH excluded_characters AS ( - SELECT DISTINCT director_character_id AS id - FROM falukant_data.director_proposal - WHERE employer_user_id = :falukantUserId - UNION - SELECT DISTINCT director_character_id AS id - FROM falukant_data.director - ), - older_characters AS ( - SELECT - c.id, - c.title_of_nobility, - t.level, - COALESCE(AVG(k.knowledge), 0) AS avg_knowledge - FROM falukant_data.character c - LEFT JOIN falukant_type.title t ON t.id = c.title_of_nobility - LEFT JOIN falukant_data.knowledge k ON k.character_id = c.id - WHERE c.region_id = :regionId - AND c.user_id IS NULL - AND c.created_at < :threeWeeksAgo - AND c.id NOT IN (SELECT id FROM excluded_characters) - GROUP BY c.id, c.title_of_nobility, t.level - ORDER BY RANDOM() - LIMIT :proposalCount - ), - all_characters AS ( - SELECT - c.id, - c.title_of_nobility, - t.level, - COALESCE(AVG(k.knowledge), 0) AS avg_knowledge - FROM falukant_data.character c - LEFT JOIN falukant_type.title t ON t.id = c.title_of_nobility - LEFT JOIN falukant_data.knowledge k ON k.character_id = c.id - WHERE c.region_id = :regionId - AND c.user_id IS NULL - AND c.id NOT IN (SELECT id FROM excluded_characters) - AND c.id NOT IN (SELECT id FROM older_characters) - GROUP BY c.id, c.title_of_nobility, t.level - ORDER BY RANDOM() - LIMIT GREATEST(0, :proposalCount - (SELECT COUNT(*) FROM older_characters)) - ) - SELECT * FROM older_characters - UNION ALL - SELECT * FROM all_characters - LIMIT :proposalCount - `; - - const results = await sequelize.query(sqlQuery, { - replacements: { - falukantUserId, - regionId, - threeWeeksAgo, - proposalCount - }, - type: sequelize.QueryTypes.SELECT - }); - - if (results.length === 0) { - console.error(`[generateProposals] No NPCs found in region ${regionId}`); - throw new Error('No directors available for the region'); - } - - console.log(`[generateProposals] Found ${results.length} available NPCs`); - - // Erstelle alle Proposals in einem Batch - const proposalsToCreate = results.map(row => { - const avgKnowledge = parseFloat(row.avg_knowledge) || 0; + for (let i = 0; i < proposalCount; i++) { + const directorCharacter = await FalukantCharacter.findOne({ + where: { + regionId, + createdAt: { [Op.lt]: threeWeeksAgo }, + }, + include: [ + { + model: TitleOfNobility, + as: 'nobleTitle', + attributes: ['level'], + }, + ], + order: sequelize.literal('RANDOM()'), + }); + if (!directorCharacter) { + throw new Error('No directors available for the region'); + } + const avgKnowledge = await this.calculateAverageKnowledge(directorCharacter.id); const proposedIncome = Math.round( - row.level * Math.pow(1.231, avgKnowledge / 1.5) + directorCharacter.nobleTitle.level * Math.pow(1.231, avgKnowledge / 1.5) ); - - return { - directorCharacterId: row.id, + await DirectorProposal.create({ + directorCharacterId: directorCharacter.id, employerUserId: falukantUserId, proposedIncome, - }; - }); - - await DirectorProposal.bulkCreate(proposalsToCreate); - - console.log(`[generateProposals] Created ${proposalsToCreate.length} director proposals for region ${regionId}`); + }); + } } catch (error) { - console.error('[generateProposals] Error:', error.message, error.stack); + console.log(error.message, error.stack); throw new Error(error.message); } } @@ -2811,7 +2339,6 @@ class FalukantService extends BaseService { mayProduce: director.mayProduce, maySell: director.maySell, mayStartTransport: director.mayStartTransport, - mayRepairVehicles: director.mayRepairVehicles, region: director.character.region?.name || null, wishedIncome, }, @@ -2882,17 +2409,9 @@ class FalukantService extends BaseService { return { id: director.id, - character: { - name: `${director.character.definedFirstName.name} ${director.character.definedLastName.name}`, - title: director.character.nobleTitle.labelTr, - age: Math.floor((Date.now() - new Date(director.character.birthdate)) / (24 * 60 * 60 * 1000)), - gender: director.character.gender, - nobleTitle: director.character.nobleTitle, - definedFirstName: director.character.definedFirstName, - definedLastName: director.character.definedLastName, - knowledges: director.character.knowledges, - }, satisfaction: director.satisfaction, + character: director.character, + age: calcAge(director.character.birthdate), income: director.income, region: director.character.region.name, wishedIncome, @@ -2954,236 +2473,109 @@ class FalukantService extends BaseService { } async getFamily(hashedUserId) { - const startTime = Date.now(); - const timings = {}; - - try { - // 1. User und Character laden (optimiert: nur benötigte Felder) - const step1Start = Date.now(); - const user = await FalukantUser.findOne({ - include: [ - { model: User, as: 'user', attributes: ['hashedId'], where: { hashedId: hashedUserId } }, - { + const user = await this.getFalukantUserByHashedId(hashedUserId); + if (!user) throw new Error('User not found'); + const character = await FalukantCharacter.findOne({ where: { userId: user.id } }); + if (!character) throw new Error('Character not found for this user'); + let relationships = await Relationship.findAll({ + where: { character1Id: character.id }, + attributes: ['createdAt', 'widowFirstName2', 'nextStepProgress'], + include: [ + { + model: FalukantCharacter, as: 'character2', + attributes: ['id', 'birthdate', 'gender', 'moodId'], + include: [ + { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, + { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] }, + { model: CharacterTrait, as: 'traits' }, + { model: Mood, as: 'mood' }, + ] + }, + { model: RelationshipType, as: 'relationshipType', attributes: ['tr'] } + ] + }); + relationships = relationships.map(r => ({ + createdAt: r.createdAt, + widowFirstName2: r.widowFirstName2, + progress: r.nextStepProgress, + character2: { + id: r.character2.id, + age: calcAge(r.character2.birthdate), + gender: r.character2.gender, + firstName: r.character2.definedFirstName?.name || 'Unknown', + nobleTitle: r.character2.nobleTitle?.labelTr || '', + mood: r.character2.mood, + traits: r.character2.traits + }, + relationshipType: r.relationshipType.tr + })); + const charsWithChildren = await FalukantCharacter.findAll({ + where: { userId: user.id }, + include: [ + { + model: ChildRelation, + as: 'childrenFather', + include: [{ model: FalukantCharacter, - as: 'character', - attributes: ['id', 'birthdate', 'gender', 'regionId', 'titleOfNobility'], - required: true - } - ] - }); - if (!user) throw new Error('User not found'); - const character = user.character; - if (!character) throw new Error('Character not found for this user'); - timings.step1_user_character = Date.now() - step1Start; - - // 2. Relationships und Children parallel laden - const step2Start = Date.now(); - const [relationshipsRaw, charsWithChildren] = await Promise.all([ - Relationship.findAll({ - where: { character1Id: character.id }, - attributes: ['createdAt', 'widowFirstName2', 'nextStepProgress', 'relationshipTypeId'], - include: [ - { - model: FalukantCharacter, as: 'character2', - attributes: ['id', 'birthdate', 'gender', 'moodId', 'firstName', 'lastName', 'titleOfNobility'], - required: false - }, - { model: RelationshipType, as: 'relationshipType', attributes: ['tr'], required: false } - ] - }), - FalukantCharacter.findAll({ - where: { userId: user.id }, - attributes: ['id'], - include: [ - { - model: ChildRelation, - as: 'childrenFather', - attributes: ['nameSet', 'isHeir', 'createdAt', 'childCharacterId'], - include: [{ - model: FalukantCharacter, - as: 'child', - attributes: ['id', 'birthdate', 'gender', 'firstName'], - required: false - }], - required: false - }, - { - model: ChildRelation, - as: 'childrenMother', - attributes: ['nameSet', 'isHeir', 'createdAt', 'childCharacterId'], - include: [{ - model: FalukantCharacter, - as: 'child', - attributes: ['id', 'birthdate', 'gender', 'firstName'], - required: false - }], - required: false - } - ] - }) - ]); - timings.step2_relationships_children = Date.now() - step2Start; - - // 3. Batch-Loading für Relationship-Character-Daten - const step3Start = Date.now(); - const relationshipCharacters = relationshipsRaw - .filter(r => r.character2) - .map(r => r.character2); - const relationshipCharacterIds = relationshipCharacters.map(c => c.id); - const childCharacters = charsWithChildren - .flatMap(c => [ - ...(c.childrenFather || []).map(r => r.child).filter(Boolean), - ...(c.childrenMother || []).map(r => r.child).filter(Boolean) - ]); - const childCharacterIds = childCharacters.map(c => c.id); - - // Sammle alle benötigten IDs - const relationshipFirstNameIds = [...new Set(relationshipCharacters.map(c => c.firstName).filter(Boolean))]; - const relationshipLastNameIds = [...new Set(relationshipCharacters.map(c => c.lastName).filter(Boolean))]; - const relationshipTitleIds = [...new Set(relationshipCharacters.map(c => c.titleOfNobility).filter(Boolean))]; - const relationshipMoodIds = [...new Set(relationshipCharacters.map(c => c.moodId).filter(Boolean))]; - const childFirstNameIds = [...new Set(childCharacters.map(c => c.firstName).filter(Boolean))]; - const allFirstNameIds = [...new Set([...relationshipFirstNameIds, ...childFirstNameIds])]; - - // Batch-Load alle benötigten Daten parallel - const [firstNames, lastNames, titles, traitRelations, moods] = await Promise.all([ - allFirstNameIds.length > 0 ? FalukantPredefineFirstname.findAll({ - where: { id: { [Op.in]: allFirstNameIds } }, - attributes: ['id', 'name'] - }) : [], - relationshipLastNameIds.length > 0 ? FalukantPredefineLastname.findAll({ - where: { id: { [Op.in]: relationshipLastNameIds } }, - attributes: ['id', 'name'] - }) : [], - relationshipTitleIds.length > 0 ? TitleOfNobility.findAll({ - where: { id: { [Op.in]: relationshipTitleIds } }, - attributes: ['id', 'labelTr'] - }) : [], - relationshipCharacterIds.length > 0 ? FalukantCharacterTrait.findAll({ - where: { characterId: { [Op.in]: relationshipCharacterIds } }, - attributes: ['characterId', 'traitId'] - }) : [], - relationshipMoodIds.length > 0 ? Mood.findAll({ - where: { id: { [Op.in]: relationshipMoodIds } }, - attributes: ['id', 'tr'] - }) : [] - ]); - - // Sammle alle eindeutigen Trait-IDs und lade die Trait-Types - const allTraitIds = [...new Set(traitRelations.map(t => t.traitId))]; - const traitTypes = allTraitIds.length > 0 ? await CharacterTrait.findAll({ - where: { id: { [Op.in]: allTraitIds } }, - attributes: ['id', 'tr'] - }) : []; - - // Erstelle Maps für schnellen Zugriff - const firstNameMap = new Map(firstNames.map(fn => [fn.id, fn.name])); - const lastNameMap = new Map(lastNames.map(ln => [ln.id, ln.name])); - const titleMap = new Map(titles.map(t => [t.id, t.labelTr])); - const moodMap = new Map(moods.map(m => [m.id, m.tr])); - const traitTypeMap = new Map(traitTypes.map(t => [t.id, { id: t.id, tr: t.tr }])); - const traitsMap = new Map(); - traitRelations.forEach(t => { - if (!traitsMap.has(t.characterId)) { - traitsMap.set(t.characterId, []); - } - const traitObj = traitTypeMap.get(t.traitId); - if (traitObj) { - traitsMap.get(t.characterId).push(traitObj); - } - }); - timings.step3_batch_loading = Date.now() - step3Start; - - // 4. Relationships mappen - const step4Start = Date.now(); - const relationships = relationshipsRaw.map(r => { - const char2 = r.character2; - return { - createdAt: r.createdAt, - widowFirstName2: r.widowFirstName2, - progress: r.nextStepProgress, - character2: char2 ? { - id: char2.id, - age: calcAge(char2.birthdate), - gender: char2.gender, - firstName: firstNameMap.get(char2.firstName) || 'Unknown', - nobleTitle: titleMap.get(char2.titleOfNobility) || '', - mood: char2.moodId ? { tr: moodMap.get(char2.moodId) || null } : null, - moodId: char2.moodId, - traits: traitsMap.get(char2.id) || [] - } : null, - relationshipType: r.relationshipType?.tr || '' - }; - }).filter(r => r.character2 !== null); - timings.step4_map_relationships = Date.now() - step4Start; - - // 5. Children mappen - const step5Start = Date.now(); - const children = []; - for (const parentChar of charsWithChildren) { - const allRels = [ - ...(parentChar.childrenFather || []), - ...(parentChar.childrenMother || []) - ]; - for (const rel of allRels) { - const kid = rel.child; - if (!kid) continue; - children.push({ - childCharacterId: kid.id, - name: firstNameMap.get(kid.firstName) || 'Unknown', - gender: kid.gender, - age: calcAge(kid.birthdate), - hasName: rel.nameSet, - isHeir: rel.isHeir || false, - _createdAt: rel.createdAt, - }); + as: 'child', + include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] + }] + }, + { + model: ChildRelation, + as: 'childrenMother', + include: [{ + model: FalukantCharacter, + as: 'child', + include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] + }] } + ] + }); + const children = []; + for (const parentChar of charsWithChildren) { + const allRels = [ + ...(parentChar.childrenFather || []), + ...(parentChar.childrenMother || []) + ]; + for (const rel of allRels) { + const kid = rel.child; + children.push({ + childCharacterId: kid.id, + name: kid.definedFirstName?.name || 'Unknown', + gender: kid.gender, + age: calcAge(kid.birthdate), + hasName: rel.nameSet, + isHeir: rel.isHeir || false, + _createdAt: rel.createdAt, + }); } - children.sort((a, b) => new Date(a._createdAt) - new Date(b._createdAt)); - timings.step5_map_children = Date.now() - step5Start; - - // 6. Family-Objekt erstellen - const step6Start = Date.now(); - const inProgress = ['wooing', 'engaged', 'married']; - const family = { - relationships: relationships.filter(r => inProgress.includes(r.relationshipType)), - lovers: relationships.filter(r => r.relationshipType === 'lover'), - deathPartners: relationships.filter(r => r.relationshipType === 'widowed'), - children: children.map(({ _createdAt, ...rest }) => rest), - possiblePartners: [] - }; - timings.step6_create_family = Date.now() - step6Start; - - // 7. Possible Partners (nur wenn nötig, asynchron) - const step7Start = Date.now(); - const ownAge = calcAge(character.birthdate); - if (ownAge >= 12 && family.relationships.length === 0) { - try { - family.possiblePartners = await this.getPossiblePartners(character.id); - if (family.possiblePartners.length === 0) { - // Asynchron erstellen, nicht blockieren - this.createPossiblePartners( - character.id, - character.gender, - character.regionId, - character.titleOfNobility, - ownAge - ).catch(err => console.error('[getFamily] Error creating partners (async):', err)); - } - } catch (err) { - // Fehler beim Laden nicht an Frontend durchreichen – Seite bleibt nutzbar, Log für Analyse - console.error('[getFamily] getPossiblePartners failed (characterId=%s):', character.id, err?.message || err); - if (err?.stack) console.error('[getFamily] getPossiblePartners stack:', err.stack); - family.possiblePartners = []; - } - } - timings.step7_possible_partners = Date.now() - step7Start; - - return family; - } catch (error) { - console.error('[getFamily] Error:', error); - throw error; } + // Sort children globally by relation createdAt ascending (older first) + children.sort((a, b) => new Date(a._createdAt) - new Date(b._createdAt)); + const inProgress = ['wooing', 'engaged', 'married']; + const family = { + relationships: relationships.filter(r => inProgress.includes(r.relationshipType)), + lovers: relationships.filter(r => r.relationshipType === 'lover'), + deathPartners: relationships.filter(r => r.relationshipType === 'widowed'), + children: children.map(({ _createdAt, ...rest }) => rest), + possiblePartners: [] + }; + const ownAge = calcAge(character.birthdate); + if (ownAge >= 12 && family.relationships.length === 0) { + family.possiblePartners = await this.getPossiblePartners(character.id); + if (family.possiblePartners.length === 0) { + await this.createPossiblePartners( + character.id, + character.gender, + character.regionId, + character.titleOfNobility, + ownAge + ); + family.possiblePartners = await this.getPossiblePartners(character.id); + } + } + return family; } async setHeir(hashedUserId, childCharacterId) { @@ -3226,177 +2618,6 @@ class FalukantService extends BaseService { return { success: true, childCharacterId }; } - async getPotentialHeirs(hashedUserId) { - const user = await getFalukantUserOrFail(hashedUserId); - - // Prüfe, ob der User bereits einen Charakter hat - const existingCharacter = await FalukantCharacter.findOne({ where: { userId: user.id } }); - if (existingCharacter) { - throw new Error('User already has a character'); - } - - if (!user.mainBranchRegionId) { - throw new Error('User has no main branch region'); - } - - // Hole den noncivil Titel - const noncivilTitle = await TitleOfNobility.findOne({ where: { labelTr: 'noncivil' } }); - if (!noncivilTitle) { - throw new Error('Noncivil title not found'); - } - - // Berechne das Datum für 10-14 Jahre alt (in Tagen) - const now = new Date(); - now.setHours(0, 0, 0, 0); - const minDate = new Date(now); - minDate.setDate(minDate.getDate() - 14); // 14 Tage = 14 Jahre - const maxDate = new Date(now); - maxDate.setDate(maxDate.getDate() - 10); // 10 Tage = 10 Jahre - - // Hole zufällige Charaktere aus der Hauptregion, die 10-14 Jahre alt sind - // und keinen userId haben (also noch keinem User zugeordnet sind) - // und den noncivil Titel haben - let potentialHeirs = await FalukantCharacter.findAll({ - where: { - regionId: user.mainBranchRegionId, - userId: null, - titleOfNobility: noncivilTitle.id, - birthdate: { - [Op.between]: [minDate, maxDate] - } - }, - include: [ - { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, - { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] } - ], - order: Sequelize.fn('RANDOM'), - limit: 5 - }); - - // Wenn keine Charaktere gefunden wurden, erstelle 5 neue - if (potentialHeirs.length === 0) { - const genders = ['male', 'female']; - const createdHeirs = []; - - // Erstelle neue Charaktere in einer Transaktion - await sequelize.transaction(async (t) => { - for (let i = 0; i < 5; i++) { - // Zufälliges Geschlecht - const gender = genders[Math.floor(Math.random() * genders.length)]; - - // Zufälliger Vorname für das Geschlecht - const firstName = await this.randomFirstName(gender); - let fnObj = await FalukantPredefineFirstname.findOne({ - where: { name: firstName, gender }, - transaction: t - }); - if (!fnObj) { - fnObj = await FalukantPredefineFirstname.create({ - name: firstName, gender - }, { transaction: t }); - } - - // Zufälliger Nachname - const lastName = await this.randomLastName(); - let lnObj = await FalukantPredefineLastname.findOne({ - where: { name: lastName }, - transaction: t - }); - if (!lnObj) { - lnObj = await FalukantPredefineLastname.create({ - name: lastName - }, { transaction: t }); - } - - // Zufälliges Alter zwischen 10 und 14 Jahren (in Tagen) - const randomAge = Math.floor(Math.random() * 5) + 10; // 10-14 - const birthdate = new Date(now); - birthdate.setDate(birthdate.getDate() - randomAge); - - // Erstelle den Charakter - const newCharacter = await FalukantCharacter.create({ - userId: null, // Wichtig: noch keinem User zugeordnet - regionId: user.mainBranchRegionId, - firstName: fnObj.id, - lastName: lnObj.id, - gender: gender, - birthdate: birthdate, - titleOfNobility: noncivilTitle.id, - health: 100, - moodId: 1 - }, { transaction: t }); - - // Lade die Namen für die Rückgabe - const heirWithNames = await FalukantCharacter.findOne({ - where: { id: newCharacter.id }, - include: [ - { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, - { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] } - ], - transaction: t - }); - - createdHeirs.push(heirWithNames); - } - }); - - potentialHeirs = createdHeirs; - } - - // Berechne das Alter für jeden Charakter - return potentialHeirs.map(heir => { - const age = calcAge(heir.birthdate); - return { - id: heir.id, - definedFirstName: heir.definedFirstName, - definedLastName: heir.definedLastName, - gender: heir.gender, - age: age - }; - }); - } - - async selectHeir(hashedUserId, heirId) { - const user = await getFalukantUserOrFail(hashedUserId); - - // Prüfe, ob der User bereits einen Charakter hat - const existingCharacter = await FalukantCharacter.findOne({ where: { userId: user.id } }); - if (existingCharacter) { - throw new Error('User already has a character'); - } - - // Hole den Erben-Charakter - const heir = await FalukantCharacter.findOne({ - where: { - id: heirId, - userId: null // Stelle sicher, dass er noch keinem User zugeordnet ist - } - }); - - if (!heir) { - throw new Error('Heir not found or already assigned'); - } - - // Prüfe, ob der Erbe in der Hauptregion ist - if (heir.regionId !== user.mainBranchRegionId) { - throw new Error('Heir is not in main branch region'); - } - - // Prüfe das Alter (10-14 Jahre) - const age = calcAge(heir.birthdate); - if (age < 10 || age > 14) { - throw new Error('Heir age is not between 10 and 14'); - } - - // Weise den Charakter dem User zu - await heir.update({ userId: user.id }); - - // Benachrichtige den User - notifyUser(hashedUserId, 'falukantUserUpdated', {}); - - return { success: true, characterId: heir.id }; - } - async getPossiblePartners(requestingCharacterId) { const proposals = await MarriageProposal.findAll({ where: { @@ -3406,43 +2627,30 @@ class FalukantService extends BaseService { { model: FalukantCharacter, as: 'proposedCharacter', - required: false, attributes: ['id', 'firstName', 'lastName', 'gender', 'regionId', 'birthdate'], include: [ - { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'], required: false }, - { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'], required: false }, - { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'], required: false }, + { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, + { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }, + { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] }, ], }, ], }); - const valid = []; - for (const proposal of proposals) { - if (proposal.proposedCharacter == null) { - console.warn('[getPossiblePartners] Proposal id=%s filtered out (proposedCharacterId=%s missing)', proposal.id, proposal.proposedCharacterId); - continue; - } - const pc = proposal.proposedCharacter; - const birthdate = pc.birthdate ? new Date(pc.birthdate) : null; - const age = birthdate ? calcAge(birthdate) : 0; - const name = `${pc.definedFirstName?.name ?? ''} ${pc.definedLastName?.name ?? ''}`.trim() || null; - if (!name) { - console.warn('[getPossiblePartners] Proposal id=%s filtered out (proposedCharacterId=%s has no name)', proposal.id, pc.id); - continue; - } - valid.push({ + return proposals.map(proposal => { + const birthdate = new Date(proposal.proposedCharacter.birthdate); + const age = calcAge(birthdate); + return { id: proposal.id, requesterCharacterId: proposal.requesterCharacterId, - proposedCharacterId: pc.id, - proposedCharacterName: name, - proposedCharacterGender: pc.gender, - proposedCharacterRegionId: pc.regionId, + proposedCharacterId: proposal.proposedCharacter.id, + proposedCharacterName: `${proposal.proposedCharacter.definedFirstName?.name} ${proposal.proposedCharacter.definedLastName?.name}`, + proposedCharacterGender: proposal.proposedCharacter.gender, + proposedCharacterRegionId: proposal.proposedCharacter.regionId, proposedCharacterAge: age, - proposedCharacterNobleTitle: pc.nobleTitle?.labelTr ?? null, + proposedCharacterNobleTitle: proposal.proposedCharacter.nobleTitle.labelTr, cost: proposal.cost, - }); - } - return valid; + }; + }); } async createPossiblePartners(requestingCharacterId, requestingCharacterGender, requestingRegionId, requestingCharacterTitleOfNobility, ownAge) { @@ -3456,180 +2664,35 @@ class FalukantService extends BaseService { } const minTitle = minTitleResult.id; - // Logging für Debugging - console.log(`[createPossiblePartners] Searching for partners:`, { - requestingCharacterId, - requestingCharacterGender, - requestingRegionId, - requestingCharacterTitleOfNobility, - ownAge - }); - - const minAgeDate = new Date(new Date() - 12 * 24 * 60 * 60 * 1000); - const titleMin = Math.max(1, requestingCharacterTitleOfNobility - 1); - const titleMax = requestingCharacterTitleOfNobility + 1; - - console.log(`[createPossiblePartners] Search criteria:`, { - excludeId: requestingCharacterId, - gender: `not ${requestingCharacterGender}`, - regionId: requestingRegionId, - minAge: '12 days old', - titleRange: `${titleMin}-${titleMax}`, - userId: 'null (NPCs only)' - }); - - const whereClause = { - id: { [Op.ne]: requestingCharacterId }, - gender: { [Op.ne]: requestingCharacterGender }, - regionId: requestingRegionId, - createdAt: { [Op.lt]: minAgeDate }, - titleOfNobility: { [Op.between]: [titleMin, titleMax] }, - userId: null // Nur NPCs suchen - }; - - let potentialPartners = await FalukantCharacter.findAll({ - where: whereClause, + const potentialPartners = await FalukantCharacter.findAll({ + where: { + id: { [Op.ne]: requestingCharacterId }, + gender: { [Op.ne]: requestingCharacterGender }, + regionId: requestingRegionId, + createdAt: { [Op.lt]: new Date(new Date() - 12 * 24 * 60 * 60 * 1000) }, + titleOfNobility: { [Op.between]: [requestingCharacterTitleOfNobility - 1, requestingCharacterTitleOfNobility + 1] } + }, order: [ [Sequelize.literal(`ABS((EXTRACT(EPOCH FROM (NOW() - "birthdate")) / 86400) - ${ownAge})`), 'ASC'] ], limit: 5, }); - console.log(`[createPossiblePartners] Found ${potentialPartners.length} potential partners in region ${requestingRegionId}`); - - // Fallback: Wenn keine Partner in der gleichen Region gefunden werden, suche in allen Regionen - if (potentialPartners.length === 0) { - console.log(`[createPossiblePartners] No partners in region ${requestingRegionId}, trying all regions...`); - const fallbackWhereClause = { - id: { [Op.ne]: requestingCharacterId }, - gender: { [Op.ne]: requestingCharacterGender }, - createdAt: { [Op.lt]: minAgeDate }, - titleOfNobility: { [Op.between]: [titleMin, titleMax] }, - userId: null - }; - - potentialPartners = await FalukantCharacter.findAll({ - where: fallbackWhereClause, - order: [ - [Sequelize.literal(`ABS((EXTRACT(EPOCH FROM (NOW() - "birthdate")) / 86400) - ${ownAge})`), 'ASC'] - ], - limit: 5, - }); - - console.log(`[createPossiblePartners] Found ${potentialPartners.length} potential partners in all regions`); - } - - if (potentialPartners.length === 0) { - console.log(`[createPossiblePartners] No partners found, creating new NPCs...`); - // Erstelle automatisch 5 neue NPCs, die den Kriterien entsprechen - const targetGender = requestingCharacterGender === 'male' ? 'female' : 'male'; - const createdNPCs = await this._createNPCsForMarriage( - requestingRegionId, - targetGender, - titleMin, - titleMax, - ownAge, - 5 - ); - - if (createdNPCs.length > 0) { - console.log(`[createPossiblePartners] Created ${createdNPCs.length} new NPCs, using them as partners`); - potentialPartners = createdNPCs; - } else { - console.warn(`[createPossiblePartners] Failed to create NPCs. Consider creating NPCs manually with:`); - console.warn(` - gender: ${targetGender}`); - console.warn(` - regionId: ${requestingRegionId}`); - console.warn(` - titleOfNobility: ${titleMin}-${titleMax}`); - console.warn(` - age: ~${ownAge} years`); - return; // Keine Partner gefunden, aber kein Fehler - } - } - const proposals = potentialPartners.map(partner => { const age = calcAge(partner.birthdate); return { requesterCharacterId: requestingCharacterId, proposedCharacterId: partner.id, - cost: calculateMarriageCost(partner.titleOfNobility, age), + cost: calculateMarriageCost(partner.titleOfNobility, age, minTitle), }; }); - await MarriageProposal.bulkCreate(proposals); - console.log(`[createPossiblePartners] Created ${proposals.length} marriage proposals`); } catch (error) { console.error('Error creating possible partners:', error); throw error; } } - async _createNPCsForMarriage(regionId, gender, minTitle, maxTitle, targetAge, count = 5) { - try { - const sequelize = FalukantCharacter.sequelize; - const createdNPCs = []; - - await sequelize.transaction(async (t) => { - for (let i = 0; i < count; i++) { - // Zufälliger Titel im Bereich - const randomTitle = Math.floor(Math.random() * (maxTitle - minTitle + 1)) + minTitle; - - // Alter: ±2 Jahre um targetAge - const ageVariation = Math.floor(Math.random() * 5) - 2; // -2 bis +2 - const randomAge = Math.max(12, targetAge + ageVariation); // Mindestens 12 Jahre - - // Zufälliger Vorname für das Geschlecht - const firstName = await FalukantPredefineFirstname.findAll({ - where: { gender }, - order: sequelize.fn('RANDOM'), - limit: 1, - transaction: t - }); - - if (!firstName || firstName.length === 0) { - console.warn(`[_createNPCsForMarriage] No first names found for gender ${gender}`); - continue; - } - - // Zufälliger Nachname - const lastName = await FalukantPredefineLastname.findAll({ - order: sequelize.fn('RANDOM'), - limit: 1, - transaction: t - }); - - if (!lastName || lastName.length === 0) { - console.warn(`[_createNPCsForMarriage] No last names found`); - continue; - } - - // Geburtsdatum berechnen (Alter in Tagen) - const birthdate = new Date(); - birthdate.setDate(birthdate.getDate() - randomAge); - - // Erstelle den NPC-Charakter - const npc = await FalukantCharacter.create({ - userId: null, // Wichtig: null = NPC - regionId: regionId, - firstName: firstName[0].id, - lastName: lastName[0].id, - gender: gender, - birthdate: birthdate, - titleOfNobility: randomTitle, - health: 100, - moodId: 1 - }, { transaction: t }); - - createdNPCs.push(npc); - } - }); - - console.log(`[_createNPCsForMarriage] Created ${createdNPCs.length} NPCs`); - return createdNPCs; - } catch (error) { - console.error('[_createNPCsForMarriage] Error creating NPCs:', error); - return []; // Bei Fehler leeres Array zurückgeben - } - } - async acceptMarriageProposal(hashedUserId, proposedCharacterId) { const user = await this.getFalukantUserByHashedId(hashedUserId); const character = await FalukantCharacter.findOne({ where: { userId: user.id } }); @@ -3671,129 +2734,65 @@ class FalukantService extends BaseService { } async getGifts(hashedUserId) { - const startTime = Date.now(); - const timings = {}; - - try { - // 1) User & Character optimiert laden (nur benötigte Felder) - const step1Start = Date.now(); - const user = await FalukantUser.findOne({ - include: [ - { model: User, as: 'user', attributes: ['hashedId'], where: { hashedId: hashedUserId } }, - { - model: FalukantCharacter, - as: 'character', - attributes: ['id', 'titleOfNobility'], - required: true - } + // 1) Mein User & Character + const user = await this.getFalukantUserByHashedId(hashedUserId); + const myChar = await FalukantCharacter.findOne({ where: { userId: user.id } }); + if (!myChar) throw new Error('Character not found'); + + // 2) Beziehung finden und „anderen“ Character bestimmen + const rel = await Relationship.findOne({ + where: { + [Op.or]: [ + { character1Id: myChar.id }, + { character2Id: myChar.id } ] - }); - if (!user) throw new Error('User not found'); - const myChar = user.character; - if (!myChar) throw new Error('Character not found'); - timings.step1_user_character = Date.now() - step1Start; + }, + include: [ + { model: FalukantCharacter, as: 'character1', include: [{ model: CharacterTrait, as: 'traits' }] }, + { model: FalukantCharacter, as: 'character2', include: [{ model: CharacterTrait, as: 'traits' }] } + ] + }); + if (!rel) throw new Error('Beziehung nicht gefunden'); - // 2) Beziehung finden (zwei separate Queries für bessere Index-Nutzung) - const step2Start = Date.now(); - const [relAsChar1, relAsChar2] = await Promise.all([ - Relationship.findOne({ - where: { character1Id: myChar.id }, - attributes: ['character1Id', 'character2Id'] - }), - Relationship.findOne({ - where: { character2Id: myChar.id }, - attributes: ['character1Id', 'character2Id'] - }) - ]); - const rel = relAsChar1 || relAsChar2; - timings.step2_relationship = Date.now() - step2Start; + const relatedChar = rel.character1.id === myChar.id ? rel.character2 : rel.character1; - // 3) Related Character und Traits laden (nur wenn Relationship existiert) - const step3Start = Date.now(); - let relatedTraitIds = []; - let relatedMoodId = null; + // 3) Trait-IDs und Mood des relatedChar + const relatedTraitIds = relatedChar.traits.map(t => t.id); + const relatedMoodId = relatedChar.moodId; - if (rel) { - const relatedCharId = rel.character1Id === myChar.id ? rel.character2Id : rel.character1Id; - - // Parallel: Character (moodId) und Traits laden - const [relatedChar, traitRows] = await Promise.all([ - FalukantCharacter.findOne({ - where: { id: relatedCharId }, - attributes: ['id', 'moodId'] - }), - FalukantCharacterTrait.findAll({ - where: { characterId: relatedCharId }, - attributes: ['traitId'] - }) - ]); - - if (relatedChar) { - relatedMoodId = relatedChar.moodId; - relatedTraitIds = traitRows.map(t => t.traitId); - } - } - timings.step3_load_character_and_traits = Date.now() - step3Start; - - // 4) Gifts laden – mit Mood/Trait-Filter nur wenn Beziehung existiert - const step4Start = Date.now(); - const giftIncludes = [ + // 4) Gifts laden – aber nur die passenden Moods und Traits als Unter-Arrays + const gifts = await PromotionalGift.findAll({ + include: [ { model: PromotionalGiftMood, as: 'promotionalgiftmoods', attributes: ['mood_id', 'suitability'], - required: false + where: { mood_id: relatedMoodId }, + required: false // Gifts ohne Mood-Match bleiben erhalten, haben dann leeres Array }, { model: PromotionalGiftCharacterTrait, as: 'characterTraits', attributes: ['trait_id', 'suitability'], - required: false + where: { trait_id: relatedTraitIds }, + required: false // Gifts ohne Trait-Match bleiben erhalten } - ]; + ] + }); - // Wenn Beziehung existiert, Filter anwenden - if (rel && relatedMoodId) { - giftIncludes[0].where = { mood_id: relatedMoodId }; - } - if (rel && relatedTraitIds.length > 0) { - giftIncludes[1].where = { trait_id: { [Op.in]: relatedTraitIds } }; - } - timings.step4_prepare_gift_includes = Date.now() - step4Start; - - // 5) Parallel: Gifts und lowestTitleOfNobility laden - const step5Start = Date.now(); - const [gifts, lowestTitleOfNobility] = await Promise.all([ - PromotionalGift.findAll({ - include: giftIncludes - }), - TitleOfNobility.findOne({ order: [['id', 'ASC']] }) - ]); - timings.step5_load_gifts_and_title = Date.now() - step5Start; - - // 6) Kosten berechnen (getGiftCost ist synchron) - const step6Start = Date.now(); - const result = gifts.map(gift => ({ - id: gift.id, - name: gift.name, - cost: this.getGiftCost( - gift.value, - myChar.titleOfNobility, - lowestTitleOfNobility.id - ), - moodsAffects: gift.promotionalgiftmoods || [], // nur Einträge mit relatedMoodId (wenn Filter angewendet) - charactersAffects: gift.characterTraits || [] // nur Einträge mit relatedTraitIds (wenn Filter angewendet) - })); - timings.step6_calculate_costs = Date.now() - step6Start; - - const totalTime = Date.now() - startTime; - console.log(`[getGifts] Performance: ${totalTime}ms total`, timings); - - return result; - } catch (error) { - console.error('[getGifts] Error:', error); - throw error; - } + // 5) Rest wie gehabt: Kosten berechnen und zurückgeben + const lowestTitleOfNobility = await TitleOfNobility.findOne({ order: [['id', 'ASC']] }); + return Promise.all(gifts.map(async gift => ({ + id: gift.id, + name: gift.name, + cost: await this.getGiftCost( + gift.value, + myChar.titleOfNobility, + lowestTitleOfNobility.id + ), + moodsAffects: gift.promotionalgiftmoods, // nur Einträge mit relatedMoodId + charactersAffects: gift.characterTraits // nur Einträge mit relatedTraitIds + }))); } async getChildren(hashedUserId) { @@ -3891,7 +2890,7 @@ class FalukantService extends BaseService { if (!gift) { throw new Error('notFound'); } - const cost = this.getGiftCost( + const cost = await this.getGiftCost( gift.value, user.character.nobleTitle.id, lowestTitle.id @@ -3947,7 +2946,7 @@ class FalukantService extends BaseService { } } - getGiftCost(value, titleOfNobility, lowestTitleOfNobility) { + async getGiftCost(value, titleOfNobility, lowestTitleOfNobility) { const titleLevel = titleOfNobility - lowestTitleOfNobility + 1; return Math.round(value * Math.pow(1 + titleLevel * 0.3, 1.3) * 100) / 100; } @@ -4110,312 +3109,71 @@ 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({ - where: { - falukantUserId: falukantUser.id, - partyTypeId, - createdAt: { [Op.gte]: since }, - }, - 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, { 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 } }, transaction: t }) - : []; - - // Prüfe, ob alle angegebenen IDs gefunden wurden - if (nobilityIds && nobilityIds.length > 0 && nobilities.length !== nobilityIds.length) { - throw new Error('Einige ausgewählte Adelstitel existieren nicht'); - } - - let cost = (ptype.cost || 0) + (music.cost || 0) + (banquette.cost || 0); - 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, - t - ); - if (!moneyResult.success) { - throw new Error('Geld konnte nicht abgezogen werden'); - } - - const party = await Party.create({ - partyTypeId, + const falukantUser = await getFalukantUserOrFail(hashedUserId); + const since = new Date(Date.now() - 24 * 3600 * 1000); + const already = await Party.findOne({ + where: { falukantUserId: falukantUser.id, - musicTypeId: musicId, - banquetteTypeId: banquetteId, - servantRatio, - cost: cost - }, { transaction: t }); - - if (nobilities.length > 0) { - await party.addInvitedNobilities(nobilities, { transaction: t }); - } - - // 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 }; + partyTypeId, + createdAt: { [Op.gte]: since }, + }, + attributes: ['id'] }); + 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), + ]); + 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 } } }) + : []; + + // Prüfe, ob alle angegebenen IDs gefunden wurden + if (nobilityIds && nobilityIds.length > 0 && nobilities.length !== nobilityIds.length) { + throw new Error('Einige ausgewählte Adelstitel existieren nicht'); + } + + let cost = (ptype.cost || 0) + (music.cost || 0) + (banquette.cost || 0); + 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'); + } + const moneyResult = await updateFalukantUserMoney( + falukantUser.id, + -cost, + 'partyOrder', + falukantUser.id + ); + if (!moneyResult.success) { + throw new Error('Geld konnte nicht abgezogen werden'); + } + const party = await Party.create({ + partyTypeId, + falukantUserId: falukantUser.id, + musicTypeId: musicId, + banquetteTypeId: banquetteId, + servantRatio, + cost: cost + }); + if (nobilities.length > 0) { + // Verwende die bereits geladenen Objekte + await party.addInvitedNobilities(nobilities); + } + const user = await User.findByPk(falukantUser.userId); + notifyUser(user.hashedId, 'falukantPartyUpdate', { + partyId: party.id, + cost, + }); + return { 'success': true }; } async getParties(hashedUserId) { @@ -4874,7 +3632,7 @@ class FalukantService extends BaseService { limit: 1 }); if (lastHealthActivity) { - throw new PreconditionError('tr:falukant.healthview.errors.tooClose'); + throw new Error('too close'); } const activityObject = FalukantService.HEALTH_ACTIVITIES.find((a) => a.tr === activity); if (!activityObject) { @@ -4883,7 +3641,7 @@ class FalukantService extends BaseService { if (user.money - activityObject.cost < 0) { throw new Error('no money'); } - // Hinweis: health ist ein Status (0..100) und darf nicht mit Geldkosten vermischt werden. + user.character.health -= activityObject.cost; const healthChange = await this[activityObject.method](user); await HealthActivity.create({ characterId: user.character.id, @@ -4891,18 +3649,12 @@ class FalukantService extends BaseService { successPercentage: healthChange, cost: activityObject.cost }); - const moneyResult = await updateFalukantUserMoney(user.id, -activityObject.cost, 'health.' + activity, user.id); - if (!moneyResult.success) throw new Error('Failed to update money'); + updateFalukantUserMoney(user.id, -activityObject.cost, 'health.' + activity); // Status-Update Notification senden notifyUser(user.user.hashedId, 'falukantUpdateStatus', {}); - // Give client enough info to update UI without ambiguity - const updatedChar = await FalukantCharacter.findOne({ - where: { id: user.character.id }, - attributes: ['health'] - }); - return { success: true, delta: healthChange, health: updatedChar?.health ?? null }; + return { success: true }; } async healthChange(user, delta) { @@ -5024,62 +3776,7 @@ class FalukantService extends BaseService { ] }); - // Job-Hierarchie-Ebene (höhere Zahl = höhere Position) - const jobHierarchy = { - 'assessor': 1, - 'councillor': 2, - 'council': 3, - 'beadle': 4, - 'town-clerk': 4, - 'mayor': 5, - 'master-builder': 6, - 'village-major': 7, - 'judge': 8, - 'bailif': 9, - 'taxman': 10, - 'sheriff': 11, - 'consultant': 12, - 'treasurer': 13, - 'hangman': 12, - 'territorial-council': 13, - 'territorial-council-speaker': 14, - 'ruler-consultant': 15, - 'state-administrator': 16, - 'super-state-administrator': 17, - 'governor': 18, - 'ministry-helper': 19, - 'minister': 20, - 'chancellor': 21 - }; - - // Region-Hierarchie-Tiefe berechnen (0 = oberste Parent, höhere Zahl = tiefer) - const regionDepths = new Map(); - const calculateRegionDepth = async (regionId) => { - if (regionDepths.has(regionId)) { - return regionDepths.get(regionId); - } - let depth = 0; - let currentId = regionId; - while (currentId !== null) { - const region = await RegionData.findByPk(currentId, { - attributes: ['parentId'] - }); - if (region && region.parentId) { - depth++; - currentId = region.parentId; - } else { - break; - } - } - regionDepths.set(regionId, depth); - return depth; - }; - - // Alle Region-Tiefen parallel berechnen - const uniqueRegionIds = [...new Set(offices.map(o => o.regionId))]; - await Promise.all(uniqueRegionIds.map(id => calculateRegionDepth(id))); - - const mapped = offices.map(office => { + return offices.map(office => { const o = office.get({ plain: true }); // Enddatum der Amtszeit berechnen: Start = createdAt, Dauer = termLength Jahre @@ -5099,64 +3796,20 @@ class FalukantService extends BaseService { name: o.type?.name }, region: { - id: o.region?.id, name: o.region?.name, regionType: o.region?.regionType ? { labelTr: o.region.regionType.labelTr } - : undefined, - depth: regionDepths.get(o.region?.id) || 0 + : undefined }, character: o.holder ? { - id: o.holder.id, definedFirstName: o.holder.definedFirstName, definedLastName: o.holder.definedLastName, nobleTitle: o.holder.nobleTitle, gender: o.holder.gender } : null, - termEnds, - jobHierarchyLevel: jobHierarchy[o.type?.name] || 0 - }; - }); - - // Sortierung: 1. Region-Tiefe (aufsteigend, oberste Parent zuerst), 2. Job-Hierarchie (aufsteigend), 3. TermEnds (aufsteigend, frühere zuerst), 4. Vorname, 5. Nachname - mapped.sort((a, b) => { - // 1. Region-Tiefe (aufsteigend) - if (a.region.depth !== b.region.depth) { - return a.region.depth - b.region.depth; - } - // 2. Job-Hierarchie (aufsteigend) - if (a.jobHierarchyLevel !== b.jobHierarchyLevel) { - return a.jobHierarchyLevel - b.jobHierarchyLevel; - } - // 3. TermEnds (aufsteigend, frühere zuerst) - const termA = a.termEnds ? new Date(a.termEnds).getTime() : Infinity; - const termB = b.termEnds ? new Date(b.termEnds).getTime() : Infinity; - if (termA !== termB) { - return termA - termB; - } - // 4. Vorname - const firstNameA = a.character?.definedFirstName?.name || ''; - const firstNameB = b.character?.definedFirstName?.name || ''; - if (firstNameA !== firstNameB) { - return firstNameA.localeCompare(firstNameB); - } - // 5. Nachname - const lastNameA = a.character?.definedLastName?.name || ''; - const lastNameB = b.character?.definedLastName?.name || ''; - return lastNameA.localeCompare(lastNameB); - }); - - // Entferne temporäre Felder vor der Rückgabe - return mapped.map(({ jobHierarchyLevel, ...rest }) => { - const { depth, id, ...regionRest } = rest.region; - return { - ...rest, - region: { - name: regionRest.name, - regionType: regionRest.regionType - } + termEnds }; }); } @@ -5492,57 +4145,6 @@ class FalukantService extends BaseService { return regions; } - async getProductPricesInRegionBatch(hashedUserId, productIds, regionId) { - const user = await this.getFalukantUserByHashedId(hashedUserId); - const character = await FalukantCharacter.findOne({ where: { userId: user.id } }); - if (!character) { - throw new Error(`No FalukantCharacter found for user with id ${user.id}`); - } - - if (!Array.isArray(productIds) || productIds.length === 0) { - return {}; - } - - // Hole alle Produkte auf einmal - const products = await ProductType.findAll({ - where: { id: { [Op.in]: productIds } } - }); - - // Hole alle Knowledge-Werte auf einmal - const knowledges = await Knowledge.findAll({ - where: { - characterId: character.id, - productId: { [Op.in]: productIds } - } - }); - const knowledgeMap = new Map(knowledges.map(k => [k.productId, k.knowledge])); - - // Hole alle TownProductWorth-Werte auf einmal - const townWorths = await TownProductWorth.findAll({ - where: { - productId: { [Op.in]: productIds }, - regionId: regionId - } - }); - const worthMap = new Map(townWorths.map(tw => [tw.productId, tw.worthPercent])); - - // Berechne Preise für alle Produkte - const prices = {}; - for (const product of products) { - const knowledgeFactor = knowledgeMap.get(product.id) || 0; - const worthPercent = worthMap.get(product.id) || 50; - - const basePrice = product.sellCost * (worthPercent / 100); - const min = basePrice * 0.6; - const max = basePrice; - const price = min + (max - min) * (knowledgeFactor / 100); - - prices[product.id] = Math.round(price * 100) / 100; // Auf 2 Dezimalstellen runden - } - - return prices; - } - async getProductPriceInRegion(hashedUserId, productId, regionId) { const user = await this.getFalukantUserByHashedId(hashedUserId); const character = await FalukantCharacter.findOne({ where: { userId: user.id } }); @@ -5773,41 +4375,18 @@ class FalukantService extends BaseService { limit, }); - // Convert to plain objects so we can add properties - const plainRows = rows.map(r => r.get({ plain: true })); - await enrichNotificationsWithCharacterNames(plainRows); + await enrichNotificationsWithCharacterNames(rows); - return { items: plainRows, total: count, page: Number(page) || 1, size: limit }; + return { items: rows, total: count, page: Number(page) || 1, size: limit }; } async markNotificationsShown(hashedUserId) { const user = await getFalukantUserOrFail(hashedUserId); - - // Use transaction to prevent deadlocks and ensure atomicity - const transaction = await sequelize.transaction({ - isolationLevel: Sequelize.Transaction.ISOLATION_LEVELS.READ_COMMITTED - }); - - try { - const [count] = await Notification.update( - { shown: true }, - { - where: { userId: user.id, shown: false }, - transaction - } - ); - await transaction.commit(); - - // Send socket notification to update statusbar - if (count > 0) { - notifyUser(hashedUserId, 'falukantUpdateStatus', {}); - } - - return { updated: count }; - } catch (error) { - await transaction.rollback(); - throw error; - } + const [count] = await Notification.update( + { shown: true }, + { where: { userId: user.id, shown: false } } + ); + return { updated: count }; } async getPoliticalOfficeHolders(hashedUserId) { @@ -6111,560 +4690,6 @@ class FalukantService extends BaseService { all: mapped }; } - - // ==================== Church Career Methods ==================== - - async getChurchOverview(hashedUserId) { - // Liefert alle aktuell besetzten kirchlichen Ämter im eigenen Gebiet - const user = await getFalukantUserOrFail(hashedUserId); - const character = await FalukantCharacter.findOne({ - where: { userId: user.id }, - attributes: ['id', 'regionId'] - }); - if (!character) { - return []; - } - - const relevantRegionIds = await this.getRegionAndParentIds(character.regionId); - - const offices = await ChurchOffice.findAll({ - where: { - regionId: { - [Op.in]: relevantRegionIds - } - }, - include: [ - { - model: ChurchOfficeType, - as: 'type', - attributes: ['name', 'hierarchyLevel'] - }, - { - model: RegionData, - as: 'region', - attributes: ['name'] - }, - { - model: FalukantCharacter, - as: 'holder', - attributes: ['id', 'gender'], - include: [ - { - model: FalukantPredefineFirstname, - as: 'definedFirstName', - attributes: ['name'] - }, - { - model: FalukantPredefineLastname, - as: 'definedLastName', - attributes: ['name'] - }, - { - model: TitleOfNobility, - as: 'nobleTitle', - attributes: ['labelTr'] - } - ] - }, - { - model: FalukantCharacter, - as: 'supervisor', - attributes: ['id'], - include: [ - { - model: FalukantPredefineFirstname, - as: 'definedFirstName', - attributes: ['name'] - }, - { - model: FalukantPredefineLastname, - as: 'definedLastName', - attributes: ['name'] - } - ], - required: false - } - ], - order: [ - [{ model: ChurchOfficeType, as: 'type' }, 'hierarchyLevel', 'ASC'], - [{ model: RegionData, as: 'region' }, 'name', 'ASC'] - ] - }); - - return offices.map(office => { - const o = office.get({ plain: true }); - return { - id: o.id, - officeType: { - name: o.type.name, - hierarchyLevel: o.type.hierarchyLevel - }, - region: { - name: o.region.name - }, - character: o.holder ? { - id: o.holder.id, - name: `${o.holder.definedFirstName?.name || ''} ${o.holder.definedLastName?.name || ''}`.trim(), - gender: o.holder.gender, - title: o.holder.nobleTitle?.labelTr - } : null, - supervisor: o.supervisor ? { - id: o.supervisor.id, - name: `${o.supervisor.definedFirstName?.name || ''} ${o.supervisor.definedLastName?.name || ''}`.trim() - } : null - }; - }); - } - - async getAvailableChurchPositions(hashedUserId) { - // Liefert verfügbare kirchliche Positionen, für die sich der User bewerben kann - const user = await getFalukantUserOrFail(hashedUserId); - const character = await FalukantCharacter.findOne({ - where: { userId: user.id }, - attributes: ['id', 'regionId'], - include: [ - { - model: TitleOfNobility, - as: 'nobleTitle', - attributes: ['labelTr', 'level'] - } - ] - }); - if (!character) { - return []; - } - - // Prüfe, ob User bereits ein kirchliches Amt hat - const existingOffice = await ChurchOffice.findOne({ - where: { characterId: character.id } - }); - if (existingOffice) { - // User hat bereits ein Amt, kann sich nur auf höhere Positionen bewerben - const currentOffice = await ChurchOffice.findOne({ - where: { characterId: character.id }, - include: [{ - model: ChurchOfficeType, - as: 'type', - attributes: ['hierarchyLevel'] - }] - }); - const currentLevel = currentOffice?.type?.hierarchyLevel || 0; - - // Finde höhere Positionen - const availableOffices = await ChurchOfficeType.findAll({ - where: { - hierarchyLevel: { - [Op.gt]: currentLevel - } - }, - include: [ - { - model: ChurchOfficeRequirement, - as: 'requirements' - } - ] - }); - - return this.filterAvailablePositions(availableOffices, character, existingOffice); - } else { - // User hat noch kein Amt, kann sich auf Einstiegspositionen bewerben - const entryLevelOffices = await ChurchOfficeType.findAll({ - where: { - hierarchyLevel: 1 // Dorfgeistlicher ist Einstieg - }, - include: [ - { - model: ChurchOfficeRequirement, - as: 'requirements' - } - ] - }); - - return this.filterAvailablePositions(entryLevelOffices, character, null); - } - } - - async filterAvailablePositions(officeTypes, character, existingOffice) { - const available = []; - const relevantRegionIds = await this.getRegionAndParentIds(character.regionId); - - for (const officeType of officeTypes) { - // Prüfe Voraussetzungen - const requirements = officeType.requirements || []; - let canApply = true; - - // Prüfe, ob Voraussetzung erfüllt ist (niedrigere Position) - if (requirements.length > 0 && requirements[0].prerequisiteOfficeTypeId) { - if (!existingOffice) { - canApply = false; // Benötigt niedrigere Position, aber User hat keine - } else { - const currentOfficeType = await ChurchOfficeType.findByPk(existingOffice.officeTypeId); - if (currentOfficeType?.hierarchyLevel >= officeType.hierarchyLevel) { - canApply = false; // Aktuelle Position ist nicht niedrig genug - } - } - } - - if (!canApply) continue; - - // Finde verfügbare Positionen in relevanten Regionen - const filledPositions = await ChurchOffice.count({ - where: { - officeTypeId: officeType.id, - regionId: { - [Op.in]: relevantRegionIds - } - } - }); - - // Prüfe, ob noch Plätze frei sind - if (filledPositions < officeType.seatsPerRegion) { - // Finde Vorgesetzten für diese Position - const supervisor = await this.findSupervisorForPosition(officeType, character.regionId); - - // Finde passende Region für diese Position - const region = await RegionData.findOne({ - where: { - id: { - [Op.in]: relevantRegionIds - } - }, - include: [{ - model: RegionType, - as: 'regionType', - attributes: ['labelTr'] - }], - order: [['id', 'ASC']] // Nimm die erste passende Region - }); - - available.push({ - id: officeType.id, - officeType: { - name: officeType.name, - hierarchyLevel: officeType.hierarchyLevel, - seatsPerRegion: officeType.seatsPerRegion, - regionType: officeType.regionType - }, - region: region ? { - id: region.id, - name: region.name - } : null, - regionId: region?.id || character.regionId, - availableSeats: officeType.seatsPerRegion - filledPositions, - supervisor: supervisor ? { - id: supervisor.id, - name: `${supervisor.definedFirstName?.name || ''} ${supervisor.definedLastName?.name || ''}`.trim() - } : null - }); - } - } - - return available; - } - - async findSupervisorForPosition(officeType, regionId) { - // Finde den Vorgesetzten (höhere Position in der Hierarchie) - const supervisorOfficeType = await ChurchOfficeType.findOne({ - where: { - hierarchyLevel: { - [Op.gt]: officeType.hierarchyLevel - } - }, - order: [['hierarchyLevel', 'ASC']] // Nimm die nächsthöhere Position - }); - - if (!supervisorOfficeType) { - return null; // Kein Vorgesetzter (z.B. Papst) - } - - // Finde den Vorgesetzten in der Region oder übergeordneten Regionen - const relevantRegionIds = await this.getRegionAndParentIds(regionId); - const supervisorOffice = await ChurchOffice.findOne({ - where: { - officeTypeId: supervisorOfficeType.id, - regionId: { - [Op.in]: relevantRegionIds - } - }, - include: [ - { - model: FalukantCharacter, - as: 'holder', - attributes: ['id'], - include: [ - { - model: FalukantPredefineFirstname, - as: 'definedFirstName', - attributes: ['name'] - }, - { - model: FalukantPredefineLastname, - as: 'definedLastName', - attributes: ['name'] - } - ] - } - ] - }); - - return supervisorOffice?.holder || null; - } - - async applyForChurchPosition(hashedUserId, officeTypeId, regionId) { - // Bewerbung für eine kirchliche Position - const user = await getFalukantUserOrFail(hashedUserId); - const character = await FalukantCharacter.findOne({ - where: { userId: user.id }, - attributes: ['id', 'regionId'] - }); - if (!character) { - throw new Error('Character not found'); - } - - // Prüfe, ob bereits eine Bewerbung für diese Position existiert - const existingApplication = await ChurchApplication.findOne({ - where: { - characterId: character.id, - officeTypeId: officeTypeId, - status: 'pending' - } - }); - if (existingApplication) { - throw new Error('Application already exists'); - } - - // Finde Vorgesetzten - const officeType = await ChurchOfficeType.findByPk(officeTypeId); - if (!officeType) { - throw new Error('Office type not found'); - } - - const supervisor = await this.findSupervisorForPosition(officeType, regionId); - if (!supervisor) { - throw new Error('Supervisor not found'); - } - - // Erstelle Bewerbung - const application = await ChurchApplication.create({ - officeTypeId: officeTypeId, - characterId: character.id, - regionId: regionId, - supervisorId: supervisor.id, - status: 'pending' - }); - - // Benachrichtige Vorgesetzten - const supervisorUser = await FalukantUser.findOne({ - where: { id: supervisor.userId }, - attributes: ['id'] - }); - if (supervisorUser) { - await notifyUser(supervisorUser.id, { - tr: 'falukant.church.application.received', - characterId: character.id, - officeTypeId: officeTypeId - }); - } - - return application; - } - - async getSupervisedApplications(hashedUserId) { - // Liefert alle Bewerbungen, über die der User als Vorgesetzter entscheiden kann - const user = await getFalukantUserOrFail(hashedUserId); - const character = await FalukantCharacter.findOne({ - where: { userId: user.id }, - attributes: ['id'] - }); - if (!character) { - return []; - } - - const applications = await ChurchApplication.findAll({ - where: { - supervisorId: character.id, - status: 'pending' - }, - include: [ - { - model: ChurchOfficeType, - as: 'officeType', - attributes: ['name', 'hierarchyLevel'] - }, - { - model: RegionData, - as: 'region', - attributes: ['name'] - }, - { - model: FalukantCharacter, - as: 'applicant', - attributes: ['id', 'gender', 'age'], - include: [ - { - model: FalukantPredefineFirstname, - as: 'definedFirstName', - attributes: ['name'] - }, - { - model: FalukantPredefineLastname, - as: 'definedLastName', - attributes: ['name'] - }, - { - model: TitleOfNobility, - as: 'nobleTitle', - attributes: ['labelTr'] - } - ] - } - ], - order: [['createdAt', 'DESC']] - }); - - return applications.map(app => { - const a = app.get({ plain: true }); - return { - id: a.id, - officeType: { - name: a.officeType.name, - hierarchyLevel: a.officeType.hierarchyLevel - }, - region: { - name: a.region.name - }, - applicant: { - id: a.applicant.id, - name: `${a.applicant.definedFirstName?.name || ''} ${a.applicant.definedLastName?.name || ''}`.trim(), - gender: a.applicant.gender, - age: a.applicant.age, - title: a.applicant.nobleTitle?.labelTr - }, - createdAt: a.createdAt - }; - }); - } - - async decideOnChurchApplication(hashedUserId, applicationId, decision) { - // Entscheidung über eine Bewerbung (approve/reject) - const user = await getFalukantUserOrFail(hashedUserId); - const character = await FalukantCharacter.findOne({ - where: { userId: user.id }, - attributes: ['id'] - }); - if (!character) { - throw new Error('Character not found'); - } - - const application = await ChurchApplication.findOne({ - where: { - id: applicationId, - supervisorId: character.id, - status: 'pending' - }, - include: [ - { - model: ChurchOfficeType, - as: 'officeType' - } - ] - }); - - if (!application) { - throw new Error('Application not found or already processed'); - } - - if (decision === 'approve') { - // Prüfe, ob noch Platz verfügbar ist - const filledPositions = await ChurchOffice.count({ - where: { - officeTypeId: application.officeTypeId, - regionId: application.regionId - } - }); - - if (filledPositions >= application.officeType.seatsPerRegion) { - throw new Error('No available seats'); - } - - // Erstelle kirchliches Amt - await ChurchOffice.create({ - officeTypeId: application.officeTypeId, - characterId: application.characterId, - regionId: application.regionId, - supervisorId: character.id - }); - - // Aktualisiere Bewerbung - application.status = 'approved'; - application.decisionDate = new Date(); - await application.save(); - - // Benachrichtige Bewerber - const applicantCharacter = await FalukantCharacter.findByPk(application.characterId); - if (applicantCharacter && applicantCharacter.userId) { - await notifyUser(applicantCharacter.userId, { - tr: 'falukant.church.application.approved', - officeTypeId: application.officeTypeId - }); - } - - // Wenn User bereits ein niedrigeres Amt hatte, entferne es - const lowerOffice = await ChurchOffice.findOne({ - where: { - characterId: application.characterId, - officeTypeId: { - [Op.ne]: application.officeTypeId - } - }, - include: [{ - model: ChurchOfficeType, - as: 'type', - attributes: ['hierarchyLevel'] - }] - }); - - if (lowerOffice && lowerOffice.type.hierarchyLevel < application.officeType.hierarchyLevel) { - await lowerOffice.destroy(); - } - - } else if (decision === 'reject') { - application.status = 'rejected'; - application.decisionDate = new Date(); - await application.save(); - - // Benachrichtige Bewerber - const applicantCharacter = await FalukantCharacter.findByPk(application.characterId); - if (applicantCharacter && applicantCharacter.userId) { - await notifyUser(applicantCharacter.userId, { - tr: 'falukant.church.application.rejected', - officeTypeId: application.officeTypeId - }); - } - } else { - throw new Error('Invalid decision'); - } - - return application; - } - - async hasChurchCareer(hashedUserId) { - // Prüft, ob der User eine kirchliche Karriere hat - const user = await getFalukantUserOrFail(hashedUserId); - const character = await FalukantCharacter.findOne({ - where: { userId: user.id }, - attributes: ['id'] - }); - if (!character) { - return false; - } - - const churchOffice = await ChurchOffice.findOne({ - where: { characterId: character.id } - }); - - return !!churchOffice; - } } export default new FalukantService(); @@ -6674,10 +4699,8 @@ async function enrichNotificationsWithCharacterNames(notifications) { if (!Array.isArray(notifications) || notifications.length === 0) return; const charIds = new Set(); - const firstNameIds = new Set(); - const lastNameIds = new Set(); - // recursive collector that extracts any character id fields and name IDs + // recursive collector that extracts any character id fields function collectIds(obj) { if (!obj) return; if (Array.isArray(obj)) { @@ -6691,15 +4714,6 @@ async function enrichNotificationsWithCharacterNames(notifications) { charIds.add(Number(v)); continue; } - // Sammle first_name und last_name IDs - if (k === 'character_first_name' && !isNaN(Number(v))) { - firstNameIds.add(Number(v)); - continue; - } - if (k === 'character_last_name' && !isNaN(Number(v))) { - lastNameIds.add(Number(v)); - continue; - } if (k === 'character' && typeof v === 'object') { if (v.id) charIds.add(Number(v.id)); if (v.character_id) charIds.add(Number(v.character_id)); @@ -6724,10 +4738,7 @@ async function enrichNotificationsWithCharacterNames(notifications) { // parse n.effects if present try { if (n.effects) { - let eff = n.effects; - if (typeof eff === 'string') { - eff = JSON.parse(eff); - } + const eff = typeof n.effects === 'string' && n.effects.trim().startsWith('{') ? JSON.parse(n.effects) : n.effects; collectIds(eff); } } catch (err) { /* ignore */ } @@ -6737,29 +4748,18 @@ async function enrichNotificationsWithCharacterNames(notifications) { if (n.characterId) charIds.add(Number(n.characterId)); } - // Batch load first names and last names - const [firstNames, lastNames] = await Promise.all([ - firstNameIds.size > 0 - ? FalukantPredefineFirstname.findAll({ where: { id: { [Op.in]: Array.from(firstNameIds) } }, attributes: ['id', 'name'] }) - : [], - lastNameIds.size > 0 - ? FalukantPredefineLastname.findAll({ where: { id: { [Op.in]: Array.from(lastNameIds) } }, attributes: ['id', 'name'] }) - : [] - ]); - const firstNameMap = new Map(firstNames.map(fn => [fn.id, fn.name])); - const lastNameMap = new Map(lastNames.map(ln => [ln.id, ln.name])); - const ids = Array.from(charIds).filter(Boolean); + if (!ids.length) return; // Batch load characters and their display names - const characters = ids.length > 0 ? await FalukantCharacter.findAll({ + const characters = await FalukantCharacter.findAll({ where: { id: { [Op.in]: ids } }, include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] } ], attributes: ['id'] - }) : []; + }); const nameMap = new Map(); for (const c of characters) { @@ -6794,60 +4794,27 @@ async function enrichNotificationsWithCharacterNames(notifications) { return null; } - // Helper: resolve name IDs in an object and build characterName - function resolveNameFromObject(obj) { - if (!obj || typeof obj !== 'object') return null; - const firstNameId = obj.character_first_name; - const lastNameId = obj.character_last_name; - if (firstNameId || lastNameId) { - const first = firstNameMap.get(Number(firstNameId)) || ''; - const last = lastNameMap.get(Number(lastNameId)) || ''; - const name = `${first} ${last}`.trim(); - if (name) return name; - } - return null; - } - // Attach resolved name to notifications (set both characterName and character_name) for (const n of notifications) { let foundId = null; - let resolvedName = null; - try { if (typeof n.tr === 'string' && n.tr.trim().startsWith('{')) { const parsed = JSON.parse(n.tr); foundId = findFirstId(parsed) || foundId; - // Versuche Namen aus first_name/last_name IDs aufzulösen - resolvedName = resolveNameFromObject(parsed.value || parsed) || resolvedName; } } catch (err) { /* ignore */ } try { if (n.effects) { - let eff = n.effects; - if (typeof eff === 'string') { - eff = JSON.parse(eff); - } + const eff = typeof n.effects === 'string' && n.effects.trim().startsWith('{') ? JSON.parse(n.effects) : n.effects; foundId = findFirstId(eff) || foundId; - // Auch in effects nach Namen suchen - if (Array.isArray(eff)) { - for (const e of eff) { - resolvedName = resolveNameFromObject(e) || resolvedName; - } - } else { - resolvedName = resolveNameFromObject(eff) || resolvedName; - } } } catch (err) { /* ignore */ } if (!foundId && n.character_id) foundId = Number(n.character_id); if (!foundId && n.characterId) foundId = Number(n.characterId); - // Priorität: aufgelöster Name aus IDs > Name aus character_id > Fallback - if (resolvedName) { - n.characterName = resolvedName; - n.character_name = resolvedName; - } else if (foundId && nameMap.has(Number(foundId))) { + if (foundId && nameMap.has(Number(foundId))) { const resolved = nameMap.get(Number(foundId)); n.characterName = resolved; n.character_name = resolved; diff --git a/backend/utils/falukant/initializeFalukantPredefines.js b/backend/utils/falukant/initializeFalukantPredefines.js index 9c6e7d3..2f9679a 100644 --- a/backend/utils/falukant/initializeFalukantPredefines.js +++ b/backend/utils/falukant/initializeFalukantPredefines.js @@ -282,7 +282,11 @@ async function initializeFalukantProducts() { { labelTr: 'ox', category: 5, productionTime: 5, sellCost: 60 }, ]; - const productsToInsert = baseProducts; + const productsToInsert = baseProducts.map(p => ({ + ...p, + sellCostMinNeutral: Math.ceil(p.sellCost * factorMin), + sellCostMaxNeutral: Math.ceil(p.sellCost * factorMax), + })); await ProductType.bulkCreate(productsToInsert, { ignoreDuplicates: true, diff --git a/frontend/src/components/falukant/DirectorInfo.vue b/frontend/src/components/falukant/DirectorInfo.vue index 68c0d39..446081c 100644 --- a/frontend/src/components/falukant/DirectorInfo.vue +++ b/frontend/src/components/falukant/DirectorInfo.vue @@ -104,14 +104,6 @@ /> {{ $t('falukant.branch.director.starttransport') }} -
{{ $t('falukant.branch.sale.noInventory') }}
@@ -190,9 +183,6 @@ data() { return { inventory: [], - sellingItemIndex: null, - sellingAll: false, - sellAllStatus: null, transportForm: { sourceKey: null, vehicleTypeId: null, @@ -261,6 +251,13 @@ 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(); @@ -277,22 +274,12 @@ } }, 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) { @@ -313,11 +300,10 @@ currentPrice: currentPrice } }); - // Vue3: direkte Zuweisung ist reaktiv - item.betterPrices = Array.isArray(data) ? data : []; + this.$set(item, 'betterPrices', data || []); } catch (error) { console.error(`Error loading prices for item ${itemKey}:`, error); - item.betterPrices = []; + this.$set(item, 'betterPrices', []); } finally { this.loadingPrices.delete(itemKey); } @@ -334,61 +320,23 @@ maximumFractionDigits: 2, }).format(price); }, - async sellItem(index) { - if (this.sellingItemIndex !== null || this.sellingAll) return; - + sellItem(index) { const item = this.inventory[index]; const quantityToSell = item.sellQuantity || item.totalQuantity; - this.sellingItemIndex = index; - - try { - await apiClient.post(`/api/falukant/sell`, { - branchId: this.branchId, - productId: item.product.id, - quantity: quantityToSell, - quality: item.quality, - }); - // UI sofort freigeben (Label/Disabled zurücksetzen), dann Inventory refreshen - this.sellingItemIndex = null; - await this.loadInventory(); - } catch (error) { + apiClient.post(`/api/falukant/sell`, { + branchId: this.branchId, + productId: item.product.id, + quantity: quantityToSell, + quality: item.quality, + }).catch(() => { alert(this.$t('falukant.branch.sale.sellError')); - } finally { - this.sellingItemIndex = null; - } + }); }, - 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); - } + sellAll() { + apiClient.post(`/api/falukant/sell/all`, { branchId: this.branchId }) + .catch(() => { + alert(this.$t('falukant.branch.sale.sellAllError')); + }); }, inventoryOptions() { return this.inventory.map((item, index) => ({ @@ -627,11 +575,11 @@ cursor: help; } .city-price-green { - background-color: var(--color-primary-green); + background-color: #90EE90; color: #000; } .city-price-orange { - background-color: var(--color-primary-orange); + background-color: #FFA500; color: #000; } .city-price-red { @@ -642,19 +590,5 @@ 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; - } + \ No newline at end of file diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index e4accf4..e6d532a 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -32,9 +32,8 @@ }, "notifications": { "notify_election_created": "Es wurde eine neue Wahl ausgeschrieben.", - "notify_office_filled": "Ein politisches Amt wurde neu besetzt.", "production": { - "overproduction": "Überproduktion: Deine Produktion liegt {value} Einheiten über dem Bedarf{branch_info}." + "overproduction": "Überproduktion: Deine Produktion liegt {value}% über dem Bedarf." }, "transport": { "waiting": "Transport wartet" @@ -136,14 +135,6 @@ "store": "Verkauf", "fullstack": "Produktion mit Verkauf" } - }, - "heirSelection": { - "title": "Charakter verloren - Erben auswählen", - "description": "Dein Charakter wurde durch einen Fehler verloren. Bitte wähle einen Erben aus deiner Hauptregion aus, um fortzufahren.", - "loading": "Lade mögliche Erben...", - "noHeirs": "Es wurden keine passenden Erben gefunden.", - "select": "Als Erben wählen", - "error": "Fehler beim Auswählen des Erben." } }, "titles": { @@ -241,7 +232,6 @@ "produce": "Darf produzieren", "sell": "Darf verkaufen", "starttransport": "Darf Transporte veranlassen", - "repairVehicles": "Darf Fahrzeuge reparieren", "emptyTransport": { "title": "Transport ohne Produkte", "description": "Bewege Transportmittel von dieser Niederlassung zu einer anderen, um sie besser zu nutzen.", @@ -270,10 +260,6 @@ "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", @@ -321,10 +307,7 @@ "current": "Laufende Produktionen", "product": "Produkt", "remainingTime": "Verbleibende Zeit (Sekunden)", - "noProductions": "Keine laufenden Produktionen.", - "status": "Status", - "sleep": "Zurückgestellt", - "active": "Aktiv" + "noProductions": "Keine laufenden Produktionen." }, "columns": { "city": "Stadt", @@ -595,7 +578,6 @@ "Production cost": "Produktionskosten", "Sell all products": "Alle Produkte verkauft", "sell products": "Produkte verkauft", - "taxFromSaleProduct": "Steuer aus Verkauf: {product}", "director starts production": "Direktor beginnt Produktion", "director payed out": "Direktorgehalt ausgezahlt", "Buy storage (type: field)": "Lagerplatz gekauft (Typ: Feld)", @@ -614,9 +596,6 @@ "new nobility title": "Neuer Adelstitel", "partyOrder": "Fest bestellt", "renovation_all": "Haus komplett renoviert", - "reputationAction": { - "school_funding": "Sozialstatus: Schule/Lehrstuhl finanziert" - }, "health": { "pill": "Gesundheitsmaßnahme: Tablette", "doctor": "Gesundheitsmaßnahme: Arztbesuch", @@ -759,8 +738,7 @@ "reputation": { "title": "Reputation", "overview": { - "title": "Übersicht", - "current": "Deine aktuelle Reputation" + "title": "Übersicht" }, "party": { "title": "Feste", @@ -799,34 +777,6 @@ "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": { @@ -838,58 +788,6 @@ } }, "church": { - "title": "Kirche", - "tabs": { - "current": "Aktuelle Positionen", - "available": "Verfügbare Positionen", - "applications": "Bewerbungen" - }, - "current": { - "office": "Amt", - "region": "Region", - "holder": "Inhaber", - "supervisor": "Vorgesetzter", - "none": "Keine aktuellen Positionen vorhanden." - }, - "available": { - "office": "Amt", - "region": "Region", - "supervisor": "Vorgesetzter", - "seats": "Verfügbare Plätze", - "action": "Aktion", - "apply": "Bewerben", - "applySuccess": "Bewerbung erfolgreich eingereicht.", - "applyError": "Fehler beim Einreichen der Bewerbung.", - "none": "Keine verfügbaren Positionen." - }, - "applications": { - "office": "Amt", - "region": "Region", - "applicant": "Bewerber", - "date": "Datum", - "action": "Aktion", - "approve": "Annehmen", - "reject": "Ablehnen", - "approveSuccess": "Bewerbung angenommen.", - "rejectSuccess": "Bewerbung abgelehnt.", - "decideError": "Fehler bei der Entscheidung.", - "none": "Keine Bewerbungen vorhanden." - }, - "offices": { - "village-priest": "Dorfgeistlicher", - "parish-priest": "Pfarrer", - "dean": "Dekan", - "archdeacon": "Erzdiakon", - "bishop": "Bischof", - "archbishop": "Erzbischof", - "cardinal": "Kardinal", - "pope": "Papst" - }, - "application": { - "received": "Neue Bewerbung erhalten", - "approved": "Bewerbung angenommen", - "rejected": "Bewerbung abgelehnt" - }, "title": "Kirche", "baptism": { "title": "Taufen", @@ -985,9 +883,6 @@ "success": "Erfolg", "selectMeasure": "Maßnahme", "perform": "Durchführen", - "errors": { - "tooClose": "Aktionen zu dicht hintereinander (maximal 1× pro 24 Stunden)." - }, "measures": { "pill": "Tablette", "doctor": "Arztbesuch", diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json index 22898c1..fd7ed8f 100644 --- a/frontend/src/i18n/locales/en/falukant.json +++ b/frontend/src/i18n/locales/en/falukant.json @@ -18,9 +18,8 @@ }, "notifications": { "notify_election_created": "A new election has been scheduled.", - "notify_office_filled": "A political office has been filled.", "production": { - "overproduction": "Overproduction: your production is {value} units above demand{branch_info}." + "overproduction": "Overproduction: your production is {value}% above demand." }, "transport": { "waiting": "Transport waiting" @@ -101,12 +100,6 @@ "bad": "Bad", "very_bad": "Very bad" }, - "healthview": { - "title": "Health", - "errors": { - "tooClose": "Actions too close together (max once per 24 hours)." - } - }, "moneyHistory": { "title": "Money history", "filter": "Filter", @@ -123,7 +116,6 @@ "Production cost": "Production cost", "Sell all products": "Sell all products", "sell products": "Sell products", - "taxFromSaleProduct": "Tax from product sale: {product}", "director starts production": "Director starts production", "director payed out": "Director salary paid out", "Buy storage (type: field)": "Bought storage (type: field)", @@ -142,9 +134,6 @@ "new nobility title": "New title of nobility", "partyOrder": "Party ordered", "renovation_all": "House fully renovated", - "reputationAction": { - "school_funding": "Social status: funded a school/chair" - }, "health": { "pill": "Health measure: pill", "doctor": "Health measure: doctor", @@ -174,8 +163,7 @@ }, "director": { "income": "Income", - "incomeUpdated": "Salary has been successfully updated.", - "repairVehicles": "May repair vehicles" + "incomeUpdated": "Salary has been successfully updated." }, "vehicles": { "cargo_cart": "Cargo cart", @@ -193,31 +181,8 @@ "storage": "Storage", "transport": "Transport", "taxes": "Taxes" - }, - "production": { - "title": "Production", - "info": "Details about production in the branch.", - "selectProduct": "Select product", - "quantity": "Quantity", - "storageAvailable": "Free storage", - "cost": "Cost", - "duration": "Duration", - "revenue": "Revenue", - "start": "Start production", - "success": "Production started successfully!", - "error": "Error starting production.", - "minutes": "Minutes", - "ending": "Completed:", - "time": "Time", - "current": "Running productions", - "product": "Product", - "remainingTime": "Remaining time (seconds)", - "noProductions": "No running productions.", - "status": "Status", - "sleep": "Suspended", - "active": "Active" - }, - "taxes": { + } + ,"taxes": { "title": "Taxes", "loading": "Loading tax data...", "loadingError": "Failed to load tax data: {error}", @@ -230,80 +195,9 @@ } } }, - "overview": { - "title": "Falukant - Overview", - "metadata": { - "title": "Personal", - "name": "Name", - "money": "Wealth", - "age": "Age", - "mainbranch": "Home City", - "nobleTitle": "Status" - }, - "productions": { - "title": "Productions" - }, - "stock": { - "title": "Stock" - }, - "branches": { - "title": "Branches", - "level": { - "production": "Production", - "store": "Store", - "fullstack": "Production with Store" - } - }, - "heirSelection": { - "title": "Character Lost - Select Heir", - "description": "Your character was lost due to an error. Please select an heir from your main region to continue.", - "loading": "Loading potential heirs...", - "noHeirs": "No suitable heirs were found.", - "select": "Select as Heir", - "error": "Error selecting heir." - } - }, "nobility": { "cooldown": "You can only advance again on {date}." }, - "reputation": { - "title": "Reputation", - "overview": { - "title": "Overview", - "current": "Your current reputation" - }, - "party": { - "title": "Parties" - }, - "actions": { - "title": "Actions", - "description": "These actions let you gain reputation. The more often you repeat the same action, the less reputation it yields (independent of cost).", - "action": "Action", - "cost": "Cost", - "gain": "Reputation", - "timesUsed": "Times used", - "execute": "Execute", - "running": "Running...", - "none": "No actions available.", - "dailyLimit": "Available today: {remaining} / {cap} reputation (from actions).", - "cooldown": "Next social status action available in about {minutes} minutes.", - "success": "Action successful! Reputation +{gain}, cost {cost}.", - "successSimple": "Action successful!", - "type": { - "library_donation": "Donate to a library", - "orphanage_build": "Build an orphanage", - "statue_build": "Erect a statue", - "hospital_donation": "Found a hospital/infirmary", - "school_funding": "Fund a school/chair", - "well_build": "Build a well/waterworks", - "bridge_build": "Fund roads/bridges", - "soup_kitchen": "Organize a soup kitchen", - "patronage": "Arts & patronage", - "church_hospice": "Hospice/church donation", - "scholarships": "Fund scholarships" - } - } - }, "branchProduction": { "storageAvailable": "Free storage" }, @@ -377,76 +271,6 @@ "assessor": "Assessor" } }, - "church": { - "title": "Church", - "tabs": { - "current": "Current Positions", - "available": "Available Positions", - "applications": "Applications" - }, - "current": { - "office": "Office", - "region": "Region", - "holder": "Holder", - "supervisor": "Supervisor", - "none": "No current positions available." - }, - "available": { - "office": "Office", - "region": "Region", - "supervisor": "Supervisor", - "seats": "Available Seats", - "action": "Action", - "apply": "Apply", - "applySuccess": "Application submitted successfully.", - "applyError": "Error submitting application.", - "none": "No available positions." - }, - "applications": { - "office": "Office", - "region": "Region", - "applicant": "Applicant", - "date": "Date", - "action": "Action", - "approve": "Approve", - "reject": "Reject", - "approveSuccess": "Application approved.", - "rejectSuccess": "Application rejected.", - "decideError": "Error making decision.", - "none": "No applications available." - }, - "offices": { - "village-priest": "Village Priest", - "parish-priest": "Parish Priest", - "dean": "Dean", - "archdeacon": "Archdeacon", - "bishop": "Bishop", - "archbishop": "Archbishop", - "cardinal": "Cardinal", - "pope": "Pope" - }, - "application": { - "received": "New application received", - "approved": "Application approved", - "rejected": "Application rejected" - }, - "baptism": { - "title": "Baptism", - "table": { - "name": "First Name", - "gender": "Gender", - "age": "Age", - "baptise": "Baptize (50)", - "newName": "Suggest Name" - }, - "gender": { - "male": "Boy", - "female": "Girl" - }, - "success": "The child has been baptized.", - "error": "The child could not be baptized." - } - }, "family": { "children": { "title": "Children", diff --git a/frontend/src/views/falukant/BranchView.vue b/frontend/src/views/falukant/BranchView.vue index c017bd3..5ccae65 100644 --- a/frontend/src/views/falukant/BranchView.vue +++ b/frontend/src/views/falukant/BranchView.vue @@ -572,49 +572,27 @@ export default { return; } - if (!this.products || this.products.length === 0) { - this.productPricesCache = {}; - return; - } - - // OPTIMIERUNG: Lade alle Preise in einem Batch-Request - try { - const productIds = this.products.map(p => p.id).join(','); - const { data } = await apiClient.get('/api/falukant/products/prices-in-region-batch', { - params: { - productIds: productIds, - regionId: this.selectedBranch.regionId - } - }); - this.productPricesCache = data || {}; - } catch (error) { - console.error('Error loading prices in batch:', error); - // Fallback: Lade Preise einzeln (aber parallel) - const pricePromises = this.products.map(async (product) => { - try { - const { data } = await apiClient.get('/api/falukant/products/price-in-region', { - params: { - productId: product.id, - regionId: this.selectedBranch.regionId - } - }); - return { productId: product.id, price: data.price }; - } catch (err) { - console.error(`Error loading price for product ${product.id}:`, err); - // Fallback auf Standard-Berechnung - const knowledgeFactor = product.knowledges?.[0]?.knowledge || 0; - const maxPrice = product.sellCost; - const minPrice = maxPrice * 0.6; - return { productId: product.id, price: minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100) }; - } - }); - - const results = await Promise.all(pricePromises); - this.productPricesCache = {}; - results.forEach(({ productId, price }) => { - this.productPricesCache[productId] = price; - }); + // Lade Preise für alle Produkte in der aktuellen Region + const prices = {}; + for (const product of this.products) { + try { + const { data } = await apiClient.get('/api/falukant/products/price-in-region', { + params: { + productId: product.id, + regionId: this.selectedBranch.regionId + } + }); + prices[product.id] = data.price; + } catch (error) { + console.error(`Error loading price for product ${product.id}:`, error); + // Fallback auf Standard-Berechnung + const knowledgeFactor = product.knowledges?.[0]?.knowledge || 0; + const maxPrice = product.sellCost; + const minPrice = maxPrice * 0.6; + prices[product.id] = minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100); + } } + this.productPricesCache = prices; }, formatPercent(value) { @@ -714,10 +692,7 @@ export default { }, conditionLabel(value) { - // 0 ist ein gültiger Zustand (z.B. komplett kaputt) und darf nicht als "Unbekannt" enden. - if (value === null || value === undefined) return 'Unbekannt'; - const v = Number(value); - if (!Number.isFinite(v)) return 'Unbekannt'; + const v = Number(value) || 0; if (v >= 95) return 'Ausgezeichnet'; // 95–100 if (v >= 72) return 'Sehr gut'; // 72–94 if (v >= 54) return 'Gut'; // 54–71 @@ -725,7 +700,7 @@ export default { if (v >= 22) return 'Schlecht'; // 22–38 if (v >= 6) return 'Sehr schlecht'; // 6–21 if (v >= 1) return 'Katastrophal'; // 1–5 - return 'Katastrophal'; // 0 oder kleiner + return 'Unbekannt'; }, speedLabel(value) { @@ -1039,15 +1014,12 @@ export default { }); await this.loadVehicles(); this.closeRepairAllVehiclesDialog(); - // Statt JS-alert: Dialog schließen und MessageDialog anzeigen - this.$root.$refs.messageDialog?.open('tr:falukant.branch.transport.repairAllSuccess'); + alert(this.$t('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'); - // Bestätigungsdialog ebenfalls schließen und Fehler im MessageDialog anzeigen - this.closeRepairAllVehiclesDialog(); - this.$root.$refs.messageDialog?.open(String(errorMessage), this.$t('error.title')); + alert(errorMessage); } }, @@ -1104,15 +1076,12 @@ export default { await apiClient.post(`/api/falukant/vehicles/${this.repairVehicleDialog.vehicle.id}/repair`); await this.loadVehicles(); this.closeRepairVehicleDialog(); - // Statt JS-alert: Dialog schließen und MessageDialog anzeigen - this.$root.$refs.messageDialog?.open('tr:falukant.branch.transport.repairSuccess'); + alert(this.$t('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'); - // Bestätigungsdialog ebenfalls schließen und Fehler im MessageDialog anzeigen - this.closeRepairVehicleDialog(); - this.$root.$refs.messageDialog?.open(String(errorMessage), this.$t('error.title')); + alert(errorMessage); } }, }, diff --git a/frontend/src/views/falukant/MoneyHistoryView.vue b/frontend/src/views/falukant/MoneyHistoryView.vue index 6e167f5..fda9101 100644 --- a/frontend/src/views/falukant/MoneyHistoryView.vue +++ b/frontend/src/views/falukant/MoneyHistoryView.vue @@ -67,28 +67,12 @@ export default { currentPage: 1, totalPages: 1, }, - productsById: {}, }; }, async mounted() { - await Promise.all([this.loadProducts(), this.fetchMoneyHistory(1)]); + await this.fetchMoneyHistory(1); }, methods: { - async loadProducts() { - try { - const { data } = await apiClient.get('/api/falukant/products'); - const map = {}; - for (const p of (data || [])) { - if (p && p.id != null && p.labelTr) { - map[String(p.id)] = p.labelTr; - } - } - this.productsById = map; - } catch (e) { - console.error('Error loading products for money history', e); - this.productsById = {}; - } - }, async fetchMoneyHistory(page) { try { const response = await apiClient.post('/api/falukant/moneyhistory', { @@ -101,25 +85,6 @@ export default { } }, translateActivity(activity) { - try { - const raw = String(activity ?? ''); - // Handle legacy format: "tax from sale product 3" - const m = raw.match(/^tax\s+from\s+sale\s+product\s+(\d+)$/i); - if (m && m[1]) { - const id = m[1]; - const labelTr = this.productsById[String(id)]; - const productName = labelTr ? this.$t(`falukant.product.${labelTr}`) : `#${id}`; - return this.$t('falukant.moneyHistory.activities.taxFromSaleProduct', { product: productName }); - } - // New/structured format: "taxFromSaleProduct.