diff --git a/backend/controllers/falukantController.js b/backend/controllers/falukantController.js index 923912e..5c43c7b 100644 --- a/backend/controllers/falukantController.js +++ b/backend/controllers/falukantController.js @@ -56,10 +56,6 @@ class FalukantController { if (!page) page = 1; return this.service.moneyHistory(userId, page, filter); }); - this.moneyHistoryGraph = this._wrapWithUser((userId, req) => { - const { range } = req.body || {}; - return this.service.moneyHistoryGraph(userId, range || '24h'); - }); this.getStorage = this._wrapWithUser((userId, req) => this.service.getStorage(userId, req.params.branchId)); this.buyStorage = this._wrapWithUser((userId, req) => { const { branchId, amount, stockTypeId } = req.body; @@ -125,9 +121,6 @@ class FalukantController { }); this.getTitlesOfNobility = this._wrapWithUser((userId) => this.service.getTitlesOfNobility(userId)); - this.getReputationActions = this._wrapWithUser((userId) => this.service.getReputationActions(userId)); - this.executeReputationAction = this._wrapWithUser((userId, req) => - this.service.executeReputationAction(userId, req.body?.actionTypeId), { successStatus: 201 }); this.getHouseTypes = this._wrapWithUser((userId) => this.service.getHouseTypes(userId)); this.getMoodAffect = this._wrapWithUser((userId) => this.service.getMoodAffect(userId)); this.getCharacterAffect = this._wrapWithUser((userId) => this.service.getCharacterAffect(userId)); @@ -147,17 +140,6 @@ class FalukantController { const { characterId: childId, firstName } = req.body; return this.service.baptise(userId, childId, firstName); }); - this.getChurchOverview = this._wrapWithUser((userId) => this.service.getChurchOverview(userId)); - this.getAvailableChurchPositions = this._wrapWithUser((userId) => this.service.getAvailableChurchPositions(userId)); - this.getSupervisedApplications = this._wrapWithUser((userId) => this.service.getSupervisedApplications(userId)); - this.applyForChurchPosition = this._wrapWithUser((userId, req) => { - const { officeTypeId, regionId } = req.body; - return this.service.applyForChurchPosition(userId, officeTypeId, regionId); - }); - this.decideOnChurchApplication = this._wrapWithUser((userId, req) => { - const { applicationId, decision } = req.body; - return this.service.decideOnChurchApplication(userId, applicationId, decision); - }); this.getEducation = this._wrapWithUser((userId) => this.service.getEducation(userId)); this.sendToSchool = this._wrapWithUser((userId, req) => { @@ -170,20 +152,25 @@ class FalukantController { this.takeBankCredits = this._wrapWithUser((userId, req) => this.service.takeBankCredits(userId, req.body.height)); this.getNobility = this._wrapWithUser((userId) => this.service.getNobility(userId)); - this.advanceNobility = this._wrapWithUser((userId) => this.service.advanceNobility(userId)); - - this.getHealth = this._wrapWithUser((userId) => this.service.getHealth(userId)); - this.healthActivity = this._wrapWithUser(async (userId, req) => { + this.advanceNobility = this._wrapWithUser(async (userId) => { try { - return await this.service.healthActivity(userId, req.body.measureTr); + return await this.service.advanceNobility(userId); } catch (e) { - if (e && e.name === 'PreconditionError' && e.message === 'tooClose') { - throw { status: 412, message: 'tooClose', retryAt: e.meta?.retryAt }; + if (e && e.name === 'PreconditionError') { + if (e.message === 'nobilityTooSoon') { + throw { status: 412, message: 'nobilityTooSoon', retryAt: e.meta?.retryAt }; + } + if (e.message === 'nobilityRequirements') { + throw { status: 412, message: 'nobilityRequirements', unmet: e.meta?.unmet || [] }; + } } throw e; } }); + this.getHealth = this._wrapWithUser((userId) => this.service.getHealth(userId)); + this.healthActivity = this._wrapWithUser((userId, req) => this.service.healthActivity(userId, req.body.measureTr)); + this.getPoliticsOverview = this._wrapWithUser((userId) => this.service.getPoliticsOverview(userId)); this.getOpenPolitics = this._wrapWithUser((userId) => this.service.getOpenPolitics(userId)); this.getElections = this._wrapWithUser((userId) => this.service.getElections(userId)); @@ -200,13 +187,6 @@ class FalukantController { } return this.service.getProductPriceInRegion(userId, productId, regionId); }); - this.getAllProductPricesInRegion = this._wrapWithUser((userId, req) => { - const regionId = parseInt(req.query.regionId, 10); - if (Number.isNaN(regionId)) { - throw new Error('regionId is required'); - } - return this.service.getAllProductPricesInRegion(userId, regionId); - }); this.getProductPricesInCities = this._wrapWithUser((userId, req) => { const productId = parseInt(req.query.productId, 10); const currentPrice = parseFloat(req.query.currentPrice); @@ -216,16 +196,6 @@ class FalukantController { } return this.service.getProductPricesInCities(userId, productId, currentPrice, currentRegionId); }); - this.getProductPricesInCitiesBatch = this._wrapWithUser((userId, req) => { - const body = req.body || {}; - const items = Array.isArray(body.items) ? body.items : []; - const currentRegionId = body.currentRegionId != null ? parseInt(body.currentRegionId, 10) : null; - const valid = items.map(i => ({ - productId: parseInt(i.productId, 10), - currentPrice: parseFloat(i.currentPrice) - })).filter(i => !Number.isNaN(i.productId) && !Number.isNaN(i.currentPrice)); - return this.service.getProductPricesInCitiesBatch(userId, valid, Number.isNaN(currentRegionId) ? null : currentRegionId); - }); this.renovate = this._wrapWithUser((userId, req) => this.service.renovate(userId, req.body.element)); this.renovateAll = this._wrapWithUser((userId) => this.service.renovateAll(userId)); @@ -233,7 +203,6 @@ class FalukantController { this.getNotifications = this._wrapWithUser((userId) => this.service.getNotifications(userId)); this.getAllNotifications = this._wrapWithUser((userId, req) => this.service.getAllNotifications(userId, req.query.page, req.query.size)); this.markNotificationsShown = this._wrapWithUser((userId) => this.service.markNotificationsShown(userId), { successStatus: 202 }); - this.getDashboardWidget = this._wrapWithUser((userId) => this.service.getDashboardWidget(userId)); this.getUndergroundTargets = this._wrapWithUser((userId) => this.service.getPoliticalOfficeHolders(userId)); this.searchUsers = this._wrapWithUser((userId, req) => { @@ -308,13 +277,7 @@ class FalukantController { } catch (error) { console.error('Controller error:', error); const status = error.status && typeof error.status === 'number' ? error.status : 500; - // Wenn error ein Objekt mit status ist, alle Felder außer status übernehmen - if (error && typeof error === 'object' && error.status && typeof error.status === 'number') { - const { status: errorStatus, ...errorData } = error; - res.status(errorStatus).json({ error: error.message || errorData.message || 'Internal error', ...errorData }); - } else { - res.status(status).json({ error: error.message || 'Internal error' }); - } + res.status(status).json({ error: error.message || 'Internal error' }); } }; } diff --git a/backend/models/falukant/type/product.js b/backend/models/falukant/type/product.js index 1170edc..7393139 100644 --- a/backend/models/falukant/type/product.js +++ b/backend/models/falukant/type/product.js @@ -15,7 +15,17 @@ ProductType.init({ allowNull: false}, sellCost: { type: DataTypes.INTEGER, - allowNull: false + 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, diff --git a/backend/routers/falukantRouter.js b/backend/routers/falukantRouter.js index 2f88afc..cf3f967 100644 --- a/backend/routers/falukantRouter.js +++ b/backend/routers/falukantRouter.js @@ -11,7 +11,6 @@ router.get('/character/affect', falukantController.getCharacterAffect); router.get('/name/randomfirstname/:gender', falukantController.randomFirstName); router.get('/name/randomlastname', falukantController.randomLastName); router.get('/info', falukantController.getInfo); -router.get('/dashboard-widget', falukantController.getDashboardWidget); router.get('/branches/types', falukantController.getBranchTypes); router.get('/branches/:branch', falukantController.getBranch); router.get('/branches', falukantController.getBranches); @@ -29,7 +28,6 @@ router.get('/inventory/?:branchId', falukantController.getInventory); router.post('/sell/all', falukantController.sellAllProducts); router.post('/sell', falukantController.sellProduct); router.post('/moneyhistory', falukantController.moneyHistory); -router.post('/moneyhistory/graph', falukantController.moneyHistoryGraph); router.get('/storage/:branchId', falukantController.getStorage); router.post('/storage', falukantController.buyStorage); router.delete('/storage', falukantController.sellStorage); @@ -47,8 +45,6 @@ router.get('/family/children', falukantController.getChildren); router.post('/family/gift', falukantController.sendGift); router.get('/family', falukantController.getFamily); router.get('/nobility/titels', falukantController.getTitlesOfNobility); -router.get('/reputation/actions', falukantController.getReputationActions); -router.post('/reputation/actions', falukantController.executeReputationAction); router.get('/houses/types', falukantController.getHouseTypes); router.get('/houses/buyable', falukantController.getBuyableHouses); router.get('/houses', falukantController.getUserHouse); @@ -60,11 +56,6 @@ router.post('/party', falukantController.createParty); router.get('/party', falukantController.getParties); 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.get('/church/applications/supervised', falukantController.getSupervisedApplications); -router.post('/church/positions/apply', falukantController.applyForChurchPosition); -router.post('/church/applications/decide', falukantController.decideOnChurchApplication); router.get('/education', falukantController.getEducation); router.post('/education', falukantController.sendToSchool); router.get('/bank/overview', falukantController.getBankOverview); @@ -76,14 +67,13 @@ router.get('/health', falukantController.getHealth); router.post('/health', falukantController.healthActivity); router.get('/politics/overview', falukantController.getPoliticsOverview); router.get('/politics/open', falukantController.getOpenPolitics); -router.post('/politics/open', falukantController.applyForElections); router.get('/politics/elections', falukantController.getElections); router.post('/politics/elections', falukantController.vote); +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', falukantController.getAllProductPricesInRegion); router.get('/products/prices-in-cities', falukantController.getProductPricesInCities); -router.post('/products/prices-in-cities-batch', falukantController.getProductPricesInCitiesBatch); router.get('/branches/:branchId/taxes', falukantController.getBranchTaxes); router.get('/vehicles/types', falukantController.getVehicleTypes); router.post('/vehicles', falukantController.buyVehicles); diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index 1387127..c67f464 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -10,7 +10,6 @@ import RegionType from '../models/falukant/type/region.js'; import FalukantStock from '../models/falukant/data/stock.js'; import FalukantStockType from '../models/falukant/type/stock.js'; import TitleOfNobility from '../models/falukant/type/title_of_nobility.js'; -import TitleBenefit from '../models/falukant/type/title_benefit.js'; import Branch from '../models/falukant/data/branch.js'; import BranchType from '../models/falukant/type/branch.js'; import Production from '../models/falukant/data/production.js'; @@ -34,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'; @@ -50,10 +48,6 @@ import Credit from '../models/falukant/data/credit.js'; import TitleRequirement from '../models/falukant/type/title_requirement.js'; import HealthActivity from '../models/falukant/log/health_activity.js'; import Election from '../models/falukant/data/election.js'; -import ChurchOffice from '../models/falukant/data/church_office.js'; -import ChurchOfficeType from '../models/falukant/type/church_office_type.js'; -import ChurchApplication from '../models/falukant/data/church_application.js'; -import ChurchOfficeRequirement from '../models/falukant/predefine/church_office_requirement.js'; import PoliticalOfficeType from '../models/falukant/type/political_office_type.js'; import Candidate from '../models/falukant/data/candidate.js'; import Vote from '../models/falukant/data/vote.js'; @@ -71,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); @@ -100,15 +92,6 @@ function calcSellPrice(product, knowledgeFactor = 0) { return min + (max - min) * (knowledgeFactor / 100); } -/** Synchrone Preisberechnung, wenn worthPercent bereits bekannt ist (kein DB-Zugriff). */ -function calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent) { - if (product.sellCost === null || product.sellCost === undefined) return null; - 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) { @@ -118,11 +101,6 @@ async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPe worthPercent = townWorth?.worthPercent || 50; // Default 50% wenn nicht gefunden } - // Prüfe ob sellCost vorhanden ist - if (product.sellCost === null || product.sellCost === undefined) { - throw new Error(`Product ${product.id} has no sellCost defined`); - } - // Basispreis basierend auf regionalem worthPercent const basePrice = product.sellCost * (worthPercent / 100); @@ -168,7 +146,6 @@ async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPe async function getCumulativeTaxPercentWithExemptions(userId, regionId) { if (!regionId) return 0; - if (await hasTitleTaxExempt(userId)) return 0; // fetch user's political offices (active) and their region types const offices = await PoliticalOffice.findAll({ where: { userId }, @@ -214,81 +191,6 @@ async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPe return parseFloat(val) || 0; } - /** Standesvorteil: Steuerbefreiung für bestimmte Titel */ - async function hasTitleTaxExempt(falukantUserId) { - const char = await FalukantCharacter.findOne({ where: { userId: falukantUserId }, attributes: ['titleOfNobility'] }); - if (!char?.titleOfNobility) return false; - const benefit = await TitleBenefit.findOne({ where: { titleId: char.titleOfNobility, benefitType: 'tax_exempt' } }); - return !!benefit; - } - - /** - * Oberster Stand einer Region bekommt die Steuereinnahmen; Aufteilung auf alle Mitglieder dieses Standes. - * Returns { recipientUserIds: number[], useTreasury: boolean }. useTreasury true = an TREASURY_FALUKANT_USER_ID zahlen. - */ - async function getTaxRecipientsForRegion(regionId) { - const treasuryId = process.env.TREASURY_FALUKANT_USER_ID ? parseInt(process.env.TREASURY_FALUKANT_USER_ID, 10) : null; - const chars = await FalukantCharacter.findAll({ - where: { regionId }, - attributes: ['userId', 'titleOfNobility'], - include: [{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['id', 'level'], required: true }] - }); - if (!chars.length) return { recipientUserIds: [], useTreasury: true }; - const maxLevel = Math.max(...chars.map(c => c.nobleTitle?.level ?? 0)); - const topTitleId = chars.find(c => (c.nobleTitle?.level ?? 0) === maxLevel)?.nobleTitle?.id; - if (!topTitleId) return { recipientUserIds: [], useTreasury: true }; - const hasTaxShare = await TitleBenefit.findOne({ where: { titleId: topTitleId, benefitType: 'tax_share' } }); - if (!hasTaxShare) return { recipientUserIds: [], useTreasury: true }; - const topCharUserIds = [...new Set(chars.filter(c => c.titleOfNobility === topTitleId).map(c => c.userId).filter(Boolean))]; - if (!topCharUserIds.length) return { recipientUserIds: [], useTreasury: true }; - return { recipientUserIds: topCharUserIds, useTreasury: false }; - } - - /** Standesvorteil: Welche politischen Ämter (officeType.name) darf dieser Titel besetzen? */ - async function getAllowedOfficeTypeNamesByTitle(titleId) { - const benefits = await TitleBenefit.findAll({ - where: { titleId, benefitType: 'office_eligibility' }, - attributes: ['parameters'] - }); - const names = new Set(); - for (const b of benefits) { - const arr = b.parameters?.officeTypeNames; - if (Array.isArray(arr)) arr.forEach(n => names.add(n)); - } - return names; - } - - /** Standesvorteil: Ist dieser Festtyp (partyTypeId oder partyType.tr) für diesen Titel kostenfrei? */ - async function isPartyTypeFreeForTitle(titleId, partyTypeId, partyTypeTr) { - const benefits = await TitleBenefit.findAll({ - where: { titleId, benefitType: 'free_party_type' }, - attributes: ['parameters'] - }); - for (const b of benefits) { - const p = b.parameters || {}; - const ids = p.partyTypeIds; - const trs = p.partyTypeLabelTrs || p.partyTypeTrs; - if (Array.isArray(ids) && ids.includes(partyTypeId)) return true; - if (Array.isArray(trs) && trs.includes(partyTypeTr)) return true; - } - return false; - } - - /** Standesvorteil: Beliebtheits-Bonus 5–15 % (nur Anzeige, nicht gespeichert). Deterministisch pro Charakter. */ - async function getDisplayReputation(character) { - const base = character?.reputation ?? 0; - const benefit = await TitleBenefit.findOne({ - where: { titleId: character?.titleOfNobility, benefitType: 'reputation_bonus' }, - attributes: ['parameters'] - }); - if (!benefit?.parameters) return base; - const minP = benefit.parameters.minPercent ?? 5; - const maxP = benefit.parameters.maxPercent ?? 15; - const range = Math.max(1, maxP - minP + 1); - const bonusPercent = minP + (Math.abs(character.id) % range); - return Math.min(100, Math.round(base * (1 + bonusPercent / 100))); - } - function calculateMarriageCost(titleOfNobility, age) { const minTitle = 1; const adjustedTitle = titleOfNobility - minTitle + 1; @@ -418,6 +320,7 @@ class FalukantService extends BaseService { `; async getFalukantUserByHashedId(hashedId) { + console.log('🔍 getFalukantUserByHashedId called with hashedId:', hashedId); const user = await FalukantUser.findOne({ include: [ { model: User, as: 'user', attributes: ['username', 'hashedId'], where: { hashedId } }, @@ -427,40 +330,27 @@ class FalukantService extends BaseService { include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }, - { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr', 'id'] } + { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr', 'id'] }, + { model: CharacterTrait, as: 'traits', attributes: ['id', 'tr'] } ], attributes: ['id', 'birthdate', 'gender', 'moodId', 'health'] }, + { + model: UserHouse, + as: 'userHouse', + attributes: ['roofCondition', 'wallCondition', 'floorCondition', 'windowCondition'], + include: [ + { + model: HouseType, + as: 'houseType', + attributes: ['labelTr', 'position'] + } + ] + }, ] }); + console.log('🔍 getFalukantUserByHashedId result:', user ? 'User found' : 'User not found'); if (!user) throw new Error('User not found'); - // Load character traits in a separate query to avoid EagerLoadingError - if (user.character?.id) { - const ctRows = await FalukantCharacterTrait.findAll({ - where: { characterId: user.character.id }, - attributes: ['traitId'] - }); - const traitIds = [...new Set(ctRows.map(r => r.traitId))]; - const traits = traitIds.length - ? await CharacterTrait.findAll({ where: { id: traitIds }, attributes: ['id', 'tr'] }) - : []; - user.character.setDataValue('traits', traits); - } - // Load UserHouse (and HouseType) in separate queries to avoid EagerLoadingError - if (user.id != null) { - const userHouse = await UserHouse.findOne({ - where: { userId: user.id }, - attributes: ['roofCondition', 'wallCondition', 'floorCondition', 'windowCondition', 'houseTypeId'] - }); - if (userHouse?.houseTypeId) { - const houseType = await HouseType.findOne({ - where: { id: userHouse.houseTypeId }, - attributes: ['labelTr', 'position'] - }); - if (houseType) userHouse.setDataValue('houseType', houseType); - } - if (userHouse) user.setDataValue('userHouse', userHouse); - } return user; } @@ -497,26 +387,22 @@ class FalukantService extends BaseService { } ] }, + { + model: UserHouse, + as: 'userHouse', + include: [ + { + model: HouseType, + as: 'houseType', + 'attributes': ['labelTr', 'position'] + }, + ], + attributes: ['roofCondition'], + }, ], - attributes: ['id', 'money', 'creditAmount', 'todayCreditTaken'] + attributes: ['money', 'creditAmount', 'todayCreditTaken',] }); if (!u) throw new Error('User not found'); - // Load UserHouse and HouseType in separate queries to avoid EagerLoadingError - let userHouse = null; - if (u.id != null) { - userHouse = await UserHouse.findOne({ - where: { userId: u.id }, - attributes: ['roofCondition', 'houseTypeId'] - }); - } - if (userHouse?.houseTypeId) { - const houseType = await HouseType.findOne({ - where: { id: userHouse.houseTypeId }, - attributes: ['labelTr', 'position'] - }); - if (houseType) userHouse.setDataValue('houseType', houseType); - } - if (userHouse) u.setDataValue('userHouse', userHouse); if (u.character?.birthdate) u.character.setDataValue('age', calcAge(u.character.birthdate)); return u; } @@ -571,45 +457,39 @@ class FalukantService extends BaseService { { model: FalukantCharacter, as: 'character', - attributes: ['id', 'birthdate', 'health', 'reputation', 'titleOfNobility'], + attributes: ['birthdate', 'health'], + include: [ + { + model: Relationship, + as: 'relationshipsAsCharacter1', + required: false, + attributes: ['id', 'character2Id', 'relationshipTypeId'], + include: [{ + model: RelationshipType, + as: 'relationshipType', + attributes: ['tr'], + where: { tr: { [Op.not]: 'lover' } } + }] + }, + { + model: Relationship, + as: 'relationshipsAsCharacter2', + required: false, + attributes: ['id', 'character1Id', 'relationshipTypeId'], + include: [{ + model: RelationshipType, + as: 'relationshipType', + attributes: ['tr'], + where: { tr: { [Op.not]: 'lover' } } + }] + } + ] }, ], attributes: ['id', 'money'] }); if (!falukantUser) throw new Error('User not found'); - // Load relationships and types in separate queries to avoid EagerLoadingError - if (falukantUser.character?.id) { - const [rawRelsAs1, rawRelsAs2] = await Promise.all([ - Relationship.findAll({ - where: { character1Id: falukantUser.character.id }, - attributes: ['id', 'character2Id', 'relationshipTypeId'] - }), - Relationship.findAll({ - where: { character2Id: falukantUser.character.id }, - attributes: ['id', 'character1Id', 'relationshipTypeId'] - }) - ]); - const typeIds = [...new Set([ - ...rawRelsAs1.map(r => r.relationshipTypeId), - ...rawRelsAs2.map(r => r.relationshipTypeId) - ])].filter(Boolean); - const types = typeIds.length - ? await RelationshipType.findAll({ where: { id: typeIds, tr: { [Op.not]: 'lover' } }, attributes: ['id', 'tr'] }) - : []; - const typeMap = Object.fromEntries(types.map(t => [t.id, t])); - const attachType = (r) => { - const t = typeMap[r.relationshipTypeId]; - if (t) r.setDataValue('relationshipType', t); - return r; - }; - falukantUser.character.setDataValue('relationshipsAsCharacter1', rawRelsAs1.filter(r => typeMap[r.relationshipTypeId]).map(attachType)); - falukantUser.character.setDataValue('relationshipsAsCharacter2', rawRelsAs2.filter(r => typeMap[r.relationshipTypeId]).map(attachType)); - } if (falukantUser.character?.birthdate) falukantUser.character.setDataValue('age', calcAge(falukantUser.character.birthdate)); - if (falukantUser.character?.id) { - const displayRep = await getDisplayReputation(falukantUser.character); - falukantUser.character.setDataValue('reputationDisplay', displayRep); - } // Aggregate status additions: children counts and unread notifications try { @@ -683,13 +563,7 @@ class FalukantService extends BaseService { } async getBranches(hashedUserId) { - const startTime = Date.now(); - console.log(`[getBranches] Start für userId: ${hashedUserId}`); - const u = await getFalukantUserOrFail(hashedUserId); - const userTime = Date.now(); - console.log(`[getBranches] User geladen in ${userTime - startTime}ms`); - const bs = await Branch.findAll({ where: { falukantUserId: u.id }, include: [ @@ -714,8 +588,6 @@ class FalukantService extends BaseService { attributes: ['id', 'regionId'], order: [['branchTypeId', 'ASC']] }); - const branchesTime = Date.now(); - console.log(`[getBranches] Branches geladen (${bs.length} Stück) in ${branchesTime - userTime}ms`); // Lade Wetter explizit für alle Regionen, um sicherzustellen, dass es korrekt geladen wird const regionIds = [...new Set(bs.map(b => b.regionId))]; @@ -726,10 +598,8 @@ class FalukantService extends BaseService { ] }); const weatherMap = new Map(weathers.map(w => [w.regionId, w.weatherType?.tr || null])); - const weatherTime = Date.now(); - console.log(`[getBranches] Weather geladen in ${weatherTime - branchesTime}ms`); - const result = bs.map(b => { + return bs.map(b => { const branchJson = b.toJSON(); // Verwende das explizit geladene Wetter, falls vorhanden, sonst das aus der Include-Beziehung const weather = weatherMap.get(b.regionId) || branchJson.region?.weather?.weatherType?.tr || null; @@ -739,11 +609,6 @@ class FalukantService extends BaseService { weather: weather }; }); - - const totalTime = Date.now() - startTime; - console.log(`[getBranches] Gesamtzeit: ${totalTime}ms für ${result.length} Branches`); - - return result; } async createBranch(hashedUserId, cityId, branchTypeId) { @@ -858,32 +723,21 @@ class FalukantService extends BaseService { const vehicles = await Vehicle.findAll({ where, - attributes: ['id', 'vehicleTypeId', 'regionId', 'condition', 'availableFrom'], + include: [ + { + model: VehicleType, + as: 'type', + attributes: ['id', 'tr', 'capacity', 'transportMode', 'speed', 'buildTimeMinutes', 'cost'], + }, + { + model: Transport, + as: 'transports', + attributes: ['id', 'sourceRegionId', 'targetRegionId'], + required: false, + }, + ], order: [['availableFrom', 'ASC'], ['id', 'ASC']], }); - const vehicleIds = vehicles.map(v => v.id); - const typeIds = [...new Set(vehicles.map(v => v.vehicleTypeId))]; - const [types, transports] = await Promise.all([ - typeIds.length ? VehicleType.findAll({ - where: { id: typeIds }, - attributes: ['id', 'tr', 'capacity', 'transportMode', 'speed', 'buildTimeMinutes', 'cost'], - }) : [], - vehicleIds.length ? Transport.findAll({ - where: { vehicleId: vehicleIds }, - attributes: ['id', 'vehicleId', 'sourceRegionId', 'targetRegionId'], - }) : [], - ]); - const typeMap = Object.fromEntries(types.map(t => [t.id, t])); - const transportsByVehicle = transports.reduce((acc, t) => { - const vid = t.vehicleId; - if (!acc[vid]) acc[vid] = []; - acc[vid].push(t); - return acc; - }, {}); - for (const v of vehicles) { - v.setDataValue('type', typeMap[v.vehicleTypeId] || null); - v.setDataValue('transports', transportsByVehicle[v.id] || []); - } const now = new Date(); const branchRegionId = regionId ? parseInt(regionId, 10) : undefined; @@ -1107,33 +961,33 @@ class FalukantService extends BaseService { // Produkt-Transport oder leerer Transport (nur Fahrzeuge bewegen)? const isEmptyTransport = !productId || !quantity || quantity <= 0; - let sourceStockIds = []; + let inventory = []; let available = 0; let maxByInventory = 0; let hardMax = 0; let requested = 0; let transportCost = 0.1; // Minimale Kosten für leeren Transport - let productIdForTransport = productId; if (!isEmptyTransport) { - // Produkt-Transport: alle Stocks der Quell-Niederlassung (wie getInventory) - const sourceStocks = await FalukantStock.findAll({ where: { branchId: sourceBranch.id }, attributes: ['id'] }); - if (!sourceStocks?.length) { + // Produkt-Transport: Inventar prüfen + const stock = await FalukantStock.findOne({ where: { branchId: sourceBranch.id } }); + if (!stock) { throw new Error('Stock not found'); } - sourceStockIds = sourceStocks.map((s) => s.id); - const inventoryCheck = await Inventory.findAll({ - where: { - stockId: { [Op.in]: sourceStockIds }, - productId, - }, + inventory = await Inventory.findAll({ + where: { stockId: stock.id }, include: [ - { model: ProductType, as: 'productType', required: true, where: { id: productId }, attributes: ['id', 'sellCost'] }, + { + model: ProductType, + as: 'productType', + required: true, + where: { id: productId }, + }, ], }); - available = inventoryCheck.reduce((sum, i) => sum + i.quantity, 0); + available = inventory.reduce((sum, i) => sum + i.quantity, 0); if (available <= 0) { throw new PreconditionError('noInventory'); } @@ -1147,7 +1001,7 @@ class FalukantService extends BaseService { } // Transportkosten: 1 % des Warenwerts, mindestens 0,1 - const productType = inventoryCheck[0]?.productType; + const productType = inventory[0]?.productType; const unitValue = productType?.sellCost || 0; const totalValue = unitValue * requested; transportCost = Math.max(0.1, totalValue * 0.01); @@ -1203,28 +1057,15 @@ class FalukantService extends BaseService { } // Inventar in der Quell-Niederlassung reduzieren (nur bei Produkt-Transport) - // Innerhalb der Transaktion mit Lock laden, damit aktuelle Mengen verwendet werden - if (!isEmptyTransport && sourceStockIds.length > 0) { - const inventoryRows = await Inventory.findAll({ - where: { - stockId: { [Op.in]: sourceStockIds }, - productId: productIdForTransport, - }, - order: [['id', 'ASC']], - lock: true, // SELECT ... FOR UPDATE - transaction: tx, - }); - + if (!isEmptyTransport && inventory.length > 0) { let left = requested; - for (const inv of inventoryRows) { + for (const inv of inventory) { if (left <= 0) break; - const qty = Number(inv.quantity) || 0; - if (qty <= 0) continue; - if (qty <= left) { - left -= qty; + if (inv.quantity <= left) { + left -= inv.quantity; await inv.destroy({ transaction: tx }); } else { - await inv.update({ quantity: qty - left }, { transaction: tx }); + await inv.update({ quantity: inv.quantity - left }, { transaction: tx }); left = 0; break; } @@ -1758,22 +1599,11 @@ class FalukantService extends BaseService { 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: oberster Stand der Region oder Treasury - if (taxValue > 0) { - const { recipientUserIds, useTreasury } = await getTaxRecipientsForRegion(branch.regionId); - if (!useTreasury && recipientUserIds.length > 0) { - const share = Math.round((taxValue / recipientUserIds.length) * 100) / 100; - for (const recipientId of recipientUserIds) { - const taxResult = await updateFalukantUserMoney(recipientId, share, `Steueranteil Region (Verkauf)`, user.id); - if (!taxResult.success) throw new Error('Failed to update money for tax recipient'); - } - } else { - const treasuryId = process.env.TREASURY_FALUKANT_USER_ID; - if (treasuryId) { - 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'); - } - } + // 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) { @@ -1838,22 +1668,30 @@ class FalukantService extends BaseService { }); if (!inventory.length) return { success: true, revenue: 0 }; let total = 0; - /** regionId -> total tax amount to distribute in that region */ - const taxPerRegion = new Map(); 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(falukantUser.id, 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; - total += itemRevenue; const itemTax = Math.round((itemRevenue * cumulativeTax / 100) * 100) / 100; - if (itemTax > 0) taxPerRegion.set(regionId, (taxPerRegion.get(regionId) || 0) + itemTax); - await this.addSellItem(item.stock.branch.id, falukantUser.id, item.productType.id, item.quantity); + totalTax += itemTax; } - const totalTax = [...taxPerRegion.values()].reduce((s, t) => s + t, 0); + const totalNet = Math.round((total - totalTax) * 100) / 100; const moneyResult = await updateFalukantUserMoney( @@ -1865,19 +1703,9 @@ class FalukantService extends BaseService { if (!moneyResult.success) throw new Error('Failed to update money for seller'); const treasuryId = process.env.TREASURY_FALUKANT_USER_ID; - for (const [regionId, regionTax] of taxPerRegion) { - if (regionTax <= 0) continue; - const { recipientUserIds, useTreasury } = await getTaxRecipientsForRegion(regionId); - if (!useTreasury && recipientUserIds.length > 0) { - const share = Math.round((regionTax / recipientUserIds.length) * 100) / 100; - for (const recipientId of recipientUserIds) { - const taxResult = await updateFalukantUserMoney(recipientId, share, `Steueranteil Region (Verkauf)`, falukantUser.id); - if (!taxResult.success) throw new Error('Failed to update money for tax recipient'); - } - } else if (treasuryId) { - const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), Math.round(regionTax * 100) / 100, `Sales tax (aggregate)`, falukantUser.id); - if (!taxResult.success) throw new Error('Failed to update money for treasury'); - } + 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 } }); @@ -1950,62 +1778,6 @@ class FalukantService extends BaseService { return { data: rows, total: count, currentPage: page, totalPages: Math.ceil(count / limit) }; } - /** - * Geldverlauf für Graphenansicht. - * range: 'today' | '24h' | 'week' | 'month' | 'year' | 'all' - */ - async moneyHistoryGraph(hashedUserId, range = '24h') { - const u = await getFalukantUserOrFail(hashedUserId); - - const where = { falukantUserId: u.id }; - const now = new Date(); - let start = null; - - switch (range) { - case 'today': { - start = new Date(); - start.setHours(0, 0, 0, 0); - break; - } - case '24h': { - start = new Date(now.getTime() - 24 * 60 * 60 * 1000); - break; - } - case 'week': { - start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - break; - } - case 'month': { - start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - break; - } - case 'year': { - start = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000); - break; - } - case 'all': - default: - start = null; - } - - if (start) { - where.time = { [Op.gte]: start }; - } - - const rows = await MoneyFlow.findAll({ - where, - order: [['time', 'ASC']] - }); - - return rows.map(r => ({ - time: r.time, - moneyBefore: r.moneyBefore, - moneyAfter: r.moneyAfter, - changeValue: r.changeValue, - activity: r.activity - })); - } - async getStorage(hashedUserId, branchId) { const user = await getFalukantUserOrFail(hashedUserId); const branch = await getBranchOrFail(user.id, branchId); @@ -2349,64 +2121,26 @@ class FalukantService extends BaseService { async generateProposals(falukantUserId, regionId) { try { - const twentyOneDaysAgo = new Date(Date.now() - 21 * 24 * 60 * 60 * 1000); - const relevantRegionIds = await this.getRegionAndParentIds(regionId); - - const employerCharacter = await FalukantCharacter.findOne({ - where: { userId: falukantUserId }, - attributes: ['id'] - }); - const excludeCharacterId = employerCharacter?.id ?? null; - + const threeWeeksAgo = new Date(Date.now() - 21 * 24 * 60 * 60 * 1000); const proposalCount = Math.floor(Math.random() * 3) + 3; - const usedCharacterIds = new Set(); - for (let i = 0; i < proposalCount; i++) { - const buildWhere = (includeMinAge = true) => { - const w = { - regionId: { [Op.in]: relevantRegionIds }, - }; - if (includeMinAge) w.birthdate = { [Op.lte]: twentyOneDaysAgo }; - if (usedCharacterIds.size > 0) w.id = { [Op.notIn]: Array.from(usedCharacterIds) }; - if (excludeCharacterId) { - w[Op.or] = [ - { userId: null }, - { userId: { [Op.ne]: falukantUserId } } - ]; - } - return w; - }; - - let directorCharacter = await FalukantCharacter.findOne({ - where: buildWhere(true), // birthdate <= 21 Tage her = mind. 21 Tage alt + const directorCharacter = await FalukantCharacter.findOne({ + where: { + regionId, + createdAt: { [Op.lt]: threeWeeksAgo }, + }, include: [ { model: TitleOfNobility, as: 'nobleTitle', attributes: ['level'], - required: true }, ], order: sequelize.literal('RANDOM()'), }); - if (!directorCharacter) { - directorCharacter = await FalukantCharacter.findOne({ - where: buildWhere(false), // Fallback ohne Altersfilter - include: [ - { - model: TitleOfNobility, - as: 'nobleTitle', - attributes: ['level'], - required: true - }, - ], - order: sequelize.literal('RANDOM()'), - }); - } if (!directorCharacter) { throw new Error('No directors available for the region'); } - usedCharacterIds.add(directorCharacter.id); const avgKnowledge = await this.calculateAverageKnowledge(directorCharacter.id); const proposedIncome = Math.round( directorCharacter.nobleTitle.level * Math.pow(1.231, avgKnowledge / 1.5) @@ -2661,28 +2395,28 @@ class FalukantService extends BaseService { ], attributes: ['id', 'satisfaction', 'income'], }); - return directors - .filter(director => director.character != null) - .map(director => { - const char = director.character; - const knowledges = char.knowledges || []; - const avgKnowledge = knowledges.length - ? knowledges.reduce((sum, k) => sum + (k.knowledge || 0), 0) / knowledges.length - : 0; - const nobleLevel = char.nobleTitle?.level ?? 1; - const wishedIncome = Math.round( - nobleLevel * Math.pow(1.231, avgKnowledge / 1.5) - ); - return { - id: director.id, - satisfaction: director.satisfaction, - character: char, - age: calcAge(char.birthdate), - income: director.income, - region: char.region?.name ?? '', - wishedIncome, - }; - }); + return directors.map(director => { + // 1) avgKnowledge berechnen + const knowledges = director.character.knowledges || []; + const avgKnowledge = knowledges.length + ? knowledges.reduce((sum, k) => sum + k.knowledge, 0) / knowledges.length + : 0; + + // 2) wishedIncome anhand der JS-Formel + const wishedIncome = Math.round( + director.character.nobleTitle.level * Math.pow(1.231, avgKnowledge / 1.5) + ); + + return { + id: director.id, + satisfaction: director.satisfaction, + character: director.character, + age: calcAge(director.character.birthdate), + income: director.income, + region: director.character.region.name, + wishedIncome, + }; + }); } async updateDirector(hashedUserId, directorId, income) { @@ -2743,108 +2477,82 @@ class FalukantService extends BaseService { 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'); - // Load relationships without includes to avoid EagerLoadingError - const relRows = await Relationship.findAll({ + let relationships = await Relationship.findAll({ where: { character1Id: character.id }, - attributes: ['createdAt', 'widowFirstName2', 'nextStepProgress', 'character2Id', 'relationshipTypeId'] - }); - let relationships; - if (relRows.length === 0) { - relationships = []; - } else { - const typeIds = [...new Set(relRows.map(r => r.relationshipTypeId))]; - const char2Ids = relRows.map(r => r.character2Id); - const [types, character2s] = await Promise.all([ - RelationshipType.findAll({ where: { id: typeIds }, attributes: ['id', 'tr'] }), - FalukantCharacter.findAll({ - where: { id: char2Ids }, + 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: Mood, as: 'mood' } - ] - }) - ]); - const typeMap = Object.fromEntries(types.map(t => [t.id, t])); - const char2Map = Object.fromEntries(character2s.map(c => [c.id, c])); - const ctRows = await FalukantCharacterTrait.findAll({ - where: { characterId: char2Ids }, - attributes: ['characterId', 'traitId'] - }); - const allTraitIds = [...new Set(ctRows.map(r => r.traitId))]; - const traitsList = allTraitIds.length - ? await CharacterTrait.findAll({ where: { id: allTraitIds }, attributes: ['id', 'tr'] }) - : []; - const traitMap = Object.fromEntries(traitsList.map(t => [t.id, t])); - const traitsByChar = {}; - for (const row of ctRows) { - if (!traitsByChar[row.characterId]) traitsByChar[row.characterId] = []; - const t = traitMap[row.traitId]; - if (t) traitsByChar[row.characterId].push(t); - } - for (const c of character2s) { - c.setDataValue('traits', traitsByChar[c.id] || []); - } - const relationships = relRows.map(r => { - const c2 = char2Map[r.character2Id]; - const type = typeMap[r.relationshipTypeId]; - return { - createdAt: r.createdAt, - widowFirstName2: r.widowFirstName2, - progress: r.nextStepProgress, - character2: c2 ? { - id: c2.id, - age: calcAge(c2.birthdate), - gender: c2.gender, - firstName: c2.definedFirstName?.name || 'Unknown', - nobleTitle: c2.nobleTitle?.labelTr || '', - mood: c2.mood, - traits: c2.traits || [] - } : null, - relationshipType: type ? type.tr : '' - }; - }); - } - // Load child relations without FalukantCharacter includes to avoid EagerLoadingError - const userCharacterIds = (await FalukantCharacter.findAll({ - where: { userId: user.id }, - attributes: ['id'] - })).map(c => c.id); - const childRels = userCharacterIds.length - ? await ChildRelation.findAll({ - where: { - [Op.or]: [ - { fatherCharacterId: { [Op.in]: userCharacterIds } }, - { motherCharacterId: { [Op.in]: userCharacterIds } } + { model: CharacterTrait, as: 'traits' }, + { model: Mood, as: 'mood' }, ] }, - attributes: ['childCharacterId', 'nameSet', 'isHeir', 'createdAt'] - }) - : []; - const childCharIds = [...new Set(childRels.map(r => r.childCharacterId))]; - const childChars = childCharIds.length - ? await FalukantCharacter.findAll({ - where: { id: childCharIds }, - attributes: ['id', 'birthdate', 'gender'], - include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] - }) - : []; - const childCharMap = Object.fromEntries(childChars.map(c => [c.id, c])); - const children = childRels.map(rel => { - const kid = childCharMap[rel.childCharacterId]; - return { - childCharacterId: rel.childCharacterId, - name: kid?.definedFirstName?.name || 'Unknown', - gender: kid?.gender, - age: kid?.birthdate ? calcAge(kid.birthdate) : null, - hasName: rel.nameSet, - isHeir: rel.isHeir || false, - _createdAt: rel.createdAt, - }; + { model: RelationshipType, as: 'relationshipType', attributes: ['tr'] } + ] }); - // Sort children globally by relation createdAt ascending (older first) - children.sort((a, b) => new Date(a._createdAt) - new Date(b._createdAt)); + 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: '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, + }); + } + } + // 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)), @@ -3053,12 +2761,12 @@ class FalukantService extends BaseService { } async getGifts(hashedUserId) { - // 1) Mein aktiver Falukant-User & dessen aktueller Charakter + // 1) Mein User & Character const user = await this.getFalukantUserByHashedId(hashedUserId); - const myChar = user.character; + const myChar = await FalukantCharacter.findOne({ where: { userId: user.id } }); if (!myChar) throw new Error('Character not found'); - // 2) Beziehung finden und „anderen“ Character bestimmen (ohne Include, um EagerLoadingError zu vermeiden) + // 2) Beziehung finden und „anderen“ Character bestimmen const rel = await Relationship.findOne({ where: { [Op.or]: [ @@ -3066,25 +2774,14 @@ class FalukantService extends BaseService { { character2Id: myChar.id } ] }, - attributes: ['character1Id', 'character2Id'] + include: [ + { model: FalukantCharacter, as: 'character1', include: [{ model: CharacterTrait, as: 'traits' }] }, + { model: FalukantCharacter, as: 'character2', include: [{ model: CharacterTrait, as: 'traits' }] } + ] }); - if (!rel) return []; + if (!rel) throw new Error('Beziehung nicht gefunden'); - const relatedCharId = rel.character1Id === myChar.id ? rel.character2Id : rel.character1Id; - const relatedChar = await FalukantCharacter.findOne({ - where: { id: relatedCharId }, - attributes: ['id', 'moodId'] - }); - if (!relatedChar) throw new Error('Related character not found'); - const ctRows = await FalukantCharacterTrait.findAll({ - where: { characterId: relatedCharId }, - attributes: ['traitId'] - }); - const traitIds = ctRows.map(r => r.traitId); - const traits = traitIds.length - ? await CharacterTrait.findAll({ where: { id: traitIds }, attributes: ['id'] }) - : []; - relatedChar.setDataValue('traits', traits); + const relatedChar = rel.character1.id === myChar.id ? rel.character2 : rel.character1; // 3) Trait-IDs und Mood des relatedChar const relatedTraitIds = relatedChar.traits.map(t => t.id); @@ -3096,15 +2793,15 @@ class FalukantService extends BaseService { { model: PromotionalGiftMood, as: 'promotionalgiftmoods', - attributes: ['moodId', 'suitability'], - where: { moodId: relatedMoodId }, + attributes: ['mood_id', 'suitability'], + where: { mood_id: relatedMoodId }, required: false // Gifts ohne Mood-Match bleiben erhalten, haben dann leeres Array }, { model: PromotionalGiftCharacterTrait, as: 'characterTraits', - attributes: ['traitId', 'suitability'], - where: { traitId: relatedTraitIds }, + attributes: ['trait_id', 'suitability'], + where: { trait_id: relatedTraitIds }, required: false // Gifts ohne Trait-Match bleiben erhalten } ] @@ -3127,6 +2824,7 @@ class FalukantService extends BaseService { async getChildren(hashedUserId) { const user = await this.getFalukantUserByHashedId(hashedUserId); + console.log(user); const children = await ChildRelation.findAll({ where: { [Op.or]: [ @@ -3208,13 +2906,13 @@ class FalukantService extends BaseService { { model: PromotionalGiftCharacterTrait, as: 'characterTraits', - where: { traitId: { [Op.in]: user.character.traits.map(t => t.id) } }, + where: { trait_id: { [Op.in]: user.character.traits.map(t => t.id) } }, required: false }, { model: PromotionalGiftMood, as: 'promotionalgiftmoods', - where: { moodId: currentMoodId }, + where: { mood_id: currentMoodId }, required: false } ] @@ -3287,193 +2985,6 @@ class FalukantService extends BaseService { return TitleOfNobility.findAll(); } - async getReputationActions(hashedUserId) { - const user = await getFalukantUserOrFail(hashedUserId); - - // Lade alle Action-Typen - const actionTypes = await ReputationActionType.findAll({ - order: [['cost', 'ASC']] - }); - - // Berechne tägliche Nutzung (heute) - const todayStart = new Date(); - todayStart.setHours(0, 0, 0, 0); - const todayEnd = new Date(); - todayEnd.setHours(23, 59, 59, 999); - - const todayActions = await ReputationActionLog.count({ - where: { - falukantUserId: user.id, - actionTimestamp: { - [Op.between]: [todayStart, todayEnd] - } - } - }); - - // Standard-Limits (können später konfigurierbar gemacht werden) - const dailyCap = 10; // Maximal 10 Actions pro Tag - const dailyUsed = todayActions; - const dailyRemaining = Math.max(0, dailyCap - dailyUsed); - - // Cooldown: 60 Minuten zwischen Actions - const cooldownMinutes = 60; - const lastAction = await ReputationActionLog.findOne({ - where: { falukantUserId: user.id }, - order: [['actionTimestamp', 'DESC']], - attributes: ['actionTimestamp'] - }); - - let cooldownRemainingSec = 0; - if (lastAction && lastAction.actionTimestamp) { - const now = new Date(); - const lastActionTime = new Date(lastAction.actionTimestamp); - const secondsSinceLastAction = Math.floor((now - lastActionTime) / 1000); - const cooldownSec = cooldownMinutes * 60; - cooldownRemainingSec = Math.max(0, cooldownSec - secondsSinceLastAction); - } - - // Berechne timesUsed für jede Action (basierend auf decay_window_days) - const actionsWithUsage = await Promise.all(actionTypes.map(async (actionType) => { - const windowStart = new Date(); - windowStart.setDate(windowStart.getDate() - actionType.decayWindowDays); - - const usageCount = await ReputationActionLog.count({ - where: { - falukantUserId: user.id, - actionTypeId: actionType.id, - actionTimestamp: { - [Op.gte]: windowStart - } - } - }); - - // Berechne aktuellen Gewinn basierend auf decay - let currentGain = actionType.baseGain; - for (let i = 0; i < usageCount; i++) { - currentGain = Math.max( - actionType.minGain, - Math.floor(currentGain * actionType.decayFactor) - ); - } - - return { - id: actionType.id, - tr: actionType.tr, - cost: actionType.cost, - baseGain: actionType.baseGain, - currentGain: currentGain, - timesUsed: usageCount, - decayFactor: actionType.decayFactor, - minGain: actionType.minGain, - decayWindowDays: actionType.decayWindowDays - }; - })); - - return { - actions: actionsWithUsage, - dailyCap, - dailyUsed, - dailyRemaining, - cooldownMinutes, - cooldownRemainingSec - }; - } - - async executeReputationAction(hashedUserId, actionTypeId) { - const user = await getFalukantUserOrFail(hashedUserId); - const character = await FalukantCharacter.findOne({ - where: { userId: user.id }, - attributes: ['id', 'reputation'] - }); - if (!character) { - throw new Error('Character not found'); - } - - const actionType = await ReputationActionType.findByPk(actionTypeId); - if (!actionType) { - throw new Error('Action type not found'); - } - - const todayStart = new Date(); - todayStart.setHours(0, 0, 0, 0); - const todayEnd = new Date(); - todayEnd.setHours(23, 59, 59, 999); - const dailyCap = 10; - const todayActions = await ReputationActionLog.count({ - where: { - falukantUserId: user.id, - actionTimestamp: { [Op.between]: [todayStart, todayEnd] } - } - }); - if (todayActions >= dailyCap) { - throw new Error('Daily limit reached'); - } - - const lastAction = await ReputationActionLog.findOne({ - where: { falukantUserId: user.id }, - order: [['actionTimestamp', 'DESC']], - attributes: ['actionTimestamp'] - }); - const cooldownMinutes = 60; - if (lastAction?.actionTimestamp) { - const cooldownSec = cooldownMinutes * 60; - const secondsSinceLast = Math.floor((Date.now() - new Date(lastAction.actionTimestamp)) / 1000); - if (secondsSinceLast < cooldownSec) { - throw new Error('Cooldown active'); - } - } - - const reloadedUser = await FalukantUser.findByPk(user.id, { attributes: ['id', 'money'] }); - if (reloadedUser.money < actionType.cost) { - throw new Error('notenoughmoney'); - } - - const windowStart = new Date(); - windowStart.setDate(windowStart.getDate() - actionType.decayWindowDays); - const timesUsedBefore = await ReputationActionLog.count({ - where: { - falukantUserId: user.id, - actionTypeId: actionType.id, - actionTimestamp: { [Op.gte]: windowStart } - } - }); - - let gain = actionType.baseGain; - for (let i = 0; i < timesUsedBefore; i++) { - gain = Math.max(actionType.minGain, Math.floor(gain * actionType.decayFactor)); - } - - const transaction = await sequelize.transaction(); - try { - await updateFalukantUserMoney( - user.id, - -actionType.cost, - 'Reputation action: ' + actionType.tr, - user.id, - transaction - ); - await ReputationActionLog.create( - { - falukantUserId: user.id, - actionTypeId: actionType.id, - cost: actionType.cost, - baseGain: actionType.baseGain, - gain, - timesUsedBefore - }, - { transaction } - ); - const newReputation = Math.min(100, Math.max(0, (character.reputation ?? 0) + gain)); - await character.update({ reputation: newReputation }, { transaction }); - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - - return { gain, cost: actionType.cost }; - } - async getHouseTypes() { // return House } @@ -3640,9 +3151,7 @@ class FalukantService extends BaseService { attributes: ['id'] }); if (already) { - const error = new Error('Diese Party wurde bereits innerhalb der letzten 24 Stunden bestellt'); - error.status = 409; - throw error; + throw new Error('Diese Party wurde bereits innerhalb der letzten 24 Stunden bestellt'); } const [ptype, music, banquette] = await Promise.all([ PartyType.findByPk(partyTypeId), @@ -3661,9 +3170,7 @@ class FalukantService extends BaseService { throw new Error('Einige ausgewählte Adelstitel existieren nicht'); } - const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, attributes: ['titleOfNobility'] }); - const partyTypeCost = character && (await isPartyTypeFreeForTitle(character.titleOfNobility, ptype.id, ptype.tr)) ? 0 : (ptype.cost || 0); - let cost = partyTypeCost + (music.cost || 0) + (banquette.cost || 0); + 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; @@ -3688,10 +3195,6 @@ class FalukantService extends BaseService { cost: cost }); if (nobilities.length > 0) { - // Stelle sicher, dass die Party eine ID hat - if (!party.id) { - throw new Error('Party wurde erstellt, hat aber keine ID'); - } // Verwende die bereits geladenen Objekte await party.addInvitedNobilities(nobilities); } @@ -3780,10 +3283,12 @@ class FalukantService extends BaseService { async baptise(hashedUserId, childId, firstName) { try { - // Nutze den aktuell aktiven Charakter (wie in getFalukantUserByHashedId definiert), - // statt „irgendeinen“ Charakter des Users per userId zu suchen. - const falukantUser = await this.getFalukantUserByHashedId(hashedUserId); - const parentCharacter = falukantUser.character; + const falukantUser = await getFalukantUserOrFail(hashedUserId); + const parentCharacter = await FalukantCharacter.findOne({ + where: { + userId: falukantUser.id, + }, + }); if (!parentCharacter) { throw new Error('Parent character not found'); } @@ -3831,9 +3336,8 @@ class FalukantService extends BaseService { firstName: firstNameObject.id, }); updateFalukantUserMoney(falukantUser.id, -50, 'Baptism', falukantUser.id); - // Trigger status bar refresh (children count) and family view update - await notifyUser(hashedUserId, 'falukantUpdateStatus', {}); - await notifyUser(hashedUserId, 'familychanged', {}); + // Trigger status bar refresh for the user after baptism + notifyUser(hashedUserId, 'falukantUpdateStatus', {}); return { success: true }; } catch (error) { throw new Error(error.message); @@ -3889,37 +3393,6 @@ class FalukantService extends BaseService { return Math.round(cfg.min + (cfg.max - cfg.min) * p); } - /** Resolve character id for education: self = user's character, children/director = studentId */ - async _resolveLearningCharacterId(hashedUserId, student, studentId) { - if (student === 'self') { - const user = await getFalukantUserOrFail(hashedUserId); - const char = await FalukantCharacter.findOne({ where: { userId: user.id }, attributes: ['id'] }); - return char?.id ?? null; - } - return studentId ?? null; - } - - async getKnowledgeSingle(hashedUserId, student, studentId, productId) { - const characterId = await this._resolveLearningCharacterId(hashedUserId, student, studentId); - if (!characterId) throw new Error('Character not found'); - const row = await Knowledge.findOne({ - where: { characterId, productId }, - attributes: ['knowledge'] - }); - return { knowledge: row?.knowledge ?? 0 }; - } - - async getKnowledgeForAll(hashedUserId, student, studentId) { - const characterId = await this._resolveLearningCharacterId(hashedUserId, student, studentId); - if (!characterId) throw new Error('Character not found'); - const [products, knowledges] = await Promise.all([ - ProductType.findAll({ attributes: ['id'] }), - Knowledge.findAll({ where: { characterId }, attributes: ['productId', 'knowledge'] }) - ]); - const knowledgeMap = new Map((knowledges || []).map(k => [k.productId, k.knowledge ?? 0])); - return products.map(p => ({ productId: p.id, knowledge: knowledgeMap.get(p.id) ?? 0 })); - } - async sendToSchool(hashedUserId, item, student, studentId) { const falukantUser = await getFalukantUserOrFail(hashedUserId); @@ -3939,14 +3412,11 @@ class FalukantService extends BaseService { let percent; if (item === 'all') { const all = await this.getKnowledgeForAll(hashedUserId, student, studentId); - if (!all.length) percent = 0; - else { - const sum = all.reduce((s, k) => s + (k.knowledge || 0), 0); - percent = sum / all.length; - } + const sum = all.reduce((s, k) => s + k.knowledge, 0); + percent = sum / all.length; } else { const single = await this.getKnowledgeSingle(hashedUserId, student, studentId, item); - percent = single.knowledge ?? 0; + percent = single.knowledge; } // 4) Kosten berechnen @@ -4111,30 +3581,46 @@ class FalukantService extends BaseService { const oneWeekAgo = new Date(now.getTime()); oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); if (user.lastNobilityAdvanceAt > oneWeekAgo) { - throw new Error('too soon'); + const next = new Date(new Date(user.lastNobilityAdvanceAt).getTime()); + next.setDate(next.getDate() + 7); + const err = new PreconditionError('nobilityTooSoon'); + err.meta = { retryAt: next.toISOString() }; + throw err; } } const nextTitle = nobility.next.toJSON(); let fulfilled = true; let cost = 0; + const unmet = []; for (const requirement of nextTitle.requirements) { switch (requirement.requirementType) { case 'money': - fulfilled = fulfilled && await this.checkMoneyRequirement(user, requirement); + if (!(await this.checkMoneyRequirement(user, requirement))) { + fulfilled = false; + unmet.push({ type: 'money', required: requirement.requirementValue, current: Number(user.money) }); + } break; case 'cost': - fulfilled = fulfilled && await this.checkMoneyRequirement(user, requirement); + if (!(await this.checkMoneyRequirement(user, requirement))) { + fulfilled = false; + unmet.push({ type: 'cost', required: requirement.requirementValue, current: Number(user.money) }); + } cost = requirement.requirementValue; break; case 'branches': - fulfilled = fulfilled && await this.checkBranchesRequirement(hashedUserId, requirement); + if (!(await this.checkBranchesRequirement(hashedUserId, requirement))) { + fulfilled = false; + unmet.push({ type: 'branches', required: requirement.requirementValue }); + } break; default: fulfilled = false; }; } if (!fulfilled) { - throw new Error('Requirements not fulfilled'); + const err = new PreconditionError('nobilityRequirements'); + err.meta = { unmet }; + throw err; } const newTitle = await TitleOfNobility.findOne({ where: { level: nobility.current.level + 1 } @@ -4192,11 +3678,7 @@ class FalukantService extends BaseService { limit: 1 }); if (lastHealthActivity) { - // Berechne, wann die nächste Maßnahme möglich ist (24 Stunden nach der letzten) - const retryAt = new Date(lastHealthActivity.createdAt.getTime() + 24 * 60 * 60 * 1000); - const err = new PreconditionError('tooClose'); - err.meta = { retryAt: retryAt.toISOString() }; - throw err; + throw new Error('too close'); } const activityObject = FalukantService.HEALTH_ACTIVITIES.find((a) => a.tr === activity); if (!activityObject) { @@ -4367,7 +3849,6 @@ class FalukantService extends BaseService { }, character: o.holder ? { - id: o.holder.id, definedFirstName: o.holder.definedFirstName, definedLastName: o.holder.definedLastName, nobleTitle: o.holder.nobleTitle, @@ -4379,6 +3860,14 @@ class FalukantService extends BaseService { }); } + async getOpenPolitics(hashedUserId) { + const user = await this.getFalukantUserByHashedId(hashedUserId); + if (!user || user.character.nobleTitle.labelTr === 'noncivil') { + return []; + } + + } + async getElections(hashedUserId) { const user = await this.getFalukantUserByHashedId(hashedUserId); if (!user || user.character.nobleTitle.labelTr === 'noncivil') { @@ -4530,15 +4019,9 @@ class FalukantService extends BaseService { }); } - /** Mindestalter für Bewerbung auf politische Ämter (in Tagen; 16 Tage Realzeit = 16 Spieljahre) */ - static MIN_AGE_POLITICS_DAYS = 16; - async getOpenPolitics(hashedUserId) { const user = await this.getFalukantUserByHashedId(hashedUserId); - const character = user.character; - const characterId = character.id; - const ageDays = character.birthdate ? calcAge(character.birthdate) : 0; - const canApplyByAge = ageDays >= FalukantService.MIN_AGE_POLITICS_DAYS; + const characterId = user.character.id; const rows = await sequelize.query( FalukantService.RECURSIVE_REGION_SEARCH, { @@ -4579,13 +4062,7 @@ class FalukantService extends BaseService { } ] }); - const titleId = character.titleOfNobility ?? character.nobleTitle?.id; - const allowedOfficeNames = await getAllowedOfficeTypeNamesByTitle(titleId); const result = openPositions - .filter(election => { - if (allowedOfficeNames.size > 0 && !allowedOfficeNames.has(election.officeType?.name)) return false; - return true; - }) .filter(election => { const prereqs = election.officeType.prerequisites || []; return prereqs.some(pr => { @@ -4615,11 +4092,9 @@ class FalukantService extends BaseService { return { ...e, history: matchingHistory, - alreadyApplied, - canApplyByAge + alreadyApplied }; - }) - .filter(election => !election.alreadyApplied); // Nur Positionen ohne bestehende Bewerbung + }); return result; } @@ -4634,30 +4109,24 @@ class FalukantService extends BaseService { throw new Error('Kein Charakter zum User gefunden'); } - // 2) Mindestalter 16 (Spieljahre = 16 Tage Realzeit) - const ageDays = character.birthdate ? calcAge(character.birthdate) : 0; - if (ageDays < FalukantService.MIN_AGE_POLITICS_DAYS) { - throw new Error('too_young'); - } - - // 3) Noncivil‐Titel aussperren + // 2) Noncivil‐Titel aussperren if (character.nobleTitle.labelTr === 'noncivil') { return { applied: [], skipped: electionIds }; } - // 4) Ermittle die offenen Wahlen, auf die er zugreifen darf + // 3) Ermittle die offenen Wahlen, auf die er zugreifen darf // Verwende getOpenPolitics statt getElections, da getOpenPolitics die gleichen Wahlen // zurückgibt, die im Frontend angezeigt werden const openPolitics = await this.getOpenPolitics(hashedUserId); const allowedIds = new Set(openPolitics.map(e => e.id)); - // 5) Filter alle electionIds auf gültige/erlaubte + // 4) Filter alle electionIds auf gültige/erlaubte const toTry = electionIds.filter(id => allowedIds.has(id)); if (toTry.length === 0) { return { applied: [], skipped: electionIds }; } - // 6) Prüfe, auf welche dieser Wahlen der Character bereits als Candidate eingetragen ist + // 5) Prüfe, auf welche dieser Wahlen der Character bereits als Candidate eingetragen ist const existing = await Candidate.findAll({ where: { electionId: { [Op.in]: toTry }, @@ -4667,11 +4136,13 @@ class FalukantService extends BaseService { }); const alreadyIds = new Set(existing.map(c => c.electionId)); - // 7) Erstelle Liste der Wahlen, für die er sich noch nicht beworben hat + // 6) Erstelle Liste der Wahlen, für die er sich noch nicht beworben hat const newApplications = toTry.filter(id => !alreadyIds.has(id)); const skipped = electionIds.filter(id => !newApplications.includes(id)); - // 8) Bulk-Insert aller neuen Bewerbungen + console.log(newApplications, skipped); + + // 7) Bulk-Insert aller neuen Bewerbungen if (newApplications.length > 0) { const toInsert = newApplications.map(eid => ({ electionId: eid, @@ -4721,74 +4192,28 @@ class FalukantService extends BaseService { } async getProductPriceInRegion(hashedUserId, productId, regionId) { - try { - 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}`); - } - - // Produkt abrufen - const product = await ProductType.findOne({ where: { id: productId } }); - if (!product) { - throw new Error(`Product not found with id ${productId}`); - } - - // Prüfe ob sellCost vorhanden ist - if (product.sellCost === null || product.sellCost === undefined) { - throw new Error(`Product ${productId} has no sellCost defined`); - } - - // Knowledge für dieses Produkt abrufen - const knowledge = await Knowledge.findOne({ - where: { characterId: character.id, productId: productId } - }); - const knowledgeFactor = knowledge?.knowledge || 0; - - // Verwende die bereits existierende calcRegionalSellPrice Funktion - const price = await calcRegionalSellPrice(product, knowledgeFactor, regionId); - - return { price }; - } catch (error) { - console.error(`[getProductPriceInRegion] Error for productId=${productId}, regionId=${regionId}:`, error); - throw error; + 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}`); } - } - async getAllProductPricesInRegion(hashedUserId, regionId) { - try { - 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}`); - } - const [products, knowledges, townWorths] = await Promise.all([ - ProductType.findAll({ attributes: ['id', 'sellCost'] }), - Knowledge.findAll({ - where: { characterId: character.id }, - attributes: ['productId', 'knowledge'] - }), - TownProductWorth.findAll({ - where: { regionId: regionId }, - attributes: ['productId', 'worthPercent'] - }) - ]); - - const knowledgeMap = new Map(knowledges.map(k => [k.productId, k.knowledge || 0])); - const worthMap = new Map(townWorths.map(tw => [tw.productId, tw.worthPercent || 50])); - - const prices = {}; - for (const product of products) { - const worthPercent = worthMap.get(product.id) ?? 50; - const knowledgeFactor = knowledgeMap.get(product.id) || 0; - const price = calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent); - if (price !== null) prices[product.id] = price; - } - return { prices }; - } catch (error) { - console.error(`[getAllProductPricesInRegion] Error for regionId=${regionId}:`, error); - throw error; + // Produkt abrufen + const product = await ProductType.findOne({ where: { id: productId } }); + if (!product) { + throw new Error(`Product not found with id ${productId}`); } + + // Knowledge für dieses Produkt abrufen + const knowledge = await Knowledge.findOne({ + where: { characterId: character.id, productId: productId } + }); + const knowledgeFactor = knowledge?.knowledge || 0; + + // Verwende die bereits existierende calcRegionalSellPrice Funktion + const price = await calcRegionalSellPrice(product, knowledgeFactor, regionId); + + return { price }; } async getProductPricesInCities(hashedUserId, productId, currentPrice, currentRegionId = null) { @@ -4798,61 +4223,79 @@ class FalukantService extends BaseService { throw new Error(`No FalukantCharacter found for user with id ${user.id}`); } - const [product, knowledge, cities, townWorths] = await Promise.all([ - ProductType.findOne({ where: { id: productId } }), - Knowledge.findOne({ - where: { characterId: character.id, productId: productId } - }), - RegionData.findAll({ - attributes: ['id', 'name'], - include: [ - { - model: RegionType, - as: 'regionType', - where: { labelTr: 'city' }, - attributes: ['labelTr'] - }, - { - model: Branch, - as: 'branches', - where: { falukantUserId: user.id }, - include: [ - { - model: BranchType, - as: 'branchType', - attributes: ['labelTr'] - } - ], - attributes: ['branchTypeId'], - required: false - } - ] - }), - TownProductWorth.findAll({ - where: { productId: productId }, - attributes: ['regionId', 'worthPercent'] - }) - ]); - + // Produkt abrufen + const product = await ProductType.findOne({ where: { id: productId } }); if (!product) { throw new Error(`Product not found with id ${productId}`); } + + // Knowledge für dieses Produkt abrufen + const knowledge = await Knowledge.findOne({ + where: { characterId: character.id, productId: productId } + }); const knowledgeFactor = knowledge?.knowledge || 0; + + // Alle Städte abrufen + const cities = await RegionData.findAll({ + attributes: ['id', 'name'], + include: [ + { + model: RegionType, + as: 'regionType', + where: { labelTr: 'city' }, + attributes: ['labelTr'] + }, + { + model: Branch, + as: 'branches', + where: { falukantUserId: user.id }, + include: [ + { + model: BranchType, + as: 'branchType', + attributes: ['labelTr'] + } + ], + attributes: ['branchTypeId'], + required: false + } + ] + }); + + // TownProductWorth für alle Städte und dieses Produkt einmalig abrufen + // (vermeidet N+1 Query Problem) + const townWorths = await TownProductWorth.findAll({ + where: { productId: productId }, + attributes: ['regionId', 'worthPercent'] + }); const worthMap = new Map(townWorths.map(tw => [tw.regionId, tw.worthPercent])); - let currentRegionalPrice = currentPrice; + // Berechne den regionalen Preis für die aktuelle Region (falls angegeben) + // WICHTIG: Ignoriere den übergebenen currentPrice, da er möglicherweise nicht + // den regionalen Faktor berücksichtigt. Berechne stattdessen immer den korrekten + // regionalen Preis basierend auf currentRegionId. + let currentRegionalPrice = currentPrice; // Fallback auf übergebenen Preis if (currentRegionId) { const currentWorthPercent = worthMap.get(currentRegionId) || 50; - currentRegionalPrice = calcRegionalSellPriceSync(product, knowledgeFactor, currentWorthPercent) ?? currentPrice; + // Verwende calcRegionalSellPrice mit bereits geladenem worthPercent (keine DB-Query) + currentRegionalPrice = await calcRegionalSellPrice(product, knowledgeFactor, currentRegionId, currentWorthPercent); } + // Für jede Stadt den Preis berechnen und Branch-Typ bestimmen const results = []; - const PRICE_TOLERANCE = 0.01; for (const city of cities) { - if (currentRegionId && city.id === currentRegionId) continue; + // Aktuelle Stadt ausschließen + if (currentRegionId && city.id === currentRegionId) { + continue; + } + const worthPercent = worthMap.get(city.id) || 50; - const priceInCity = calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent); - if (priceInCity == null) continue; + // Verwende calcRegionalSellPrice mit bereits geladenem worthPercent (keine DB-Query) + const priceInCity = await calcRegionalSellPrice(product, knowledgeFactor, city.id, worthPercent); + + // Nur Städte zurückgeben, wo der Preis höher ist + // Kleine Toleranz (0.01) für Rundungsfehler bei Gleitkommaberechnungen + const PRICE_TOLERANCE = 0.01; if (priceInCity > currentRegionalPrice - PRICE_TOLERANCE) { // Branch-Typ bestimmen let branchType = null; // null = kein Branch @@ -4881,102 +4324,6 @@ class FalukantService extends BaseService { return results; } - /** - * Batch-Variante: Preise für mehrere Produkte in einem Request. - * @param {string} hashedUserId - * @param {Array<{ productId: number, currentPrice: number }>} items - * @param {number|null} currentRegionId - * @returns {Promise>>} - */ - async getProductPricesInCitiesBatch(hashedUserId, items, currentRegionId = null) { - if (!items || items.length === 0) return {}; - const productIds = [...new Set(items.map(i => i.productId))]; - const priceByProduct = new Map(items.map(i => [i.productId, i.currentPrice])); - - const user = await this.getFalukantUserByHashedId(hashedUserId); - const character = user.character || await FalukantCharacter.findOne({ where: { userId: user.id }, attributes: ['id'] }); - if (!character) { - throw new Error(`No FalukantCharacter found for user with id ${user.id}`); - } - const characterId = character.id; - - let citiesWithBranchType = FalukantService._citiesBatchCache?.get(user.id); - const now = Date.now(); - if (citiesWithBranchType && citiesWithBranchType.expires > now) { - citiesWithBranchType = citiesWithBranchType.data; - } else { - const cityRows = await sequelize.query( - `SELECT r.id, r.name, - MAX(CASE WHEN bt.label_tr IN ('store','fullstack') THEN 2 WHEN bt.label_tr = 'production' THEN 1 ELSE 0 END) AS branch_type_sort -FROM falukant_data.region r -INNER JOIN falukant_type.region rt ON r.region_type_id = rt.id AND rt.label_tr = 'city' -LEFT JOIN falukant_data.branch b ON b.region_id = r.id AND b.falukant_user_id = :userId -LEFT JOIN falukant_type.branch bt ON b.branch_type_id = bt.id -GROUP BY r.id, r.name -ORDER BY r.id`, - { replacements: { userId: user.id }, type: sequelize.QueryTypes.SELECT } - ); - const branchTypeByCityId = new Map(); - const cities = []; - for (const row of cityRows) { - cities.push({ id: row.id, name: row.name }); - const sort = row.branch_type_sort ?? 0; - branchTypeByCityId.set(row.id, sort === 2 ? 'store' : sort === 1 ? 'production' : null); - } - citiesWithBranchType = { cities, branchTypeByCityId }; - if (!FalukantService._citiesBatchCache) FalukantService._citiesBatchCache = new Map(); - FalukantService._citiesBatchCache.set(user.id, { data: citiesWithBranchType, expires: now + 60000 }); - } - const { cities, branchTypeByCityId } = citiesWithBranchType; - - const [products, knowledges, townWorths] = await Promise.all([ - ProductType.findAll({ where: { id: { [Op.in]: productIds } }, attributes: ['id', 'sellCost'] }), - Knowledge.findAll({ - where: { characterId: characterId, productId: { [Op.in]: productIds } }, - attributes: ['productId', 'knowledge'] - }), - TownProductWorth.findAll({ - where: { productId: { [Op.in]: productIds } }, - attributes: ['productId', 'regionId', 'worthPercent'] - }) - ]); - - const knowledgeByProduct = new Map(knowledges.map(k => [k.productId, k.knowledge || 0])); - const worthByProductRegion = new Map(townWorths.map(tw => [`${tw.productId}-${tw.regionId}`, tw.worthPercent])); - - const PRICE_TOLERANCE = 0.01; - const out = {}; - - for (const product of products) { - const knowledgeFactor = knowledgeByProduct.get(product.id) || 0; - const currentPrice = priceByProduct.get(product.id) ?? product.sellCost ?? 0; - let currentRegionalPrice = currentPrice; - if (currentRegionId) { - const wp = worthByProductRegion.get(`${product.id}-${currentRegionId}`) ?? 50; - currentRegionalPrice = calcRegionalSellPriceSync(product, knowledgeFactor, wp) ?? currentPrice; - } - - const results = []; - for (const city of cities) { - if (currentRegionId && city.id === currentRegionId) continue; - const worthPercent = worthByProductRegion.get(`${product.id}-${city.id}`) ?? 50; - const priceInCity = calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent); - if (priceInCity == null) continue; - if (priceInCity <= currentRegionalPrice - PRICE_TOLERANCE) continue; - results.push({ - regionId: city.id, - regionName: city.name, - price: priceInCity, - branchType: branchTypeByCityId.get(city.id) ?? null - }); - } - results.sort((a, b) => b.price - a.price); - out[product.id] = results; - } - - return out; - } - async renovate(hashedUserId, element) { const user = await getFalukantUserOrFail(hashedUserId); const house = await UserHouse.findOne({ @@ -5088,63 +4435,6 @@ ORDER BY r.id`, return { updated: count }; } - /** - * Kompakte Daten für das Dashboard-Widget (Charakter-Name, Geschlecht, Alter, Geld, ungelesene Nachrichten, Kinder). - * @param {string} hashedUserId - * @returns {Promise<{ characterName: string, gender: string|null, age: number|null, money: number, unreadNotificationsCount: number, childrenCount: number }>} - */ - async getDashboardWidget(hashedUserId) { - const falukantUser = await FalukantUser.findOne({ - include: [ - { model: User, as: 'user', attributes: [], where: { hashedId: hashedUserId } }, - { - model: FalukantCharacter, - as: 'character', - attributes: ['id', 'birthdate', 'gender'], - include: [ - { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, - { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }, - { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] } - ] - } - ], - attributes: ['id', 'money'] - }); - if (!falukantUser || !falukantUser.character) { - throw new Error('No Falukant character found for this user'); - } - const character = falukantUser.character; - const firstName = character.definedFirstName?.name ?? ''; - const lastName = character.definedLastName?.name ?? ''; - const titleLabelTr = character.nobleTitle?.labelTr ?? ''; - const nameWithoutTitle = [firstName, lastName].filter(Boolean).join(' ') || '—'; - const characterName = titleLabelTr ? [titleLabelTr, firstName, lastName].filter(Boolean).join(' ') : nameWithoutTitle; - const age = character.birthdate ? calcAge(character.birthdate) : null; - - const [unreadNotificationsCount, childrenCount] = await Promise.all([ - Notification.count({ where: { userId: falukantUser.id, shown: false } }), - ChildRelation.count({ - where: { - [Op.or]: [ - { fatherCharacterId: character.id }, - { motherCharacterId: character.id } - ] - } - }) - ]); - - return { - characterName, - titleLabelTr: titleLabelTr || null, - nameWithoutTitle, - gender: character.gender ?? null, - age, - money: Number(falukantUser.money ?? 0), - unreadNotificationsCount, - childrenCount - }; - } - async getPoliticalOfficeHolders(hashedUserId) { const user = await getFalukantUserOrFail(hashedUserId); const character = await FalukantCharacter.findOne({ @@ -5446,595 +4736,6 @@ ORDER BY r.id`, all: mapped }; } - - async getChurchOverview(hashedUserId) { - try { - const user = await getFalukantUserOrFail(hashedUserId); - const character = await FalukantCharacter.findOne({ - where: { userId: user.id }, - attributes: ['id', 'regionId'] - }); - if (!character) { - console.log('[getChurchOverview] No character found for user', user.id); - return []; - } - - // Alle relevanten Regionen (Region + Eltern) laden - const relevantRegionIds = await this.getRegionAndParentIds(character.regionId); - console.log('[getChurchOverview] Relevant region IDs:', relevantRegionIds); - - // Aktuell besetzte Kirchenämter in diesen Regionen laden - 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'], - required: false, - 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: FalukantCharacter, - as: 'supervisor', - attributes: ['id', 'gender'], - required: false, - include: [ - { - model: FalukantPredefineFirstname, - as: 'definedFirstName', - attributes: ['name'], - required: false - }, - { - model: FalukantPredefineLastname, - as: 'definedLastName', - attributes: ['name'], - required: false - } - ] - } - ], - order: [ - [{ model: ChurchOfficeType, as: 'type' }, 'hierarchyLevel', 'DESC'], - [{ model: RegionData, as: 'region' }, 'name', 'ASC'] - ] - }); - - console.log('[getChurchOverview] Found', offices.length, 'offices'); - return offices.map(office => { - const o = office.get({ plain: true }); - return { - id: o.id, - officeType: { - name: o.type?.name - }, - 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 - }; - }); - } catch (error) { - console.error('[getChurchOverview] Error:', error); - console.error('[getChurchOverview] Stack:', error.stack); - throw error; - } - } - - async getAvailableChurchPositions(hashedUserId) { - const user = await getFalukantUserOrFail(hashedUserId); - const character = await FalukantCharacter.findOne({ - where: { userId: user.id }, - attributes: ['id', 'regionId'] - }); - if (!character) { - return []; - } - - // Prüfe welche Kirchenämter der Charakter bereits innehat - const heldOffices = await ChurchOffice.findAll({ - where: { characterId: character.id }, - include: [ - { - model: ChurchOfficeType, - as: 'type', - attributes: ['id'] - } - ] - }); - const heldOfficeTypeIds = heldOffices.map(o => o.type.id); - - // Prüfe welche Bewerbungen bereits existieren - const existingApplications = await ChurchApplication.findAll({ - where: { - characterId: character.id, - status: 'pending' - }, - attributes: ['officeTypeId', 'regionId'] - }); - const appliedPositions = new Set( - existingApplications.map(a => `${a.officeTypeId}-${a.regionId}`) - ); - - // Alle relevanten Regionen (Region + Eltern) laden - const relevantRegionIds = await this.getRegionAndParentIds(character.regionId); - - // Alle Kirchenamt-Typen mit Voraussetzungen laden - const officeTypes = await ChurchOfficeType.findAll({ - include: [ - { - model: ChurchOfficeRequirement, - as: 'requirements', - required: false, - attributes: ['id', 'officeTypeId', 'prerequisiteOfficeTypeId'] - } - ], - order: [['hierarchyLevel', 'ASC']] - }); - - console.log(`[getAvailableChurchPositions] Loaded ${officeTypes.length} office types. Held offices:`, heldOfficeTypeIds); - // Debug: Zeige alle geladenen Voraussetzungen - officeTypes.forEach(ot => { - if (ot.requirements && ot.requirements.length > 0) { - console.log(` - ${ot.name} (id=${ot.id}): prerequisiteOfficeTypeId=${ot.requirements[0].prerequisiteOfficeTypeId}`); - } else { - console.log(` - ${ot.name} (id=${ot.id}): NO REQUIREMENT DEFINED`); - } - }); - - const availablePositions = []; - - for (const officeType of officeTypes) { - // Prüfe Voraussetzungen: Hat der User bereits das erforderliche niedrigere Amt? - const requirement = officeType.requirements?.[0]; - - console.log(`[getAvailableChurchPositions] Checking ${officeType.name} (id=${officeType.id}, hierarchyLevel=${officeType.hierarchyLevel}):`, { - hasRequirement: !!requirement, - prerequisiteOfficeTypeId: requirement?.prerequisiteOfficeTypeId, - heldOfficeTypeIds: heldOfficeTypeIds - }); - - // Prüfe Voraussetzungen - if (requirement) { - // Wenn eine Voraussetzung definiert ist - const prerequisiteId = requirement.prerequisiteOfficeTypeId; - if (prerequisiteId !== null && prerequisiteId !== undefined) { - // Prüfe ob der User das erforderliche Amt innehat - if (!heldOfficeTypeIds.includes(prerequisiteId)) { - console.log(`[getAvailableChurchPositions] Skipping ${officeType.name}: User doesn't have prerequisite office ${prerequisiteId}. Held offices:`, heldOfficeTypeIds); - continue; // Voraussetzung nicht erfüllt - User hat das erforderliche Amt nicht - } - } - // Wenn prerequisiteOfficeTypeId === null, dann keine Voraussetzung = Einstiegsposition, OK - } else { - // Wenn keine Voraussetzung in der DB definiert ist, bedeutet das: - // - Entweder ist es eine Einstiegsposition (hierarchyLevel 0) - // - Oder die Voraussetzung wurde noch nicht initialisiert - // Sicherheitshalber: Nur Einstiegspositionen ohne Voraussetzung erlauben - if (officeType.hierarchyLevel !== 0) { - console.log(`[getAvailableChurchPositions] Skipping ${officeType.name}: No requirement defined in DB and hierarchyLevel > 0 (${officeType.hierarchyLevel}). This might be a configuration issue.`); - continue; // Keine Voraussetzung definiert für höheres Amt = vermutlich Konfigurationsfehler - } - } - - // Prüfe ob der User bereits dieses Amt innehat - if (heldOfficeTypeIds.includes(officeType.id)) { - continue; // User hat bereits dieses Amt - } - - // Finde den RegionType für diesen officeType - const regionType = await RegionType.findOne({ - where: { labelTr: officeType.regionType } - }); - if (!regionType) continue; - - // Finde alle Regionen dieses Typs in den relevanten Regionen - const regions = await RegionData.findAll({ - where: { - id: { [Op.in]: relevantRegionIds }, - regionTypeId: regionType.id - }, - attributes: ['id', 'name'] - }); - - for (const region of regions) { - // Prüfe ob bereits eine Bewerbung für diese Position existiert - const applicationKey = `${officeType.id}-${region.id}`; - if (appliedPositions.has(applicationKey)) { - continue; // Bereits beworben - } - - // Prüfe ob der User bereits dieses Amt in dieser Region innehat - const hasOfficeInRegion = await ChurchOffice.findOne({ - where: { - characterId: character.id, - officeTypeId: officeType.id, - regionId: region.id - } - }); - if (hasOfficeInRegion) { - continue; // User hat bereits dieses Amt in dieser Region - } - - // Zähle besetzte Positionen dieses Typs in dieser Region - const occupiedCount = await ChurchOffice.count({ - where: { - officeTypeId: officeType.id, - regionId: region.id - } - }); - - const availableSeats = officeType.seatsPerRegion - occupiedCount; - - if (availableSeats > 0) { - // Finde den Supervisor (höheres Amt in derselben Region oder Eltern-Region) - let supervisor = null; - const higherOfficeTypeIds = await ChurchOfficeType.findAll({ - where: { - hierarchyLevel: { [Op.gt]: officeType.hierarchyLevel } - }, - attributes: ['id'] - }).then(types => types.map(t => t.id)); - - if (higherOfficeTypeIds.length > 0) { - const supervisorOffice = await ChurchOffice.findOne({ - where: { - regionId: region.id, - officeTypeId: { [Op.in]: higherOfficeTypeIds } - }, - include: [ - { - model: ChurchOfficeType, - as: 'type', - attributes: ['hierarchyLevel'] - }, - { - model: FalukantCharacter, - as: 'holder', - attributes: ['id'] - } - ], - order: [ - [{ model: ChurchOfficeType, as: 'type' }, 'hierarchyLevel', 'ASC'] - ], - limit: 1 - }); - - if (supervisorOffice && supervisorOffice.holder) { - supervisor = { - id: supervisorOffice.holder.id, - name: 'Supervisor' // Wird später geladen falls nötig - }; - } - } - - availablePositions.push({ - id: officeType.id, // Verwende officeTypeId als ID für die Frontend-Identifikation - officeType: { - name: officeType.name - }, - region: { - name: region.name, - id: region.id - }, - regionId: region.id, - availableSeats: availableSeats, - supervisor: supervisor - }); - } - } - } - - return availablePositions; - } - - async getSupervisedApplications(hashedUserId) { - const user = await getFalukantUserOrFail(hashedUserId); - const character = await FalukantCharacter.findOne({ - where: { userId: user.id }, - attributes: ['id'] - }); - if (!character) { - return []; - } - - // Finde alle Kirchenämter, die dieser Charakter hält - const heldOffices = await ChurchOffice.findAll({ - where: { characterId: character.id }, - include: [ - { - model: ChurchOfficeType, - as: 'type', - attributes: ['id', 'hierarchyLevel'] - } - ] - }); - - if (heldOffices.length === 0) { - return []; - } - - // Finde alle niedrigeren Ämter, die dieser Charakter superviden kann - const maxHierarchyLevel = Math.max(...heldOffices.map(o => o.type.hierarchyLevel)); - const supervisedOfficeTypeIds = await ChurchOfficeType.findAll({ - where: { - hierarchyLevel: { [Op.lt]: maxHierarchyLevel } - }, - attributes: ['id'] - }).then(types => types.map(t => t.id)); - - // Finde alle Bewerbungen für diese Ämter, bei denen dieser Charakter Supervisor ist - const applications = await ChurchApplication.findAll({ - where: { - supervisorId: character.id, - status: 'pending', - officeTypeId: { [Op.in]: supervisedOfficeTypeIds } - }, - include: [ - { - model: ChurchOfficeType, - as: 'officeType', - attributes: ['name'] - }, - { - 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 - }, - 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 applyForChurchPosition(hashedUserId, officeTypeId, regionId) { - 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 Position verfügbar ist - const officeType = await ChurchOfficeType.findByPk(officeTypeId); - if (!officeType) { - throw new Error('Office type not found'); - } - - const occupiedCount = await ChurchOffice.count({ - where: { - officeTypeId: officeTypeId, - regionId: regionId - } - }); - - if (occupiedCount >= officeType.seatsPerRegion) { - throw new Error('No available seats'); - } - - // Finde Supervisor (nur wenn es nicht die niedrigste Position ist) - let supervisorId = null; - if (officeType.hierarchyLevel > 0) { - const higherOfficeTypeIds = await ChurchOfficeType.findAll({ - where: { - hierarchyLevel: { [Op.gt]: officeType.hierarchyLevel } - }, - attributes: ['id'] - }).then(types => types.map(t => t.id)); - - if (higherOfficeTypeIds.length > 0) { - const supervisorOffice = await ChurchOffice.findOne({ - where: { - regionId: regionId, - officeTypeId: { [Op.in]: higherOfficeTypeIds } - }, - include: [ - { - model: ChurchOfficeType, - as: 'type', - attributes: ['id', 'name', 'hierarchyLevel'] - }, - { - model: FalukantCharacter, - as: 'holder', - attributes: ['id'] - } - ], - order: [ - [{ model: ChurchOfficeType, as: 'type' }, 'hierarchyLevel', 'ASC'] - ], - limit: 1 - }); - - if (!supervisorOffice) { - throw new Error('No supervisor position exists in this region. Higher church offices must be filled before you can apply.'); - } - if (!supervisorOffice.holder) { - const officeName = supervisorOffice.type?.name || 'higher'; - throw new Error(`The ${officeName} position in this region is vacant. It must be filled before you can apply.`); - } - supervisorId = supervisorOffice.holder.id; - } else { - throw new Error('No supervisor office type found'); - } - } - // Für Einstiegspositionen (hierarchyLevel 0) ist kein Supervisor erforderlich - - // Prüfe ob bereits eine Bewerbung existiert - const existingApplication = await ChurchApplication.findOne({ - where: { - characterId: character.id, - officeTypeId: officeTypeId, - regionId: regionId, - status: 'pending' - } - }); - - if (existingApplication) { - throw new Error('Application already exists'); - } - - // Erstelle Bewerbung - await ChurchApplication.create({ - officeTypeId: officeTypeId, - characterId: character.id, - regionId: regionId, - supervisorId: supervisorId, // Kann null sein für Einstiegspositionen - status: 'pending' - }); - - return { success: true }; - } - - async decideOnChurchApplication(hashedUserId, applicationId, decision) { - 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', - attributes: ['id', 'seatsPerRegion'] - } - ] - }); - - if (!application) { - throw new Error('Application not found or not authorized'); - } - - if (decision === 'approve') { - // Prüfe ob noch Platz verfügbar ist - const occupiedCount = await ChurchOffice.count({ - where: { - officeTypeId: application.officeTypeId, - regionId: application.regionId - } - }); - - if (occupiedCount >= application.officeType.seatsPerRegion) { - throw new Error('No available seats'); - } - - // Erstelle Kirchenamt - await ChurchOffice.create({ - officeTypeId: application.officeTypeId, - characterId: application.characterId, - regionId: application.regionId, - supervisorId: application.supervisorId - }); - } - - // Aktualisiere Bewerbung - application.status = decision === 'approve' ? 'approved' : 'rejected'; - application.decisionDate = new Date(); - await application.save(); - - return { success: true }; - } } export default new FalukantService(); @@ -6139,7 +4840,7 @@ async function enrichNotificationsWithCharacterNames(notifications) { return null; } - // Attach resolved name to notifications (set character_name; characterName is a getter that reads from it) + // Attach resolved name to notifications (set both characterName and character_name) for (const n of notifications) { let foundId = null; try { @@ -6161,7 +4862,7 @@ async function enrichNotificationsWithCharacterNames(notifications) { if (foundId && nameMap.has(Number(foundId))) { const resolved = nameMap.get(Number(foundId)); - // Set character_name directly (characterName is a getter that reads from character_name) + n.characterName = resolved; n.character_name = resolved; } } diff --git a/frontend/src/components/falukant/DirectorInfo.vue b/frontend/src/components/falukant/DirectorInfo.vue index b7aeda1..446081c 100644 --- a/frontend/src/components/falukant/DirectorInfo.vue +++ b/frontend/src/components/falukant/DirectorInfo.vue @@ -210,14 +210,6 @@ export default { }, }; }, - watch: { - branchId: { - immediate: false, - handler() { - this.loadDirector(); - }, - }, - }, async mounted() { await this.loadDirector(); }, @@ -264,17 +256,11 @@ export default { }, speedLabel(value) { - if (value == null) return this.$t('falukant.branch.transport.speed.unknown') || '—'; - if (typeof value === 'object') { - const k = value.tr ?? value.id ?? 'unknown'; - const tKey = `falukant.branch.transport.speed.${k}`; - const t = this.$t(tKey); - return (t && t !== tKey) ? t : String(k); - } - const key = String(value); + const key = value == null ? 'unknown' : String(value); const tKey = `falukant.branch.transport.speed.${key}`; const translated = this.$t(tKey); - return (!translated || translated === tKey) ? key : translated; + if (!translated || translated === tKey) return value; + return translated; }, openNewDirectorDialog() { diff --git a/frontend/src/components/falukant/SaleSection.vue b/frontend/src/components/falukant/SaleSection.vue index 27aead0..c481234 100644 --- a/frontend/src/components/falukant/SaleSection.vue +++ b/frontend/src/components/falukant/SaleSection.vue @@ -251,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(); @@ -267,19 +274,6 @@ } }, methods: { - speedLabel(value) { - if (value == null) return this.$t('falukant.branch.transport.speed.unknown') || '—'; - if (typeof value === 'object') { - const k = value.tr ?? value.id ?? 'unknown'; - const tKey = `falukant.branch.transport.speed.${k}`; - const t = this.$t(tKey); - return (t && t !== tKey) ? t : String(k); - } - const key = String(value); - const tKey = `falukant.branch.transport.speed.${key}`; - const translated = this.$t(tKey); - return (!translated || translated === tKey) ? key : translated; - }, async loadInventory() { try { const response = await apiClient.get(`/api/falukant/inventory/${this.branchId}`); @@ -293,24 +287,25 @@ } }, async loadPricesForInventory() { - if (this.inventory.length === 0) return; - const currentRegionId = this.inventory[0]?.region?.id ?? null; - const items = this.inventory.map(item => ({ - productId: item.product.id, - currentPrice: item.product.sellCost || 0 - })); - try { - const { data } = await apiClient.post('/api/falukant/products/prices-in-cities-batch', { - currentRegionId, - items - }); - for (const item of this.inventory) { - item.betterPrices = data && data[item.product.id] ? data[item.product.id] : []; - } - } catch (error) { - console.error('Error loading prices for inventory:', error); - for (const item of this.inventory) { - item.betterPrices = []; + for (const item of this.inventory) { + const itemKey = `${item.region.id}-${item.product.id}-${item.quality}`; + if (this.loadingPrices.has(itemKey)) continue; + this.loadingPrices.add(itemKey); + try { + // Aktueller Preis basierend auf sellCost + const currentPrice = item.product.sellCost || 0; + const { data } = await apiClient.get('/api/falukant/products/prices-in-cities', { + params: { + productId: item.product.id, + currentPrice: currentPrice + } + }); + this.$set(item, 'betterPrices', data || []); + } catch (error) { + console.error(`Error loading prices for item ${itemKey}:`, error); + this.$set(item, 'betterPrices', []); + } finally { + this.loadingPrices.delete(itemKey); } } }, diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index db0eda4..bf8606b 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -114,21 +114,11 @@ }, "overview": { "title": "Falukant - Übersicht", - "heirSelection": { - "title": "Erben-Auswahl", - "description": "Dein bisheriger Charakter ist nicht mehr verfügbar. Wähle einen Erben aus der Liste, um mit diesem weiterzuspielen.", - "loading": "Lade mögliche Erben…", - "noHeirs": "Keine Erben verfügbar.", - "select": "Als Spielcharakter wählen", - "error": "Fehler beim Auswählen des Erben." - }, "metadata": { "title": "Persönliches", "name": "Name", "money": "Vermögen", "age": "Alter", - "years": "Jahre", - "days": "Tage", "mainbranch": "Heimatstadt", "nobleTitle": "Stand" }, @@ -317,9 +307,6 @@ "current": "Laufende Produktionen", "product": "Produkt", "remainingTime": "Verbleibende Zeit (Sekunden)", - "status": "Status", - "sleep": "Pausiert", - "active": "Aktiv", "noProductions": "Keine laufenden Produktionen." }, "columns": { @@ -591,23 +578,6 @@ "time": "Zeit", "prev": "Zurück", "next": "Weiter", - "graph": { - "open": "Verlauf anzeigen", - "title": "Geldentwicklung", - "close": "Schließen", - "loading": "Lade Verlauf...", - "noData": "Für den gewählten Zeitraum liegen keine Buchungen vor.", - "yesterday": "Gestern", - "range": { - "label": "Zeitraum", - "today": "Heute", - "24h": "Letzte 24 Stunden", - "week": "Letzte Woche", - "month": "Letzter Monat", - "year": "Letztes Jahr", - "all": "Gesamter Verlauf" - } - }, "activities": { "Product sale": "Produkte verkauft", "Production cost": "Produktionskosten", @@ -678,7 +648,6 @@ "happy": "Glücklich", "sad": "Traurig", "angry": "Wütend", - "calm": "Ruhig", "nervous": "Nervös", "excited": "Aufgeregt", "bored": "Gelangweilt", @@ -774,34 +743,7 @@ "reputation": { "title": "Reputation", "overview": { - "title": "Übersicht", - "current": "Aktuelle Reputation" - }, - "actions": { - "title": "Reputations-Aktionen", - "description": "Du kannst verschiedene Aktionen durchführen, um deine Reputation zu verbessern.", - "none": "Keine Reputations-Aktionen verfügbar.", - "action": "Aktion", - "cost": "Kosten", - "gain": "Gewinn", - "timesUsed": "Verwendet", - "execute": "Ausführen", - "running": "Läuft...", - "dailyLimit": "Tägliches Limit: {remaining} von {cap} Aktionen übrig", - "cooldown": "Cooldown: Noch {minutes} Minuten", - "type": { - "soup_kitchen": "Suppenküche", - "library_donation": "Bibliotheksspende", - "scholarships": "Stipendien", - "church_hospice": "Kirchenhospiz", - "school_funding": "Schulfinanzierung", - "orphanage_build": "Waisenhaus bauen", - "bridge_build": "Brücke bauen", - "hospital_donation": "Krankenhausspende", - "patronage": "Mäzenatentum", - "statue_build": "Statue errichten", - "well_build": "Brunnen bauen" - } + "title": "Übersicht" }, "party": { "title": "Feste", @@ -852,53 +794,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": { - "lay-preacher": "Laienprediger", - "village-priest": "Dorfgeistlicher", - "parish-priest": "Pfarrer", - "dean": "Dekan", - "archdeacon": "Erzdiakon", - "bishop": "Bischof", - "archbishop": "Erzbischof", - "cardinal": "Kardinal", - "pope": "Papst" - }, "baptism": { "title": "Taufen", "table": { @@ -1000,12 +895,7 @@ "drunkOfLife": "Trunk des Lebens", "barber": "Barbier" }, - "choose": "Bitte auswählen", - "errors": { - "tooClose": "Du kannst nicht so oft Maßnahmen durchführen.", - "generic": "Ein Fehler ist aufgetreten." - }, - "nextMeasureAt": "Nächste Maßnahme ab" + "choose": "Bitte auswählen" }, "politics": { "title": "Politik", @@ -1029,13 +919,9 @@ "region": "Region", "date": "Datum", "candidacy": "Kandidatur", - "candidacyWithAge": "Kandidatur (ab 16 Jahren)", "none": "Keine offenen Positionen.", - "apply": "Für ausgewählte Positionen kandidieren", - "minAgeHint": "Kandidatur erst ab 16 Jahren möglich.", - "ageRequirement": "Für alle politischen Ämter gilt: Kandidatur erst ab 16 Jahren." + "apply": "Für ausgewählte Positionen kandidieren" }, - "too_young": "Dein Charakter ist noch zu jung. Eine Bewerbung ist erst ab 16 Jahren möglich.", "upcoming": { "office": "Amt", "region": "Region", diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json index 14a6d19..9a2d59f 100644 --- a/frontend/src/i18n/locales/en/falukant.json +++ b/frontend/src/i18n/locales/en/falukant.json @@ -93,27 +93,6 @@ "children": "Children", "children_unbaptised": "Unbaptised children" }, - "overview": { - "title": "Falukant - Overview", - "heirSelection": { - "title": "Heir Selection", - "description": "Your previous character is no longer available. Choose an heir from the list to continue playing.", - "loading": "Loading potential heirs…", - "noHeirs": "No heirs available.", - "select": "Select as play character", - "error": "Error selecting heir." - }, - "metadata": { - "title": "Personal", - "name": "Name", - "money": "Wealth", - "age": "Age", - "years": "Years", - "days": "Days", - "mainbranch": "Home city", - "nobleTitle": "Title" - } - }, "health": { "amazing": "Amazing", "good": "Good", @@ -132,23 +111,6 @@ "time": "Time", "prev": "Previous", "next": "Next", - "graph": { - "open": "Show graph", - "title": "Money over time", - "close": "Close", - "loading": "Loading history...", - "noData": "No entries for the selected period.", - "yesterday": "Yesterday", - "range": { - "label": "Range", - "today": "Today", - "24h": "Last 24 hours", - "week": "Last week", - "month": "Last month", - "year": "Last year", - "all": "All history" - } - }, "activities": { "Product sale": "Product sale", "Production cost": "Production cost", @@ -203,29 +165,6 @@ "income": "Income", "incomeUpdated": "Salary has been successfully updated." }, - "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": "Ending:", - "time": "Time", - "current": "Running productions", - "product": "Product", - "remainingTime": "Remaining time (seconds)", - "status": "Status", - "sleep": "Paused", - "active": "Active", - "noProductions": "No running productions." - }, "vehicles": { "cargo_cart": "Cargo cart", "ox_cart": "Ox cart", @@ -258,86 +197,7 @@ }, "nobility": { "cooldown": "You can only advance again on {date}." - }, - "mood": { - "happy": "Happy", - "sad": "Sad", - "angry": "Angry", - "calm": "Calm", - "nervous": "Nervous", - "excited": "Excited", - "bored": "Bored", - "fearful": "Fearful", - "confident": "Confident", - "curious": "Curious", - "hopeful": "Hopeful", - "frustrated": "Frustrated", - "lonely": "Lonely", - "grateful": "Grateful", - "jealous": "Jealous", - "guilty": "Guilty", - "apathetic": "Apathetic", - "relieved": "Relieved", - "proud": "Proud", - "ashamed": "Ashamed" - }, - "character": { - "brave": "Brave", - "kind": "Kind", - "greedy": "Greedy", - "wise": "Wise", - "loyal": "Loyal", - "cunning": "Cunning", - "generous": "Generous", - "arrogant": "Arrogant", - "honest": "Honest", - "ambitious": "Ambitious", - "patient": "Patient", - "impatient": "Impatient", - "selfish": "Selfish", - "charismatic": "Charismatic", - "empathetic": "Empathetic", - "timid": "Timid", - "stubborn": "Stubborn", - "resourceful": "Resourceful", - "reckless": "Reckless", - "disciplined": "Disciplined", - "optimistic": "Optimistic", - "pessimistic": "Pessimistic", - "manipulative": "Manipulative", - "independent": "Independent", - "dependent": "Dependent", - "adventurous": "Adventurous", - "humble": "Humble", - "vengeful": "Vengeful", - "pragmatic": "Pragmatic", - "idealistic": "Idealistic" - }, - "healthview": { - "title": "Health", - "age": "Age", - "status": "Health Status", - "measuresTaken": "Measures Taken", - "measure": "Measure", - "date": "Date", - "cost": "Cost", - "success": "Success", - "selectMeasure": "Select Measure", - "perform": "Perform", - "measures": { - "pill": "Pill", - "doctor": "Doctor Visit", - "witch": "Witch", - "drunkOfLife": "Elixir of Life", - "barber": "Barber" - }, - "choose": "Please select", - "errors": { - "tooClose": "You cannot perform measures so often.", - "generic": "An error occurred." - }, - "nextMeasureAt": "Next measure from" - }, + }, "branchProduction": { "storageAvailable": "Free storage" }, @@ -363,13 +223,9 @@ "region": "Region", "date": "Date", "candidacy": "Candidacy", - "candidacyWithAge": "Candidacy (from age 16)", "none": "No open positions.", - "apply": "Apply for selected positions", - "minAgeHint": "Candidacy is only possible from age 16.", - "ageRequirement": "All political offices require candidates to be at least 16 years old." + "apply": "Apply for selected positions" }, - "too_young": "Your character is too young. Applications are only possible from age 16.", "upcoming": { "office": "Office", "region": "Region", @@ -466,143 +322,6 @@ "success": "The gift has been given.", "nextGiftAt": "Next gift from" } - }, - "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": { - "lay-preacher": "Lay Preacher", - "village-priest": "Village Priest", - "parish-priest": "Parish Priest", - "dean": "Dean", - "archdeacon": "Archdeacon", - "bishop": "Bishop", - "archbishop": "Archbishop", - "cardinal": "Cardinal", - "pope": "Pope" - }, - "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." - } - }, - "reputation": { - "title": "Reputation", - "overview": { - "title": "Overview", - "current": "Current Reputation" - }, - "actions": { - "title": "Reputation Actions", - "description": "You can perform various actions to improve your reputation.", - "none": "No reputation actions available.", - "action": "Action", - "cost": "Cost", - "gain": "Gain", - "timesUsed": "Used", - "execute": "Execute", - "running": "Running...", - "dailyLimit": "Daily limit: {remaining} of {cap} actions remaining", - "cooldown": "Cooldown: {minutes} minutes remaining", - "type": { - "soup_kitchen": "Soup Kitchen", - "library_donation": "Library Donation", - "scholarships": "Scholarships", - "church_hospice": "Church Hospice", - "school_funding": "School Funding", - "orphanage_build": "Build Orphanage", - "bridge_build": "Build Bridge", - "hospital_donation": "Hospital Donation", - "patronage": "Patronage", - "statue_build": "Build Statue", - "well_build": "Build Well" - } - }, - "party": { - "title": "Parties", - "totalCost": "Total Cost", - "order": "Order Party", - "inProgress": "Parties in Preparation", - "completed": "Completed Parties", - "newpartyview": { - "open": "Create New Party", - "close": "Hide New Party", - "type": "Party Type" - }, - "music": { - "label": "Music", - "none": "No Music", - "bard": "A Bard", - "villageBand": "A Village Band", - "chamberOrchestra": "A Chamber Orchestra", - "symphonyOrchestra": "A Symphony Orchestra", - "symphonyOrchestraWithChorusAndSolists": "A Symphony Orchestra with Chorus and Soloists" - }, - "banquette": { - "label": "Food", - "bread": "Bread", - "roastWithBeer": "Roast with Beer", - "poultryWithVegetablesAndWine": "Poultry with Vegetables and Wine", - "extensiveBuffet": "Festive Meal" - }, - "servants": { - "label": "One servant per ", - "perPersons": " persons" - }, - "esteemedInvites": { - "label": "Invited Estates" - }, - "type": "Party Type", - "cost": "Cost", - "date": "Date" - } } } } \ No newline at end of file diff --git a/frontend/src/views/falukant/BranchView.vue b/frontend/src/views/falukant/BranchView.vue index aaf91b4..5ccae65 100644 --- a/frontend/src/views/falukant/BranchView.vue +++ b/frontend/src/views/falukant/BranchView.vue @@ -360,7 +360,6 @@ export default { vehicles: [], activeTab: 'production', productPricesCache: {}, // Cache für regionale Preise: { productId: price } - productPricesCacheRegionId: null, // regionId, für die der Cache gültig ist tabs: [ { value: 'production', label: 'falukant.branch.tabs.production' }, { value: 'inventory', label: 'falukant.branch.tabs.inventory' }, @@ -570,46 +569,30 @@ export default { async loadProductPricesForCurrentBranch() { if (!this.selectedBranch || !this.selectedBranch.regionId) { this.productPricesCache = {}; - this.productPricesCacheRegionId = null; return; } - if (this.productPricesCacheRegionId === this.selectedBranch.regionId && Object.keys(this.productPricesCache).length > 0) { - return; - } - try { - const { data } = await apiClient.get('/api/falukant/products/prices-in-region', { - params: { - regionId: this.selectedBranch.regionId - } - }); - this.productPricesCache = data.prices || {}; - this.productPricesCacheRegionId = this.selectedBranch.regionId; - } catch (error) { - console.error(`Error loading product prices for region ${this.selectedBranch.regionId}:`, error); - // Fallback: Lade Preise einzeln (alte Methode) - console.warn('[BranchView] Falling back to individual product price requests'); - 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 (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; - prices[product.id] = minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100); - } + + // 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; - this.productPricesCacheRegionId = this.selectedBranch?.regionId ?? null; } + this.productPricesCache = prices; }, formatPercent(value) { @@ -721,17 +704,13 @@ export default { }, speedLabel(value) { - if (value == null) return this.$t('falukant.branch.transport.speed.unknown') || '—'; - if (typeof value === 'object') { - const k = value.tr ?? value.id ?? 'unknown'; - const tKey = `falukant.branch.transport.speed.${k}`; - const t = this.$t(tKey); - return (t && t !== tKey) ? t : String(k); - } - const key = String(value); + // Expect numeric speeds 1..4; provide localized labels as fallback to raw value + const key = value == null ? 'unknown' : String(value); const tKey = `falukant.branch.transport.speed.${key}`; const translated = this.$t(tKey); - return (!translated || translated === tKey) ? key : translated; + // If translation returns the key (no translation found), fall back to the numeric value + if (!translated || translated === tKey) return value; + return translated; }, transportModeLabel(mode) { diff --git a/frontend/src/views/falukant/FamilyView.vue b/frontend/src/views/falukant/FamilyView.vue index 31493ea..c762129 100644 --- a/frontend/src/views/falukant/FamilyView.vue +++ b/frontend/src/views/falukant/FamilyView.vue @@ -25,7 +25,7 @@ {{ $t('falukant.family.spouse.mood') }} - {{ relationships[0].character2.mood?.tr ? $t(`falukant.mood.${relationships[0].character2.mood.tr}`) : '—' }} + {{ $t(`falukant.mood.${relationships[0].character2.mood.tr}`) }} {{ $t('falukant.family.spouse.status') }} diff --git a/frontend/src/views/falukant/MoneyHistoryView.vue b/frontend/src/views/falukant/MoneyHistoryView.vue index bc939ad..fda9101 100644 --- a/frontend/src/views/falukant/MoneyHistoryView.vue +++ b/frontend/src/views/falukant/MoneyHistoryView.vue @@ -9,12 +9,6 @@ -
- -
- @@ -48,21 +42,17 @@ {{ $t('falukant.moneyHistory.next') }} - - @@ -119,10 +106,6 @@ export default { margin-bottom: 1rem; } -.graph-section { - margin-bottom: 1rem; -} - table { width: 100%; border-collapse: collapse; diff --git a/frontend/src/views/falukant/PoliticsView.vue b/frontend/src/views/falukant/PoliticsView.vue index e767a6d..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 }} @@ -57,7 +57,6 @@
-

{{ $t('falukant.politics.open.ageRequirement') }}

{{ $t('loading') }}
@@ -66,7 +65,7 @@ - + @@ -75,13 +74,13 @@ - @@ -194,7 +193,6 @@ export default { elections: [], selectedCandidates: {}, selectedApplications: [], - ownCharacterId: null, loading: { current: false, openPolitics: false, @@ -212,7 +210,6 @@ export default { } }, mounted() { - this.loadOwnCharacterId(); this.loadCurrentPositions(); }, methods: { @@ -232,12 +229,9 @@ export default { this.loading.current = true; try { const { data } = await apiClient.get('/api/falukant/politics/overview'); - console.log('[PoliticsView] loadCurrentPositions - API response:', data); - console.log('[PoliticsView] loadCurrentPositions - ownCharacterId at load time:', this.ownCharacterId); this.currentPositions = data; - console.log('[PoliticsView] loadCurrentPositions - Loaded', data.length, 'positions'); } catch (err) { - console.error('[PoliticsView] Error loading current positions', err); + console.error('Error loading current positions', err); } finally { this.loading.current = false; } @@ -247,10 +241,10 @@ export default { this.loading.openPolitics = true; try { const { data } = await apiClient.get('/api/falukant/politics/open'); - this.openPolitics = Array.isArray(data) ? data : []; + this.openPolitics = data; // Bereits beworbene Positionen vorselektieren, damit die Checkbox // sichtbar markiert bleibt. - this.selectedApplications = this.openPolitics + this.selectedApplications = data .filter(e => e.alreadyApplied) .map(e => e.id); } catch (err) { @@ -336,44 +330,6 @@ export default { }); }, - async loadOwnCharacterId() { - try { - const { data } = await apiClient.get('/api/falukant/info'); - console.log('[PoliticsView] loadOwnCharacterId - API response:', data); - console.log('[PoliticsView] loadOwnCharacterId - data.character:', data.character); - console.log('[PoliticsView] loadOwnCharacterId - data.character?.id:', data.character?.id); - if (data.character && data.character.id) { - this.ownCharacterId = data.character.id; - console.log('[PoliticsView] loadOwnCharacterId - Set ownCharacterId to:', this.ownCharacterId); - } else { - console.warn('[PoliticsView] loadOwnCharacterId - No character ID found in response', { - hasCharacter: !!data.character, - characterKeys: data.character ? Object.keys(data.character) : null, - characterId: data.character?.id - }); - } - } catch (err) { - console.error('[PoliticsView] Error loading own character ID', err); - } - }, - - isOwnPosition(pos) { - console.log('[PoliticsView] isOwnPosition - Checking position:', { - posId: pos.id, - posCharacter: pos.character, - posCharacterId: pos.character?.id, - ownCharacterId: this.ownCharacterId, - match: pos.character?.id === this.ownCharacterId - }); - if (!this.ownCharacterId || !pos.character) { - console.log('[PoliticsView] isOwnPosition - Returning false (missing ownCharacterId or pos.character)'); - return false; - } - const isMatch = pos.character.id === this.ownCharacterId; - console.log('[PoliticsView] isOwnPosition - Result:', isMatch); - return isMatch; - }, - async submitApplications() { try { const response = await apiClient.post( @@ -390,10 +346,6 @@ export default { .map(e => e.id); } catch (err) { console.error('Error submitting applications', err); - const msg = err?.response?.data?.error === 'too_young' - ? this.$t('falukant.politics.too_young') - : (err?.response?.data?.error || err?.message || this.$t('falukant.politics.applyError')); - this.$root.$refs?.messageDialog?.open?.(msg, this.$t('falukant.politics.title')); } } } @@ -432,13 +384,6 @@ h2 { overflow: hidden; } -.politics-age-requirement { - flex: 0 0 auto; - margin: 0 0 10px 0; - font-size: 0.95em; - color: #555; -} - .table-scroll { flex: 1; overflow-y: auto; @@ -466,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;
{{ $t('falukant.politics.open.office') }} {{ $t('falukant.politics.open.region') }} {{ $t('falukant.politics.open.date') }}{{ $t('falukant.politics.open.candidacyWithAge') }}{{ $t('falukant.politics.open.candidacy') }}
{{ e.region.name }} {{ formatDate(e.date) }} +