From 71748f6aa06518f875184d325ed51fe032739d14 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Mon, 26 Jan 2026 16:03:48 +0100 Subject: [PATCH] Refactor SaleSection component: Simplify sell item and sell all logic, remove unnecessary state management, and improve UI feedback. Update translations and clean up unused code in i18n files. Optimize price loading in BranchView and remove legacy product loading in MoneyHistoryView. Streamline PoliticsView by removing own character ID handling and related logic. --- backend/controllers/falukantController.js | 34 - backend/models/falukant/data/region.js | 13 +- backend/models/falukant/data/stock.js | 3 +- backend/models/falukant/type/product.js | 11 + backend/routers/falukantRouter.js | 11 - backend/services/falukantService.js | 3045 +++-------------- .../falukant/initializeFalukantPredefines.js | 6 +- .../src/components/falukant/DirectorInfo.vue | 15 +- .../components/falukant/MessagesDialog.vue | 933 ++--- .../src/components/falukant/SaleSection.vue | 122 +- frontend/src/i18n/locales/de/falukant.json | 111 +- frontend/src/i18n/locales/en/falukant.json | 184 +- frontend/src/views/falukant/BranchView.vue | 83 +- .../src/views/falukant/MoneyHistoryView.vue | 37 +- frontend/src/views/falukant/PoliticsView.vue | 29 +- 15 files changed, 822 insertions(+), 3815 deletions(-) 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') }} -
@@ -181,7 +173,7 @@
- + @@ -794,62 +346,21 @@ export default { padding: 0; margin: 0; } - -.messages > li { - border: 1px solid #7BBE55; - margin-bottom: 0.25em; - padding: 0.5em; -} - -.messages > li.unread { - font-weight: bold; -} - -.messages > li .body { - display: flex; - flex-direction: column; - gap: 0.25em; -} - -.messages > li .notification-title { - font-weight: bold; - font-size: 1.05em; -} - -.messages > li .notification-description { - color: #555; -} - -.messages > li .footer { - color: var(--color-primary-orange); - font-size: 0.8em; - margin-top: 0.3em; - display: flex; -} - -.messages > li .footer span:first-child { - flex: 1; -} - -.empty { - text-align: center; - color: #777; - padding: 1em; -} - +.messages > li { border: 1px solid #7BBE55; margin-bottom: .25em; padding: .5em; } +.messages > li.unread { font-weight: bold; } +.messages > li .body { display: flex; flex-direction: column; gap: 0.25em; } +.messages > li .notification-title { font-weight: bold; font-size: 1.05em; } +.messages > li .notification-description { color: #555; } +.messages > li .footer { color: #f9a22c; font-size: .8em; margin-top: .3em; display: flex; } +.messages > li .footer span:first-child { flex: 1; } +.empty { text-align: center; color: #777; padding: 1em; } .pagination { display: flex; justify-content: center; - gap: 0.5em; - margin-top: 0.5em; -} - -.pagination button { - padding: 0.25em 0.6em; -} - -.pagination input[type="number"] { - width: 4em; - text-align: right; + gap: .5em; + margin-top: .5em; } +.pagination button { padding: .25em .6em; } +.pagination input[type="number"] { width: 4em; text-align: right; } + diff --git a/frontend/src/components/falukant/SaleSection.vue b/frontend/src/components/falukant/SaleSection.vue index 7ab6103..c481234 100644 --- a/frontend/src/components/falukant/SaleSection.vue +++ b/frontend/src/components/falukant/SaleSection.vue @@ -20,10 +20,8 @@ {{ item.quality }} {{ item.totalQuantity }} - - + +
@@ -38,12 +36,7 @@ - -
- {{ sellAllStatus.message }} -
+

{{ $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." - if (raw.startsWith('taxFromSaleProduct.')) { - const labelTr = raw.substring('taxFromSaleProduct.'.length); - const productName = labelTr ? this.$t(`falukant.product.${labelTr}`) : labelTr; - return this.$t('falukant.moneyHistory.activities.taxFromSaleProduct', { product: productName }); - } - } catch (_) { - // ignore and fall back - } // Handle nested keys like "health.pill" -> "health.pill" const key = `falukant.moneyHistory.activities.${activity}`; const translation = this.$t(key); diff --git a/frontend/src/views/falukant/PoliticsView.vue b/frontend/src/views/falukant/PoliticsView.vue index de2214d..2303524 100644 --- a/frontend/src/views/falukant/PoliticsView.vue +++ b/frontend/src/views/falukant/PoliticsView.vue @@ -23,7 +23,7 @@ - + {{ $t(`falukant.politics.offices.${pos.officeType.name}`) }} {{ pos.region.name }} @@ -193,7 +193,6 @@ export default { elections: [], selectedCandidates: {}, selectedApplications: [], - ownCharacterId: null, loading: { current: false, openPolitics: false, @@ -210,8 +209,7 @@ export default { return this.elections.some(e => !e.voted); } }, - async mounted() { - await this.loadOwnCharacterId(); + mounted() { this.loadCurrentPositions(); }, methods: { @@ -332,24 +330,6 @@ export default { }); }, - async loadOwnCharacterId() { - try { - const { data } = await apiClient.get('/api/falukant/info'); - if (data.character && data.character.id) { - this.ownCharacterId = data.character.id; - } - } catch (err) { - console.error('Error loading own character ID', err); - } - }, - - isOwnPosition(pos) { - if (!this.ownCharacterId || !pos.character) { - return false; - } - return pos.character.id === this.ownCharacterId; - }, - async submitApplications() { try { const response = await apiClient.post( @@ -431,11 +411,6 @@ h2 { border: 1px solid #ddd; } -.politics-table tbody tr.own-position { - background-color: #e0e0e0; - font-weight: bold; -} - .loading { text-align: center; font-style: italic;