import BaseService from './BaseService.js'; import { Sequelize, Op } from 'sequelize'; import { sequelize } from '../utils/sequelize.js'; import FalukantPredefineFirstname from '../models/falukant/predefine/firstname.js'; import FalukantPredefineLastname from '../models/falukant/predefine/lastname.js'; import FalukantUser from '../models/falukant/data/user.js'; import FalukantCharacter from '../models/falukant/data/character.js'; import RegionData from '../models/falukant/data/region.js'; 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 Branch from '../models/falukant/data/branch.js'; import BranchType from '../models/falukant/type/branch.js'; import Production from '../models/falukant/data/production.js'; import ProductType from '../models/falukant/type/product.js'; import Knowledge from '../models/falukant/data/product_knowledge.js'; import Inventory from '../models/falukant/data/inventory.js'; import MoneyFlow from '../models/falukant/log/moneyflow.js'; import User from '../models/community/user.js'; import { notifyUser } from '../utils/socket.js'; import { differenceInDays } from 'date-fns'; import { updateFalukantUserMoney } from '../utils/sequelize.js'; import BuyableStock from '../models/falukant/data/buyable_stock.js'; import DirectorProposal from '../models/falukant/data/director_proposal.js'; import Director from '../models/falukant/data/director.js'; import DaySell from '../models/falukant/log/daysell.js'; import MarriageProposal from '../models/falukant/data/marriage_proposal.js'; import RelationshipType from '../models/falukant/type/relationship.js'; import Relationship from '../models/falukant/data/relationship.js'; import PromotionalGift from '../models/falukant/type/promotional_gift.js'; import PromotionalGiftCharacterTrait from '../models/falukant/predefine/promotional_gift_character_trait.js'; 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 Mood from '../models/falukant/type/mood.js'; import UserHouse from '../models/falukant/data/user_house.js'; import HouseType from '../models/falukant/type/house.js'; import BuyableHouse from '../models/falukant/data/buyable_house.js'; import PartyType from '../models/falukant/type/party.js'; import MusicType from '../models/falukant/type/music.js'; import BanquetteType from '../models/falukant/type/banquette.js'; import Party from '../models/falukant/data/party.js'; import ChildRelation from '../models/falukant/data/child_relation.js'; import Learning from '../models/falukant/data/learning.js'; import LearnRecipient from '../models/falukant/type/learn_recipient.js'; 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 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'; import PoliticalOfficePrerequisite from '../models/falukant/predefine/political_office_prerequisite.js'; import PoliticalOfficeHistory from '../models/falukant/log/political_office_history.js'; import UndergroundType from '../models/falukant/type/underground.js'; import Notification from '../models/falukant/log/notification.js'; import PoliticalOffice from '../models/falukant/data/political_office.js'; import Underground from '../models/falukant/data/underground.js'; function calcAge(birthdate) { const b = new Date(birthdate); b.setHours(0, 0); const now = new Date(); now.setHours(0, 0); return differenceInDays(now, b); } async function getFalukantUserOrFail(hashedId) { const user = await FalukantUser.findOne({ include: [{ model: User, as: 'user', attributes: ['username', 'hashedId'], where: { hashedId } }] }); if (!user) throw new Error('User not found'); return user; } async function getBranchOrFail(userId, branchId) { const branch = await Branch.findOne({ where: { id: branchId, falukantUserId: userId } }); if (!branch) throw new Error('Branch not found'); return branch; } function calcSellPrice(product, knowledgeFactor = 0) { const max = product.sellCost; const min = max * 0.6; return min + (max - min) * (knowledgeFactor / 100); } function calculateMarriageCost(titleOfNobility, age) { const minTitle = 1; const adjustedTitle = titleOfNobility - minTitle + 1; const baseCost = 500; return baseCost * Math.pow(adjustedTitle, 1.3) - (age - 12) * 20; } class PreconditionError extends Error { constructor(label) { super(label); this.name = 'PreconditionError'; this.status = 412; } } class FalukantService extends BaseService { static KNOWLEDGE_MAX = 99; static COST_CONFIG = { one: { min: 50, max: 5000 }, all: { min: 400, max: 40000 } }; static HEALTH_ACTIVITIES = [ { tr: "barber", method: "healthBarber", cost: 10 }, { tr: "doctor", method: "healthDoctor", cost: 50 }, { tr: "witch", method: "healthWitch", cost: 500 }, { tr: "pill", method: "healthPill", cost: 5000 }, { tr: "drunkOfLife", method: "healthDruckOfLife", cost: 5000000 } ]; static RECURSIVE_REGION_SEARCH = ` WITH RECURSIVE ancestors AS ( SELECT r.id, r.parent_id FROM falukant_data.region r join falukant_data."character" c on c.region_id = r.id WHERE c.user_id = :user_id UNION ALL SELECT r.id, r.parent_id FROM falukant_data.region r JOIN ancestors a ON r.id = a.parent_id ) SELECT id FROM ancestors; `; 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 } }, { model: FalukantCharacter, as: 'character', 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'] } ], 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'); return user; } async getUser(hashedUserId) { const u = await FalukantUser.findOne({ include: [ { model: User, as: 'user', attributes: ['username', 'hashedId'], where: { hashedId: hashedUserId } }, { model: FalukantCharacter, as: 'character', include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }, { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] } ], attributes: ['birthdate', 'gender'] }, { model: RegionData, as: 'mainBranchRegion', include: [{ model: RegionType, as: 'regionType' }], attributes: ['name'] }, { model: Branch, as: 'branches', include: [ { model: BranchType, as: 'branchType', attributes: ['labelTr'] }, { model: RegionData, as: 'region', include: [{ model: RegionType, as: 'regionType' }], attributes: ['name'] } ] }, { model: UserHouse, as: 'userHouse', include: [ { model: HouseType, as: 'houseType', 'attributes': ['labelTr', 'position'] }, ], attributes: ['roofCondition'], }, ], attributes: ['money', 'creditAmount', 'todayCreditTaken',] }); if (!u) throw new Error('User not found'); if (u.character?.birthdate) u.character.setDataValue('age', calcAge(u.character.birthdate)); return u; } async randomFirstName(gender) { const names = await FalukantPredefineFirstname.findAll({ where: { gender } }); return names[Math.floor(Math.random() * names.length)].name; } async randomLastName() { const names = await FalukantPredefineLastname.findAll(); return names[Math.floor(Math.random() * names.length)].name; } async createUser(hashedUserId, gender, firstName, lastName) { const user = await this.getUserByHashedId(hashedUserId); if (await FalukantUser.findOne({ where: { userId: user.id } })) throw new Error('User already exists in Falukant.'); let fnObj = await FalukantPredefineFirstname.findOne({ where: { name: firstName } }); let lnObj = await FalukantPredefineLastname.findOne({ where: { name: lastName } }); if (!fnObj) fnObj = await FalukantPredefineFirstname.create({ name: firstName, gender }); if (!lnObj) lnObj = await FalukantPredefineLastname.create({ name: lastName }); const region = await RegionData.findOne({ order: Sequelize.fn('RANDOM'), limit: 1, include: [{ model: RegionType, as: 'regionType', where: { labelTr: 'city' } }] }); if (!region) throw new Error('No region found with the label "city".'); const nobility = await TitleOfNobility.findOne({ where: { labelTr: 'noncivil' } }); if (!nobility) throw new Error('No title of nobility found with the label "noncivil".'); const falukantUser = await FalukantUser.create({ userId: user.id, money: 50, creditAmount: 0, todayCreditTaken: 0, creditInterestRate: 0, mainBranchRegionId: region.id }); const date = new Date(); date.setDate(date.getDate() - 14); const ch = await FalukantCharacter.create({ userId: falukantUser.id, regionId: region.id, firstName: fnObj.id, lastName: lnObj.id, gender, birthdate: date, titleOfNobility: nobility.id }); falukantUser.character = ch; const bType = await BranchType.findOne({ where: { labelTr: 'fullstack' } }); const branch = await Branch.create({ falukantUserId: falukantUser.id, regionId: region.id, branchTypeId: bType.id }); const stType = await FalukantStockType.findOne({ where: [{ label_tr: 'wood' }] }); await FalukantStock.create({ branchId: branch.id, stockTypeId: stType.id, quantity: 20 }); notifyUser(user.hashedId, 'reloadmenu', {}); return falukantUser; } async getInfo(hashedUserId) { const user = await User.findOne({ where: { hashedId: hashedUserId } }); if (!user) throw new Error('User not found'); const falukantUser = await FalukantUser.findOne({ include: [ { model: User, as: 'user', attributes: ['hashedId'], where: { hashedId: hashedUserId } }, { 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', 'money'] }); if (!falukantUser) throw new Error('User not found'); if (falukantUser.character?.birthdate) falukantUser.character.setDataValue('age', calcAge(falukantUser.character.birthdate)); // Aggregate status additions: children counts and unread notifications try { const bm = (step, payload = {}) => { try { console.log(`[BLOCKMARKER][falukant.getInfo] ${step}`, payload); } catch (_) { /* ignore */ } }; bm('aggregate.start', { userId: user.id, falukantUserId: falukantUser.id }); // Determine all character IDs belonging to the user if (!falukantUser.id) { bm('aggregate.noFalukantUserId'); throw new Error('Missing falukantUser.id in getInfo aggregation'); } const userCharacterIdsRows = await FalukantCharacter.findAll({ attributes: ['id'], where: { userId: falukantUser.id }, raw: true }); const userCharacterIds = userCharacterIdsRows.map(r => r.id); bm('aggregate.userCharacters', { count: userCharacterIds.length, ids: userCharacterIds.slice(0, 5) }); // Count distinct children for any of the user's characters (as father or mother) let childrenCount = 0; let unbaptisedChildrenCount = 0; if (userCharacterIds.length > 0) { const childRels = await ChildRelation.findAll({ attributes: ['childCharacterId'], where: { [Op.or]: [ { fatherCharacterId: { [Op.in]: userCharacterIds } }, { motherCharacterId: { [Op.in]: userCharacterIds } }, ] }, raw: true }); const distinctChildIds = new Set(childRels.map(r => r.childCharacterId)); childrenCount = distinctChildIds.size; bm('aggregate.children', { relations: childRels.length, distinct: childrenCount, sample: Array.from(distinctChildIds).slice(0, 5) }); const unbaptised = await ChildRelation.findAll({ attributes: ['childCharacterId'], where: { nameSet: false, [Op.or]: [ { fatherCharacterId: { [Op.in]: userCharacterIds } }, { motherCharacterId: { [Op.in]: userCharacterIds } }, ] }, raw: true }); const distinctUnbaptisedIds = new Set(unbaptised.map(r => r.childCharacterId)); unbaptisedChildrenCount = distinctUnbaptisedIds.size; bm('aggregate.unbaptised', { relations: unbaptised.length, distinct: unbaptisedChildrenCount, sample: Array.from(distinctUnbaptisedIds).slice(0, 5) }); } // Unread notifications count const unreadNotifications = await Notification.count({ where: { userId: falukantUser.id, shown: false } }); bm('aggregate.unread', { unreadNotifications }); falukantUser.setDataValue('childrenCount', childrenCount); falukantUser.setDataValue('unbaptisedChildrenCount', unbaptisedChildrenCount); falukantUser.setDataValue('unreadNotifications', unreadNotifications); bm('aggregate.done', { childrenCount, unbaptisedChildrenCount }); } catch (e) { console.error('Error aggregating status info:', e); falukantUser.setDataValue('childrenCount', 0); falukantUser.setDataValue('unbaptisedChildrenCount', 0); falukantUser.setDataValue('unreadNotifications', 0); } return falukantUser; } async getBranches(hashedUserId) { const u = await getFalukantUserOrFail(hashedUserId); const bs = await Branch.findAll({ where: { falukantUserId: u.id }, include: [ { model: BranchType, as: 'branchType', attributes: ['labelTr'] }, { model: RegionData, as: 'region', attributes: ['name'] } ], attributes: ['id', 'regionId'], order: [['branchTypeId', 'ASC']] }); return bs.map(b => ({ ...b.toJSON(), isMainBranch: u.mainBranchRegionId === b.regionId })); } async createBranch(hashedUserId, cityId, branchTypeId) { const user = await getFalukantUserOrFail(hashedUserId); const branchType = await BranchType.findByPk(branchTypeId); if (!branchType) { throw new Error(`Unknown branchTypeId ${branchTypeId}`); } const existingCount = await Branch.count({ where: { falukantUserId: user.id } }); const exponentBase = Math.max(existingCount, 1); const rawCost = branchType.baseCost * Math.pow(exponentBase, 1.2); const cost = Math.round(rawCost * 100) / 100; await updateFalukantUserMoney( user.id, -cost, 'create_branch' ); const branch = await Branch.create({ branchTypeId, regionId: cityId, falukantUserId: user.id }); return branch; } async getBranchTypes(hashedUserId) { const user = await getFalukantUserOrFail(hashedUserId); const branchTypes = await BranchType.findAll(); return branchTypes; } async getBranch(hashedUserId, branchId) { const u = await getFalukantUserOrFail(hashedUserId); const br = await Branch.findOne({ where: { id: branchId, falukantUserId: u.id }, include: [ { model: BranchType, as: 'branchType', attributes: ['labelTr'] }, { model: RegionData, as: 'region', attributes: ['name'] }, { model: Production, as: 'productions', attributes: ['quantity', 'startTimestamp'], include: [{ model: ProductType, as: 'productType', attributes: ['id', 'category', 'labelTr', 'sellCost', 'productionTime'] }] } ], attributes: ['id', 'regionId'] }); if (!br) throw new Error('Branch not found'); return br; } async getStock(hashedUserId, branchId) { const u = await getFalukantUserOrFail(hashedUserId); const b = await getBranchOrFail(u.id, branchId); return FalukantStock.findAll({ where: { regionId: b.regionId, userId: u.id } }); } async createStock(hashedUserId, branchId, stockData) { const u = await getFalukantUserOrFail(hashedUserId); const b = await getBranchOrFail(u.id, branchId); return FalukantStock.create({ userId: u.id, regionId: b.regionId, stockTypeId: stockData.stockTypeId, quantity: stockData.quantity }); } async createProduction(hashedUserId, branchId, productId, quantity) { const u = await getFalukantUserOrFail(hashedUserId); const b = await getBranchOrFail(u.id, branchId); const p = await ProductType.findOne({ where: { id: productId } }); const runningProductions = await Production.findAll({ where: { branchId: b.id } }); if (runningProductions.length >= 2) { throw new Error('Too many productions'); return; // wird spĂ€ter implementiert, wenn familie implementiert ist. } if (!p) throw new Error('Product not found'); quantity = Math.min(100, quantity); const cost = quantity * p.category * 6; if (u.money < cost) throw new Error('notenoughmoney'); const r = await updateFalukantUserMoney(u.id, -cost, 'Production cost', u.id); if (!r.success) throw new Error('Failed to update money'); const d = await Production.create({ branchId: b.id, productId, quantity }); notifyUser(u.user.hashedId, 'falukantUpdateStatus', {}); notifyUser(u.user.hashedId, 'falukantBranchUpdate', { branchId: b.id }); return d; } async getProduction(hashedUserId, branchId) { const u = await getFalukantUserOrFail(hashedUserId); const b = await getBranchOrFail(u.id, branchId); return Production.findOne({ where: { regionId: b.regionId } }); } async getProducts(hashedUserId) { const u = await getFalukantUserOrFail(hashedUserId); const c = await FalukantCharacter.findOne({ where: { userId: u.id } }); if (!c) { throw new Error(`No FalukantCharacter found for user with id ${u.id}`); } const ps = await ProductType.findAll({ where: { category: { [Op.lte]: u.certificate } }, include: [{ model: Knowledge, as: 'knowledges', attributes: ['knowledge'], where: { characterId: c.id } }], attributes: ['labelTr', 'id', 'sellCost', 'productionTime', 'category'] }); return ps; } async getInventory(hashedUserId, branchId) { const u = await getFalukantUserOrFail(hashedUserId); const f = branchId ? { id: branchId, falukantUserId: u.id } : { falukantUserId: u.id }; const br = await Branch.findAll({ where: f, include: [ { model: FalukantStock, as: 'stocks', include: [{ model: FalukantStockType, as: 'stockType' }] }, { model: RegionData, as: 'region', include: [{ model: RegionType, as: 'regionType' }] } ] }); const stockIds = br.flatMap(b => b.stocks.map(s => s.id)); const inv = await Inventory.findAll({ where: { stockId: stockIds }, include: [ { model: FalukantStock, as: 'stock', include: [ { model: Branch, as: 'branch', include: [{ model: RegionData, as: 'region', include: [{ model: RegionType, as: 'regionType' }] }] }, { model: FalukantStockType, as: 'stockType' } ] }, { model: ProductType, as: 'productType' } ] }); const grouped = inv.reduce((acc, i) => { const r = i.stock.branch.region; const k = `${r.id}-${i.productType.id}-${i.quality}`; acc[k] = acc[k] || { region: r, product: i.productType, quality: i.quality, totalQuantity: 0 }; acc[k].totalQuantity += i.quantity; return acc; }, {}); return Object.values(grouped).sort((a, b) => { if (a.region.id !== b.region.id) return a.region.id - b.region.id; if (a.product.id !== b.product.id) return a.product.id - b.product.id; return a.quality - b.quality; }); } async sellProduct(hashedUserId, branchId, productId, quality, quantity) { const user = await getFalukantUserOrFail(hashedUserId); const branch = await getBranchOrFail(user.id, branchId); const character = await FalukantCharacter.findOne({ where: { userId: user.id } }); if (!character) throw new Error('No character found for user'); const stock = await FalukantStock.findOne({ where: { branchId: branch.id } }); if (!stock) throw new Error('Stock not found'); const inventory = await Inventory.findAll({ where: { quality }, include: [ { model: ProductType, as: 'productType', required: true, where: { id: productId }, include: [ { model: Knowledge, as: 'knowledges', required: false, where: { characterId: character.id } } ] } ] }); if (!inventory.length) { throw new Error('No inventory found'); } const available = inventory.reduce((sum, i) => sum + i.quantity, 0); if (available < quantity) throw new Error('Not enough inventory available'); const item = inventory[0].productType; const knowledgeVal = item.knowledges?.[0]?.knowledge || 0; const revenue = quantity * calcSellPrice(item, knowledgeVal); const moneyResult = await updateFalukantUserMoney(user.id, revenue, 'Product sale', user.id); if (!moneyResult.success) throw new Error('Failed to update money'); let remaining = quantity; for (const inv of inventory) { if (inv.quantity <= remaining) { remaining -= inv.quantity; await inv.destroy(); } else { await inv.update({ quantity: inv.quantity - remaining }); remaining = 0; break; } } await this.addSellItem(branchId, user.id, productId, quantity); console.log('[FalukantService.sellProduct] emitting events for user', user.user.hashedId, 'branch', branch?.id); notifyUser(user.user.hashedId, 'falukantUpdateStatus', {}); notifyUser(user.user.hashedId, 'falukantBranchUpdate', { branchId: branch.id }); return { success: true }; } async sellAllProducts(hashedUserId, branchId) { const falukantUser = await getFalukantUserOrFail(hashedUserId); const branch = await Branch.findOne({ where: { id: branchId, falukantUserId: falukantUser.id }, include: [{ model: FalukantStock, as: 'stocks' }] }); if (!branch) throw new Error('Branch not found'); const stockIds = branch.stocks.map(s => s.id); const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id } }); if (!character) throw new Error('No character for user'); const inventory = await Inventory.findAll({ where: { stockId: stockIds }, include: [ { model: ProductType, as: 'productType', include: [ { model: Knowledge, as: 'knowledges', required: false, where: { characterId: character.id } } ] }, { model: FalukantStock, as: 'stock', include: [ { model: Branch, as: 'branch' }, { model: FalukantStockType, as: 'stockType' } ] } ] }); if (!inventory.length) return { success: true, revenue: 0 }; let total = 0; for (const item of inventory) { const knowledgeVal = item.productType.knowledges[0]?.knowledge || 0; total += item.quantity * calcSellPrice(item.productType, knowledgeVal); await this.addSellItem(item.stock.branch.id, falukantUser.id, item.productType.id, item.quantity); } const moneyResult = await updateFalukantUserMoney( falukantUser.id, total, 'Sell all products', falukantUser.id ); if (!moneyResult.success) throw new Error('Failed to update money'); for (const item of inventory) { await Inventory.destroy({ where: { id: item.id } }); } console.log('[FalukantService.sellAllProducts] emitting events for user', falukantUser.user.hashedId, 'branch', branchId, 'revenue', total, 'items', inventory.length); notifyUser(falukantUser.user.hashedId, 'falukantUpdateStatus', {}); notifyUser(falukantUser.user.hashedId, 'falukantBranchUpdate', { branchId }); return { success: true, revenue: total }; } async addSellItem(branchId, userId, productId, quantity) { const branch = await Branch.findOne({ where: { id: branchId }, }) ; const daySell = await DaySell.findOne({ where: { regionId: branch.regionId, productId: productId, sellerId: userId, } }); if (daySell) { daySell.quantity += quantity; await daySell.save(); } else { await DaySell.create({ regionId: branch.regionId, productId: productId, sellerId: userId, quantity: quantity, }); } } async moneyHistory(hashedUserId, page = 1, filter = '') { const u = await getFalukantUserOrFail(hashedUserId); const limit = 25, offset = (page - 1) * limit; const w = { falukantUserId: u.id }; if (filter) w.activity = { [Op.iLike]: `%${filter}%` }; const { rows, count } = await MoneyFlow.findAndCountAll({ where: w, order: [['time', 'DESC']], limit, offset }); return { data: rows, total: count, currentPage: page, totalPages: Math.ceil(count / limit) }; } async getStorage(hashedUserId, branchId) { const user = await getFalukantUserOrFail(hashedUserId); const branch = await getBranchOrFail(user.id, branchId); const stocks = await FalukantStock.findAll({ where: { branchId: branch.id }, include: [{ model: FalukantStockType, as: 'stockType' }], }); const stockIds = stocks.map(s => s.id); const inventoryItems = await Inventory.findAll({ where: { stockId: stockIds }, include: [ { model: FalukantStock, as: 'stock', include: [{ model: FalukantStockType, as: 'stockType' }], }, ], }); let totalUsedCapacity = 0; const usageByType = {}; for (const s of stocks) { const stId = s.stockTypeId; if (!usageByType[stId]) { usageByType[stId] = { stockTypeId: stId, stockTypeLabelTr: s.stockType?.labelTr || `stockType:${stId}`, totalCapacity: 0, used: 0 }; } usageByType[stId].totalCapacity += s.quantity; } for (const item of inventoryItems) { totalUsedCapacity += item.quantity; const stId = item.stock.stockTypeId; if (!usageByType[stId]) { usageByType[stId] = { stockTypeId: stId, stockTypeLabelTr: item.stock.stockType?.labelTr || `stockType:${stId}`, totalCapacity: 0, used: 0 }; } usageByType[stId].used += item.quantity; } const buyableStocks = await BuyableStock.findAll({ where: { regionId: branch.regionId }, include: [{ model: FalukantStockType, as: 'stockType' }], }); const buyableByType = {}; for (const b of buyableStocks) { const stId = b.stockTypeId; if (!buyableByType[stId]) { buyableByType[stId] = { stockTypeId: stId, stockTypeLabelTr: b.stockType?.labelTr || `stockType:${stId}`, quantity: 0 }; } buyableByType[stId].quantity += b.quantity; } let maxCapacity = stocks.reduce((sum, s) => sum + s.quantity, 0); return { branchId, totalUsedCapacity, maxCapacity, usageByType: Object.values(usageByType), buyableByType: Object.values(buyableByType) }; } async buyStorage(hashedUserId, branchId, amount, stockTypeId) { const user = await getFalukantUserOrFail(hashedUserId); const branch = await getBranchOrFail(user.id, branchId); const buyableStocks = await BuyableStock.findAll({ where: { regionId: branch.regionId, stockTypeId }, include: [{ model: FalukantStockType, as: 'stockType' }] }); if (!buyableStocks || buyableStocks.length === 0) { throw new Error('Not enough buyable stock'); } const totalAvailable = buyableStocks.reduce((sum, entry) => sum + entry.quantity, 0); if (totalAvailable < amount) { throw new Error('Not enough buyable stock'); } const costPerUnit = buyableStocks[0].stockType.cost; const totalCost = costPerUnit * amount; if (user.money < totalCost) { throw new Error('notenoughmoney'); } const moneyResult = await updateFalukantUserMoney( user.id, -totalCost, `Buy storage (type: ${buyableStocks[0].stockType.labelTr})`, user.id ); if (!moneyResult.success) { throw new Error('Failed to update money'); } let remainingToDeduct = amount; for (const entry of buyableStocks) { if (entry.quantity > remainingToDeduct) { entry.quantity -= remainingToDeduct; await entry.save(); remainingToDeduct = 0; break; } else if (entry.quantity === remainingToDeduct) { await entry.destroy(); remainingToDeduct = 0; break; } else { remainingToDeduct -= entry.quantity; await entry.destroy(); } } let stock = await FalukantStock.findOne({ where: { branchId: branch.id, stockTypeId }, include: [{ model: FalukantStockType, as: 'stockType' }] }); if (!stock) { stock = await FalukantStock.create({ branchId: branch.id, stockTypeId, quantity: amount, }); } else { stock.quantity += amount; await stock.save(); } notifyUser(user.user.hashedId, 'falukantUpdateStatus', {}); notifyUser(user.user.hashedId, 'falukantBranchUpdate', { branchId }); return { success: true, bought: amount, totalCost, stockType: buyableStocks[0].stockType.labelTr }; } async sellStorage(hashedUserId, branchId, amount, stockTypeId) { const user = await getFalukantUserOrFail(hashedUserId); const branch = await getBranchOrFail(user.id, branchId); const stock = await FalukantStock.findOne({ where: { branchId: branch.id, stockTypeId }, include: [{ model: FalukantStockType, as: 'stockType' }] }); if (!stock || stock.quantity < amount) throw new Error('Not enough stock to sell'); const costPerUnit = stock.stockType.cost; const totalRevenue = costPerUnit * amount; const moneyResult = await updateFalukantUserMoney( user.id, totalRevenue, `Sell storage (type: ${stock.stockType.labelTr})`, user.id ); if (!moneyResult.success) throw new Error('Failed to update money'); stock.quantity -= amount; await stock.save(); const buyable = await BuyableStock.findOne({ where: { regionId: branch.regionId, stockTypeId }, include: [{ model: FalukantStockType, as: 'stockType' }] }); if (!buyable) throw new Error('No buyable record found for this region and stockType'); buyable.quantity += amount; await buyable.save(); return { success: true, sold: amount, totalRevenue, stockType: stock.stockType.labelTr }; } async notifyRegionUsersAboutStockChange(regionId) { const users = await FalukantCharacter.findAll({ where: { regionId: regionId }, include: [ { model: FalukantUser, as: 'user', include: [ { model: User, as: 'user' }, { model: Branch, as: 'branch', where: { regionId: regionId } } ] } ] }); for (const user of users) { notifyUser(user.user[0].user[0].hashedId, 'stock_change', { branchId: user.user[0].branch[0].id }); } } async getStockTypes() { return FalukantStockType.findAll(); } async getStockOverview() { const items = await Inventory.findAll({ include: [ { model: FalukantStock, as: 'stock', include: [ { model: Branch, as: 'branch', include: [{ model: RegionData, as: 'region' }] } ] }, { model: ProductType, as: 'productType' } ] }); const result = items.map(inv => ({ regionName: inv.stock?.branch?.region?.name || '???', productLabelTr: inv.productType?.labelTr || '???', quantity: inv.quantity })); return result; } async getAllProductions(hashedUserId) { const user = await getFalukantUserOrFail(hashedUserId); const productions = await Production.findAll({ include: [ { model: Branch, as: 'branch', include: [ { model: RegionData, as: 'region', attributes: ['name'] } ], where: { falukantUserId: user.id } }, { model: ProductType, as: 'productType', attributes: ['labelTr', 'productionTime'] } ], attributes: ['startTimestamp', 'quantity'], }); const formattedProductions = productions.map((production) => { const startTimestamp = new Date(production.startTimestamp).getTime(); const endTimestamp = startTimestamp + production.productType.productionTime * 60 * 1000; return { cityName: production.branch.region.name, productName: production.productType.labelTr, quantity: production.quantity, endTimestamp: new Date(endTimestamp).toISOString(), }; }); formattedProductions.sort((a, b) => new Date(a.endTimestamp) - new Date(b.endTimestamp)); return formattedProductions; } async getDirectorProposals(hashedUserId, branchId) { const user = await this.getFalukantUserByHashedId(hashedUserId); if (!user) { throw new Error('User not found'); } const branch = await Branch.findOne({ where: { id: branchId, falukantUserId: user.id } }); if (!branch) { throw new Error('Branch not found or does not belong to the user'); } const { falukantUserId, regionId } = branch; await this.deleteExpiredProposals(); const existingProposals = await this.fetchProposals(falukantUserId, regionId); if (existingProposals.length > 0) { return this.formatProposals(existingProposals); } await this.generateProposals(falukantUserId, regionId); const newProposals = await this.fetchProposals(falukantUserId, regionId); return this.formatProposals(newProposals); } async deleteExpiredProposals() { const expirationTime = new Date(Date.now() - 24 * 60 * 60 * 1000); await DirectorProposal.destroy({ where: { createdAt: { [Op.lt]: expirationTime, }, }, }); } async fetchProposals(falukantUserId, regionId) { return DirectorProposal.findAll({ where: { employerUserId: falukantUserId }, include: [ { model: FalukantCharacter, as: 'character', attributes: ['firstName', 'lastName', 'birthdate', 'titleOfNobility', 'gender'], where: { regionId }, include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName' }, { model: FalukantPredefineLastname, as: 'definedLastName' }, { model: TitleOfNobility, as: 'nobleTitle' }, { model: Knowledge, as: 'knowledges', include: [ { model: ProductType, as: 'productType' }, ] }, ], }, ], }); } async generateProposals(falukantUserId, regionId) { try { const threeWeeksAgo = new Date(Date.now() - 21 * 24 * 60 * 60 * 1000); const proposalCount = Math.floor(Math.random() * 3) + 3; for (let i = 0; i < proposalCount; i++) { const directorCharacter = await FalukantCharacter.findOne({ where: { regionId, createdAt: { [Op.lt]: threeWeeksAgo }, }, include: [ { model: TitleOfNobility, as: 'nobleTitle', attributes: ['level'], }, ], order: sequelize.literal('RANDOM()'), }); if (!directorCharacter) { throw new Error('No directors available for the region'); } const avgKnowledge = await this.calculateAverageKnowledge(directorCharacter.id); const proposedIncome = Math.round( directorCharacter.nobleTitle.level * Math.pow(1.231, avgKnowledge / 1.5) ); await DirectorProposal.create({ directorCharacterId: directorCharacter.id, employerUserId: falukantUserId, proposedIncome, }); } } catch (error) { console.log(error.message, error.stack); throw new Error(error.message); } } async calculateAverageKnowledge(characterId) { const averageKnowledge = await Knowledge.findAll({ where: { characterId }, attributes: [[Sequelize.fn('AVG', Sequelize.col('knowledge')), 'avgKnowledge']], raw: true, }); return parseFloat(averageKnowledge[0]?.avgKnowledge || 0); } formatProposals(proposals) { return proposals.map((proposal) => { const age = Math.floor((Date.now() - new Date(proposal.character.birthdate)) / (24 * 60 * 60 * 1000)); const knowledge = proposal.character.knowledges?.map(k => ({ productId: k.productId, value: k.knowledge, labelTr: k.productType.labelTr, })) || []; return { id: proposal.id, proposedIncome: proposal.proposedIncome, character: { name: `${proposal.character.definedFirstName.name} ${proposal.character.definedLastName.name}`, title: proposal.character.nobleTitle.labelTr, age, knowledge, gender: proposal.character.gender, }, }; }); } async convertProposalToDirector(hashedUserId, proposalId) { const user = await getFalukantUserOrFail(hashedUserId); if (!user) { throw new Error('User not found'); } const proposal = await DirectorProposal.findOne( { where: { id: proposalId }, include: [ { model: FalukantCharacter, as: 'character' }, ] } ); if (!proposal || proposal.employerUserId !== user.id) { throw new Error('Proposal does not belong to the user'); } const existingDirector = await Director.findOne({ where: { employerUserId: user.id }, include: [ { model: FalukantCharacter, as: 'character', where: { regionId: proposal.character.regionId, } }, ] }); if (existingDirector) { throw new Error('A director already exists for this region'); } const newDirector = await Director.create({ directorCharacterId: proposal.directorCharacterId, employerUserId: proposal.employerUserId, income: proposal.proposedIncome, }); const regionUserDirectorProposals = await DirectorProposal.findAll({ where: { employerUserId: proposal.employerUserId, }, include: [ { model: FalukantCharacter, as: 'character', where: { regionId: proposal.character.regionId, } }, ] }); if (regionUserDirectorProposals.length > 0) { for (const p of regionUserDirectorProposals) { await p.destroy(); } } notifyUser(hashedUserId, 'directorchanged'); return newDirector; } async getDirectorForBranch(hashedUserId, branchId) { const user = await getFalukantUserOrFail(hashedUserId); if (!user) { throw new Error('User not found'); } const branch = await Branch.findOne({ where: { id: branchId, falukantUserId: user.id } }); if (!branch) { throw new Error('Branch not found or does not belong to the user'); } const director = await Director.findOne({ where: { employerUserId: user.id }, include: [ { model: FalukantCharacter, as: 'character', attributes: ['firstName', 'lastName', 'birthdate', 'titleOfNobility', 'gender'], where: { regionId: branch.regionId, }, include: [ { model: TitleOfNobility, as: 'nobleTitle' }, { model: FalukantPredefineFirstname, as: 'definedFirstName' }, { model: FalukantPredefineLastname, as: 'definedLastName' }, ] }, ], }); if (!director) { return null; } const age = Math.floor((Date.now() - new Date(director.character.birthdate)) / (24 * 60 * 60 * 1000)); return { director: { id: director.id, character: { name: `${director.character.definedFirstName.name} ${director.character.definedLastName.name}`, title: director.character.nobleTitle.labelTr, age, gender: director.character.gender, }, income: director.income, satisfaction: director.satisfaction, mayProduce: director.mayProduce, maySell: director.maySell, mayStartTransport: director.mayStartTransport, }, }; } async getAllDirectors(hashedUserId) { const user = await getFalukantUserOrFail(hashedUserId); if (!user) { throw new Error('User not found'); } const directors = await Director.findAll({ where: { employerUserId: user.id }, include: [ { model: FalukantCharacter, as: 'character', attributes: ['id', 'birthdate', 'gender'], include: [ { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr', 'level'], }, { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'], }, { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'], }, { model: Knowledge, as: 'knowledges', attributes: ['productId', 'knowledge'], include: [ { model: ProductType, as: 'productType', attributes: ['labelTr'], }, ], }, { model: RegionData, as: 'region', attributes: ['name'] } ] }, ], 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, }; }); } async updateDirector(hashedUserId, directorId, income) { const user = await this.getFalukantUserByHashedId(hashedUserId); const director = await Director.findOne({ where: { id: directorId, employerUserId: user.id } }); if (!director) { throw new Error('Director not found'); } director.update({ income: income }); return { success: true }; } async setSetting(hashedUserId, branchId, directorId, settingKey, value) { const user = await this.getFalukantUserByHashedId(hashedUserId); const branch = await Branch.findOne({ where: { id: branchId, falukantUserId: user.id, }, }); if (!branch) { return null; } const director = await Director.findOne({ where: { id: directorId, employerUserId: user.id, }, include: [{ model: FalukantCharacter, as: 'character', where: { regionId: branch.regionId, } }] }); if (!director) { return null; } const updateData = {}; updateData[settingKey] = value || false; await Director.update(updateData, { where: { id: director.id, }, }); return { result: 'ok' }; } async getFamily(hashedUserId) { const user = await this.getFalukantUserByHashedId(hashedUserId); if (!user) throw new Error('User not found'); const character = await FalukantCharacter.findOne({ where: { userId: user.id } }); if (!character) throw new Error('Character not found for this user'); let relationships = await Relationship.findAll({ where: { character1Id: character.id }, attributes: ['createdAt', 'widowFirstName2', 'nextStepProgress'], include: [ { model: FalukantCharacter, as: 'character2', attributes: ['id', 'birthdate', 'gender', 'moodId'], include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] }, { model: CharacterTrait, as: 'traits' }, { model: Mood, as: 'mood' }, ] }, { model: RelationshipType, as: 'relationshipType', attributes: ['tr'] } ] }); relationships = relationships.map(r => ({ createdAt: r.createdAt, widowFirstName2: r.widowFirstName2, progress: r.nextStepProgress, character2: { id: r.character2.id, age: calcAge(r.character2.birthdate), gender: r.character2.gender, firstName: r.character2.definedFirstName?.name || 'Unknown', nobleTitle: r.character2.nobleTitle?.labelTr || '', mood: r.character2.mood, traits: r.character2.traits }, relationshipType: r.relationshipType.tr })); const charsWithChildren = await FalukantCharacter.findAll({ where: { userId: user.id }, include: [ { model: ChildRelation, as: 'childrenFather', include: [{ model: FalukantCharacter, as: '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({ name: kid.definedFirstName?.name || 'Unknown', gender: kid.gender, age: calcAge(kid.birthdate), hasName: rel.nameSet, _createdAt: rel.createdAt, }); } } // Sort children globally by relation createdAt ascending (older first) children.sort((a, b) => new Date(a._createdAt) - new Date(b._createdAt)); const inProgress = ['wooing', 'engaged', 'married']; const family = { relationships: relationships.filter(r => inProgress.includes(r.relationshipType)), lovers: relationships.filter(r => r.relationshipType === 'lover'), deathPartners: relationships.filter(r => r.relationshipType === 'widowed'), children: children.map(({ _createdAt, ...rest }) => rest), possiblePartners: [] }; const ownAge = calcAge(character.birthdate); if (ownAge >= 12 && family.relationships.length === 0) { family.possiblePartners = await this.getPossiblePartners(character.id); if (family.possiblePartners.length === 0) { await this.createPossiblePartners( character.id, character.gender, character.regionId, character.titleOfNobility, ownAge ); family.possiblePartners = await this.getPossiblePartners(character.id); } } return family; } async getPossiblePartners(requestingCharacterId) { const proposals = await MarriageProposal.findAll({ where: { requesterCharacterId: requestingCharacterId, }, include: [ { model: FalukantCharacter, as: 'proposedCharacter', attributes: ['id', 'firstName', 'lastName', 'gender', 'regionId', 'birthdate'], include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }, { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] }, ], }, ], }); return proposals.map(proposal => { const birthdate = new Date(proposal.proposedCharacter.birthdate); const age = calcAge(birthdate); return { id: proposal.id, requesterCharacterId: proposal.requesterCharacterId, proposedCharacterId: proposal.proposedCharacter.id, proposedCharacterName: `${proposal.proposedCharacter.definedFirstName?.name} ${proposal.proposedCharacter.definedLastName?.name}`, proposedCharacterGender: proposal.proposedCharacter.gender, proposedCharacterRegionId: proposal.proposedCharacter.regionId, proposedCharacterAge: age, proposedCharacterNobleTitle: proposal.proposedCharacter.nobleTitle.labelTr, cost: proposal.cost, }; }); } async createPossiblePartners(requestingCharacterId, requestingCharacterGender, requestingRegionId, requestingCharacterTitleOfNobility, ownAge) { try { const minTitleResult = await TitleOfNobility.findOne({ order: [['id', 'ASC']], attributes: ['id'], }); if (!minTitleResult) { throw new Error('No title of nobility found'); } const minTitle = minTitleResult.id; const potentialPartners = await FalukantCharacter.findAll({ where: { id: { [Op.ne]: requestingCharacterId }, gender: { [Op.ne]: requestingCharacterGender }, regionId: requestingRegionId, createdAt: { [Op.lt]: new Date(new Date() - 12 * 24 * 60 * 60 * 1000) }, titleOfNobility: { [Op.between]: [requestingCharacterTitleOfNobility - 1, requestingCharacterTitleOfNobility + 1] } }, order: [ [Sequelize.literal(`ABS((EXTRACT(EPOCH FROM (NOW() - "birthdate")) / 86400) - ${ownAge})`), 'ASC'] ], limit: 5, }); const proposals = potentialPartners.map(partner => { const age = calcAge(partner.birthdate); return { requesterCharacterId: requestingCharacterId, proposedCharacterId: partner.id, cost: calculateMarriageCost(partner.titleOfNobility, age, minTitle), }; }); await MarriageProposal.bulkCreate(proposals); } catch (error) { console.error('Error creating possible partners:', error); throw error; } } async acceptMarriageProposal(hashedUserId, proposedCharacterId) { const user = await this.getFalukantUserByHashedId(hashedUserId); const character = await FalukantCharacter.findOne({ where: { userId: user.id } }); if (!user) { throw new Error('User not found'); } const proposal = await MarriageProposal.findOne({ where: { requesterCharacterId: character.id, proposedCharacterId: proposedCharacterId, }, }); if (!proposal) { throw new Error('Proposal not found'); } if (user.money < proposal.cost) { throw new Error('Not enough money to accept the proposal'); } const moneyResult = await updateFalukantUserMoney(user.id, -proposal.cost, 'Marriage cost', user.id); if (!moneyResult.success) { throw new Error('Failed to update money'); } const marriedType = await RelationshipType.findOne({ where: { tr: 'wooing' }, }); if (!marriedType) { throw new Error('Relationship type "married" not found'); } await Relationship.create({ character1Id: proposal.requesterCharacterId, character2Id: proposal.proposedCharacterId, relationshipTypeId: marriedType.id, }); await MarriageProposal.destroy({ where: { requesterCharacterId: character.id }, }) ; return { success: true, message: 'Marriage proposal accepted' }; } async getGifts(hashedUserId) { // 1) Mein User & Character const user = await this.getFalukantUserByHashedId(hashedUserId); const myChar = await FalukantCharacter.findOne({ where: { userId: user.id } }); if (!myChar) throw new Error('Character not found'); // 2) Beziehung finden und „anderen“ Character bestimmen const rel = await Relationship.findOne({ where: { [Op.or]: [ { character1Id: myChar.id }, { character2Id: myChar.id } ] }, include: [ { model: FalukantCharacter, as: 'character1', include: [{ model: CharacterTrait, as: 'traits' }] }, { model: FalukantCharacter, as: 'character2', include: [{ model: CharacterTrait, as: 'traits' }] } ] }); if (!rel) throw new Error('Beziehung nicht gefunden'); const relatedChar = rel.character1.id === myChar.id ? rel.character2 : rel.character1; // 3) Trait-IDs und Mood des relatedChar const relatedTraitIds = relatedChar.traits.map(t => t.id); const relatedMoodId = relatedChar.moodId; // 4) Gifts laden – aber nur die passenden Moods und Traits als Unter-Arrays const gifts = await PromotionalGift.findAll({ include: [ { model: PromotionalGiftMood, as: 'promotionalgiftmoods', attributes: ['mood_id', 'suitability'], where: { mood_id: 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 }, required: false // Gifts ohne Trait-Match bleiben erhalten } ] }); // 5) Rest wie gehabt: Kosten berechnen und zurĂŒckgeben const lowestTitleOfNobility = await TitleOfNobility.findOne({ order: [['id', 'ASC']] }); return Promise.all(gifts.map(async gift => ({ id: gift.id, name: gift.name, cost: await this.getGiftCost( gift.value, myChar.titleOfNobility, lowestTitleOfNobility.id ), moodsAffects: gift.promotionalgiftmoods, // nur EintrĂ€ge mit relatedMoodId charactersAffects: gift.characterTraits // nur EintrĂ€ge mit relatedTraitIds }))); } async getChildren(hashedUserId) { const user = await this.getFalukantUserByHashedId(hashedUserId); console.log(user); const children = await ChildRelation.findAll({ where: { [Op.or]: [ { fatherCharacterId: user.character.id }, { motherCharacterId: user.character.id } ] }, include: [ { model: FalukantCharacter, as: 'child', attributes: ['id', 'birthdate'], include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: Knowledge, as: 'knowledges', attributes: ['knowledge'], include: [ { model: ProductType, as: 'productType', attributes: ['id', 'labelTr'] } ] } ] }, ] }); return children.map(rel => ({ id: rel.child.id, name: rel.child.definedFirstName.name, age: calcAge(rel.child.birthdate), knowledge: rel.child.knowledges.map(k => ({ id: k.productType.id, tr: k.productType.labelTr, knowledge: k.knowledge })) })); } async sendGift(hashedUserId, giftId) { const user = await this.getFalukantUserByHashedId(hashedUserId); const lowestTitle = await TitleOfNobility.findOne({ order: [['id', 'ASC']] }); const currentMoodId = user.character.moodId; if (currentMoodId == null) { throw new Error('moodNotSet'); } const relation = await Relationship.findOne({ where: { character1Id: user.character.id }, include: [{ model: RelationshipType, as: 'relationshipType', where: { tr: 'wooing' } }] }); if (!relation) { throw new Error('notRelated'); } const lastGift = await PromotionalGiftLog.findOne({ where: { senderCharacterId: user.character.id }, order: [['createdAt', 'DESC']], limit: 1 }); if (lastGift && (lastGift.createdAt.getTime() + 3_600_000) > Date.now()) { throw new PreconditionError('tooOften'); } const gift = await PromotionalGift.findOne({ where: { id: giftId }, include: [ { model: PromotionalGiftCharacterTrait, as: 'characterTraits', where: { trait_id: { [Op.in]: user.character.traits.map(t => t.id) } }, required: false }, { model: PromotionalGiftMood, as: 'promotionalgiftmoods', where: { mood_id: currentMoodId }, required: false } ] }); if (!gift) { throw new Error('notFound'); } const cost = await this.getGiftCost( gift.value, user.character.nobleTitle.id, lowestTitle.id ); if (user.money < cost) { throw new PreconditionError('insufficientFunds'); } const traits = gift.characterTraits; if (!traits.length) { throw new Error('noTraits'); } const traitAvg = traits.reduce((sum, ct) => sum + ct.suitability, 0) / traits.length; const moodRecord = gift.promotionalgiftmoods[0]; if (!moodRecord) { throw new Error('noMoodData'); } const moodSuitability = moodRecord.suitability; const changeValue = Math.round(traitAvg + moodSuitability - 5); await updateFalukantUserMoney(user.id, -cost, 'Gift cost', user.id); await relation.update({ nextStepProgress: relation.nextStepProgress + changeValue }); await PromotionalGiftLog.create({ senderCharacterId: user.character.id, recipientCharacterId: relation.character2Id, giftId, changeValue }); this.checkProposalProgress(relation); return { success: true, message: 'sent' }; } async checkProposalProgress(relation) { const { nextStepProgress } = relation; if (nextStepProgress >= 100) { const engagedStatus = await RelationshipType.findOne({ where: { tr: 'engaged' } }); await relation.update({ nextStepProgress: 0, relationshipTypeId: engagedStatus.id }); const user = await User.findOne({ include: [{ model: FalukantUser, as: 'falukantData', include: [{ model: FalukantCharacter, as: 'character', where: { id: relation.character1Id } }] }] }); await notifyUser(user.hashedId, 'familychanged'); } } async getGiftCost(value, titleOfNobility, lowestTitleOfNobility) { const titleLevel = titleOfNobility - lowestTitleOfNobility + 1; return Math.round(value * Math.pow(1 + titleLevel * 0.3, 1.3) * 100) / 100; } async getTitlesOfNobility() { return TitleOfNobility.findAll(); } async getHouseTypes() { // return House } async getMoodAffect() { return PromotionalGiftMood.findAll(); } async getCharacterAffect() { return PromotionalGiftCharacterTrait.findAll(); } async getUserHouse(hashedUserId) { try { const user = await User.findOne({ where: { hashedId: hashedUserId }, include: [{ model: FalukantUser, as: 'falukantData', include: [{ model: UserHouse, as: 'userHouse', include: [{ model: HouseType, as: 'houseType', attributes: ['position', 'cost'] }], attributes: ['roofCondition', 'wallCondition', 'floorCondition', 'windowCondition'] }], } ] }); return user.falukantData[0].userHouse ?? { position: 0, roofCondition: 100, wallCondition: 100, floorCondition: 100, windowCondition: 100 }; } catch (error) { console.log(error); return {}; } } async getBuyableHouses(hashedUserId) { try { const user = await this.getFalukantUserByHashedId(hashedUserId); const houses = await BuyableHouse.findAll({ include: [{ model: HouseType, as: 'houseType', attributes: ['position', 'cost', 'labelTr'], where: { minimumNobleTitle: { [Op.lte]: user.character.nobleTitle.id } } }], attributes: ['roofCondition', 'wallCondition', 'floorCondition', 'windowCondition', 'id'], order: [ [{ model: HouseType, as: 'houseType' }, 'position', 'DESC'], ['wallCondition', 'DESC'], ['roofCondition', 'DESC'], ['floorCondition', 'DESC'], ['windowCondition', 'DESC'] ] }); return houses; } catch (error) { console.error('Fehler beim Laden der kaufbaren HĂ€user:', error); throw error; } } async buyUserHouse(hashedUserId, houseId) { try { const falukantUser = await getFalukantUserOrFail(hashedUserId); const house = await BuyableHouse.findByPk(houseId, { include: [{ model: HouseType, as: 'houseType', }], }); if (!house) { throw new Error('Das Haus wurde nicht gefunden.'); } const housePrice = this.housePrice(house); const oldHouse = await UserHouse.findOne({ where: { userId: falukantUser.id } }); if (Number(falukantUser.money) < Number(housePrice)) { throw new Error('notenoughmoney.'); } if (oldHouse) { await oldHouse.destroy(); } await UserHouse.create({ userId: falukantUser.id, houseTypeId: house.houseTypeId, }); await house.destroy(); await updateFalukantUserMoney(falukantUser.id, -housePrice, "housebuy", falukantUser.id); const user = await User.findByPk(falukantUser.userId); notifyUser(user.hashedId, 'falukantHouseUpdate', {}); return {}; } catch (error) { console.error('Fehler beim Kaufen des Hauses:', error); throw error; } } housePrice(house) { const houseQuality = (house.roofCondition + house.windowCondition + house.floorCondition + house.wallCondition) / 4; return (house.houseType.cost / 100 * houseQuality).toFixed(2); } async getPartyTypes(hashedUserId) { const falukantUser = await getFalukantUserOrFail(hashedUserId); const engagedCount = await Relationship.count({ include: [ { model: RelationshipType, as: 'relationshipType', where: { tr: 'engaged' }, required: true }, { model: FalukantCharacter, as: 'character1', where: { userId: falukantUser.id }, required: false }, { model: FalukantCharacter, as: 'character2', where: { userId: falukantUser.id }, required: false } ], where: { [Op.or]: [ { '$character1.user_id$': falukantUser.id }, { '$character2.user_id$': falukantUser.id } ] } }); const orConditions = [{ forMarriage: false }]; if (engagedCount > 0) { orConditions.push({ forMarriage: true }); } const partyTypes = await PartyType.findAll({ where: { [Op.or]: orConditions }, order: [['cost', 'ASC']] }); const musicTypes = await MusicType.findAll(); const banquetteTypes = await BanquetteType.findAll(); return { partyTypes, musicTypes, banquetteTypes }; } async createParty(hashedUserId, partyTypeId, musicId, banquetteId, nobilityIds = [], servantRatio) { const falukantUser = await getFalukantUserOrFail(hashedUserId); const since = new Date(Date.now() - 24 * 3600 * 1000); const already = await Party.findOne({ where: { falukantUserId: falukantUser.id, partyTypeId, createdAt: { [Op.gte]: since }, }, attributes: ['id'] }); if (already) { throw new Error('Diese Party wurde bereits innerhalb der letzten 24 Stunden bestellt'); } const [ptype, music, banquette] = await Promise.all([ PartyType.findByPk(partyTypeId), MusicType.findByPk(musicId), BanquetteType.findByPk(banquetteId), ]); if (!ptype || !music || !banquette) { throw new Error('UngĂŒltige Party-, Musik- oder Bankett-Auswahl'); } const nobilities = nobilityIds.length ? await TitleOfNobility.findAll({ where: { id: { [Op.in]: nobilityIds } } }) : []; let cost = (ptype.cost || 0) + (music.cost || 0) + (banquette.cost || 0); cost += (50 / servantRatio - 1) * 1000; const nobilityCost = nobilities.reduce((sum, n) => sum + ((n.id ^ 5) * 1000), 0); cost += nobilityCost; if (Number(falukantUser.money) < cost) { throw new Error('Nicht genĂŒgend Guthaben fĂŒr diese Party'); } const moneyResult = await updateFalukantUserMoney( falukantUser.id, -cost, 'partyOrder', falukantUser.id ); if (!moneyResult.success) { throw new Error('Geld konnte nicht abgezogen werden'); } const party = await Party.create({ partyTypeId, falukantUserId: falukantUser.id, musicTypeId: musicId, banquetteTypeId: banquetteId, servantRatio, cost: cost }); if (nobilityIds.length) { await party.addInvitedNobilities(nobilityIds); } const user = await User.findByPk(falukantUser.userId); notifyUser(user.hashedId, 'falukantPartyUpdate', { partyId: party.id, cost, }); return { 'success': true }; } async getParties(hashedUserId) { const falukantUser = await getFalukantUserOrFail(hashedUserId); const parties = await Party.findAll({ where: { falukantUserId: falukantUser.id }, include: [ { model: PartyType, as: 'partyType', attributes: ['tr'], }, { model: MusicType, as: 'musicType', attributes: ['tr'], }, { model: BanquetteType, as: 'banquetteType', attributes: ['tr'], }, ], order: [['createdAt', 'DESC']], attributes: ['id', 'createdAt', 'servantRatio', 'cost'], }); return parties; } async getNotBaptisedChildren(hashedUserId) { const falukantUser = await getFalukantUserOrFail(hashedUserId); const children = await ChildRelation.findAll({ include: [ { model: FalukantCharacter, as: 'father', where: { userId: falukantUser.id, }, required: false, }, { model: FalukantCharacter, as: 'mother', where: { userId: falukantUser.id, }, required: false, }, { model: FalukantCharacter, as: 'child', required: true, include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName', required: true, }, ] }, ], where: { nameSet: false, }, order: [['createdAt', 'DESC']], }); return children.map(child => { return { id: child.child.id, gender: child.child.gender, age: calcAge(child.child.birthdate), proposedFirstName: child.child.definedFirstName.name, }; }); } async baptise(hashedUserId, childId, firstName) { try { const falukantUser = await getFalukantUserOrFail(hashedUserId); const parentCharacter = await FalukantCharacter.findOne({ where: { userId: falukantUser.id, }, }); if (!parentCharacter) { throw new Error('Parent character not found'); } const child = await FalukantCharacter.findOne({ where: { id: childId, }, }); if (!child) { throw new Error('Child not found'); } const childRelation = await ChildRelation.findOne({ where: { [Op.or]: [ { fatherCharacterId: parentCharacter.id, childCharacterId: child.id, }, { motherCharacterId: parentCharacter.id, childCharacterId: child.id, } ] } }); if (!childRelation) { throw new Error('Child relation not found'); } await childRelation.update({ nameSet: true, }); let firstNameObject = await FalukantPredefineFirstname.findOne({ where: { name: firstName, gender: child.gender, }, }); if (!firstNameObject) { firstNameObject = await FalukantPredefineFirstname.create({ name: firstName, gender: child.gender, }); } await child.update({ firstName: firstNameObject.id, }); updateFalukantUserMoney(falukantUser.id, -50, 'Baptism', falukantUser.id); // Trigger status bar refresh for the user after baptism notifyUser(hashedUserId, 'falukantUpdateStatus', {}); return { success: true }; } catch (error) { throw new Error(error.message); } } async getEducation(hashedUserId) { try { const falukantUser = await getFalukantUserOrFail(hashedUserId); const education = await Learning.findAll({ where: { createdAt: { [Op.gt]: new Date().getTime() - 1000 * 60 * 60 * 24 }, }, include: [ { model: LearnRecipient, as: 'recipient', attributes: ['tr'] }, { model: ProductType, as: 'productType', attributes: ['labelTr'] }, { model: FalukantUser, as: 'learner', where: { id: falukantUser.id }, attributes: [] }, { model: FalukantCharacter, as: 'learningCharacter', attributes: ['id'] } ], attributes: ['createdAt'], }); return education; } catch (error) { console.log(error); throw new Error(error.message); } } computeCost(percent, mode) { const cfg = FalukantService.COST_CONFIG[mode]; // clamp percent auf [0, KNOWLEDGE_MAX] const p = Math.min(Math.max(percent, 0), FalukantService.KNOWLEDGE_MAX) / FalukantService.KNOWLEDGE_MAX; return Math.round(cfg.min + (cfg.max - cfg.min) * p); } async sendToSchool(hashedUserId, item, student, studentId) { const falukantUser = await getFalukantUserOrFail(hashedUserId); // 1) prĂŒfen, ob schon in Arbeit const education = await this.getEducation(hashedUserId); const already = education.some(e => e.recipient.tr === student && (studentId == null || e.learningCharacter?.id === studentId) ); if (already) throw new Error('Already learning this character'); // 2) EmpfĂ€nger holen const rec = await LearnRecipient.findOne({ where: { tr: student } }); if (!rec) throw new Error('Character not found'); // 3) Wissens-Prozentsatz ermitteln 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; } else { const single = await this.getKnowledgeSingle(hashedUserId, student, studentId, item); percent = single.knowledge; } // 4) Kosten berechnen const mode = item === 'all' ? 'all' : 'one'; const cost = this.computeCost(percent, mode); // 5) Kontostand prĂŒfen if (parseFloat(falukantUser.money) < cost) { throw new Error('Not enough money'); } // 6) Learning anlegen await Learning.create({ learningRecipientId: rec.id, associatedLearningCharacterId: studentId, associatedFalukantUserId: falukantUser.id, productId: item === 'all' ? null : item, learnAllProducts: (item === 'all') }); // 7) Geld abziehen const upd = await updateFalukantUserMoney( falukantUser.id, -cost, item === 'all' ? 'learnAll' : `learnItem:${item}`, falukantUser.id ); if (!upd.success) throw new Error(upd.message); return true; } async getBankOverview(hashedUserId) { const falukantUser = await getFalukantUserOrFail(hashedUserId); if (!falukantUser) throw new Error('User not found'); // 1) offene Schulden const totalDebt = await Credit.sum('remaining_amount', { where: { falukant_user_id: falukantUser.id } }) || 0; // 2) HĂ€user ermitteln const userHouses = await UserHouse.findAll({ where: { userId: falukantUser.id }, include: [{ model: HouseType, as: 'houseType', attributes: ['cost'] }] }); // 3) Hauswert berechnen: buyCost * 0.8 let houseValue = 0; for (const uh of userHouses) { const { roofCondition, wallCondition, floorCondition, windowCondition } = uh; const qualityAvg = (roofCondition + wallCondition + floorCondition + windowCondition) / 4; const buyWorth = (uh.houseType.cost / 100) * qualityAvg; houseValue += buyWorth * 0.8; } // 4) Filialwert (1000 pro Branch) const branchCount = await Branch.count({ where: { falukantUserId: falukantUser.id } }); const branchValue = branchCount * 1000; // 5) Maximaler Kredit und verfĂŒgbare Linie const maxCredit = Math.floor(houseValue + branchValue); const availableCredit = maxCredit - totalDebt; // 6) aktive Kredite laden const activeCredits = await Credit.findAll({ where: { falukantUserId: falukantUser.id }, attributes: ['id', 'amount', 'remainingAmount', 'interestRate'] }); return { money: falukantUser.money, totalDebt, maxCredit, availableCredit, activeCredits, fee: 7 }; } async getBankCredits(hashedUserId) { const falukantUser = await getFalukantUserOrFail(hashedUserId); if (!falukantUser) throw new Error('User not found'); const credits = await Credit.findAll({ where: { falukantUserId: falukantUser.id }, }); return credits; } async takeBankCredits(hashedUserId, height) { const falukantUser = await getFalukantUserOrFail(hashedUserId); if (!falukantUser) throw new Error('User not found'); const financialData = await this.getBankOverview(hashedUserId); if (financialData.availableCredit < height) { throw new Error('Not enough credit'); } const newCredit = await Credit.create({ falukantUserId: falukantUser.id, amount: height, remainingAmount: height, interestRate: financialData.fee, }); updateFalukantUserMoney(falukantUser.id, height, 'credit taken', falukantUser.id); return { height: newCredit.amount, fee: newCredit.interestRate }; } async getNobility(hashedUserId) { const falukantUser = await getFalukantUserOrFail(hashedUserId); const nobility = await TitleOfNobility.findOne({ include: [ { model: FalukantCharacter, as: 'charactersWithNobleTitle', attributes: ['gender'], where: { userId: falukantUser.id } }, { model: TitleRequirement, as: 'requirements', attributes: ['requirementType', 'requirementValue'] } ], attributes: ['labelTr', 'level'] }); const currentTitleLevel = nobility.level; const nextTitle = await TitleOfNobility.findOne({ where: { level: currentTitleLevel + 1 }, include: [ { model: TitleRequirement, as: 'requirements', } ], attributes: ['labelTr'] }); return { current: nobility, next: nextTitle }; } async advanceNobility(hashedUserId) { const nobility = await this.getNobility(hashedUserId); if (!nobility || !nobility.next) { throw new Error('User does not have a nobility'); } const nextTitle = nobility.next.toJSON(); const user = await this.getFalukantUserByHashedId(hashedUserId); let fulfilled = true; let cost = 0; for (const requirement of nextTitle.requirements) { switch (requirement.requirementType) { case 'money': fulfilled = fulfilled && await this.checkMoneyRequirement(user, requirement); break; case 'cost': fulfilled = fulfilled && await this.checkMoneyRequirement(user, requirement); cost = requirement.requirementValue; break; case 'branches': fulfilled = fulfilled && await this.checkBranchesRequirement(hashedUserId, requirement); break; default: fulfilled = false; }; } if (!fulfilled) { throw new Error('Requirements not fulfilled'); } const newTitle = await TitleOfNobility.findOne({ where: { level: nobility.current.level + 1 } }); const character = await FalukantCharacter.findOne({ where: { userId: user.id } }); await character.update({ titleOfNobility: newTitle.id }); if (cost > 0) { updateFalukantUserMoney(user.id, -cost, 'new nobility title', user.id); } return {}; } async checkMoneyRequirement(user, requirement) { return user.money >= requirement.requirementValue; } async checkBranchesRequirement(hashedUserId, requirement) { const user = await this.getFalukantUserByHashedId(hashedUserId); } async getHealth(hashedUserId) { const user = await this.getFalukantUserByHashedId(hashedUserId); const healthActivities = FalukantService.HEALTH_ACTIVITIES.map((activity) => { return { tr: activity.tr, cost: activity.cost } }); const healthHistory = await HealthActivity.findAll({ where: { characterId: user.character.id }, order: [['createdAt', 'DESC']], }); return { age: calcAge(user.character.birthdate), health: user.character.health, healthActivities: healthActivities, history: healthHistory.map((activity) => { return { tr: activity.activityTr, cost: activity.cost, createdAt: activity.createdAt, success: activity.successPercentage } }), }; } async healthActivity(hashedUserId, activity) { const user = await this.getFalukantUserByHashedId(hashedUserId); const lastHealthActivity = await HealthActivity.findOne({ where: { characterId: user.character.id, activityTr: activity, createdAt: { [Op.gte]: new Date(new Date().setDate(new Date().getDate() - 1)) } }, order: [['createdAt', 'DESC']], limit: 1 }); if (lastHealthActivity) { throw new Error('too close'); } const activityObject = FalukantService.HEALTH_ACTIVITIES.find((a) => a.tr === activity); if (!activityObject) { throw new Error('invalid'); } if (user.money - activityObject.cost < 0) { throw new Error('no money'); } user.character.health -= activityObject.cost; await HealthActivity.create({ characterId: user.character.id, activityTr: activity, successPercentage: await this[activityObject.method](user), cost: activityObject.cost }); updateFalukantUserMoney(user.id, -activityObject.cost, 'health.' + activity); return { success: true }; } async healthChange(user, delta) { const char = await FalukantCharacter.findOne({ where: { id: user.character.id } }); await char.update({ health: Math.min(FalukantService.HEALTH_MAX || 100, Math.max(0, char.health + delta)) }); return delta; } async healthBarber(user) { const raw = Math.floor(Math.random() * 11) - 5; return this.healthChange(user, raw); } async healthDoctor(user) { const raw = Math.floor(Math.random() * 8) - 2; return this.healthChange(user, raw); } async healthWitch(user) { const raw = Math.floor(Math.random() * 7) - 1; return this.healthChange(user, raw); } async healthPill(user) { const raw = Math.floor(Math.random() * 8); return this.healthChange(user, raw); } async healthDrunkOfLife(user) { const raw = Math.floor(Math.random() * 26); return this.healthChange(user, raw); } async getPoliticsOverview(hashedUserId) { } 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') { return []; } const rows = await sequelize.query( FalukantService.RECURSIVE_REGION_SEARCH, { replacements: { user_id: user.id }, type: sequelize.QueryTypes.SELECT } ); const regionIds = rows.map(r => r.id); // 3) Zeitbereich "heute" const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); const todayEnd = new Date(); todayEnd.setHours(23, 59, 59, 999); // 4) Wahlen laden (inkl. Kandidaten, Stimmen und VerknĂŒpfungen) const rawElections = await Election.findAll({ where: { regionId: { [Op.in]: regionIds }, date: { [Op.between]: [todayStart, todayEnd] } }, include: [ { model: RegionData, as: 'region', attributes: ['name'], include: [{ model: RegionType, as: 'regionType', attributes: ['labelTr'] }] }, { model: PoliticalOfficeType, as: 'officeType', attributes: ['name'] }, { model: Candidate, as: 'candidates', attributes: ['id'], include: [{ model: FalukantCharacter, as: 'character', attributes: ['birthdate', 'gender'], include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }, { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] } ] }] }, { model: Vote, as: 'votes', attributes: ['candidateId'], where: { falukantUserId: user.id }, required: false } ] }); return rawElections.map(election => { const e = election.get({ plain: true }); const voted = Array.isArray(e.votes) && e.votes.length > 0; const reducedCandidates = (e.candidates || []).map(cand => { const ch = cand.character || {}; const firstname = ch.definedFirstName?.name || ''; const lastname = ch.definedLastName?.name || ''; return { id: cand.id, title: ch.nobleTitle?.labelTr || null, name: `${firstname} ${lastname}`.trim(), age: calcAge(ch.birthdate), gender: ch.gender }; }); return { id: e.id, officeType: { name: e.officeType.name }, region: { name: e.region.name, regionType: { labelTr: e.region.regionType.labelTr } }, date: e.date, postsToFill: e.postsToFill, candidates: reducedCandidates, voted: voted, votedFor: voted ? e.votes.map(vote => { return vote.candidateId }) : null, }; }); } async vote(hashedUserId, votes) { const elections = await this.getElections(hashedUserId); if (!Array.isArray(elections) || elections.length === 0) { throw new Error('No elections found'); } const user = await this.getFalukantUserByHashedId(hashedUserId); if (!user) { throw new Error('User not found'); } const validElections = votes.filter(voteEntry => { const e = elections.find(el => el.id === voteEntry.electionId); return e && !e.voted; }); if (validElections.length === 0) { throw new Error('No valid elections to vote for (either non‐existent or already voted)'); } validElections.forEach(voteEntry => { const e = elections.find(el => el.id === voteEntry.electionId); const allowedIds = e.candidates.map(c => c.id); voteEntry.candidateIds.forEach(cid => { if (!allowedIds.includes(cid)) { throw new Error(`Candidate ID ${cid} is not valid for election ${e.id}`); } }); if (voteEntry.candidateIds.length > e.postsToFill) { throw new Error(`Too many candidates selected for election ${e.id}. Allowed: ${e.postsToFill}`); } }); return await sequelize.transaction(async (tx) => { const toCreate = []; validElections.forEach(voteEntry => { voteEntry.candidateIds.forEach(candidateId => { toCreate.push({ electionId: voteEntry.electionId, candidateId, falukantUserId: user.id }); }); }); await Vote.bulkCreate(toCreate, { transaction: tx, ignoreDuplicates: true, returning: false }); return { success: true }; }); } async getOpenPolitics(hashedUserId) { const user = await this.getFalukantUserByHashedId(hashedUserId); const characterId = user.character.id; const rows = await sequelize.query( FalukantService.RECURSIVE_REGION_SEARCH, { replacements: { user_id: user.id }, type: sequelize.QueryTypes.SELECT } ); const regionIds = rows.map(r => r.id); const histories = await PoliticalOfficeHistory.findAll({ where: { characterId }, attributes: ['officeTypeId', 'startDate', 'endDate'] }); const heldOfficeTypeIds = histories.map(h => h.officeTypeId); const allTypes = await PoliticalOfficeType.findAll({ attributes: ['id', 'name'] }); const nameToId = Object.fromEntries(allTypes.map(t => [t.name, t.id])); const openPositions = await Election.findAll({ where: { regionId: { [Op.in]: regionIds }, date: { [Op.lt]: new Date() } }, include: [ { model: RegionData, as: 'region', attributes: ['name'], include: [ { model: RegionType, as: 'regionType', attributes: ['labelTr'] } ] }, { model: Candidate, as: 'candidates' }, { model: PoliticalOfficeType, as: 'officeType', include: [{ model: PoliticalOfficePrerequisite, as: 'prerequisites' }] } ] }); const result = openPositions .filter(election => { const prereqs = election.officeType.prerequisites || []; return prereqs.some(pr => { const jobs = pr.prerequisite.jobs; if (!Array.isArray(jobs) || jobs.length === 0) return true; return jobs.some(jobName => { const reqId = nameToId[jobName]; return heldOfficeTypeIds.includes(reqId); }); }); }) .map(election => { const e = election.get({ plain: true }); const jobs = e.officeType.prerequisites[0]?.prerequisite.jobs || []; const matchingHistory = histories .filter(h => jobs.includes(allTypes.find(t => t.id === h.officeTypeId)?.name)) .map(h => ({ officeTypeId: h.officeTypeId, startDate: h.startDate, endDate: h.endDate })); return { ...e, history: matchingHistory }; }); return result; } async applyForElections(hashedUserId, electionIds) { // 1) Hole FalukantUser + Character const user = await this.getFalukantUserByHashedId(hashedUserId); if (!user) { throw new Error('User nicht gefunden'); } const character = user.character; if (!character) { throw new Error('Kein Charakter zum User gefunden'); } // 2) Noncivil‐Titel aussperren if (character.nobleTitle.labelTr === 'noncivil') { return { applied: [], skipped: electionIds }; } // 3) Ermittle die heute offenen Wahlen, auf die er zugreifen darf // (getElections liefert id, officeType, region, date, postsToFill, candidates, voted
) const openElections = await this.getElections(hashedUserId); const allowedIds = new Set(openElections.map(e => e.id)); // 4) 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 const existing = await Candidate.findAll({ where: { electionId: { [Op.in]: toTry }, characterId: character.id }, attributes: ['electionId'] }); const alreadyIds = new Set(existing.map(c => c.electionId)); // 6) Erstelle Liste der Wahlen, fĂŒr die er sich noch nicht beworben hat const newApplications = toTry.filter(id => !alreadyIds.has(id)); const skipped = electionIds.filter(id => !newApplications.includes(id)); console.log(newApplications, skipped); // 7) Bulk-Insert aller neuen Bewerbungen if (newApplications.length > 0) { const toInsert = newApplications.map(eid => ({ electionId: eid, characterId: character.id })); await Candidate.bulkCreate(toInsert); } return { applied: newApplications, skipped: skipped }; } async getRegions(hashedUserId) { const user = await this.getFalukantUserByHashedId(hashedUserId); const regions = await RegionData.findAll({ attributes: ['id', 'name', 'map'], 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, } ] }); return regions; } async renovate(hashedUserId, element) { const user = await getFalukantUserOrFail(hashedUserId); const house = await UserHouse.findOne({ where: { userId: user.id }, include: [{ model: HouseType, as: 'houseType' }] }); if (!house) throw new Error('House not found'); const oldValue = house[element]; if (oldValue >= 100) { return { cost: 0 }; } const baseCost = house.houseType?.cost || 0; const cost = this._calculateRenovationCost(baseCost, element, oldValue); house[element] = 100; await house.save(); await updateFalukantUserMoney( user.id, -cost, `renovation_${element}` ); return { cost }; } _calculateRenovationCost(baseCost, key, currentVal) { const weights = { roofCondition: 0.25, wallCondition: 0.25, floorCondition: 0.25, windowCondition: 0.25 }; const weight = weights[key] || 0; const missing = 100 - currentVal; const raw = (missing / 100) * baseCost * weight; return Math.round(raw * 100) / 100; } async renovateAll(hashedUserId) { const user = await getFalukantUserOrFail(hashedUserId); const house = await UserHouse.findOne({ where: { userId: user.id }, include: [{ model: HouseType, as: 'houseType' }] }); if (!house) throw new Error('House not found'); const baseCost = house.houseType?.cost || 0; const keys = ['roofCondition', 'wallCondition', 'floorCondition', 'windowCondition']; let rawSum = 0; for (const key of keys) { const current = house[key]; if (current < 100) { rawSum += this._calculateRenovationCost(baseCost, key, current); } } const totalCost = Math.round(rawSum * 0.8 * 100) / 100; for (const key of keys) { house[key] = 100; } await house.save(); await updateFalukantUserMoney( user.id, -totalCost, 'renovation_all' ); return { cost: totalCost }; } async getUndergroundTypes(hashedUserId) { const user = await getFalukantUserOrFail(hashedUserId); const undergroundTypes = await UndergroundType.findAll(); return undergroundTypes; } async getNotifications(hashedUserId) { const user = await getFalukantUserOrFail(hashedUserId); const notifications = await Notification.findAll({ where: { userId: user.id, shown: false }, order: [['createdAt', 'DESC']] }); return notifications; } async getAllNotifications(hashedUserId, page = 1, size = 10) { const user = await getFalukantUserOrFail(hashedUserId); const limit = Math.max(1, Math.min(Number(size) || 10, 100)); const offset = Math.max(0, ((Number(page) || 1) - 1) * limit); const { rows, count } = await Notification.findAndCountAll({ where: { userId: user.id }, order: [['createdAt', 'DESC']], offset, limit, }); return { items: rows, total: count, page: Number(page) || 1, size: limit }; } async markNotificationsShown(hashedUserId) { const user = await getFalukantUserOrFail(hashedUserId); const [count] = await Notification.update( { shown: true }, { where: { userId: user.id, shown: false } } ); return { updated: count }; } async getPoliticalOfficeHolders(hashedUserId) { const user = await getFalukantUserOrFail(hashedUserId); const character = await FalukantCharacter.findOne({ where: { userId: user.id } }); const relevantRegionIds = await this.getRegionAndParentIds(character.regionId); const now = new Date(); const histories = await PoliticalOffice.findAll({ where: { regionId: { [Op.in]: relevantRegionIds }, }, include: [{ model: FalukantCharacter, as: 'holder', include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }, { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] } ], attributes: ['id', 'gender'] }, { model: PoliticalOfficeType, as: 'type', }] }); // Unikate nach character.id const map = new Map(); histories.forEach(h => { const c = h.holder; if (c && c.id && !map.has(c.id)) { map.set(c.id, { id: c.id, name: `${c.definedFirstName.name} ${c.definedLastName.name}`, title: c.nobleTitle.labelTr, officeType: h.type.name, gender: c.gender }); } }); return Array.from(map.values()); } async getRegionAndParentIds(regionId) { const relevantRegionIds = new Set(); let currentRegionId = regionId; while (currentRegionId !== null) { relevantRegionIds.add(currentRegionId); const region = await RegionData.findByPk(currentRegionId, { attributes: ['parentId'] }); if (region && region.parentId) { currentRegionId = region.parentId; } else { currentRegionId = null; // Keine weitere Elternregion gefunden } } return Array.from(relevantRegionIds); } // vorher: async searchUsers(q) { async searchUsers(hashedUserId, q) { // User-PrĂŒfung wie bei anderen Methoden await getFalukantUserOrFail(hashedUserId); // wir brauchen das Ergebnis hier nicht weiter, nur Validierung const chars = await FalukantCharacter.findAll({ where: { userId: { [Op.ne]: null }, [Op.or]: [ { '$user.user.username$': { [Op.iLike]: `%${q}%` } }, { '$definedFirstName.name$': { [Op.iLike]: `%${q}%` } }, { '$definedLastName.name$': { [Op.iLike]: `%${q}%` } } ] }, include: [ { model: FalukantUser, as: 'user', include: [ { model: User, as: 'user', attributes: ['username'] } ] }, { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }, { model: RegionData, as: 'region', attributes: ['name'] } ], limit: 50, raw: false }); // Debug-Log (optional) console.log('FalukantService.searchUsers raw result for', q, chars); const mapped = chars .map(c => ({ username: c.user?.user?.username || null, firstname: c.definedFirstName?.name || null, lastname: c.definedLastName?.name || null, town: c.region?.name || null })) .filter(u => u.username); console.log('FalukantService.searchUsers mapped result for', q, mapped); return mapped; } async createUndergroundActivity(hashedUserId, payload) { const { typeId, victimUsername, target, goal, politicalTargets } = payload; // 1) Performer auflösen const falukantUser = await this.getFalukantUserByHashedId(hashedUserId); if (!falukantUser || !falukantUser.character) { throw new Error('Performer not found'); } const performerChar = falukantUser.character; // 2) Victim auflösen ĂŒber Username (inner join) const victimChar = await FalukantCharacter.findOne({ include: [ { model: FalukantUser, as: 'user', required: true, // inner join attributes: [], include: [ { model: User, as: 'user', required: true, // inner join where: { username: victimUsername }, attributes: [] } ] } ] }); if (!victimChar) { throw new PreconditionError('Victim character not found'); } // 3) Selbstangriff verhindern if (victimChar.id === performerChar.id) { throw new PreconditionError('Cannot target yourself'); } // 4) Typ-spezifische Validierung const undergroundType = await UndergroundType.findByPk(typeId); if (!undergroundType) { throw new Error('Invalid underground type'); } if (undergroundType.tr === 'sabotage') { if (!target) { throw new PreconditionError('Sabotage target missing'); } } if (undergroundType.tr === 'corrupt_politician') { if (!goal) { throw new PreconditionError('Corrupt goal missing'); } // politicalTargets kann optional sein, falls benötigt prĂŒfen } // 5) Eintrag anlegen (optional: in Transaction) const newEntry = await Underground.create({ undergroundTypeId: typeId, performerId: performerChar.id, victimId: victimChar.id, result: null, parameters: { target: target || null, goal: goal || null, politicalTargets: politicalTargets || null } }); return newEntry; } async getUndergroundAttacks(hashedUserId) { const falukantUser = await getFalukantUserOrFail(hashedUserId); const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id } }); if (!character) throw new Error('Character not found'); const charId = character.id; const attacks = await Underground.findAll({ where: { [Op.or]: [ { performerId: charId }, { victimId: charId } ] }, include: [ { model: FalukantCharacter, as: 'performer', include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }, { model: FalukantUser, as: 'user', include: [{ model: User, as: 'user', attributes: ['username'] }], attributes: [] } ], attributes: ['id', 'gender'] }, { model: FalukantCharacter, as: 'victim', include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }, { model: FalukantUser, as: 'user', include: [{ model: User, as: 'user', attributes: ['username'] }], attributes: [] } ], attributes: ['id', 'gender'] }, { model: UndergroundType, as: 'undergroundType', // hier der korrekte Alias attributes: ['tr'] } ], order: [['createdAt', 'DESC']] }); const formatCharacter = (c) => { if (!c) return null; return { id: c.id, username: c.user?.user?.username || null, firstname: c.definedFirstName?.name || null, lastname: c.definedLastName?.name || null, gender: c.gender }; }; const mapped = attacks.map(a => ({ id: a.id, result: a.result, createdAt: a.createdAt, updatedAt: a.updatedAt, type: a.undergroundType?.tr || null, // angepasst parameters: a.parameters, performer: formatCharacter(a.performer), victim: formatCharacter(a.victim), success: !!a.result })); return { sent: mapped.filter(a => a.performer?.id === charId), received: mapped.filter(a => a.victim?.id === charId), all: mapped }; } } export default new FalukantService();