diff --git a/backend/controllers/falukantController.js b/backend/controllers/falukantController.js index f9af83b..f0c331c 100644 --- a/backend/controllers/falukantController.js +++ b/backend/controllers/falukantController.js @@ -58,6 +58,10 @@ 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; @@ -123,6 +127,9 @@ 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)); @@ -142,6 +149,17 @@ 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) => { @@ -154,25 +172,20 @@ 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(async (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) => { try { - return await this.service.advanceNobility(userId); + return await this.service.healthActivity(userId, req.body.measureTr); } catch (e) { - 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 || [] }; - } + if (e && e.name === 'PreconditionError' && e.message === 'tooClose') { + throw { status: 412, message: 'tooClose', retryAt: e.meta?.retryAt }; } 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)); @@ -189,6 +202,13 @@ 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); @@ -198,6 +218,16 @@ 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)); @@ -205,6 +235,7 @@ 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) => { @@ -279,7 +310,13 @@ class FalukantController { } catch (error) { console.error('Controller error:', error); const status = error.status && typeof error.status === 'number' ? error.status : 500; - res.status(status).json({ error: error.message || 'Internal error' }); + // 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' }); + } } }; } diff --git a/backend/models/falukant/type/product.js b/backend/models/falukant/type/product.js index 7393139..1170edc 100644 --- a/backend/models/falukant/type/product.js +++ b/backend/models/falukant/type/product.js @@ -15,17 +15,7 @@ ProductType.init({ allowNull: false}, sellCost: { type: DataTypes.INTEGER, - allowNull: false} - , - sellCostMinNeutral: { - type: DataTypes.DECIMAL, - allowNull: true, - field: 'sell_cost_min_neutral' - }, - sellCostMaxNeutral: { - type: DataTypes.DECIMAL, - allowNull: true, - field: 'sell_cost_max_neutral' + allowNull: false } }, { sequelize, diff --git a/backend/routers/falukantRouter.js b/backend/routers/falukantRouter.js index b80d406..af7cf87 100644 --- a/backend/routers/falukantRouter.js +++ b/backend/routers/falukantRouter.js @@ -11,6 +11,7 @@ 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); @@ -28,6 +29,7 @@ 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); @@ -50,6 +52,8 @@ 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); @@ -61,6 +65,11 @@ 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); @@ -72,13 +81,14 @@ 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 681a780..1387127 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -10,6 +10,7 @@ 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'; @@ -33,6 +34,7 @@ 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'; @@ -48,6 +50,10 @@ 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'; @@ -65,6 +71,8 @@ import Weather from '../models/falukant/data/weather.js'; import TownProductWorth from '../models/falukant/data/town_product_worth.js'; import ProductWeatherEffect from '../models/falukant/type/product_weather_effect.js'; import WeatherType from '../models/falukant/type/weather.js'; +import ReputationActionType from '../models/falukant/type/reputation_action.js'; +import ReputationActionLog from '../models/falukant/log/reputation_action.js'; function calcAge(birthdate) { const b = new Date(birthdate); b.setHours(0, 0); @@ -92,6 +100,15 @@ 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) { @@ -101,6 +118,11 @@ 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); @@ -146,6 +168,7 @@ 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 }, @@ -191,6 +214,81 @@ 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; @@ -320,7 +418,6 @@ 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 } }, @@ -330,27 +427,40 @@ 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: CharacterTrait, as: 'traits', attributes: ['id', 'tr'] } + { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr', 'id'] } ], 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; } @@ -387,22 +497,26 @@ class FalukantService extends BaseService { } ] }, - { - model: UserHouse, - as: 'userHouse', - include: [ - { - model: HouseType, - as: 'houseType', - 'attributes': ['labelTr', 'position'] - }, - ], - attributes: ['roofCondition'], - }, ], - attributes: ['money', 'creditAmount', 'todayCreditTaken',] + attributes: ['id', '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; } @@ -457,39 +571,45 @@ class FalukantService extends BaseService { { model: FalukantCharacter, as: 'character', - 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', 'birthdate', 'health', 'reputation', 'titleOfNobility'], }, ], 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 { @@ -563,7 +683,13 @@ 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: [ @@ -588,6 +714,8 @@ 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))]; @@ -598,8 +726,10 @@ 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`); - return bs.map(b => { + const result = 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; @@ -609,6 +739,11 @@ 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) { @@ -723,21 +858,32 @@ class FalukantService extends BaseService { const vehicles = await Vehicle.findAll({ where, - include: [ - { - model: VehicleType, - as: 'type', - attributes: ['id', 'tr', 'capacity', 'transportMode', 'speed', 'buildTimeMinutes', 'cost'], - }, - { - model: Transport, - as: 'transports', - attributes: ['id', 'sourceRegionId', 'targetRegionId'], - required: false, - }, - ], + attributes: ['id', 'vehicleTypeId', 'regionId', 'condition', 'availableFrom'], 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; @@ -961,33 +1107,33 @@ class FalukantService extends BaseService { // Produkt-Transport oder leerer Transport (nur Fahrzeuge bewegen)? const isEmptyTransport = !productId || !quantity || quantity <= 0; - let inventory = []; + let sourceStockIds = []; 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: Inventar prüfen - const stock = await FalukantStock.findOne({ where: { branchId: sourceBranch.id } }); - if (!stock) { + // Produkt-Transport: alle Stocks der Quell-Niederlassung (wie getInventory) + const sourceStocks = await FalukantStock.findAll({ where: { branchId: sourceBranch.id }, attributes: ['id'] }); + if (!sourceStocks?.length) { throw new Error('Stock not found'); } + sourceStockIds = sourceStocks.map((s) => s.id); - inventory = await Inventory.findAll({ - where: { stockId: stock.id }, + const inventoryCheck = await Inventory.findAll({ + where: { + stockId: { [Op.in]: sourceStockIds }, + productId, + }, include: [ - { - model: ProductType, - as: 'productType', - required: true, - where: { id: productId }, - }, + { model: ProductType, as: 'productType', required: true, where: { id: productId }, attributes: ['id', 'sellCost'] }, ], }); - available = inventory.reduce((sum, i) => sum + i.quantity, 0); + available = inventoryCheck.reduce((sum, i) => sum + i.quantity, 0); if (available <= 0) { throw new PreconditionError('noInventory'); } @@ -1001,7 +1147,7 @@ class FalukantService extends BaseService { } // Transportkosten: 1 % des Warenwerts, mindestens 0,1 - const productType = inventory[0]?.productType; + const productType = inventoryCheck[0]?.productType; const unitValue = productType?.sellCost || 0; const totalValue = unitValue * requested; transportCost = Math.max(0.1, totalValue * 0.01); @@ -1057,15 +1203,28 @@ class FalukantService extends BaseService { } // Inventar in der Quell-Niederlassung reduzieren (nur bei Produkt-Transport) - if (!isEmptyTransport && inventory.length > 0) { + // 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, + }); + let left = requested; - for (const inv of inventory) { + for (const inv of inventoryRows) { if (left <= 0) break; - if (inv.quantity <= left) { - left -= inv.quantity; + const qty = Number(inv.quantity) || 0; + if (qty <= 0) continue; + if (qty <= left) { + left -= qty; await inv.destroy({ transaction: tx }); } else { - await inv.update({ quantity: inv.quantity - left }, { transaction: tx }); + await inv.update({ quantity: qty - left }, { transaction: tx }); left = 0; break; } @@ -1599,11 +1758,22 @@ 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 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'); + // 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'); + } + } } let remaining = quantity; for (const inv of inventory) { @@ -1668,30 +1838,22 @@ 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(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 cumulativeTax = await getCumulativeTaxPercentWithExemptions(falukantUser.id, 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; - totalTax += itemTax; + 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); } - + const totalTax = [...taxPerRegion.values()].reduce((s, t) => s + t, 0); const totalNet = Math.round((total - totalTax) * 100) / 100; const moneyResult = await updateFalukantUserMoney( @@ -1703,9 +1865,19 @@ class FalukantService extends BaseService { if (!moneyResult.success) throw new Error('Failed to update money for seller'); const treasuryId = process.env.TREASURY_FALUKANT_USER_ID; - if (treasuryId && totalTax > 0) { - const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), Math.round(totalTax * 100) / 100, `Sales tax (aggregate)`, falukantUser.id); - if (!taxResult.success) throw new Error('Failed to update money for treasury'); + for (const [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'); + } } for (const item of inventory) { await Inventory.destroy({ where: { id: item.id } }); @@ -1778,6 +1950,62 @@ 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); @@ -2121,26 +2349,64 @@ class FalukantService extends BaseService { async generateProposals(falukantUserId, regionId) { try { - const threeWeeksAgo = new Date(Date.now() - 21 * 24 * 60 * 60 * 1000); + 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 proposalCount = Math.floor(Math.random() * 3) + 3; + const usedCharacterIds = new Set(); + for (let i = 0; i < proposalCount; i++) { - const directorCharacter = await FalukantCharacter.findOne({ - where: { - regionId, - createdAt: { [Op.lt]: threeWeeksAgo }, - }, + 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 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) @@ -2395,28 +2661,28 @@ class FalukantService extends BaseService { ], attributes: ['id', 'satisfaction', 'income'], }); - 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, - }; - }); + 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, + }; + }); } async updateDirector(hashedUserId, directorId, income) { @@ -2477,82 +2743,108 @@ 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'); - let relationships = await Relationship.findAll({ + // Load relationships without includes to avoid EagerLoadingError + const relRows = await Relationship.findAll({ where: { character1Id: character.id }, - attributes: ['createdAt', 'widowFirstName2', 'nextStepProgress'], - include: [ - { - model: FalukantCharacter, as: 'character2', + 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: ['id', 'birthdate', 'gender', 'moodId'], include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] }, - { model: CharacterTrait, as: 'traits' }, - { model: Mood, as: 'mood' }, + { model: 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: RelationshipType, as: 'relationshipType', attributes: ['tr'] } - ] + 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, + }; }); - 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)); + // 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)), @@ -2761,12 +3053,12 @@ class FalukantService extends BaseService { } async getGifts(hashedUserId) { - // 1) Mein User & Character + // 1) Mein aktiver Falukant-User & dessen aktueller Charakter const user = await this.getFalukantUserByHashedId(hashedUserId); - const myChar = await FalukantCharacter.findOne({ where: { userId: user.id } }); + const myChar = user.character; if (!myChar) throw new Error('Character not found'); - // 2) Beziehung finden und „anderen“ Character bestimmen + // 2) Beziehung finden und „anderen“ Character bestimmen (ohne Include, um EagerLoadingError zu vermeiden) const rel = await Relationship.findOne({ where: { [Op.or]: [ @@ -2774,14 +3066,25 @@ class FalukantService extends BaseService { { character2Id: myChar.id } ] }, - include: [ - { model: FalukantCharacter, as: 'character1', include: [{ model: CharacterTrait, as: 'traits' }] }, - { model: FalukantCharacter, as: 'character2', include: [{ model: CharacterTrait, as: 'traits' }] } - ] + attributes: ['character1Id', 'character2Id'] }); - if (!rel) throw new Error('Beziehung nicht gefunden'); + if (!rel) return []; - const relatedChar = rel.character1.id === myChar.id ? rel.character2 : rel.character1; + 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); // 3) Trait-IDs und Mood des relatedChar const relatedTraitIds = relatedChar.traits.map(t => t.id); @@ -2793,15 +3096,15 @@ class FalukantService extends BaseService { { model: PromotionalGiftMood, as: 'promotionalgiftmoods', - attributes: ['mood_id', 'suitability'], - where: { mood_id: relatedMoodId }, + attributes: ['moodId', 'suitability'], + where: { moodId: relatedMoodId }, required: false // Gifts ohne Mood-Match bleiben erhalten, haben dann leeres Array }, { model: PromotionalGiftCharacterTrait, as: 'characterTraits', - attributes: ['trait_id', 'suitability'], - where: { trait_id: relatedTraitIds }, + attributes: ['traitId', 'suitability'], + where: { traitId: relatedTraitIds }, required: false // Gifts ohne Trait-Match bleiben erhalten } ] @@ -2824,7 +3127,6 @@ class FalukantService extends BaseService { async getChildren(hashedUserId) { const user = await this.getFalukantUserByHashedId(hashedUserId); - console.log(user); const children = await ChildRelation.findAll({ where: { [Op.or]: [ @@ -2906,13 +3208,13 @@ class FalukantService extends BaseService { { model: PromotionalGiftCharacterTrait, as: 'characterTraits', - where: { trait_id: { [Op.in]: user.character.traits.map(t => t.id) } }, + where: { traitId: { [Op.in]: user.character.traits.map(t => t.id) } }, required: false }, { model: PromotionalGiftMood, as: 'promotionalgiftmoods', - where: { mood_id: currentMoodId }, + where: { moodId: currentMoodId }, required: false } ] @@ -2985,6 +3287,193 @@ 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 } @@ -3151,7 +3640,9 @@ class FalukantService extends BaseService { attributes: ['id'] }); if (already) { - throw new Error('Diese Party wurde bereits innerhalb der letzten 24 Stunden bestellt'); + const error = new Error('Diese Party wurde bereits innerhalb der letzten 24 Stunden bestellt'); + error.status = 409; + throw error; } const [ptype, music, banquette] = await Promise.all([ PartyType.findByPk(partyTypeId), @@ -3170,7 +3661,9 @@ class FalukantService extends BaseService { throw new Error('Einige ausgewählte Adelstitel existieren nicht'); } - let cost = (ptype.cost || 0) + (music.cost || 0) + (banquette.cost || 0); + 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); cost += (50 / servantRatio - 1) * 1000; const nobilityCost = nobilities.reduce((sum, n) => sum + ((n.id ^ 5) * 1000), 0); cost += nobilityCost; @@ -3195,6 +3688,10 @@ 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); } @@ -3283,12 +3780,10 @@ class FalukantService extends BaseService { async baptise(hashedUserId, childId, firstName) { try { - const falukantUser = await getFalukantUserOrFail(hashedUserId); - const parentCharacter = await FalukantCharacter.findOne({ - where: { - userId: falukantUser.id, - }, - }); + // 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; if (!parentCharacter) { throw new Error('Parent character not found'); } @@ -3336,8 +3831,9 @@ class FalukantService extends BaseService { firstName: firstNameObject.id, }); updateFalukantUserMoney(falukantUser.id, -50, 'Baptism', falukantUser.id); - // Trigger status bar refresh for the user after baptism - notifyUser(hashedUserId, 'falukantUpdateStatus', {}); + // Trigger status bar refresh (children count) and family view update + await notifyUser(hashedUserId, 'falukantUpdateStatus', {}); + await notifyUser(hashedUserId, 'familychanged', {}); return { success: true }; } catch (error) { throw new Error(error.message); @@ -3393,6 +3889,37 @@ 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); @@ -3412,11 +3939,14 @@ class FalukantService extends BaseService { let percent; if (item === 'all') { const all = await this.getKnowledgeForAll(hashedUserId, student, studentId); - const sum = all.reduce((s, k) => s + k.knowledge, 0); - percent = sum / all.length; + if (!all.length) percent = 0; + else { + const sum = all.reduce((s, k) => s + (k.knowledge || 0), 0); + percent = sum / all.length; + } } else { const single = await this.getKnowledgeSingle(hashedUserId, student, studentId, item); - percent = single.knowledge; + percent = single.knowledge ?? 0; } // 4) Kosten berechnen @@ -3581,46 +4111,30 @@ class FalukantService extends BaseService { const oneWeekAgo = new Date(now.getTime()); oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); if (user.lastNobilityAdvanceAt > oneWeekAgo) { - 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; + throw new Error('too soon'); } } const nextTitle = nobility.next.toJSON(); let fulfilled = true; let cost = 0; - const unmet = []; for (const requirement of nextTitle.requirements) { switch (requirement.requirementType) { case 'money': - if (!(await this.checkMoneyRequirement(user, requirement))) { - fulfilled = false; - unmet.push({ type: 'money', required: requirement.requirementValue, current: Number(user.money) }); - } + fulfilled = fulfilled && await this.checkMoneyRequirement(user, requirement); break; case 'cost': - if (!(await this.checkMoneyRequirement(user, requirement))) { - fulfilled = false; - unmet.push({ type: 'cost', required: requirement.requirementValue, current: Number(user.money) }); - } + fulfilled = fulfilled && await this.checkMoneyRequirement(user, requirement); cost = requirement.requirementValue; break; case 'branches': - if (!(await this.checkBranchesRequirement(hashedUserId, requirement))) { - fulfilled = false; - unmet.push({ type: 'branches', required: requirement.requirementValue }); - } + fulfilled = fulfilled && await this.checkBranchesRequirement(hashedUserId, requirement); break; default: fulfilled = false; }; } if (!fulfilled) { - const err = new PreconditionError('nobilityRequirements'); - err.meta = { unmet }; - throw err; + throw new Error('Requirements not fulfilled'); } const newTitle = await TitleOfNobility.findOne({ where: { level: nobility.current.level + 1 } @@ -3678,7 +4192,11 @@ class FalukantService extends BaseService { limit: 1 }); if (lastHealthActivity) { - throw new Error('too close'); + // 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; } const activityObject = FalukantService.HEALTH_ACTIVITIES.find((a) => a.tr === activity); if (!activityObject) { @@ -3849,6 +4367,7 @@ class FalukantService extends BaseService { }, character: o.holder ? { + id: o.holder.id, definedFirstName: o.holder.definedFirstName, definedLastName: o.holder.definedLastName, nobleTitle: o.holder.nobleTitle, @@ -3860,14 +4379,6 @@ 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') { @@ -4019,9 +4530,15 @@ 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 characterId = user.character.id; + 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 rows = await sequelize.query( FalukantService.RECURSIVE_REGION_SEARCH, { @@ -4062,7 +4579,13 @@ 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 => { @@ -4092,9 +4615,11 @@ class FalukantService extends BaseService { return { ...e, history: matchingHistory, - alreadyApplied + alreadyApplied, + canApplyByAge }; - }); + }) + .filter(election => !election.alreadyApplied); // Nur Positionen ohne bestehende Bewerbung return result; } @@ -4109,24 +4634,30 @@ class FalukantService extends BaseService { throw new Error('Kein Charakter zum User gefunden'); } - // 2) Noncivil‐Titel aussperren + // 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 if (character.nobleTitle.labelTr === 'noncivil') { return { applied: [], skipped: electionIds }; } - // 3) Ermittle die offenen Wahlen, auf die er zugreifen darf + // 4) 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)); - // 4) Filter alle electionIds auf gültige/erlaubte + // 5) Filter alle electionIds auf gültige/erlaubte const toTry = electionIds.filter(id => allowedIds.has(id)); if (toTry.length === 0) { return { applied: [], skipped: electionIds }; } - // 5) Prüfe, auf welche dieser Wahlen der Character bereits als Candidate eingetragen ist + // 6) Prüfe, auf welche dieser Wahlen der Character bereits als Candidate eingetragen ist const existing = await Candidate.findAll({ where: { electionId: { [Op.in]: toTry }, @@ -4136,13 +4667,11 @@ class FalukantService extends BaseService { }); const alreadyIds = new Set(existing.map(c => c.electionId)); - // 6) Erstelle Liste der Wahlen, für die er sich noch nicht beworben hat + // 7) 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)); - console.log(newApplications, skipped); - - // 7) Bulk-Insert aller neuen Bewerbungen + // 8) Bulk-Insert aller neuen Bewerbungen if (newApplications.length > 0) { const toInsert = newApplications.map(eid => ({ electionId: eid, @@ -4192,28 +4721,74 @@ class FalukantService extends BaseService { } async getProductPriceInRegion(hashedUserId, productId, regionId) { - const user = await this.getFalukantUserByHashedId(hashedUserId); - const character = await FalukantCharacter.findOne({ where: { userId: user.id } }); - if (!character) { - throw new Error(`No FalukantCharacter found for user with id ${user.id}`); + 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; } + } - // Produkt abrufen - const product = await ProductType.findOne({ where: { id: productId } }); - if (!product) { - throw new Error(`Product not found with id ${productId}`); + 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; } - - // 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) { @@ -4223,79 +4798,61 @@ class FalukantService extends BaseService { throw new Error(`No FalukantCharacter found for user with id ${user.id}`); } - // Produkt abrufen - const product = await ProductType.findOne({ where: { id: productId } }); + 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'] + }) + ]); + 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])); - // 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 + let currentRegionalPrice = currentPrice; if (currentRegionId) { const currentWorthPercent = worthMap.get(currentRegionId) || 50; - // Verwende calcRegionalSellPrice mit bereits geladenem worthPercent (keine DB-Query) - currentRegionalPrice = await calcRegionalSellPrice(product, knowledgeFactor, currentRegionId, currentWorthPercent); + currentRegionalPrice = calcRegionalSellPriceSync(product, knowledgeFactor, currentWorthPercent) ?? currentPrice; } - // Für jede Stadt den Preis berechnen und Branch-Typ bestimmen const results = []; + const PRICE_TOLERANCE = 0.01; for (const city of cities) { - // Aktuelle Stadt ausschließen - if (currentRegionId && city.id === currentRegionId) { - continue; - } - + if (currentRegionId && city.id === currentRegionId) continue; const worthPercent = worthMap.get(city.id) || 50; - // 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; + const priceInCity = calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent); + if (priceInCity == null) continue; if (priceInCity > currentRegionalPrice - PRICE_TOLERANCE) { // Branch-Typ bestimmen let branchType = null; // null = kein Branch @@ -4324,6 +4881,102 @@ 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({ @@ -4437,7 +5090,6 @@ class FalukantService extends BaseService { /** * Kompakte Daten für das Dashboard-Widget (Charakter-Name, Geschlecht, Alter, Geld, ungelesene Nachrichten, Kinder). - * (Originale Implementierung aus Commit 62d8cd7) * @param {string} hashedUserId * @returns {Promise<{ characterName: string, gender: string|null, age: number|null, money: number, unreadNotificationsCount: number, childrenCount: number }>} */ @@ -4464,8 +5116,9 @@ class FalukantService extends BaseService { const character = falukantUser.character; const firstName = character.definedFirstName?.name ?? ''; const lastName = character.definedLastName?.name ?? ''; - const title = character.nobleTitle?.labelTr ?? ''; - const characterName = [title, firstName, lastName].filter(Boolean).join(' ') || '—'; + 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([ @@ -4482,6 +5135,8 @@ class FalukantService extends BaseService { return { characterName, + titleLabelTr: titleLabelTr || null, + nameWithoutTitle, gender: character.gender ?? null, age, money: Number(falukantUser.money ?? 0), @@ -4791,6 +5446,595 @@ class FalukantService extends BaseService { 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(); @@ -4895,7 +6139,7 @@ async function enrichNotificationsWithCharacterNames(notifications) { return null; } - // Attach resolved name to notifications (set both characterName and character_name) + // Attach resolved name to notifications (set character_name; characterName is a getter that reads from it) for (const n of notifications) { let foundId = null; try { @@ -4917,7 +6161,7 @@ async function enrichNotificationsWithCharacterNames(notifications) { if (foundId && nameMap.has(Number(foundId))) { const resolved = nameMap.get(Number(foundId)); - n.characterName = resolved; + // Set character_name directly (characterName is a getter that reads from character_name) n.character_name = resolved; } } diff --git a/frontend/src/components/falukant/DirectorInfo.vue b/frontend/src/components/falukant/DirectorInfo.vue index 446081c..b7aeda1 100644 --- a/frontend/src/components/falukant/DirectorInfo.vue +++ b/frontend/src/components/falukant/DirectorInfo.vue @@ -210,6 +210,14 @@ export default { }, }; }, + watch: { + branchId: { + immediate: false, + handler() { + this.loadDirector(); + }, + }, + }, async mounted() { await this.loadDirector(); }, @@ -256,11 +264,17 @@ export default { }, speedLabel(value) { - const key = value == null ? 'unknown' : String(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); - if (!translated || translated === tKey) return value; - return translated; + return (!translated || translated === tKey) ? key : translated; }, openNewDirectorDialog() { diff --git a/frontend/src/components/falukant/SaleSection.vue b/frontend/src/components/falukant/SaleSection.vue index c481234..27aead0 100644 --- a/frontend/src/components/falukant/SaleSection.vue +++ b/frontend/src/components/falukant/SaleSection.vue @@ -251,13 +251,6 @@ return new Date(a.eta).getTime() - new Date(b.eta).getTime(); }); }, - speedLabel(value) { - const key = value == null ? 'unknown' : String(value); - const tKey = `falukant.branch.transport.speed.${key}`; - const translated = this.$t(tKey); - if (!translated || translated === tKey) return value; - return translated; - }, }, async mounted() { await this.loadInventory(); @@ -274,6 +267,19 @@ } }, 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}`); @@ -287,25 +293,24 @@ } }, async loadPricesForInventory() { - 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); + 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 = []; } } }, diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index e0ce7d3..db0eda4 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -114,12 +114,21 @@ }, "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" }, @@ -138,32 +147,6 @@ } } }, - "genderAge": { - "ageGroups": "infant:2|toddler:4|child:12|teen:18|youngAdult:25|adult:50|mature:70|elder:999", - "neutral": { - "child": "Kind" - }, - "male": { - "infant": "Säugling", - "toddler": "Bübchen", - "child": "Knabe", - "teen": "Jüngling", - "youngAdult": "Junker", - "adult": "Mann", - "mature": "Herr", - "elder": "Greis" - }, - "female": { - "infant": "Säugling", - "toddler": "Mädel", - "child": "Göre", - "teen": "Dirn", - "youngAdult": "Jungfrau", - "adult": "Frau", - "mature": "Dame", - "elder": "Greisin" - } - }, "titles": { "male": { "noncivil": "Leibeigener", @@ -334,6 +317,9 @@ "current": "Laufende Produktionen", "product": "Produkt", "remainingTime": "Verbleibende Zeit (Sekunden)", + "status": "Status", + "sleep": "Pausiert", + "active": "Aktiv", "noProductions": "Keine laufenden Produktionen." }, "columns": { @@ -605,6 +591,23 @@ "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", @@ -675,6 +678,7 @@ "happy": "Glücklich", "sad": "Traurig", "angry": "Wütend", + "calm": "Ruhig", "nervous": "Nervös", "excited": "Aufgeregt", "bored": "Gelangweilt", @@ -765,17 +769,39 @@ "advance": { "confirm": "Aufsteigen beantragen" }, - "cooldown": "Du kannst frühestens wieder am {date} aufsteigen.", - "errors": { - "tooSoon": "Aufstieg zu früh.", - "unmet": "Folgende Voraussetzungen fehlen:", - "generic": "Der Aufstieg ist fehlgeschlagen." - } + "cooldown": "Du kannst frühestens wieder am {date} aufsteigen." }, "reputation": { "title": "Reputation", "overview": { - "title": "Übersicht" + "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" + } }, "party": { "title": "Feste", @@ -826,6 +852,53 @@ }, "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": { @@ -927,7 +1000,12 @@ "drunkOfLife": "Trunk des Lebens", "barber": "Barbier" }, - "choose": "Bitte auswählen" + "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" }, "politics": { "title": "Politik", @@ -951,9 +1029,13 @@ "region": "Region", "date": "Datum", "candidacy": "Kandidatur", + "candidacyWithAge": "Kandidatur (ab 16 Jahren)", "none": "Keine offenen Positionen.", - "apply": "Für ausgewählte Positionen kandidieren" + "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." }, + "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 64c5744..14a6d19 100644 --- a/frontend/src/i18n/locales/en/falukant.json +++ b/frontend/src/i18n/locales/en/falukant.json @@ -94,29 +94,24 @@ "children_unbaptised": "Unbaptised children" }, "overview": { - "metadata": { - "years": "years" - } - }, - "genderAge": { - "ageGroups": "infant:2|toddler:5|child:13|maidenhood:20|adult:50|mature:70|elder:999", - "male": { - "infant": "babe", - "toddler": "wee one", - "child": "lad", - "maidenhood": "youth", - "adult": "man", - "mature": "goodman", - "elder": "old fellow" + "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." }, - "female": { - "infant": "babe", - "toddler": "wee one", - "child": "lass", - "maidenhood": "maiden", - "adult": "woman", - "mature": "goodwife", - "elder": "old dame" + "metadata": { + "title": "Personal", + "name": "Name", + "money": "Wealth", + "age": "Age", + "years": "Years", + "days": "Days", + "mainbranch": "Home city", + "nobleTitle": "Title" } }, "health": { @@ -137,6 +132,23 @@ "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", @@ -191,6 +203,29 @@ "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", @@ -222,13 +257,87 @@ } }, "nobility": { - "cooldown": "You can only advance again on {date}.", + "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": { - "tooSoon": "Advancement too soon.", - "unmet": "The following requirements are not met:", - "generic": "Advancement failed." - } - }, + "tooClose": "You cannot perform measures so often.", + "generic": "An error occurred." + }, + "nextMeasureAt": "Next measure from" + }, "branchProduction": { "storageAvailable": "Free storage" }, @@ -254,9 +363,13 @@ "region": "Region", "date": "Date", "candidacy": "Candidacy", + "candidacyWithAge": "Candidacy (from age 16)", "none": "No open positions.", - "apply": "Apply for selected 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." }, + "too_young": "Your character is too young. Applications are only possible from age 16.", "upcoming": { "office": "Office", "region": "Region", @@ -353,6 +466,143 @@ "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 5ccae65..aaf91b4 100644 --- a/frontend/src/views/falukant/BranchView.vue +++ b/frontend/src/views/falukant/BranchView.vue @@ -360,6 +360,7 @@ 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' }, @@ -569,30 +570,46 @@ export default { async loadProductPricesForCurrentBranch() { if (!this.selectedBranch || !this.selectedBranch.regionId) { this.productPricesCache = {}; + this.productPricesCacheRegionId = null; return; } - - // 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); - } + 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); + } + } + this.productPricesCache = prices; + this.productPricesCacheRegionId = this.selectedBranch?.regionId ?? null; } - this.productPricesCache = prices; }, formatPercent(value) { @@ -704,13 +721,17 @@ export default { }, speedLabel(value) { - // Expect numeric speeds 1..4; provide localized labels as fallback to raw value - const key = value == null ? 'unknown' : String(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); - // If translation returns the key (no translation found), fall back to the numeric value - if (!translated || translated === tKey) return value; - return translated; + return (!translated || translated === tKey) ? key : translated; }, transportModeLabel(mode) { diff --git a/frontend/src/views/falukant/FamilyView.vue b/frontend/src/views/falukant/FamilyView.vue index c762129..31493ea 100644 --- a/frontend/src/views/falukant/FamilyView.vue +++ b/frontend/src/views/falukant/FamilyView.vue @@ -25,7 +25,7 @@ {{ $t('falukant.family.spouse.mood') }} - {{ $t(`falukant.mood.${relationships[0].character2.mood.tr}`) }} + {{ 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 fda9101..bc939ad 100644 --- a/frontend/src/views/falukant/MoneyHistoryView.vue +++ b/frontend/src/views/falukant/MoneyHistoryView.vue @@ -9,6 +9,12 @@ +
+ +
+ @@ -42,17 +48,21 @@ {{ $t('falukant.moneyHistory.next') }} + + @@ -106,6 +119,10 @@ 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 2303524..e767a6d 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,6 +57,7 @@
+

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

{{ $t('loading') }}
@@ -65,7 +66,7 @@ - + @@ -74,13 +75,13 @@ - @@ -193,6 +194,7 @@ export default { elections: [], selectedCandidates: {}, selectedApplications: [], + ownCharacterId: null, loading: { current: false, openPolitics: false, @@ -210,6 +212,7 @@ export default { } }, mounted() { + this.loadOwnCharacterId(); this.loadCurrentPositions(); }, methods: { @@ -229,9 +232,12 @@ 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('Error loading current positions', err); + console.error('[PoliticsView] Error loading current positions', err); } finally { this.loading.current = false; } @@ -241,10 +247,10 @@ export default { this.loading.openPolitics = true; try { const { data } = await apiClient.get('/api/falukant/politics/open'); - this.openPolitics = data; + this.openPolitics = Array.isArray(data) ? data : []; // Bereits beworbene Positionen vorselektieren, damit die Checkbox // sichtbar markiert bleibt. - this.selectedApplications = data + this.selectedApplications = this.openPolitics .filter(e => e.alreadyApplied) .map(e => e.id); } catch (err) { @@ -330,6 +336,44 @@ 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( @@ -346,6 +390,10 @@ 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')); } } } @@ -384,6 +432,13 @@ 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; @@ -411,6 +466,11 @@ 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.candidacy') }}{{ $t('falukant.politics.open.candidacyWithAge') }}
{{ e.region.name }} {{ formatDate(e.date) }} +