4159 lines
158 KiB
JavaScript
4159 lines
158 KiB
JavaScript
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';
|
||
import VehicleType from '../models/falukant/type/vehicle.js';
|
||
import Vehicle from '../models/falukant/data/vehicle.js';
|
||
import Transport from '../models/falukant/data/transport.js';
|
||
import RegionDistance from '../models/falukant/data/region_distance.js';
|
||
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';
|
||
|
||
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);
|
||
}
|
||
|
||
async function calcRegionalSellPrice(product, knowledgeFactor, regionId) {
|
||
// Hole TownProductWorth für diese Region und dieses Produkt
|
||
const townWorth = await TownProductWorth.findOne({
|
||
where: { productId: product.id, regionId: regionId }
|
||
});
|
||
const worthPercent = townWorth?.worthPercent || 50; // Default 50% wenn nicht gefunden
|
||
|
||
// Basispreis basierend auf regionalem worthPercent
|
||
const basePrice = product.sellCost * (worthPercent / 100);
|
||
|
||
// Dann Knowledge-Faktor anwenden
|
||
const min = basePrice * 0.6;
|
||
const max = basePrice;
|
||
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;
|
||
}
|
||
|
||
async function computeShortestRoute(transportMode, sourceRegionId, targetRegionId) {
|
||
const src = parseInt(sourceRegionId, 10);
|
||
const tgt = parseInt(targetRegionId, 10);
|
||
if (Number.isNaN(src) || Number.isNaN(tgt)) {
|
||
throw new Error('Invalid region ids for route calculation');
|
||
}
|
||
if (src === tgt) {
|
||
return { distance: 0, path: [src] };
|
||
}
|
||
|
||
const rows = await RegionDistance.findAll({
|
||
where: { transportMode },
|
||
attributes: ['sourceRegionId', 'targetRegionId', 'distance'],
|
||
});
|
||
|
||
if (!rows.length) {
|
||
return null;
|
||
}
|
||
|
||
const adj = new Map();
|
||
for (const r of rows) {
|
||
const a = r.sourceRegionId;
|
||
const b = r.targetRegionId;
|
||
const d = r.distance;
|
||
if (!adj.has(a)) adj.set(a, []);
|
||
if (!adj.has(b)) adj.set(b, []);
|
||
adj.get(a).push({ to: b, w: d });
|
||
adj.get(b).push({ to: a, w: d });
|
||
}
|
||
|
||
if (!adj.has(src) || !adj.has(tgt)) {
|
||
return null;
|
||
}
|
||
|
||
const dist = new Map();
|
||
const prev = new Map();
|
||
const visited = new Set();
|
||
|
||
for (const node of adj.keys()) {
|
||
dist.set(node, Number.POSITIVE_INFINITY);
|
||
}
|
||
dist.set(src, 0);
|
||
|
||
while (true) {
|
||
let u = null;
|
||
let best = Number.POSITIVE_INFINITY;
|
||
for (const [node, d] of dist.entries()) {
|
||
if (!visited.has(node) && d < best) {
|
||
best = d;
|
||
u = node;
|
||
}
|
||
}
|
||
if (u === null || u === tgt) break;
|
||
visited.add(u);
|
||
const neighbors = adj.get(u) || [];
|
||
for (const { to, w } of neighbors) {
|
||
if (visited.has(to)) continue;
|
||
const alt = dist.get(u) + w;
|
||
if (alt < dist.get(to)) {
|
||
dist.set(to, alt);
|
||
prev.set(to, u);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!prev.has(tgt) && tgt !== src) {
|
||
return null;
|
||
}
|
||
|
||
const path = [];
|
||
let cur = tgt;
|
||
while (cur !== undefined) {
|
||
path.unshift(cur);
|
||
cur = prev.get(cur);
|
||
}
|
||
|
||
return { distance: dist.get(tgt), path };
|
||
}
|
||
|
||
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: "healthDrunkOfLife", 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'],
|
||
include: [
|
||
{
|
||
model: Weather,
|
||
as: 'weather',
|
||
include: [
|
||
{ model: WeatherType, as: 'weatherType', attributes: ['tr'] }
|
||
],
|
||
required: false
|
||
}
|
||
]
|
||
}
|
||
],
|
||
attributes: ['id', 'regionId'],
|
||
order: [['branchTypeId', 'ASC']]
|
||
});
|
||
return bs.map(b => ({
|
||
...b.toJSON(),
|
||
isMainBranch: u.mainBranchRegionId === b.regionId,
|
||
weather: b.region?.weather?.weatherType?.tr || null
|
||
}));
|
||
}
|
||
|
||
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;
|
||
if (user.money < cost) {
|
||
throw new PreconditionError('insufficientFunds');
|
||
}
|
||
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 upgradeBranch(hashedUserId, branchId) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
const branch = await Branch.findOne({
|
||
where: { id: branchId, falukantUserId: user.id },
|
||
include: [{ model: BranchType, as: 'branchType', attributes: ['id', 'labelTr'] }],
|
||
});
|
||
if (!branch) {
|
||
throw new Error('Branch not found');
|
||
}
|
||
|
||
const currentLabel = branch.branchType?.labelTr;
|
||
|
||
let targetLabel = null;
|
||
if (currentLabel === 'production') {
|
||
targetLabel = 'fullstack';
|
||
} else if (currentLabel === 'store') {
|
||
targetLabel = 'fullstack';
|
||
} else {
|
||
// already fullstack or unknown type
|
||
throw new PreconditionError('noUpgradeAvailable');
|
||
}
|
||
|
||
const targetType = await BranchType.findOne({ where: { labelTr: targetLabel } });
|
||
if (!targetType) {
|
||
throw new Error(`Target branch type '${targetLabel}' not found`);
|
||
}
|
||
|
||
// Für den Moment ohne zusätzliche Kosten – kann später erweitert werden
|
||
branch.branchTypeId = targetType.id;
|
||
await branch.save();
|
||
|
||
const updated = await this.getBranch(hashedUserId, branch.id);
|
||
return updated;
|
||
}
|
||
|
||
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 getVehicleTypes(hashedUserId) {
|
||
// Validate user existence, but we don't filter by user here
|
||
await getFalukantUserOrFail(hashedUserId);
|
||
return VehicleType.findAll();
|
||
}
|
||
|
||
async getVehicles(hashedUserId, regionId) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
|
||
const where = { falukantUserId: user.id };
|
||
if (regionId) {
|
||
where.regionId = regionId;
|
||
}
|
||
|
||
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,
|
||
},
|
||
],
|
||
order: [['availableFrom', 'ASC'], ['id', 'ASC']],
|
||
});
|
||
|
||
const now = new Date();
|
||
const branchRegionId = regionId ? parseInt(regionId, 10) : undefined;
|
||
|
||
return vehicles.map((v) => {
|
||
const plain = v.get({ plain: true });
|
||
|
||
const effectiveRegionId = branchRegionId ?? plain.regionId;
|
||
|
||
const hasTransportHere = Array.isArray(plain.transports) && plain.transports.some(
|
||
(t) =>
|
||
t.sourceRegionId === effectiveRegionId ||
|
||
t.targetRegionId === effectiveRegionId
|
||
);
|
||
|
||
let status;
|
||
if (hasTransportHere) {
|
||
// verknüpft mit Transport in dieser Region = unterwegs
|
||
status = 'travelling';
|
||
} else if (plain.availableFrom && new Date(plain.availableFrom).getTime() > now.getTime()) {
|
||
// kein Transport, aber Verfügbarkeit liegt in der Zukunft = im Bau
|
||
status = 'building';
|
||
} else {
|
||
// kein Transport und Verfügbarkeit erreicht = verfügbar
|
||
status = 'available';
|
||
}
|
||
|
||
return {
|
||
id: plain.id,
|
||
condition: plain.condition,
|
||
availableFrom: plain.availableFrom,
|
||
status,
|
||
type: {
|
||
id: plain.type?.id,
|
||
tr: plain.type?.tr,
|
||
capacity: plain.type?.capacity,
|
||
transportMode: plain.type?.transportMode,
|
||
speed: plain.type?.speed,
|
||
buildTimeMinutes: plain.type?.buildTimeMinutes,
|
||
cost: plain.type?.cost,
|
||
},
|
||
};
|
||
});
|
||
}
|
||
|
||
async getTransportRoute(hashedUserId, { sourceRegionId, targetRegionId, vehicleTypeId }) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
if (!user) {
|
||
throw new Error('User not found');
|
||
}
|
||
|
||
const type = await VehicleType.findByPk(vehicleTypeId);
|
||
if (!type) {
|
||
throw new Error('Vehicle type not found');
|
||
}
|
||
|
||
const route = await computeShortestRoute(
|
||
type.transportMode,
|
||
sourceRegionId,
|
||
targetRegionId
|
||
);
|
||
|
||
if (!route) {
|
||
return {
|
||
mode: type.transportMode,
|
||
totalDistance: null,
|
||
regions: [],
|
||
};
|
||
}
|
||
|
||
const regions = await RegionData.findAll({
|
||
where: { id: route.path },
|
||
attributes: ['id', 'name'],
|
||
});
|
||
const regionMap = new Map(regions.map((r) => [r.id, r.name]));
|
||
const ordered = route.path.map((id) => ({
|
||
id,
|
||
name: regionMap.get(id) || String(id),
|
||
}));
|
||
|
||
return {
|
||
mode: type.transportMode,
|
||
totalDistance: route.distance,
|
||
regions: ordered,
|
||
};
|
||
}
|
||
|
||
async createTransport(hashedUserId, { branchId, vehicleTypeId, productId, quantity, targetBranchId }) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
|
||
const sourceBranch = await Branch.findOne({
|
||
where: { id: branchId, falukantUserId: user.id },
|
||
include: [{ model: RegionData, as: 'region' }],
|
||
});
|
||
if (!sourceBranch) {
|
||
throw new Error('Branch not found');
|
||
}
|
||
|
||
const targetBranch = await Branch.findOne({
|
||
where: { id: targetBranchId, falukantUserId: user.id },
|
||
include: [{ model: BranchType, as: 'branchType', attributes: ['labelTr'] }, { model: RegionData, as: 'region' }],
|
||
});
|
||
if (!targetBranch) {
|
||
throw new Error('Target branch not found');
|
||
}
|
||
if (!['store', 'fullstack'].includes(targetBranch.branchType.labelTr)) {
|
||
throw new PreconditionError('invalidTargetBranch');
|
||
}
|
||
|
||
const sourceRegionId = sourceBranch.regionId;
|
||
const targetRegionId = targetBranch.regionId;
|
||
const now = new Date();
|
||
|
||
const type = await VehicleType.findByPk(vehicleTypeId);
|
||
if (!type) {
|
||
throw new Error('Vehicle type not found');
|
||
}
|
||
|
||
const route = await computeShortestRoute(type.transportMode, sourceRegionId, targetRegionId);
|
||
if (!route) {
|
||
throw new PreconditionError('noRoute');
|
||
}
|
||
|
||
// Freie Fahrzeuge dieses Typs in der Quell-Region
|
||
const vehicles = await Vehicle.findAll({
|
||
where: {
|
||
falukantUserId: user.id,
|
||
regionId: sourceRegionId,
|
||
vehicleTypeId,
|
||
availableFrom: { [Op.lte]: now },
|
||
},
|
||
include: [
|
||
{
|
||
model: Transport,
|
||
as: 'transports',
|
||
required: false,
|
||
attributes: ['id'],
|
||
},
|
||
],
|
||
});
|
||
|
||
const freeVehicles = vehicles.filter((v) => {
|
||
const t = v.transports || [];
|
||
return t.length === 0;
|
||
});
|
||
|
||
if (!freeVehicles.length) {
|
||
throw new PreconditionError('noVehiclesAvailable');
|
||
}
|
||
|
||
const capacityPerVehicle = type.capacity || 0;
|
||
if (capacityPerVehicle <= 0) {
|
||
throw new Error('Invalid vehicle capacity');
|
||
}
|
||
const maxByVehicles = capacityPerVehicle * freeVehicles.length;
|
||
|
||
const stock = await FalukantStock.findOne({ where: { branchId: sourceBranch.id } });
|
||
if (!stock) {
|
||
throw new Error('Stock not found');
|
||
}
|
||
|
||
const inventory = await Inventory.findAll({
|
||
where: { stockId: stock.id },
|
||
include: [
|
||
{
|
||
model: ProductType,
|
||
as: 'productType',
|
||
required: true,
|
||
where: { id: productId },
|
||
},
|
||
],
|
||
});
|
||
|
||
const available = inventory.reduce((sum, i) => sum + i.quantity, 0);
|
||
if (available <= 0) {
|
||
throw new PreconditionError('noInventory');
|
||
}
|
||
|
||
const maxByInventory = available;
|
||
const hardMax = Math.min(maxByVehicles, maxByInventory);
|
||
|
||
const requested = Math.max(1, parseInt(quantity, 10) || 0);
|
||
if (requested > hardMax) {
|
||
throw new PreconditionError('quantityTooHigh');
|
||
}
|
||
|
||
// Transportkosten: 1 % des Warenwerts, mindestens 0,1
|
||
const productType = inventory[0]?.productType;
|
||
const unitValue = productType?.sellCost || 0;
|
||
const totalValue = unitValue * requested;
|
||
const transportCost = Math.max(0.1, totalValue * 0.01);
|
||
|
||
if (user.money < transportCost) {
|
||
throw new PreconditionError('insufficientFunds');
|
||
}
|
||
|
||
const result = await sequelize.transaction(async (tx) => {
|
||
// Geld für den Transport abziehen
|
||
const moneyResult = await updateFalukantUserMoney(
|
||
user.id,
|
||
-transportCost,
|
||
'transport',
|
||
user.id
|
||
);
|
||
if (!moneyResult.success) {
|
||
throw new Error('Failed to update money');
|
||
}
|
||
|
||
let remaining = requested;
|
||
const transportsCreated = [];
|
||
|
||
for (const v of freeVehicles) {
|
||
if (remaining <= 0) break;
|
||
const size = Math.min(remaining, capacityPerVehicle);
|
||
const t = await Transport.create(
|
||
{
|
||
sourceRegionId,
|
||
targetRegionId,
|
||
productId,
|
||
size,
|
||
vehicleId: v.id,
|
||
},
|
||
{ transaction: tx }
|
||
);
|
||
transportsCreated.push(t);
|
||
remaining -= size;
|
||
}
|
||
|
||
if (remaining > 0) {
|
||
throw new Error('Not enough vehicle capacity for requested quantity');
|
||
}
|
||
|
||
// Inventar in der Quell-Niederlassung reduzieren
|
||
let left = requested;
|
||
for (const inv of inventory) {
|
||
if (left <= 0) break;
|
||
if (inv.quantity <= left) {
|
||
left -= inv.quantity;
|
||
await inv.destroy({ transaction: tx });
|
||
} else {
|
||
await inv.update({ quantity: inv.quantity - left }, { transaction: tx });
|
||
left = 0;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (left > 0) {
|
||
throw new Error('Inventory changed during transport creation');
|
||
}
|
||
|
||
notifyUser(user.user.hashedId, 'stock_change', { branchId: sourceBranch.id });
|
||
notifyUser(user.user.hashedId, 'falukantBranchUpdate', { branchId: sourceBranch.id });
|
||
|
||
return {
|
||
success: true,
|
||
maxQuantity: hardMax,
|
||
requested,
|
||
totalDistance: route.distance,
|
||
totalCost: transportCost,
|
||
transports: transportsCreated.map((t) => ({
|
||
id: t.id,
|
||
size: t.size,
|
||
vehicleId: t.vehicleId,
|
||
})),
|
||
};
|
||
});
|
||
|
||
return result;
|
||
}
|
||
|
||
async getBranchTransports(hashedUserId, branchId) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
const branch = await Branch.findOne({
|
||
where: { id: branchId, falukantUserId: user.id },
|
||
include: [{ model: RegionData, as: 'region', attributes: ['id', 'name'] }],
|
||
});
|
||
if (!branch) {
|
||
throw new Error('Branch not found');
|
||
}
|
||
const regionId = branch.regionId;
|
||
|
||
const transports = await Transport.findAll({
|
||
where: {
|
||
[Op.or]: [
|
||
{ sourceRegionId: regionId },
|
||
{ targetRegionId: regionId },
|
||
],
|
||
},
|
||
include: [
|
||
{ model: RegionData, as: 'sourceRegion', attributes: ['id', 'name'] },
|
||
{ model: RegionData, as: 'targetRegion', attributes: ['id', 'name'] },
|
||
{ model: ProductType, as: 'productType', attributes: ['id', 'labelTr'] },
|
||
{
|
||
model: Vehicle,
|
||
as: 'vehicle',
|
||
include: [
|
||
{
|
||
model: VehicleType,
|
||
as: 'type',
|
||
attributes: ['id', 'tr', 'speed'],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
order: [['createdAt', 'DESC']],
|
||
});
|
||
|
||
const now = Date.now();
|
||
|
||
return transports.map((t) => {
|
||
const direction = t.sourceRegionId === regionId ? 'outgoing' : 'incoming';
|
||
|
||
let eta = null;
|
||
let durationHours = null;
|
||
if (t.vehicle && t.vehicle.type && t.vehicle.type.speed && t.sourceRegionId && t.targetRegionId) {
|
||
// Näherungsweise Dauer: wir haben die exakte Distanz hier nicht, deshalb nur anhand der
|
||
// bei Erstellung verwendeten Route in der UI berechnet – für die Liste reicht createdAt + 1h.
|
||
// TODO: Optional: Distanz persistent speichern.
|
||
durationHours = 1;
|
||
const createdAt = t.createdAt ? new Date(t.createdAt).getTime() : now;
|
||
const etaMs = createdAt + durationHours * 60 * 60 * 1000;
|
||
eta = new Date(etaMs);
|
||
}
|
||
|
||
return {
|
||
id: t.id,
|
||
direction,
|
||
sourceRegion: t.sourceRegion,
|
||
targetRegion: t.targetRegion,
|
||
product: t.productType,
|
||
size: t.size,
|
||
vehicleId: t.vehicleId,
|
||
vehicleType: t.vehicle?.type || null,
|
||
createdAt: t.createdAt,
|
||
eta,
|
||
durationHours,
|
||
};
|
||
});
|
||
}
|
||
|
||
async buyVehicles(hashedUserId, { vehicleTypeId, quantity, regionId, mode = 'buy' }) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
const qty = Math.max(1, parseInt(quantity, 10) || 0);
|
||
|
||
const type = await VehicleType.findByPk(vehicleTypeId);
|
||
if (!type) {
|
||
throw new Error('Vehicle type not found');
|
||
}
|
||
|
||
const baseCost = type.cost;
|
||
const unitCost = mode === 'build'
|
||
? Math.round(baseCost * 0.75)
|
||
: baseCost;
|
||
const totalCost = unitCost * qty;
|
||
if (user.money < totalCost) {
|
||
throw new PreconditionError('insufficientFunds');
|
||
}
|
||
|
||
// Ensure the region exists (and is part of Falukant map)
|
||
const region = await RegionData.findByPk(regionId);
|
||
if (!region) {
|
||
throw new Error('Region not found');
|
||
}
|
||
|
||
// Update money and create vehicles in a transaction
|
||
await sequelize.transaction(async (tx) => {
|
||
await updateFalukantUserMoney(
|
||
user.id,
|
||
-totalCost,
|
||
mode === 'build' ? 'build_vehicles' : 'buy_vehicles',
|
||
user.id
|
||
);
|
||
|
||
const records = [];
|
||
const now = new Date();
|
||
const baseTime = now.getTime();
|
||
const buildMs = mode === 'build'
|
||
? (type.buildTimeMinutes || 0) * 60 * 1000
|
||
: 0;
|
||
|
||
for (let i = 0; i < qty; i++) {
|
||
records.push({
|
||
vehicleTypeId: type.id,
|
||
falukantUserId: user.id,
|
||
regionId: region.id,
|
||
availableFrom: new Date(baseTime + buildMs),
|
||
condition: 100,
|
||
});
|
||
}
|
||
await Vehicle.bulkCreate(records, { transaction: tx });
|
||
});
|
||
|
||
return { success: true };
|
||
}
|
||
|
||
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');
|
||
|
||
// Hole aktuelles Wetter der Region
|
||
const currentWeather = await Weather.findOne({
|
||
where: { regionId: b.regionId }
|
||
});
|
||
const weatherTypeId = currentWeather?.weatherTypeId || null;
|
||
|
||
const d = await Production.create({
|
||
branchId: b.id,
|
||
productId,
|
||
quantity,
|
||
weatherTypeId
|
||
});
|
||
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);
|
||
const production = await Production.findOne({
|
||
where: { branchId: b.id },
|
||
include: [
|
||
{ model: ProductType, as: 'productType' },
|
||
{ model: WeatherType, as: 'weatherType' }
|
||
]
|
||
});
|
||
|
||
if (!production) {
|
||
return null;
|
||
}
|
||
|
||
// Berechne Qualität basierend auf Wettereffekt
|
||
let quality = 50; // Basisqualität (50%)
|
||
|
||
if (production.weatherTypeId) {
|
||
const weatherEffect = await ProductWeatherEffect.findOne({
|
||
where: {
|
||
productId: production.productId,
|
||
weatherTypeId: production.weatherTypeId
|
||
}
|
||
});
|
||
|
||
if (weatherEffect) {
|
||
// Wettereffekt: -2 bis +2, wird auf Qualität angewendet
|
||
// Basisqualität 50%, Effekt wird als Prozentpunkte addiert
|
||
quality = Math.max(0, Math.min(100, 50 + (weatherEffect.qualityEffect * 10)));
|
||
}
|
||
}
|
||
|
||
// Konvertiere zu JSON und füge berechnete Qualität hinzu
|
||
const result = production.toJSON();
|
||
result.quality = Math.round(quality);
|
||
|
||
return result;
|
||
}
|
||
|
||
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 pricePerUnit = await calcRegionalSellPrice(item, knowledgeVal, branch.regionId);
|
||
const revenue = quantity * pricePerUnit;
|
||
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;
|
||
const regionId = item.stock.branch.regionId;
|
||
const pricePerUnit = await calcRegionalSellPrice(item.productType, knowledgeVal, regionId);
|
||
total += item.quantity * pricePerUnit;
|
||
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', 'id'] },
|
||
{ model: WeatherType, as: 'weatherType' }
|
||
],
|
||
attributes: ['startTimestamp', 'quantity', 'productId', 'weatherTypeId'],
|
||
});
|
||
|
||
// Berechne Qualität für jede Produktion
|
||
const formattedProductions = await Promise.all(productions.map(async (production) => {
|
||
const startTimestamp = new Date(production.startTimestamp).getTime();
|
||
const endTimestamp = startTimestamp + production.productType.productionTime * 60 * 1000;
|
||
|
||
// Berechne Qualität basierend auf Wettereffekt
|
||
let quality = 50; // Basisqualität (50%)
|
||
|
||
if (production.weatherTypeId) {
|
||
const weatherEffect = await ProductWeatherEffect.findOne({
|
||
where: {
|
||
productId: production.productId,
|
||
weatherTypeId: production.weatherTypeId
|
||
}
|
||
});
|
||
|
||
if (weatherEffect) {
|
||
// Wettereffekt: -2 bis +2, wird auf Qualität angewendet
|
||
quality = Math.max(0, Math.min(100, 50 + (weatherEffect.qualityEffect * 10)));
|
||
}
|
||
}
|
||
|
||
return {
|
||
cityName: production.branch.region.name,
|
||
productName: production.productType.labelTr,
|
||
quantity: production.quantity,
|
||
quality: Math.round(quality),
|
||
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',
|
||
// Wichtige Felder explizit auswählen, damit die Joins auf Titel,
|
||
// Namen und Region im äußeren Select funktionieren.
|
||
attributes: ['id', 'birthdate', 'gender', 'titleOfNobility', 'firstName', 'lastName', 'regionId'],
|
||
where: {
|
||
regionId: branch.regionId,
|
||
},
|
||
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']
|
||
}
|
||
]
|
||
},
|
||
],
|
||
});
|
||
if (!director) {
|
||
return null;
|
||
}
|
||
const age = Math.floor((Date.now() - new Date(director.character.birthdate)) / (24 * 60 * 60 * 1000));
|
||
|
||
// wishedIncome analog zu getAllDirectors() berechnen
|
||
const knowledges = director.character.knowledges || [];
|
||
const avgKnowledge = knowledges.length
|
||
? knowledges.reduce((sum, k) => sum + k.knowledge, 0) / knowledges.length
|
||
: 0;
|
||
const wishedIncome = Math.round(
|
||
director.character.nobleTitle.level * Math.pow(1.231, avgKnowledge / 1.5)
|
||
);
|
||
|
||
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,
|
||
nobleTitle: director.character.nobleTitle,
|
||
definedFirstName: director.character.definedFirstName,
|
||
definedLastName: director.character.definedLastName,
|
||
knowledges: director.character.knowledges,
|
||
},
|
||
income: director.income,
|
||
satisfaction: director.satisfaction,
|
||
mayProduce: director.mayProduce,
|
||
maySell: director.maySell,
|
||
mayStartTransport: director.mayStartTransport,
|
||
region: director.character.region?.name || null,
|
||
wishedIncome,
|
||
},
|
||
};
|
||
}
|
||
|
||
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');
|
||
}
|
||
// Finde den höchsten Charakterwert (wie im Frontend)
|
||
const highestCharacterValue = Math.max(...traits.map(ct => ct.suitability));
|
||
const moodRecord = gift.promotionalgiftmoods[0];
|
||
if (!moodRecord) {
|
||
throw new Error('noMoodData');
|
||
}
|
||
const moodSuitability = moodRecord.suitability;
|
||
// Basis-Berechnung wie im Frontend: (moodValue + highestCharacterValue) / 2
|
||
const baseChangeValue = Math.round((moodSuitability + highestCharacterValue) / 2);
|
||
// Zusätzlicher zufälliger Bonus zwischen 0 und 7 Punkten
|
||
const randomBonus = Math.floor(Math.random() * 8); // 0–7 inklusive
|
||
const changeValue = baseChangeValue + randomBonus;
|
||
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']
|
||
});
|
||
let nextAdvanceAt = null;
|
||
if (falukantUser.lastNobilityAdvanceAt) {
|
||
const last = new Date(falukantUser.lastNobilityAdvanceAt);
|
||
const next = new Date(last.getTime());
|
||
next.setDate(next.getDate() + 7);
|
||
nextAdvanceAt = next.toISOString();
|
||
}
|
||
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,
|
||
nextAdvanceAt
|
||
};
|
||
}
|
||
|
||
async advanceNobility(hashedUserId) {
|
||
const nobility = await this.getNobility(hashedUserId);
|
||
if (!nobility || !nobility.next) {
|
||
throw new Error('User does not have a nobility');
|
||
}
|
||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||
const now = new Date();
|
||
if (user.lastNobilityAdvanceAt) {
|
||
const oneWeekAgo = new Date(now.getTime());
|
||
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
||
if (user.lastNobilityAdvanceAt > oneWeekAgo) {
|
||
throw new Error('too soon');
|
||
}
|
||
}
|
||
const nextTitle = nobility.next.toJSON();
|
||
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 });
|
||
await user.update({ lastNobilityAdvanceAt: now });
|
||
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);
|
||
if (!user) {
|
||
throw new Error('User not found');
|
||
}
|
||
const branchCount = await Branch.count({
|
||
where: { falukantUserId: user.id }
|
||
});
|
||
return branchCount >= requirement.requirementValue;
|
||
}
|
||
|
||
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) {
|
||
// Erfolgschance: 90%
|
||
const success = Math.random() < 0.9;
|
||
|
||
let delta;
|
||
if (success) {
|
||
// Bei Erfolg: Gesundheit um 5-35% verbessern
|
||
delta = Math.floor(Math.random() * 31) + 5; // 5-35
|
||
} else {
|
||
// Bei Misserfolg: Gesundheit um 1-10% verschlechtern
|
||
delta = -(Math.floor(Math.random() * 10) + 1); // -1 bis -10
|
||
}
|
||
|
||
return this.healthChange(user, delta);
|
||
}
|
||
|
||
async getPoliticsOverview(hashedUserId) {
|
||
// Liefert alle aktuell besetzten Ämter im eigenen Gebiet inklusive
|
||
// Inhaber und berechnetem Enddatum der Amtszeit.
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
|
||
// Charakter des Users bestimmen (Region ist dort hinterlegt)
|
||
const character = await FalukantCharacter.findOne({
|
||
where: { userId: user.id },
|
||
attributes: ['id', 'regionId']
|
||
});
|
||
if (!character) {
|
||
return [];
|
||
}
|
||
|
||
// Alle relevanten Regionen (Region + Eltern) laden
|
||
const relevantRegionIds = await this.getRegionAndParentIds(character.regionId);
|
||
|
||
// Aktuell besetzte Ämter in diesen Regionen laden
|
||
const offices = await PoliticalOffice.findAll({
|
||
where: {
|
||
regionId: {
|
||
[Op.in]: relevantRegionIds
|
||
}
|
||
},
|
||
include: [
|
||
{
|
||
model: PoliticalOfficeType,
|
||
as: 'type',
|
||
attributes: ['name', 'termLength']
|
||
},
|
||
{
|
||
model: RegionData,
|
||
as: 'region',
|
||
attributes: ['name'],
|
||
include: [
|
||
{
|
||
model: RegionType,
|
||
as: 'regionType',
|
||
attributes: ['labelTr']
|
||
}
|
||
]
|
||
},
|
||
{
|
||
model: FalukantCharacter,
|
||
as: 'holder',
|
||
attributes: ['id', 'gender'],
|
||
include: [
|
||
{
|
||
model: FalukantPredefineFirstname,
|
||
as: 'definedFirstName',
|
||
attributes: ['name']
|
||
},
|
||
{
|
||
model: FalukantPredefineLastname,
|
||
as: 'definedLastName',
|
||
attributes: ['name']
|
||
},
|
||
{
|
||
model: TitleOfNobility,
|
||
as: 'nobleTitle',
|
||
attributes: ['labelTr']
|
||
}
|
||
]
|
||
}
|
||
],
|
||
order: [
|
||
[{ model: PoliticalOfficeType, as: 'type' }, 'name', 'ASC'],
|
||
[{ model: RegionData, as: 'region' }, 'name', 'ASC']
|
||
]
|
||
});
|
||
|
||
return offices.map(office => {
|
||
const o = office.get({ plain: true });
|
||
|
||
// Enddatum der Amtszeit berechnen: Start = createdAt, Dauer = termLength Jahre
|
||
let termEnds = null;
|
||
if (o.createdAt && o.type && typeof o.type.termLength === 'number') {
|
||
const start = new Date(o.createdAt);
|
||
if (!Number.isNaN(start.getTime())) {
|
||
const end = new Date(start);
|
||
end.setFullYear(end.getFullYear() + o.type.termLength);
|
||
termEnds = end;
|
||
}
|
||
}
|
||
|
||
return {
|
||
id: o.id,
|
||
officeType: {
|
||
name: o.type?.name
|
||
},
|
||
region: {
|
||
name: o.region?.name,
|
||
regionType: o.region?.regionType
|
||
? { labelTr: o.region.regionType.labelTr }
|
||
: undefined
|
||
},
|
||
character: o.holder
|
||
? {
|
||
definedFirstName: o.holder.definedFirstName,
|
||
definedLastName: o.holder.definedLastName,
|
||
nobleTitle: o.holder.nobleTitle,
|
||
gender: o.holder.gender
|
||
}
|
||
: null,
|
||
termEnds
|
||
};
|
||
});
|
||
}
|
||
|
||
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
|
||
}));
|
||
|
||
const alreadyApplied = (e.candidates || []).some(
|
||
c => c.characterId === characterId
|
||
);
|
||
|
||
return {
|
||
...e,
|
||
history: matchingHistory,
|
||
alreadyApplied
|
||
};
|
||
});
|
||
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 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}`);
|
||
}
|
||
|
||
// Produkt abrufen
|
||
const product = await ProductType.findOne({ where: { id: productId } });
|
||
if (!product) {
|
||
throw new Error(`Product not found with id ${productId}`);
|
||
}
|
||
|
||
// Knowledge für dieses Produkt abrufen
|
||
const knowledge = await Knowledge.findOne({
|
||
where: { characterId: character.id, productId: productId }
|
||
});
|
||
const knowledgeFactor = knowledge?.knowledge || 0;
|
||
|
||
// Verwende die bereits existierende calcRegionalSellPrice Funktion
|
||
const price = await calcRegionalSellPrice(product, knowledgeFactor, regionId);
|
||
|
||
return { price };
|
||
}
|
||
|
||
async getProductPricesInCities(hashedUserId, productId, currentPrice) {
|
||
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}`);
|
||
}
|
||
|
||
// 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 abrufen
|
||
const townWorths = await TownProductWorth.findAll({
|
||
where: { productId: productId },
|
||
attributes: ['regionId', 'worthPercent']
|
||
});
|
||
const worthMap = new Map(townWorths.map(tw => [tw.regionId, tw.worthPercent]));
|
||
|
||
// Für jede Stadt den Preis berechnen und Branch-Typ bestimmen
|
||
const results = [];
|
||
for (const city of cities) {
|
||
// Regionaler Preis-Faktor (worthPercent zwischen 40-60)
|
||
const worthPercent = worthMap.get(city.id) || 50; // Default 50% wenn nicht gefunden
|
||
|
||
// Basispreis basierend auf regionalem worthPercent
|
||
const basePrice = product.sellCost * (worthPercent / 100);
|
||
|
||
// Dann Knowledge-Faktor anwenden (wie in calcSellPrice)
|
||
const min = basePrice * 0.6;
|
||
const max = basePrice;
|
||
const priceInCity = min + (max - min) * (knowledgeFactor / 100);
|
||
|
||
// Nur Städte zurückgeben, wo der Preis höher ist
|
||
if (priceInCity > currentPrice) {
|
||
// Branch-Typ bestimmen
|
||
let branchType = null; // null = kein Branch
|
||
if (city.branches && city.branches.length > 0) {
|
||
// Finde den "besten" Branch-Typ (store/fullstack > production)
|
||
const branchTypes = city.branches.map(b => b.branchType?.labelTr).filter(Boolean);
|
||
if (branchTypes.includes('store') || branchTypes.includes('fullstack')) {
|
||
branchType = 'store'; // Grün
|
||
} else if (branchTypes.includes('production')) {
|
||
branchType = 'production'; // Orange
|
||
}
|
||
}
|
||
|
||
results.push({
|
||
regionId: city.id,
|
||
regionName: city.name,
|
||
price: priceInCity,
|
||
branchType: branchType // 'store' (grün), 'production' (orange), null (rot)
|
||
});
|
||
}
|
||
}
|
||
|
||
// Sortiere nach Preis (höchster zuerst)
|
||
results.sort((a, b) => b.price - a.price);
|
||
|
||
return results;
|
||
}
|
||
|
||
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();
|