All checks were successful
Deploy to production / deploy (push) Successful in 2m44s
- Refactored the product pricing logic in `falukantService.js` to ensure accurate regional pricing calculations based on client values and server data. - Added new notification translations for "office filled" in multiple languages (Cebuano, German, English, Spanish, French) to enhance user experience and clarity in notifications.
8519 lines
338 KiB
JavaScript
8519 lines
338 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 TitleBenefit from '../models/falukant/type/title_benefit.js';
|
||
import Branch from '../models/falukant/data/branch.js';
|
||
import BranchType from '../models/falukant/type/branch.js';
|
||
import Production from '../models/falukant/data/production.js';
|
||
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 RelationshipState from '../models/falukant/data/relationship_state.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 FalukantCharacterTrait from '../models/falukant/data/falukant_character_trait.js';
|
||
import Mood from '../models/falukant/type/mood.js';
|
||
import UserHouse from '../models/falukant/data/user_house.js';
|
||
import HouseType from '../models/falukant/type/house.js';
|
||
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 DebtorsPrism from '../models/falukant/data/debtors_prism.js';
|
||
import TitleRequirement from '../models/falukant/type/title_requirement.js';
|
||
import HealthActivity from '../models/falukant/log/health_activity.js';
|
||
import Election from '../models/falukant/data/election.js';
|
||
import ChurchOffice from '../models/falukant/data/church_office.js';
|
||
import ChurchOfficeType from '../models/falukant/type/church_office_type.js';
|
||
import ChurchApplication from '../models/falukant/data/church_application.js';
|
||
import ChurchOfficeRequirement from '../models/falukant/predefine/church_office_requirement.js';
|
||
import PoliticalOfficeType from '../models/falukant/type/political_office_type.js';
|
||
import Candidate from '../models/falukant/data/candidate.js';
|
||
import Vote from '../models/falukant/data/vote.js';
|
||
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 PoliticalOfficeBenefit from '../models/falukant/predefine/political_office_benefit.js';
|
||
import PoliticalOfficeBenefitType from '../models/falukant/type/political_office_benefit_type.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';
|
||
import ReputationActionType from '../models/falukant/type/reputation_action.js';
|
||
import ReputationActionLog from '../models/falukant/log/reputation_action.js';
|
||
import {
|
||
productionPieceCost,
|
||
productionCostTotal,
|
||
effectiveWorthPercent,
|
||
KNOWLEDGE_PRICE_FLOOR,
|
||
calcRegionalSellPriceSync,
|
||
} from '../utils/falukant/falukantProductEconomy.js';
|
||
import { sumFreePoliticalLoverSlotsForCharacter } from './falukantPoliticalPowersService.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;
|
||
}
|
||
|
||
const POLITICAL_OFFICE_RANKS = {
|
||
assessor: 1,
|
||
councillor: 1,
|
||
council: 2,
|
||
beadle: 2,
|
||
'town-clerk': 2,
|
||
mayor: 3,
|
||
'master-builder': 2,
|
||
'village-major': 2,
|
||
judge: 3,
|
||
bailif: 3,
|
||
taxman: 2,
|
||
sheriff: 3,
|
||
consultant: 3,
|
||
treasurer: 4,
|
||
hangman: 2,
|
||
'territorial-council': 3,
|
||
'territorial-council-speaker': 4,
|
||
'ruler-consultant': 4,
|
||
'state-administrator': 4,
|
||
'super-state-administrator': 5,
|
||
governor: 5,
|
||
'ministry-helper': 4,
|
||
minister: 5,
|
||
chancellor: 6
|
||
};
|
||
|
||
const _envPolSalaryStart = Number(process.env.POLITICAL_DAILY_SALARY_START);
|
||
const _envPolSalaryGrowth = Number(process.env.POLITICAL_DAILY_SALARY_GROWTH);
|
||
/** Tageshonorar niedrigste Stufe (Default ~50; per ENV überschreibbar). */
|
||
const POLITICAL_DAILY_SALARY_START_DEFAULT =
|
||
Number.isFinite(_envPolSalaryStart) && _envPolSalaryStart > 0 ? _envPolSalaryStart : 50;
|
||
/** Pro hierarchy_level leicht exponentiell (Default 1,22; per ENV überschreibbar). */
|
||
const POLITICAL_DAILY_SALARY_GROWTH_DEFAULT =
|
||
Number.isFinite(_envPolSalaryGrowth) && _envPolSalaryGrowth > 1 ? _envPolSalaryGrowth : 1.22;
|
||
|
||
/**
|
||
* Tageshonorar: value.dailyAmount fest, sonst salaryStart × salaryGrowth^(Stufe−1).
|
||
* Stufe: value.rank (JSON), sonst hierarchy_level, sonst POLITICAL_OFFICE_RANKS[name].
|
||
* Optional im JSON: salaryStart, salaryGrowth (pro Amt); ENV POLITICAL_DAILY_SALARY_* (global).
|
||
* Legacy linear: nur bei salaryFormula === 'linear' mit base und perRank.
|
||
*/
|
||
function computePoliticalDailySalaryPayout(value, officeName, hierarchyLevelFromType) {
|
||
const v = value && typeof value === 'object' ? value : {};
|
||
if (v.dailyAmount != null && Number.isFinite(Number(v.dailyAmount))) {
|
||
return Math.round(Number(v.dailyAmount) * 100) / 100;
|
||
}
|
||
const nameKey = typeof officeName === 'string' ? officeName.trim() : officeName;
|
||
let rank = 0;
|
||
if (v.rank != null && Number.isFinite(Number(v.rank))) {
|
||
rank = Math.max(0, Number(v.rank));
|
||
} else {
|
||
const fromDb = Number(hierarchyLevelFromType);
|
||
if (Number.isFinite(fromDb) && fromDb > 0) {
|
||
rank = fromDb;
|
||
} else {
|
||
rank = POLITICAL_OFFICE_RANKS[nameKey] ?? 0;
|
||
}
|
||
}
|
||
if (rank < 1) {
|
||
return 0;
|
||
}
|
||
if (v.salaryFormula === 'linear') {
|
||
const base = Number(v.base ?? 0);
|
||
const perRank = Number(v.perRank ?? v.per_rank ?? 0);
|
||
return Math.round((base + perRank * rank) * 100) / 100;
|
||
}
|
||
const startRaw = Number(v.salaryStart ?? v.salary_start);
|
||
const growthRaw = Number(v.salaryGrowth ?? v.salary_growth);
|
||
const start = Number.isFinite(startRaw) && startRaw > 0 ? startRaw : POLITICAL_DAILY_SALARY_START_DEFAULT;
|
||
const growth =
|
||
Number.isFinite(growthRaw) && growthRaw > 1 ? growthRaw : POLITICAL_DAILY_SALARY_GROWTH_DEFAULT;
|
||
const amount = start * growth ** (rank - 1);
|
||
return Math.round(amount * 100) / 100;
|
||
}
|
||
|
||
const CERTIFICATE_THRESHOLDS = {
|
||
2: { avgKnowledge: 15, completedProductions: 4, statusMode: 'none', statusRequiredCount: 0 },
|
||
3: { avgKnowledge: 28, completedProductions: 15, statusMode: 'none', statusRequiredCount: 0 },
|
||
4: {
|
||
avgKnowledge: 45,
|
||
completedProductions: 45,
|
||
statusMode: 'one_of',
|
||
statusRequiredCount: 1,
|
||
options: [
|
||
{ type: 'officePoints', required: 1 },
|
||
{ type: 'nobilityPoints', required: 1 },
|
||
{ type: 'reputationPoints', required: 2 },
|
||
{ type: 'housePoints', required: 2 },
|
||
],
|
||
},
|
||
5: {
|
||
avgKnowledge: 60,
|
||
completedProductions: 110,
|
||
reputationPoints: 2,
|
||
statusMode: 'two_of',
|
||
statusRequiredCount: 2,
|
||
options: [
|
||
{ type: 'officePoints', required: 2 },
|
||
{ type: 'nobilityPoints', required: 1 },
|
||
{ type: 'housePoints', required: 2 },
|
||
],
|
||
},
|
||
};
|
||
|
||
function getKnowledgePoints(avgKnowledge) {
|
||
if (avgKnowledge >= 80) return 5;
|
||
if (avgKnowledge >= 65) return 4;
|
||
if (avgKnowledge >= 50) return 3;
|
||
if (avgKnowledge >= 35) return 2;
|
||
if (avgKnowledge >= 20) return 1;
|
||
return 0;
|
||
}
|
||
|
||
function getProductionPoints(completedProductions) {
|
||
if (completedProductions >= 200) return 5;
|
||
if (completedProductions >= 100) return 4;
|
||
if (completedProductions >= 50) return 3;
|
||
if (completedProductions >= 20) return 2;
|
||
if (completedProductions >= 5) return 1;
|
||
return 0;
|
||
}
|
||
|
||
function getReputationPoints(reputation) {
|
||
if (reputation >= 90) return 5;
|
||
if (reputation >= 75) return 4;
|
||
if (reputation >= 60) return 3;
|
||
if (reputation >= 40) return 2;
|
||
if (reputation >= 20) return 1;
|
||
return 0;
|
||
}
|
||
|
||
function getHousePoints(housePosition) {
|
||
if (housePosition >= 10) return 5;
|
||
if (housePosition >= 8) return 4;
|
||
if (housePosition >= 6) return 3;
|
||
if (housePosition >= 4) return 2;
|
||
if (housePosition >= 2) return 1;
|
||
return 0;
|
||
}
|
||
|
||
function getNobilityPoints(nobilityLevel) {
|
||
return Math.max(0, Math.min(5, (nobilityLevel || 0) - 1));
|
||
}
|
||
|
||
function getTargetCertificateByScore(score) {
|
||
if (score >= 3.8) return 5;
|
||
if (score >= 2.8) return 4;
|
||
if (score >= 1.8) return 3;
|
||
if (score >= 0.9) return 2;
|
||
return 1;
|
||
}
|
||
|
||
function getScoreThresholdForCertificate(level) {
|
||
if (level >= 5) return 3.8;
|
||
if (level === 4) return 2.8;
|
||
if (level === 3) return 1.8;
|
||
if (level === 2) return 0.9;
|
||
return 0;
|
||
}
|
||
|
||
async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null) {
|
||
// Wenn worthPercent nicht übergeben wurde, hole es aus der Datenbank
|
||
if (worthPercent === null) {
|
||
const townWorth = await TownProductWorth.findOne({
|
||
where: { productId: product.id, regionId: regionId }
|
||
});
|
||
worthPercent = townWorth?.worthPercent || 50; // Default 50% wenn nicht gefunden
|
||
}
|
||
|
||
// Prüfe ob sellCost vorhanden ist
|
||
if (product.sellCost === null || product.sellCost === undefined) {
|
||
throw new Error(`Product ${product.id} has no sellCost defined`);
|
||
}
|
||
|
||
const w = effectiveWorthPercent(worthPercent);
|
||
const basePrice = product.sellCost * (w / 100);
|
||
|
||
const min = basePrice * KNOWLEDGE_PRICE_FLOOR;
|
||
const max = basePrice;
|
||
return min + (max - min) * (knowledgeFactor / 100);
|
||
}
|
||
|
||
// Sum tax_percent for a region and its ancestors (upwards). Returns numeric percent (e.g. 7.5)
|
||
async function getCumulativeTaxPercent(regionId) {
|
||
if (!regionId) return 0;
|
||
const rows = await sequelize.query(
|
||
`WITH RECURSIVE ancestors AS (
|
||
SELECT id, parent_id, tax_percent
|
||
FROM falukant_data.region r
|
||
WHERE id = :id
|
||
UNION ALL
|
||
SELECT reg.id, reg.parent_id, reg.tax_percent
|
||
FROM falukant_data.region reg
|
||
JOIN ancestors a ON reg.id = a.parent_id
|
||
)
|
||
SELECT COALESCE(SUM(tax_percent),0) AS total FROM ancestors;`,
|
||
{
|
||
replacements: { id: regionId },
|
||
type: sequelize.QueryTypes.SELECT
|
||
}
|
||
);
|
||
const val = rows?.[0]?.total ?? 0;
|
||
return parseFloat(val) || 0;
|
||
}
|
||
|
||
// Returns cumulative tax percent for a region, but excludes regions where the user holds
|
||
// a political office that grants tax exemption according to the rules.
|
||
// exemptionsMap maps political office.name -> array of regionType labelTr that are exempted
|
||
const POLITICAL_TAX_EXEMPTIONS = {
|
||
'council': ['city'],
|
||
'taxman': ['city', 'county'],
|
||
'treasurerer': ['city', 'county', 'shire'],
|
||
'super-state-administrator': ['city', 'county', 'shire', 'markgrave', 'duchy'],
|
||
'chancellor': ['city','county','shire','markgrave','duchy','duchy'] // chancellor = all types; we'll handle as wildcard
|
||
};
|
||
|
||
async function getCumulativeTaxPercentWithExemptions(userId, regionId) {
|
||
if (!regionId) return 0;
|
||
if (await hasTitleTaxExempt(userId)) return 0;
|
||
// fetch user's political offices (active) and their region types
|
||
const offices = await PoliticalOffice.findAll({
|
||
where: { userId },
|
||
include: [{ model: PoliticalOfficeType, as: 'type', attributes: ['name'] }, { model: RegionData, as: 'region', include: [{ model: RegionType, as: 'regionType', attributes: ['labelTr'] }] }]
|
||
});
|
||
|
||
// build set of exempt region type labels from user's offices
|
||
const exemptTypes = new Set();
|
||
let hasChancellor = false;
|
||
for (const o of offices) {
|
||
const name = o.type?.name;
|
||
if (!name) continue;
|
||
if (name === 'chancellor') { hasChancellor = true; break; }
|
||
const allowed = POLITICAL_TAX_EXEMPTIONS[name];
|
||
if (allowed && Array.isArray(allowed)) {
|
||
for (const t of allowed) exemptTypes.add(t);
|
||
}
|
||
}
|
||
|
||
// If chancellor, exempt all region types -> tax = 0
|
||
if (hasChancellor) return 0;
|
||
|
||
// Now compute cumulative tax but exclude regions whose regionType.labelTr is in exemptTypes
|
||
const rows = await sequelize.query(
|
||
`WITH RECURSIVE ancestors AS (
|
||
SELECT r.id, r.parent_id, r.tax_percent, rt.label_tr as region_type
|
||
FROM falukant_data.region r
|
||
JOIN falukant_type.region_type rt ON rt.id = r.region_type_id
|
||
WHERE r.id = :id
|
||
UNION ALL
|
||
SELECT reg.id, reg.parent_id, reg.tax_percent, rt2.label_tr
|
||
FROM falukant_data.region reg
|
||
JOIN falukant_type.region_type rt2 ON rt2.id = reg.region_type_id
|
||
JOIN ancestors a ON reg.id = a.parent_id
|
||
)
|
||
SELECT COALESCE(SUM(CASE WHEN :exempt_types::text[] && ARRAY[region_type] THEN 0 ELSE tax_percent END),0) AS total FROM ancestors;`,
|
||
{
|
||
replacements: { id: regionId, exempt_types: Array.from(exemptTypes) },
|
||
type: sequelize.QueryTypes.SELECT
|
||
}
|
||
);
|
||
const val = rows?.[0]?.total ?? 0;
|
||
return parseFloat(val) || 0;
|
||
}
|
||
|
||
/** Standesvorteil: Steuerbefreiung für bestimmte Titel */
|
||
async function hasTitleTaxExempt(falukantUserId) {
|
||
const char = await FalukantCharacter.findOne({ where: { userId: falukantUserId }, attributes: ['titleOfNobility'] });
|
||
if (!char?.titleOfNobility) return false;
|
||
const benefit = await TitleBenefit.findOne({ where: { titleId: char.titleOfNobility, benefitType: 'tax_exempt' } });
|
||
return !!benefit;
|
||
}
|
||
|
||
/**
|
||
* Oberster Stand einer Region bekommt die Steuereinnahmen; Aufteilung auf alle Mitglieder dieses Standes.
|
||
* Returns { recipientUserIds: number[], useTreasury: boolean }. useTreasury true = an TREASURY_FALUKANT_USER_ID zahlen.
|
||
*/
|
||
async function getTaxRecipientsForRegion(regionId) {
|
||
const treasuryId = process.env.TREASURY_FALUKANT_USER_ID ? parseInt(process.env.TREASURY_FALUKANT_USER_ID, 10) : null;
|
||
const chars = await FalukantCharacter.findAll({
|
||
where: { regionId },
|
||
attributes: ['userId', 'titleOfNobility'],
|
||
include: [{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['id', 'level'], required: true }]
|
||
});
|
||
if (!chars.length) return { recipientUserIds: [], useTreasury: true };
|
||
const maxLevel = Math.max(...chars.map(c => c.nobleTitle?.level ?? 0));
|
||
const topTitleId = chars.find(c => (c.nobleTitle?.level ?? 0) === maxLevel)?.nobleTitle?.id;
|
||
if (!topTitleId) return { recipientUserIds: [], useTreasury: true };
|
||
const hasTaxShare = await TitleBenefit.findOne({ where: { titleId: topTitleId, benefitType: 'tax_share' } });
|
||
if (!hasTaxShare) return { recipientUserIds: [], useTreasury: true };
|
||
const topCharUserIds = [...new Set(chars.filter(c => c.titleOfNobility === topTitleId).map(c => c.userId).filter(Boolean))];
|
||
if (!topCharUserIds.length) return { recipientUserIds: [], useTreasury: true };
|
||
return { recipientUserIds: topCharUserIds, useTreasury: false };
|
||
}
|
||
|
||
/** Standesvorteil: Welche politischen Ämter (officeType.name) darf dieser Titel besetzen? */
|
||
async function getAllowedOfficeTypeNamesByTitle(titleId) {
|
||
const benefits = await TitleBenefit.findAll({
|
||
where: { titleId, benefitType: 'office_eligibility' },
|
||
attributes: ['parameters']
|
||
});
|
||
const names = new Set();
|
||
for (const b of benefits) {
|
||
const arr = b.parameters?.officeTypeNames;
|
||
if (Array.isArray(arr)) arr.forEach(n => names.add(n));
|
||
}
|
||
return names;
|
||
}
|
||
|
||
/** Standesvorteil: Ist dieser Festtyp (partyTypeId oder partyType.tr) für diesen Titel kostenfrei? */
|
||
async function isPartyTypeFreeForTitle(titleId, partyTypeId, partyTypeTr) {
|
||
const benefits = await TitleBenefit.findAll({
|
||
where: { titleId, benefitType: 'free_party_type' },
|
||
attributes: ['parameters']
|
||
});
|
||
for (const b of benefits) {
|
||
const p = b.parameters || {};
|
||
const ids = p.partyTypeIds;
|
||
const trs = p.partyTypeLabelTrs || p.partyTypeTrs;
|
||
if (Array.isArray(ids) && ids.includes(partyTypeId)) return true;
|
||
if (Array.isArray(trs) && trs.includes(partyTypeTr)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** Standesvorteil: Beliebtheits-Bonus 5–15 % (nur Anzeige, nicht gespeichert). Deterministisch pro Charakter. */
|
||
async function getDisplayReputation(character) {
|
||
const base = character?.reputation ?? 0;
|
||
const benefit = await TitleBenefit.findOne({
|
||
where: { titleId: character?.titleOfNobility, benefitType: 'reputation_bonus' },
|
||
attributes: ['parameters']
|
||
});
|
||
if (!benefit?.parameters) return base;
|
||
const minP = benefit.parameters.minPercent ?? 5;
|
||
const maxP = benefit.parameters.maxPercent ?? 15;
|
||
const range = Math.max(1, maxP - minP + 1);
|
||
const bonusPercent = minP + (Math.abs(character.id) % range);
|
||
return Math.min(100, Math.round(base * (1 + bonusPercent / 100)));
|
||
}
|
||
|
||
function calculateMarriageCost(titleOfNobility, age) {
|
||
const minTitle = 1;
|
||
const adjustedTitle = titleOfNobility - minTitle + 1;
|
||
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 WOOING_PROGRESS_TARGET = 70;
|
||
static WOOING_GIFT_COOLDOWN_MS = 30 * 60 * 1000;
|
||
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;
|
||
`;
|
||
|
||
buildDefaultRelationshipState(typeTr) {
|
||
const base = {
|
||
marriageSatisfaction: 55,
|
||
marriagePublicStability: 55,
|
||
loverRole: null,
|
||
affection: 50,
|
||
visibility: 15,
|
||
discretion: 50,
|
||
maintenanceLevel: 50,
|
||
statusFit: 0,
|
||
monthlyBaseCost: 0,
|
||
monthsUnderfunded: 0,
|
||
active: true,
|
||
acknowledged: false,
|
||
exclusiveFlag: false,
|
||
};
|
||
|
||
if (typeTr === 'lover') {
|
||
return {
|
||
...base,
|
||
loverRole: 'lover',
|
||
visibility: 20,
|
||
discretion: 45,
|
||
monthlyBaseCost: 30,
|
||
};
|
||
}
|
||
|
||
return base;
|
||
}
|
||
|
||
async ensureRelationshipStates(relRows, typeMap) {
|
||
if (!relRows?.length) return new Map();
|
||
|
||
const relationshipIds = relRows.map((r) => r.id).filter(Boolean);
|
||
if (relationshipIds.length === 0) return new Map();
|
||
|
||
const existingStates = await RelationshipState.findAll({
|
||
where: { relationshipId: relationshipIds }
|
||
});
|
||
const stateMap = new Map(existingStates.map((state) => [state.relationshipId, state]));
|
||
|
||
const missingPayloads = relRows
|
||
.filter((r) => !stateMap.has(r.id))
|
||
.map((r) => ({
|
||
relationshipId: r.id,
|
||
...this.buildDefaultRelationshipState(typeMap[r.relationshipTypeId]?.tr || null)
|
||
}));
|
||
|
||
if (missingPayloads.length > 0) {
|
||
await RelationshipState.bulkCreate(missingPayloads, { ignoreDuplicates: true });
|
||
const reloadedStates = await RelationshipState.findAll({
|
||
where: { relationshipId: relationshipIds }
|
||
});
|
||
return new Map(reloadedStates.map((state) => [state.relationshipId, state]));
|
||
}
|
||
|
||
return stateMap;
|
||
}
|
||
|
||
async getOwnedLoverRelationState(hashedUserId, relationshipId) {
|
||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||
if (!user?.character?.id) throw new Error('User or character not found');
|
||
|
||
const relationship = await Relationship.findOne({
|
||
where: {
|
||
id: relationshipId,
|
||
character1Id: user.character.id
|
||
},
|
||
include: [{
|
||
model: RelationshipType,
|
||
as: 'relationshipType',
|
||
where: { tr: 'lover' }
|
||
}]
|
||
});
|
||
|
||
if (!relationship) {
|
||
throw { status: 404, message: 'Lover relationship not found' };
|
||
}
|
||
|
||
let state = await RelationshipState.findOne({ where: { relationshipId: relationship.id } });
|
||
if (!state) {
|
||
state = await RelationshipState.create({
|
||
relationshipId: relationship.id,
|
||
...this.buildDefaultRelationshipState('lover')
|
||
});
|
||
}
|
||
|
||
return { user, relationship, state };
|
||
}
|
||
|
||
getMarriageStateLabel(satisfaction) {
|
||
if (satisfaction == null) return null;
|
||
if (satisfaction < 40) return 'crisis';
|
||
if (satisfaction < 60) return 'strained';
|
||
return 'stable';
|
||
}
|
||
|
||
getHouseholdTensionLabel(score) {
|
||
if (score == null) return null;
|
||
if (score >= 60) return 'high';
|
||
if (score >= 25) return 'medium';
|
||
return 'low';
|
||
}
|
||
|
||
clampScore(value) {
|
||
return Math.max(0, Math.min(100, Math.round(Number(value) || 0)));
|
||
}
|
||
|
||
calculateHouseholdTension({ lovers = [], marriageSatisfaction = null, userHouse = null, children = [] }) {
|
||
let score = 10;
|
||
const reasons = [];
|
||
|
||
for (const lover of lovers) {
|
||
const visibility = Number(lover.visibility || 0);
|
||
const monthsUnderfunded = Number(lover.monthsUnderfunded || 0);
|
||
const statusFit = Number(lover.statusFit || 0);
|
||
|
||
if (visibility >= 60) {
|
||
score += 18;
|
||
reasons.push('visibleLover');
|
||
} else if (visibility >= 35) {
|
||
score += 10;
|
||
reasons.push('noticeableLover');
|
||
} else {
|
||
score += 4;
|
||
}
|
||
|
||
if (monthsUnderfunded >= 1) {
|
||
score += 6;
|
||
reasons.push('underfundedLover');
|
||
}
|
||
if (monthsUnderfunded >= 2) score += 6;
|
||
|
||
if (lover.acknowledged) {
|
||
score += 4;
|
||
reasons.push('acknowledgedAffair');
|
||
}
|
||
|
||
if (statusFit === -1) score += 3;
|
||
if (statusFit <= -2) {
|
||
score += 6;
|
||
reasons.push('statusMismatch');
|
||
}
|
||
}
|
||
|
||
for (const child of children) {
|
||
if (child.birthContext !== 'lover') continue;
|
||
score += child.publicKnown ? 6 : 2;
|
||
if (child.publicKnown) reasons.push('loverChild');
|
||
if (child.legitimacy === 'acknowledged_bastard') score += 2;
|
||
if (child.legitimacy === 'hidden_bastard') score += 4;
|
||
}
|
||
|
||
const householdOrder = Number(userHouse?.householdOrder ?? 55);
|
||
const servantCount = Number(userHouse?.servantCount ?? 0);
|
||
const servantQuality = Number(userHouse?.servantQuality ?? 50);
|
||
const servantPayLevel = userHouse?.servantPayLevel || 'normal';
|
||
const expectation = this.getServantExpectation(userHouse?.houseType, userHouse?.character || null);
|
||
|
||
if (householdOrder >= 80) score -= 6;
|
||
else if (householdOrder >= 65) score -= 3;
|
||
if (householdOrder <= 35) {
|
||
score += 8;
|
||
reasons.push('disorder');
|
||
} else if (householdOrder <= 50) {
|
||
score += 4;
|
||
}
|
||
|
||
if (servantCount < expectation.min) {
|
||
score += 5;
|
||
reasons.push('tooFewServants');
|
||
}
|
||
if (servantPayLevel === 'low') score += 2;
|
||
if (servantQuality >= 70 && servantPayLevel === 'high') score -= 3;
|
||
|
||
if (marriageSatisfaction != null && marriageSatisfaction <= 35) {
|
||
score += 6;
|
||
reasons.push('marriageCrisis');
|
||
}
|
||
if (marriageSatisfaction != null && marriageSatisfaction >= 75) score -= 2;
|
||
|
||
const normalizedScore = this.clampScore(score);
|
||
return {
|
||
score: normalizedScore,
|
||
label: this.getHouseholdTensionLabel(normalizedScore),
|
||
reasons: [...new Set(reasons)]
|
||
};
|
||
}
|
||
|
||
async refreshHouseholdTensionState(falukantUser, character = falukantUser?.character) {
|
||
if (!falukantUser?.id || !character?.id) return null;
|
||
|
||
const userHouse = await UserHouse.findOne({
|
||
where: { userId: falukantUser.id },
|
||
include: [{ model: HouseType, as: 'houseType', attributes: ['id', 'position', 'cost', 'labelTr'] }]
|
||
});
|
||
if (!userHouse) return null;
|
||
|
||
const relationshipTypes = await RelationshipType.findAll({
|
||
where: { tr: ['lover', 'married'] },
|
||
attributes: ['id', 'tr']
|
||
});
|
||
const relationshipTypeIds = relationshipTypes.map((type) => type.id);
|
||
const relationshipTypeMap = Object.fromEntries(relationshipTypes.map((type) => [type.id, type.tr]));
|
||
|
||
const relationships = relationshipTypeIds.length
|
||
? await Relationship.findAll({
|
||
where: {
|
||
character1Id: character.id,
|
||
relationshipTypeId: relationshipTypeIds
|
||
},
|
||
include: [{ model: RelationshipState, as: 'state', required: false }],
|
||
attributes: ['relationshipTypeId']
|
||
})
|
||
: [];
|
||
|
||
const marriage = relationships.find((rel) => relationshipTypeMap[rel.relationshipTypeId] === 'married') || null;
|
||
const lovers = relationships
|
||
.filter((rel) => relationshipTypeMap[rel.relationshipTypeId] === 'lover')
|
||
.map((rel) => rel.state)
|
||
.filter((state) => (state?.active ?? true) !== false)
|
||
.map((state) => ({
|
||
visibility: state?.visibility ?? 0,
|
||
monthsUnderfunded: state?.monthsUnderfunded ?? 0,
|
||
acknowledged: !!state?.acknowledged,
|
||
statusFit: state?.statusFit ?? 0
|
||
}));
|
||
|
||
const children = await ChildRelation.findAll({
|
||
where: {
|
||
[Op.or]: [
|
||
{ fatherCharacterId: character.id },
|
||
{ motherCharacterId: character.id }
|
||
]
|
||
},
|
||
attributes: ['birthContext', 'legitimacy', 'publicKnown']
|
||
});
|
||
|
||
userHouse.setDataValue('character', character);
|
||
const householdTension = this.calculateHouseholdTension({
|
||
lovers,
|
||
marriageSatisfaction: marriage?.state?.marriageSatisfaction ?? null,
|
||
userHouse,
|
||
children: children.map((rel) => ({
|
||
birthContext: rel.birthContext,
|
||
legitimacy: rel.legitimacy,
|
||
publicKnown: !!rel.publicKnown
|
||
}))
|
||
});
|
||
|
||
await userHouse.update({
|
||
householdTensionScore: householdTension.score,
|
||
householdTensionReasonsJson: householdTension.reasons
|
||
});
|
||
|
||
return householdTension;
|
||
}
|
||
|
||
getLoverRiskState(state) {
|
||
if (!state) return 'low';
|
||
if ((state.visibility ?? 0) >= 60 || (state.monthsUnderfunded ?? 0) >= 2) return 'high';
|
||
if ((state.visibility ?? 0) >= 35 || (state.monthsUnderfunded ?? 0) >= 1) return 'medium';
|
||
return 'low';
|
||
}
|
||
|
||
calculateLoverStatusFit(ownTitleId, targetTitleId) {
|
||
const diff = Math.abs(Number(ownTitleId || 0) - Number(targetTitleId || 0));
|
||
if (diff === 0) return 2;
|
||
if (diff === 1) return 1;
|
||
if (diff === 2) return 0;
|
||
if (diff === 3) return -1;
|
||
return -2;
|
||
}
|
||
|
||
calculateLoverBaseCost(ownTitleId, targetTitleId) {
|
||
const diff = Math.abs(Number(ownTitleId || 0) - Number(targetTitleId || 0));
|
||
return 20 + diff * 10;
|
||
}
|
||
|
||
getLoverRoleConfig(loverRole, ownTitleId, targetTitleId) {
|
||
const normalizedRole = ['secret_affair', 'lover', 'mistress_or_favorite'].includes(loverRole)
|
||
? loverRole
|
||
: 'secret_affair';
|
||
const baseCost = this.calculateLoverBaseCost(ownTitleId, targetTitleId);
|
||
|
||
switch (normalizedRole) {
|
||
case 'lover':
|
||
return {
|
||
loverRole: normalizedRole,
|
||
affection: 50,
|
||
visibility: 30,
|
||
discretion: 45,
|
||
acknowledged: true,
|
||
monthlyBaseCost: baseCost + 10
|
||
};
|
||
case 'mistress_or_favorite':
|
||
return {
|
||
loverRole: normalizedRole,
|
||
affection: 55,
|
||
visibility: 45,
|
||
discretion: 35,
|
||
acknowledged: true,
|
||
monthlyBaseCost: baseCost + 25
|
||
};
|
||
case 'secret_affair':
|
||
default:
|
||
return {
|
||
loverRole: 'secret_affair',
|
||
affection: 45,
|
||
visibility: 10,
|
||
discretion: 55,
|
||
acknowledged: false,
|
||
monthlyBaseCost: baseCost
|
||
};
|
||
}
|
||
}
|
||
|
||
async getFalukantUserByHashedId(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'] }
|
||
],
|
||
attributes: ['id', 'birthdate', 'gender', 'moodId', 'health']
|
||
},
|
||
]
|
||
});
|
||
if (!user) throw new Error('User not found');
|
||
// Load character traits in a separate query to avoid EagerLoadingError
|
||
if (user.character?.id) {
|
||
const ctRows = await FalukantCharacterTrait.findAll({
|
||
where: { characterId: user.character.id },
|
||
attributes: ['traitId']
|
||
});
|
||
const traitIds = [...new Set(ctRows.map(r => r.traitId))];
|
||
const traits = traitIds.length
|
||
? await CharacterTrait.findAll({ where: { id: traitIds }, attributes: ['id', 'tr'] })
|
||
: [];
|
||
user.character.setDataValue('traits', traits);
|
||
}
|
||
// Load UserHouse (and HouseType) in separate queries to avoid EagerLoadingError
|
||
if (user.id != null) {
|
||
const userHouse = await UserHouse.findOne({
|
||
where: { userId: user.id },
|
||
attributes: ['roofCondition', 'wallCondition', 'floorCondition', 'windowCondition', 'houseTypeId']
|
||
});
|
||
if (userHouse?.houseTypeId) {
|
||
const houseType = await HouseType.findOne({
|
||
where: { id: userHouse.houseTypeId },
|
||
attributes: ['labelTr', 'position']
|
||
});
|
||
if (houseType) userHouse.setDataValue('houseType', houseType);
|
||
}
|
||
if (userHouse) user.setDataValue('userHouse', userHouse);
|
||
}
|
||
user.setDataValue('debtorsPrison', await this.getDebtorsPrisonStateForUser(user));
|
||
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', 'level'] }
|
||
],
|
||
attributes: ['id', 'birthdate', 'gender', 'reputation', 'titleOfNobility']
|
||
},
|
||
{
|
||
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']
|
||
}
|
||
]
|
||
},
|
||
],
|
||
attributes: ['id', 'money', 'creditAmount', 'todayCreditTaken', 'certificate', 'certificateProductionsCountSince']
|
||
});
|
||
if (!u) throw new Error('User not found');
|
||
if (u.certificate == null) {
|
||
u.setDataValue('certificate', 1);
|
||
}
|
||
// Load UserHouse and HouseType in separate queries to avoid EagerLoadingError
|
||
let userHouse = null;
|
||
if (u.id != null) {
|
||
userHouse = await UserHouse.findOne({
|
||
where: { userId: u.id },
|
||
attributes: ['roofCondition', 'houseTypeId']
|
||
});
|
||
}
|
||
if (userHouse?.houseTypeId) {
|
||
const houseType = await HouseType.findOne({
|
||
where: { id: userHouse.houseTypeId },
|
||
attributes: ['labelTr', 'position']
|
||
});
|
||
if (houseType) userHouse.setDataValue('houseType', houseType);
|
||
}
|
||
if (userHouse) u.setDataValue('userHouse', userHouse);
|
||
if (u.character?.birthdate) u.character.setDataValue('age', calcAge(u.character.birthdate));
|
||
u.setDataValue('certificateProgress', await this.buildCertificateProgress(u));
|
||
u.setDataValue('debtorsPrison', await this.getDebtorsPrisonStateForUser(u));
|
||
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, certificate: 1, 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: ['id', 'birthdate', 'health', 'reputation', 'titleOfNobility'],
|
||
include: [
|
||
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
|
||
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] },
|
||
],
|
||
},
|
||
],
|
||
attributes: ['id', 'money']
|
||
});
|
||
if (!falukantUser) throw new Error('User not found');
|
||
// Load relationships and types in separate queries to avoid EagerLoadingError
|
||
if (falukantUser.character?.id) {
|
||
const [rawRelsAs1, rawRelsAs2] = await Promise.all([
|
||
Relationship.findAll({
|
||
where: { character1Id: falukantUser.character.id },
|
||
attributes: ['id', 'character2Id', 'relationshipTypeId']
|
||
}),
|
||
Relationship.findAll({
|
||
where: { character2Id: falukantUser.character.id },
|
||
attributes: ['id', 'character1Id', 'relationshipTypeId']
|
||
})
|
||
]);
|
||
const typeIds = [...new Set([
|
||
...rawRelsAs1.map(r => r.relationshipTypeId),
|
||
...rawRelsAs2.map(r => r.relationshipTypeId)
|
||
])].filter(Boolean);
|
||
const types = typeIds.length
|
||
? await RelationshipType.findAll({ where: { id: typeIds, tr: { [Op.not]: 'lover' } }, attributes: ['id', 'tr'] })
|
||
: [];
|
||
const typeMap = Object.fromEntries(types.map(t => [t.id, t]));
|
||
const attachType = (r) => {
|
||
const t = typeMap[r.relationshipTypeId];
|
||
if (t) r.setDataValue('relationshipType', t);
|
||
return r;
|
||
};
|
||
falukantUser.character.setDataValue('relationshipsAsCharacter1', rawRelsAs1.filter(r => typeMap[r.relationshipTypeId]).map(attachType));
|
||
falukantUser.character.setDataValue('relationshipsAsCharacter2', rawRelsAs2.filter(r => typeMap[r.relationshipTypeId]).map(attachType));
|
||
}
|
||
if (falukantUser.character?.birthdate) falukantUser.character.setDataValue('age', calcAge(falukantUser.character.birthdate));
|
||
if (falukantUser.character?.id) {
|
||
const displayRep = await getDisplayReputation(falukantUser.character);
|
||
falukantUser.character.setDataValue('reputationDisplay', displayRep);
|
||
}
|
||
|
||
// Aggregate status additions: children counts and unread notifications
|
||
try {
|
||
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);
|
||
}
|
||
|
||
falukantUser.setDataValue('debtorsPrison', await this.getDebtorsPrisonStateForUser(falukantUser));
|
||
|
||
return falukantUser;
|
||
}
|
||
|
||
async getBranches(hashedUserId) {
|
||
const startTime = Date.now();
|
||
console.log(`[getBranches] Start für userId: ${hashedUserId}`);
|
||
|
||
const u = await getFalukantUserOrFail(hashedUserId);
|
||
const userTime = Date.now();
|
||
console.log(`[getBranches] User geladen in ${userTime - startTime}ms`);
|
||
|
||
const bs = await Branch.findAll({
|
||
where: { falukantUserId: u.id },
|
||
include: [
|
||
{ model: BranchType, as: 'branchType', attributes: ['labelTr'] },
|
||
{
|
||
model: RegionData,
|
||
as: 'region',
|
||
attributes: ['id', 'name'],
|
||
include: [
|
||
{
|
||
model: Weather,
|
||
as: 'weather',
|
||
attributes: ['regionId', 'weatherTypeId'],
|
||
include: [
|
||
{ model: WeatherType, as: 'weatherType', attributes: ['tr'] }
|
||
],
|
||
required: false
|
||
}
|
||
]
|
||
}
|
||
],
|
||
attributes: ['id', 'regionId'],
|
||
order: [['branchTypeId', 'ASC']]
|
||
});
|
||
const branchesTime = Date.now();
|
||
console.log(`[getBranches] Branches geladen (${bs.length} Stück) in ${branchesTime - userTime}ms`);
|
||
|
||
// Lade Wetter explizit für alle Regionen, um sicherzustellen, dass es korrekt geladen wird
|
||
const regionIds = [...new Set(bs.map(b => b.regionId))];
|
||
const weathers = await Weather.findAll({
|
||
where: { regionId: { [Op.in]: regionIds } },
|
||
include: [
|
||
{ model: WeatherType, as: 'weatherType', attributes: ['tr'] }
|
||
]
|
||
});
|
||
const weatherMap = new Map(weathers.map(w => [w.regionId, w.weatherType?.tr || null]));
|
||
const weatherTime = Date.now();
|
||
console.log(`[getBranches] Weather geladen in ${weatherTime - branchesTime}ms`);
|
||
|
||
const result = bs.map(b => {
|
||
const branchJson = b.toJSON();
|
||
// Verwende das explizit geladene Wetter, falls vorhanden, sonst das aus der Include-Beziehung
|
||
const weather = weatherMap.get(b.regionId) || branchJson.region?.weather?.weatherType?.tr || null;
|
||
return {
|
||
...branchJson,
|
||
isMainBranch: u.mainBranchRegionId === b.regionId,
|
||
weather: weather
|
||
};
|
||
});
|
||
|
||
const totalTime = Date.now() - startTime;
|
||
console.log(`[getBranches] Gesamtzeit: ${totalTime}ms für ${result.length} Branches`);
|
||
|
||
return result;
|
||
}
|
||
|
||
async createBranch(hashedUserId, cityId, branchTypeId) {
|
||
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,
|
||
attributes: ['id', 'vehicleTypeId', 'regionId', 'condition', 'availableFrom'],
|
||
order: [['availableFrom', 'ASC'], ['id', 'ASC']],
|
||
});
|
||
const vehicleIds = vehicles.map(v => v.id);
|
||
const typeIds = [...new Set(vehicles.map(v => v.vehicleTypeId))];
|
||
const [types, transports] = await Promise.all([
|
||
typeIds.length ? VehicleType.findAll({
|
||
where: { id: typeIds },
|
||
attributes: ['id', 'tr', 'capacity', 'transportMode', 'speed', 'buildTimeMinutes', 'cost'],
|
||
}) : [],
|
||
vehicleIds.length ? Transport.findAll({
|
||
where: { vehicleId: vehicleIds },
|
||
attributes: ['id', 'vehicleId', 'sourceRegionId', 'targetRegionId'],
|
||
}) : [],
|
||
]);
|
||
const typeMap = Object.fromEntries(types.map(t => [t.id, t]));
|
||
const transportsByVehicle = transports.reduce((acc, t) => {
|
||
const vid = t.vehicleId;
|
||
if (!acc[vid]) acc[vid] = [];
|
||
acc[vid].push(t);
|
||
return acc;
|
||
}, {});
|
||
for (const v of vehicles) {
|
||
v.setDataValue('type', typeMap[v.vehicleTypeId] || null);
|
||
v.setDataValue('transports', transportsByVehicle[v.id] || []);
|
||
}
|
||
|
||
const now = new Date();
|
||
const branchRegionId = regionId ? parseInt(regionId, 10) : undefined;
|
||
|
||
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) {
|
||
const availableFromTime = new Date(plain.availableFrom).getTime();
|
||
const nowTime = now.getTime();
|
||
// Verfügbarkeit liegt in der Zukunft = im Bau oder in Reparatur
|
||
if (availableFromTime > nowTime) {
|
||
status = 'building';
|
||
} else {
|
||
// Verfügbarkeit erreicht = verfügbar
|
||
status = 'available';
|
||
}
|
||
} else {
|
||
// Kein availableFrom gesetzt = verfügbar (Fallback)
|
||
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, vehicleIds, productId, quantity, targetBranchId, guardCount: rawGuardCount }) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
const guardCount = Math.max(0, Number.parseInt(rawGuardCount ?? 0, 10) || 0);
|
||
|
||
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();
|
||
|
||
let type;
|
||
let freeVehicles = [];
|
||
|
||
// Wenn spezifische vehicleIds übergeben wurden, diese verwenden
|
||
if (vehicleIds && Array.isArray(vehicleIds) && vehicleIds.length > 0) {
|
||
const vehicles = await Vehicle.findAll({
|
||
where: {
|
||
id: { [Op.in]: vehicleIds },
|
||
falukantUserId: user.id,
|
||
regionId: sourceRegionId,
|
||
availableFrom: { [Op.lte]: now },
|
||
},
|
||
include: [
|
||
{
|
||
model: Transport,
|
||
as: 'transports',
|
||
required: false,
|
||
attributes: ['id'],
|
||
},
|
||
{
|
||
model: VehicleType,
|
||
as: 'type',
|
||
required: true,
|
||
},
|
||
],
|
||
});
|
||
|
||
freeVehicles = vehicles.filter((v) => {
|
||
const t = v.transports || [];
|
||
return t.length === 0;
|
||
});
|
||
|
||
if (freeVehicles.length === 0) {
|
||
throw new PreconditionError('noVehiclesAvailable');
|
||
}
|
||
|
||
// Alle Fahrzeuge müssen denselben Typ haben
|
||
const vehicleTypeIds = [...new Set(freeVehicles.map(v => v.vehicleTypeId))];
|
||
if (vehicleTypeIds.length !== 1) {
|
||
throw new Error('All vehicles must be of the same type');
|
||
}
|
||
|
||
type = await VehicleType.findByPk(vehicleTypeIds[0]);
|
||
if (!type) {
|
||
throw new Error('Vehicle type not found');
|
||
}
|
||
} else {
|
||
// Standard-Verhalten: Alle freien Fahrzeuge dieses Typs verwenden
|
||
if (!vehicleTypeId) {
|
||
throw new Error('Either vehicleTypeId or vehicleIds must be provided');
|
||
}
|
||
|
||
type = await VehicleType.findByPk(vehicleTypeId);
|
||
if (!type) {
|
||
throw new Error('Vehicle type not found');
|
||
}
|
||
|
||
// 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'],
|
||
},
|
||
],
|
||
});
|
||
|
||
freeVehicles = vehicles.filter((v) => {
|
||
const t = v.transports || [];
|
||
return t.length === 0;
|
||
});
|
||
}
|
||
|
||
if (!freeVehicles.length) {
|
||
throw new PreconditionError('noVehiclesAvailable');
|
||
}
|
||
|
||
const route = await computeShortestRoute(type.transportMode, sourceRegionId, targetRegionId);
|
||
if (!route) {
|
||
throw new PreconditionError('noRoute');
|
||
}
|
||
|
||
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;
|
||
|
||
// Produkt-Transport oder leerer Transport (nur Fahrzeuge bewegen)?
|
||
const isEmptyTransport = !productId || !quantity || quantity <= 0;
|
||
|
||
let sourceStockIds = [];
|
||
let available = 0;
|
||
let maxByInventory = 0;
|
||
let hardMax = 0;
|
||
let requested = 0;
|
||
let transportCost = 0.1; // Minimale Kosten für leeren Transport
|
||
let productIdForTransport = productId;
|
||
|
||
if (!isEmptyTransport) {
|
||
// Produkt-Transport: alle Stocks der Quell-Niederlassung (wie getInventory)
|
||
const sourceStocks = await FalukantStock.findAll({ where: { branchId: sourceBranch.id }, attributes: ['id'] });
|
||
if (!sourceStocks?.length) {
|
||
throw new Error('Stock not found');
|
||
}
|
||
sourceStockIds = sourceStocks.map((s) => s.id);
|
||
|
||
const inventoryCheck = await Inventory.findAll({
|
||
where: {
|
||
stockId: { [Op.in]: sourceStockIds },
|
||
productId,
|
||
},
|
||
include: [
|
||
{ model: ProductType, as: 'productType', required: true, where: { id: productId }, attributes: ['id', 'sellCost'] },
|
||
],
|
||
});
|
||
|
||
available = inventoryCheck.reduce((sum, i) => sum + i.quantity, 0);
|
||
if (available <= 0) {
|
||
throw new PreconditionError('noInventory');
|
||
}
|
||
|
||
maxByInventory = available;
|
||
hardMax = Math.min(maxByVehicles, maxByInventory);
|
||
|
||
requested = Math.max(1, parseInt(quantity, 10) || 0);
|
||
if (requested > hardMax) {
|
||
throw new PreconditionError('quantityTooHigh');
|
||
}
|
||
|
||
// Transportkosten: 1 % des Warenwerts, mindestens 0,1
|
||
const productType = inventoryCheck[0]?.productType;
|
||
const unitValue = productType?.sellCost || 0;
|
||
const totalValue = unitValue * requested;
|
||
transportCost = Math.max(0.1, totalValue * 0.01);
|
||
} else {
|
||
// Leerer Transport: Ein Fahrzeug wird bewegt
|
||
requested = 1;
|
||
hardMax = 1;
|
||
}
|
||
|
||
transportCost += guardCount * 4;
|
||
transportCost = Math.round(transportCost * 100) / 100;
|
||
|
||
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 = isEmptyTransport ? 0 : Math.min(remaining, capacityPerVehicle);
|
||
const t = await Transport.create(
|
||
{
|
||
sourceRegionId,
|
||
targetRegionId,
|
||
productId: isEmptyTransport ? null : productId,
|
||
size: isEmptyTransport ? 0 : size,
|
||
vehicleId: v.id,
|
||
guardCount,
|
||
},
|
||
{ transaction: tx }
|
||
);
|
||
transportsCreated.push(t);
|
||
if (!isEmptyTransport) {
|
||
remaining -= size;
|
||
} else {
|
||
// Bei leerem Transport nur ein Fahrzeug bewegen
|
||
remaining = 0;
|
||
}
|
||
}
|
||
|
||
if (remaining > 0 && !isEmptyTransport) {
|
||
throw new Error('Not enough vehicle capacity for requested quantity');
|
||
}
|
||
|
||
// Inventar in der Quell-Niederlassung reduzieren (nur bei Produkt-Transport)
|
||
// Innerhalb der Transaktion mit Lock laden, damit aktuelle Mengen verwendet werden
|
||
if (!isEmptyTransport && sourceStockIds.length > 0) {
|
||
const inventoryRows = await Inventory.findAll({
|
||
where: {
|
||
stockId: { [Op.in]: sourceStockIds },
|
||
productId: productIdForTransport,
|
||
},
|
||
order: [['id', 'ASC']],
|
||
lock: true, // SELECT ... FOR UPDATE
|
||
transaction: tx,
|
||
});
|
||
|
||
let left = requested;
|
||
for (const inv of inventoryRows) {
|
||
if (left <= 0) break;
|
||
const qty = Number(inv.quantity) || 0;
|
||
if (qty <= 0) continue;
|
||
if (qty <= left) {
|
||
left -= qty;
|
||
await inv.destroy({ transaction: tx });
|
||
} else {
|
||
await inv.update({ quantity: qty - 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,
|
||
guardCount: t.guardCount,
|
||
})),
|
||
};
|
||
});
|
||
|
||
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', required: false, 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,
|
||
guardCount: Number(t.guardCount || 0),
|
||
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 repairVehicle(hashedUserId, vehicleId) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
const now = new Date();
|
||
|
||
// Fahrzeug laden mit Typ-Informationen
|
||
const vehicle = await Vehicle.findOne({
|
||
where: {
|
||
id: vehicleId,
|
||
falukantUserId: user.id,
|
||
},
|
||
include: [
|
||
{
|
||
model: VehicleType,
|
||
as: 'type',
|
||
required: true,
|
||
},
|
||
{
|
||
model: Transport,
|
||
as: 'transports',
|
||
required: false,
|
||
attributes: ['id'],
|
||
},
|
||
],
|
||
});
|
||
|
||
if (!vehicle) {
|
||
throw new Error('Vehicle not found or does not belong to user');
|
||
}
|
||
|
||
// Prüfen, ob Fahrzeug in Benutzung ist
|
||
const hasActiveTransport = Array.isArray(vehicle.transports) && vehicle.transports.length > 0;
|
||
const isBuilding = vehicle.availableFrom && new Date(vehicle.availableFrom).getTime() > now.getTime();
|
||
|
||
if (hasActiveTransport) {
|
||
throw new PreconditionError('vehicleInUse');
|
||
}
|
||
|
||
if (isBuilding) {
|
||
throw new PreconditionError('vehicleAlreadyBuilding');
|
||
}
|
||
|
||
// Prüfen, ob Reparatur nötig ist (Zustand < 100)
|
||
if (vehicle.condition >= 100) {
|
||
throw new PreconditionError('vehicleAlreadyPerfect');
|
||
}
|
||
|
||
// Kosten berechnen: Baupreis * 0.8 * (100 - zustand) / 100
|
||
const baseCost = vehicle.type.cost;
|
||
const repairCost = Math.round(baseCost * 0.8 * (100 - vehicle.condition) / 100);
|
||
|
||
if (user.money < repairCost) {
|
||
throw new PreconditionError('insufficientFunds');
|
||
}
|
||
|
||
// Bauzeit verwenden (buildTimeMinutes)
|
||
const buildTimeMinutes = vehicle.type.buildTimeMinutes || 0;
|
||
const buildMs = buildTimeMinutes * 60 * 1000;
|
||
const availableFrom = new Date(now.getTime() + buildMs);
|
||
|
||
// Reparatur durchführen
|
||
await sequelize.transaction(async (tx) => {
|
||
// Geld abziehen
|
||
const moneyResult = await updateFalukantUserMoney(
|
||
user.id,
|
||
-repairCost,
|
||
'repair_vehicle',
|
||
user.id
|
||
);
|
||
if (!moneyResult.success) {
|
||
throw new Error('Failed to update money');
|
||
}
|
||
|
||
// Fahrzeug reparieren: Zustand auf 100, availableFrom auf jetzt + Bauzeit
|
||
await vehicle.update({
|
||
condition: 100,
|
||
availableFrom: availableFrom,
|
||
}, { transaction: tx });
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
repairCost,
|
||
buildTimeMinutes,
|
||
availableFrom,
|
||
};
|
||
}
|
||
|
||
async repairAllVehicles(hashedUserId, vehicleIds) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
const now = new Date();
|
||
|
||
if (!vehicleIds || !Array.isArray(vehicleIds) || vehicleIds.length === 0) {
|
||
throw new Error('Keine Fahrzeuge zum Reparieren angegeben');
|
||
}
|
||
|
||
// Alle Fahrzeuge laden
|
||
const vehicles = await Vehicle.findAll({
|
||
where: {
|
||
id: { [Op.in]: vehicleIds },
|
||
falukantUserId: user.id,
|
||
},
|
||
include: [
|
||
{
|
||
model: VehicleType,
|
||
as: 'type',
|
||
required: true,
|
||
},
|
||
{
|
||
model: Transport,
|
||
as: 'transports',
|
||
required: false,
|
||
attributes: ['id'],
|
||
},
|
||
],
|
||
});
|
||
|
||
if (vehicles.length !== vehicleIds.length) {
|
||
throw new Error('Nicht alle angegebenen Fahrzeuge gefunden oder gehören nicht dem Benutzer');
|
||
}
|
||
|
||
// Prüfe alle Fahrzeuge
|
||
const repairableVehicles = [];
|
||
let totalCost = 0;
|
||
|
||
for (const vehicle of vehicles) {
|
||
// Prüfen, ob Fahrzeug in Benutzung ist
|
||
const hasActiveTransport = Array.isArray(vehicle.transports) && vehicle.transports.length > 0;
|
||
const isBuilding = vehicle.availableFrom && new Date(vehicle.availableFrom).getTime() > now.getTime();
|
||
|
||
if (hasActiveTransport || isBuilding) {
|
||
continue; // Überspringe Fahrzeuge in Benutzung
|
||
}
|
||
|
||
// Prüfen, ob Reparatur nötig ist (Zustand < 100)
|
||
if (vehicle.condition >= 100) {
|
||
continue; // Überspringe bereits perfekte Fahrzeuge
|
||
}
|
||
|
||
// Kosten berechnen: Baupreis * 0.8 * (100 - zustand) / 100
|
||
const baseCost = vehicle.type.cost;
|
||
const repairCost = Math.round(baseCost * 0.8 * (100 - vehicle.condition) / 100);
|
||
totalCost += repairCost;
|
||
repairableVehicles.push({ vehicle, repairCost });
|
||
}
|
||
|
||
if (repairableVehicles.length === 0) {
|
||
throw new PreconditionError('noVehiclesToRepair');
|
||
}
|
||
|
||
// 10% Rabatt für Reparatur aller Fahrzeuge
|
||
const discountedCost = Math.round(totalCost * 0.9);
|
||
|
||
if (user.money < discountedCost) {
|
||
throw new PreconditionError('insufficientFunds');
|
||
}
|
||
|
||
// Alle Reparaturen in einer Transaktion durchführen
|
||
await sequelize.transaction(async (tx) => {
|
||
// Geld abziehen
|
||
const moneyResult = await updateFalukantUserMoney(
|
||
user.id,
|
||
-discountedCost,
|
||
'repair_all_vehicles',
|
||
user.id
|
||
);
|
||
if (!moneyResult.success) {
|
||
throw new Error('Failed to update money');
|
||
}
|
||
|
||
// Alle Fahrzeuge reparieren
|
||
for (const { vehicle, repairCost } of repairableVehicles) {
|
||
const buildTimeMinutes = vehicle.type.buildTimeMinutes || 0;
|
||
const buildMs = buildTimeMinutes * 60 * 1000;
|
||
const availableFrom = new Date(now.getTime() + buildMs);
|
||
|
||
await vehicle.update({
|
||
condition: 100,
|
||
availableFrom: availableFrom,
|
||
}, { transaction: tx });
|
||
}
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
repairedCount: repairableVehicles.length,
|
||
totalCost: discountedCost,
|
||
};
|
||
}
|
||
|
||
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 = productionCostTotal(quantity, p.category, u.certificate);
|
||
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 effectiveCertificate = u.certificate ?? 1;
|
||
const ps = await ProductType.findAll({
|
||
where: { category: { [Op.lte]: effectiveCertificate } },
|
||
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);
|
||
|
||
// compute cumulative tax (region + ancestors) with political exemptions and inflate price so seller net is unchanged
|
||
const cumulativeTax = await getCumulativeTaxPercentWithExemptions(user.id, branch.regionId);
|
||
const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100));
|
||
const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100;
|
||
const revenue = quantity * adjustedPricePerUnit;
|
||
|
||
// compute tax and net
|
||
const taxValue = Math.round((revenue * cumulativeTax / 100) * 100) / 100;
|
||
const net = Math.round((revenue - taxValue) * 100) / 100;
|
||
|
||
// Book net to seller
|
||
const moneyResult = await updateFalukantUserMoney(user.id, net, `Product sale (net)`, user.id);
|
||
if (!moneyResult.success) throw new Error('Failed to update money for seller');
|
||
|
||
// Book tax: oberster Stand der Region oder Treasury
|
||
if (taxValue > 0) {
|
||
const { recipientUserIds, useTreasury } = await getTaxRecipientsForRegion(branch.regionId);
|
||
if (!useTreasury && recipientUserIds.length > 0) {
|
||
const share = Math.round((taxValue / recipientUserIds.length) * 100) / 100;
|
||
for (const recipientId of recipientUserIds) {
|
||
const taxResult = await updateFalukantUserMoney(recipientId, share, `Steueranteil Region (Verkauf)`, user.id);
|
||
if (!taxResult.success) throw new Error('Failed to update money for tax recipient');
|
||
}
|
||
} else {
|
||
const treasuryId = process.env.TREASURY_FALUKANT_USER_ID;
|
||
if (treasuryId) {
|
||
const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), taxValue, `Sales tax (${cumulativeTax}%)`, user.id);
|
||
if (!taxResult.success) throw new Error('Failed to update money for treasury');
|
||
}
|
||
}
|
||
}
|
||
let remaining = quantity;
|
||
for (const inv of inventory) {
|
||
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;
|
||
/** regionId -> total tax amount to distribute in that region */
|
||
const taxPerRegion = new Map();
|
||
for (const item of inventory) {
|
||
const knowledgeVal = item.productType.knowledges[0]?.knowledge || 0;
|
||
const regionId = item.stock.branch.regionId;
|
||
const pricePerUnit = await calcRegionalSellPrice(item.productType, knowledgeVal, regionId);
|
||
const cumulativeTax = await getCumulativeTaxPercentWithExemptions(falukantUser.id, regionId);
|
||
const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100));
|
||
const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100;
|
||
const itemRevenue = item.quantity * adjustedPricePerUnit;
|
||
total += itemRevenue;
|
||
const itemTax = Math.round((itemRevenue * cumulativeTax / 100) * 100) / 100;
|
||
if (itemTax > 0) taxPerRegion.set(regionId, (taxPerRegion.get(regionId) || 0) + itemTax);
|
||
await this.addSellItem(item.stock.branch.id, falukantUser.id, item.productType.id, item.quantity);
|
||
}
|
||
const totalTax = [...taxPerRegion.values()].reduce((s, t) => s + t, 0);
|
||
const totalNet = Math.round((total - totalTax) * 100) / 100;
|
||
|
||
const moneyResult = await updateFalukantUserMoney(
|
||
falukantUser.id,
|
||
totalNet,
|
||
'Sell all products (net)',
|
||
falukantUser.id
|
||
);
|
||
if (!moneyResult.success) throw new Error('Failed to update money for seller');
|
||
|
||
const treasuryId = process.env.TREASURY_FALUKANT_USER_ID;
|
||
for (const [regionId, regionTax] of taxPerRegion) {
|
||
if (regionTax <= 0) continue;
|
||
const { recipientUserIds, useTreasury } = await getTaxRecipientsForRegion(regionId);
|
||
if (!useTreasury && recipientUserIds.length > 0) {
|
||
const share = Math.round((regionTax / recipientUserIds.length) * 100) / 100;
|
||
for (const recipientId of recipientUserIds) {
|
||
const taxResult = await updateFalukantUserMoney(recipientId, share, `Steueranteil Region (Verkauf)`, falukantUser.id);
|
||
if (!taxResult.success) throw new Error('Failed to update money for tax recipient');
|
||
}
|
||
} else if (treasuryId) {
|
||
const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), Math.round(regionTax * 100) / 100, `Sales tax (aggregate)`, falukantUser.id);
|
||
if (!taxResult.success) throw new Error('Failed to update money for treasury');
|
||
}
|
||
}
|
||
for (const item of inventory) {
|
||
await Inventory.destroy({ where: { id: item.id } });
|
||
}
|
||
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,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Return tax summary for a branch: total cumulative tax and breakdown per region (region -> parent chain)
|
||
async getBranchTaxes(hashedUserId, branchId) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
const branch = await Branch.findOne({ where: { id: branchId, falukantUserId: user.id }, include: [{ model: RegionData, as: 'region', attributes: ['id','name','parentId','taxPercent'] }] });
|
||
if (!branch) throw new Error('Branch not found');
|
||
const regionId = branch.regionId;
|
||
|
||
// gather region + ancestors with taxPercent
|
||
const rows = await sequelize.query(`
|
||
WITH RECURSIVE ancestors AS (
|
||
SELECT id, parent_id, tax_percent, name FROM falukant_data.region WHERE id = :id
|
||
UNION ALL
|
||
SELECT r.id, r.parent_id, r.tax_percent, r.name FROM falukant_data.region r JOIN ancestors a ON r.id = a.parent_id
|
||
) SELECT id, name, parent_id, COALESCE(tax_percent,0) AS tax_percent FROM ancestors;
|
||
`, { replacements: { id: regionId }, type: sequelize.QueryTypes.SELECT });
|
||
|
||
// compute total
|
||
const total = rows.reduce((sum, r) => sum + parseFloat(r.tax_percent || 0), 0);
|
||
|
||
return {
|
||
regionId,
|
||
total: Math.round(total * 100) / 100,
|
||
breakdown: rows.map(r => ({ id: r.id, name: r.name, parentId: r.parent_id, taxPercent: parseFloat(r.tax_percent || 0) }))
|
||
};
|
||
}
|
||
|
||
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) };
|
||
}
|
||
|
||
/**
|
||
* Geldverlauf für Graphenansicht.
|
||
* range: 'today' | '24h' | 'week' | 'month' | 'year' | 'all'
|
||
*/
|
||
async moneyHistoryGraph(hashedUserId, range = '24h') {
|
||
const u = await getFalukantUserOrFail(hashedUserId);
|
||
|
||
const where = { falukantUserId: u.id };
|
||
const now = new Date();
|
||
let start = null;
|
||
|
||
switch (range) {
|
||
case 'today': {
|
||
start = new Date();
|
||
start.setHours(0, 0, 0, 0);
|
||
break;
|
||
}
|
||
case '24h': {
|
||
start = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||
break;
|
||
}
|
||
case 'week': {
|
||
start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||
break;
|
||
}
|
||
case 'month': {
|
||
start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||
break;
|
||
}
|
||
case 'year': {
|
||
start = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
|
||
break;
|
||
}
|
||
case 'all':
|
||
default:
|
||
start = null;
|
||
}
|
||
|
||
if (start) {
|
||
where.time = { [Op.gte]: start };
|
||
}
|
||
|
||
const rows = await MoneyFlow.findAll({
|
||
where,
|
||
order: [['time', 'ASC']]
|
||
});
|
||
|
||
return rows.map(r => ({
|
||
time: r.time,
|
||
moneyBefore: r.moneyBefore,
|
||
moneyAfter: r.moneyAfter,
|
||
changeValue: r.changeValue,
|
||
activity: r.activity
|
||
}));
|
||
}
|
||
|
||
async getStorage(hashedUserId, branchId) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
const branch = await getBranchOrFail(user.id, branchId);
|
||
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 twentyOneDaysAgo = new Date(Date.now() - 21 * 24 * 60 * 60 * 1000);
|
||
const relevantRegionIds = await this.getRegionAndParentIds(regionId);
|
||
|
||
const employerCharacter = await FalukantCharacter.findOne({
|
||
where: { userId: falukantUserId },
|
||
attributes: ['id']
|
||
});
|
||
const excludeCharacterId = employerCharacter?.id ?? null;
|
||
|
||
const proposalCount = Math.floor(Math.random() * 3) + 3;
|
||
const usedCharacterIds = new Set();
|
||
|
||
for (let i = 0; i < proposalCount; i++) {
|
||
const buildWhere = (includeMinAge = true) => {
|
||
const w = {
|
||
regionId: { [Op.in]: relevantRegionIds },
|
||
};
|
||
if (includeMinAge) w.birthdate = { [Op.lte]: twentyOneDaysAgo };
|
||
if (usedCharacterIds.size > 0) w.id = { [Op.notIn]: Array.from(usedCharacterIds) };
|
||
if (excludeCharacterId) {
|
||
w[Op.or] = [
|
||
{ userId: null },
|
||
{ userId: { [Op.ne]: falukantUserId } }
|
||
];
|
||
}
|
||
return w;
|
||
};
|
||
|
||
let directorCharacter = await FalukantCharacter.findOne({
|
||
where: buildWhere(true), // birthdate <= 21 Tage her = mind. 21 Tage alt
|
||
include: [
|
||
{
|
||
model: TitleOfNobility,
|
||
as: 'nobleTitle',
|
||
attributes: ['level'],
|
||
required: true
|
||
},
|
||
],
|
||
order: sequelize.literal('RANDOM()'),
|
||
});
|
||
if (!directorCharacter) {
|
||
directorCharacter = await FalukantCharacter.findOne({
|
||
where: buildWhere(false), // Fallback ohne Altersfilter
|
||
include: [
|
||
{
|
||
model: TitleOfNobility,
|
||
as: 'nobleTitle',
|
||
attributes: ['level'],
|
||
required: true
|
||
},
|
||
],
|
||
order: sequelize.literal('RANDOM()'),
|
||
});
|
||
}
|
||
if (!directorCharacter) {
|
||
throw new Error('No directors available for the region');
|
||
}
|
||
usedCharacterIds.add(directorCharacter.id);
|
||
const avgKnowledge = await this.calculateAverageKnowledge(directorCharacter.id);
|
||
const proposedIncome = Math.round(
|
||
directorCharacter.nobleTitle.level * Math.pow(1.231, avgKnowledge / 1.5)
|
||
);
|
||
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);
|
||
}
|
||
|
||
async getHighestChurchOfficeInfo(userId) {
|
||
const character = await FalukantCharacter.findOne({
|
||
where: { userId },
|
||
attributes: ['id']
|
||
});
|
||
if (!character) return { rank: 0, name: null };
|
||
|
||
const churchOffices = await ChurchOffice.findAll({
|
||
where: { characterId: character.id },
|
||
include: [{ model: ChurchOfficeType, as: 'type', attributes: ['name', 'hierarchyLevel'] }],
|
||
attributes: ['officeTypeId']
|
||
});
|
||
|
||
const candidates = churchOffices
|
||
.map((office) => ({
|
||
rank: Number(office.type?.hierarchyLevel || 0),
|
||
name: office.type?.name || null,
|
||
}))
|
||
.sort((a, b) => b.rank - a.rank);
|
||
|
||
return candidates[0] || { rank: 0, name: null };
|
||
}
|
||
|
||
/**
|
||
* Zertifikat: abgeschlossene Produktionen über alle Regionen/Niederlassungen.
|
||
* Pro (Produkt, Kalendertag) nur ein Zähler – mehrere Niederlassungen in verschiedenen Regionen werden zusammengeführt.
|
||
* Filter bei gesetztem countSince wie Daemon (GET_PRODUCTION_CERTIFICATE_INPUT_ROWS):
|
||
* COALESCE(production_timestamp, production_date::timestamp) >= countSince.
|
||
*
|
||
* @param {number} producerId falukant_user.id
|
||
* @param {Date|null|undefined} countSince null/undefined = gesamte Historie (Bestand / vor erster Stufenänderung)
|
||
*/
|
||
async getCertificateCompletedProductionCount(producerId, countSince) {
|
||
const sinceClause = countSince
|
||
? ' AND COALESCE(production_timestamp, production_date::timestamp) >= :countSince'
|
||
: '';
|
||
const replacements = { producerId };
|
||
if (countSince) replacements.countSince = countSince;
|
||
const rows = await sequelize.query(
|
||
`
|
||
SELECT COUNT(*)::int AS cnt
|
||
FROM (
|
||
SELECT 1
|
||
FROM falukant_log.production
|
||
WHERE producer_id = :producerId${sinceClause}
|
||
GROUP BY product_id, production_date
|
||
) AS sub
|
||
`,
|
||
{ replacements, type: sequelize.QueryTypes.SELECT }
|
||
);
|
||
return Number(rows[0]?.cnt ?? 0);
|
||
}
|
||
|
||
async buildCertificateProgress(user) {
|
||
const character = user?.character || await FalukantCharacter.findOne({
|
||
where: { userId: user.id },
|
||
attributes: ['id', 'reputation', 'titleOfNobility']
|
||
});
|
||
if (!character?.id) {
|
||
return null;
|
||
}
|
||
|
||
const productionsSince = user.certificateProductionsCountSince ?? null;
|
||
const [avgKnowledge, completedProductions, highestPoliticalOffice, highestChurchOffice, house, title] = await Promise.all([
|
||
this.calculateAverageKnowledge(character.id),
|
||
this.getCertificateCompletedProductionCount(user.id, productionsSince),
|
||
this.getHighestPoliticalOfficeInfo(user.id),
|
||
this.getHighestChurchOfficeInfo(user.id),
|
||
UserHouse.findOne({
|
||
where: { userId: user.id },
|
||
include: [{ model: HouseType, as: 'houseType', attributes: ['position', 'labelTr'] }],
|
||
attributes: ['houseTypeId']
|
||
}),
|
||
TitleOfNobility.findOne({
|
||
where: { id: character.titleOfNobility },
|
||
attributes: ['level', 'labelTr']
|
||
}),
|
||
]);
|
||
|
||
const reputation = Number(character.reputation || 0);
|
||
const housePosition = Number(house?.houseType?.position || 0);
|
||
const highestPoliticalOfficeRank = Number(highestPoliticalOffice?.rank || 0);
|
||
const highestChurchOfficeRank = Number(highestChurchOffice?.rank || 0);
|
||
const highestOfficeRank = Math.max(highestPoliticalOfficeRank, highestChurchOfficeRank);
|
||
const nobilityLevel = Number(title?.level || 0);
|
||
|
||
const knowledgePoints = getKnowledgePoints(avgKnowledge);
|
||
const productionPoints = getProductionPoints(completedProductions);
|
||
const officePoints = Math.min(5, highestOfficeRank);
|
||
const nobilityPoints = getNobilityPoints(nobilityLevel);
|
||
const reputationPoints = getReputationPoints(reputation);
|
||
const housePoints = getHousePoints(housePosition);
|
||
|
||
const score = (
|
||
knowledgePoints * 0.45 +
|
||
productionPoints * 0.30 +
|
||
officePoints * 0.08 +
|
||
nobilityPoints * 0.05 +
|
||
reputationPoints * 0.07 +
|
||
housePoints * 0.05
|
||
);
|
||
|
||
const currentCertificate = Number(user.certificate ?? 1);
|
||
const targetCertificate = getTargetCertificateByScore(score);
|
||
const nextCertificate = Math.min(5, currentCertificate + 1);
|
||
const nextThreshold = CERTIFICATE_THRESHOLDS[nextCertificate] || null;
|
||
const nextScoreThreshold = getScoreThresholdForCertificate(nextCertificate);
|
||
|
||
const currentValues = {
|
||
avgKnowledge,
|
||
completedProductions,
|
||
highestPoliticalOfficeRank,
|
||
highestChurchOfficeRank,
|
||
highestOfficeRank,
|
||
nobilityLevel,
|
||
reputation,
|
||
housePosition,
|
||
knowledgePoints,
|
||
productionPoints,
|
||
officePoints,
|
||
nobilityPoints,
|
||
reputationPoints,
|
||
housePoints,
|
||
};
|
||
|
||
let statusRequirement = null;
|
||
if (nextThreshold?.options?.length) {
|
||
const options = nextThreshold.options.map((option) => ({
|
||
...option,
|
||
current: Number(currentValues[option.type] || 0),
|
||
met: Number(currentValues[option.type] || 0) >= Number(option.required || 0),
|
||
}));
|
||
const metCount = options.filter((option) => option.met).length;
|
||
statusRequirement = {
|
||
mode: nextThreshold.statusMode,
|
||
requiredCount: nextThreshold.statusRequiredCount,
|
||
metCount,
|
||
fulfilled: metCount >= nextThreshold.statusRequiredCount,
|
||
options,
|
||
};
|
||
}
|
||
|
||
const nextRequirements = nextThreshold ? [
|
||
{
|
||
type: 'avgKnowledge',
|
||
current: avgKnowledge,
|
||
required: nextThreshold.avgKnowledge,
|
||
met: avgKnowledge >= nextThreshold.avgKnowledge,
|
||
},
|
||
{
|
||
type: 'completedProductions',
|
||
current: completedProductions,
|
||
required: nextThreshold.completedProductions,
|
||
met: completedProductions >= nextThreshold.completedProductions,
|
||
},
|
||
...(typeof nextThreshold.reputationPoints === 'number' ? [{
|
||
type: 'reputationPoints',
|
||
current: reputationPoints,
|
||
required: nextThreshold.reputationPoints,
|
||
met: reputationPoints >= nextThreshold.reputationPoints,
|
||
}] : []),
|
||
] : [];
|
||
|
||
const minimumRequirementsMet = nextRequirements.every((requirement) => requirement.met)
|
||
&& (!statusRequirement || statusRequirement.fulfilled);
|
||
const scoreRequirementMet = nextCertificate <= targetCertificate;
|
||
|
||
return {
|
||
currentCertificate,
|
||
nextCertificate,
|
||
score: Number(score.toFixed(2)),
|
||
targetCertificate,
|
||
nextScoreThreshold,
|
||
currentValues,
|
||
nextRequirements,
|
||
statusRequirement,
|
||
scoreRequirementMet,
|
||
minimumRequirementsMet,
|
||
readyForNextCertificate: scoreRequirementMet && minimumRequirementsMet,
|
||
certificateProductionsCountSince: productionsSince
|
||
? new Date(productionsSince).toISOString()
|
||
: null,
|
||
};
|
||
}
|
||
|
||
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
|
||
.filter(director => director.character != null)
|
||
.map(director => {
|
||
const char = director.character;
|
||
const knowledges = char.knowledges || [];
|
||
const avgKnowledge = knowledges.length
|
||
? knowledges.reduce((sum, k) => sum + (k.knowledge || 0), 0) / knowledges.length
|
||
: 0;
|
||
const nobleLevel = char.nobleTitle?.level ?? 1;
|
||
const wishedIncome = Math.round(
|
||
nobleLevel * Math.pow(1.231, avgKnowledge / 1.5)
|
||
);
|
||
return {
|
||
id: director.id,
|
||
satisfaction: director.satisfaction,
|
||
character: char,
|
||
age: calcAge(char.birthdate),
|
||
income: director.income,
|
||
region: char.region?.name ?? '',
|
||
wishedIncome,
|
||
};
|
||
});
|
||
}
|
||
|
||
async updateDirector(hashedUserId, directorId, income) {
|
||
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' };
|
||
}
|
||
|
||
/** Liest Schwangerschaft nur wenn DB-Spalten existieren (nach Migration). */
|
||
async _getCharacterPregnancyOptional(characterId) {
|
||
try {
|
||
const rows = await sequelize.query(
|
||
`SELECT pregnancy_due_at, pregnancy_father_character_id
|
||
FROM falukant_data."character" WHERE id = :id`,
|
||
{ replacements: { id: characterId }, type: Sequelize.QueryTypes.SELECT }
|
||
);
|
||
const row = rows[0];
|
||
if (!row?.pregnancy_due_at) return null;
|
||
return {
|
||
dueAt: row.pregnancy_due_at,
|
||
fatherCharacterId: row.pregnancy_father_character_id,
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
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');
|
||
const pregnancy = await this._getCharacterPregnancyOptional(character.id);
|
||
// Load relationships without includes to avoid EagerLoadingError
|
||
const relRows = await Relationship.findAll({
|
||
where: { character1Id: character.id },
|
||
attributes: ['id', 'createdAt', 'widowFirstName2', 'nextStepProgress', 'character2Id', 'relationshipTypeId']
|
||
});
|
||
let relationships;
|
||
if (relRows.length === 0) {
|
||
relationships = [];
|
||
} else {
|
||
const typeIds = [...new Set(relRows.map(r => r.relationshipTypeId))];
|
||
const char2Ids = relRows.map(r => r.character2Id);
|
||
const [types, character2s] = await Promise.all([
|
||
RelationshipType.findAll({ where: { id: typeIds }, attributes: ['id', 'tr'] }),
|
||
FalukantCharacter.findAll({
|
||
where: { id: char2Ids },
|
||
attributes: ['id', 'birthdate', 'gender', 'moodId'],
|
||
include: [
|
||
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
|
||
{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] },
|
||
{ model: Mood, as: 'mood' }
|
||
]
|
||
})
|
||
]);
|
||
const typeMap = Object.fromEntries(types.map(t => [t.id, t]));
|
||
const char2Map = Object.fromEntries(character2s.map(c => [c.id, c]));
|
||
const relationshipStateMap = await this.ensureRelationshipStates(relRows, typeMap);
|
||
const ctRows = await FalukantCharacterTrait.findAll({
|
||
where: { characterId: char2Ids },
|
||
attributes: ['characterId', 'traitId']
|
||
});
|
||
const allTraitIds = [...new Set(ctRows.map(r => r.traitId))];
|
||
const traitsList = allTraitIds.length
|
||
? await CharacterTrait.findAll({ where: { id: allTraitIds }, attributes: ['id', 'tr'] })
|
||
: [];
|
||
const traitMap = Object.fromEntries(traitsList.map(t => [t.id, t]));
|
||
const traitsByChar = {};
|
||
for (const row of ctRows) {
|
||
if (!traitsByChar[row.characterId]) traitsByChar[row.characterId] = [];
|
||
const t = traitMap[row.traitId];
|
||
if (t) traitsByChar[row.characterId].push(t);
|
||
}
|
||
for (const c of character2s) {
|
||
c.setDataValue('traits', traitsByChar[c.id] || []);
|
||
}
|
||
relationships = relRows.map(r => {
|
||
const c2 = char2Map[r.character2Id];
|
||
const type = typeMap[r.relationshipTypeId];
|
||
const state = relationshipStateMap.get(r.id);
|
||
return {
|
||
id: r.id,
|
||
createdAt: r.createdAt,
|
||
widowFirstName2: r.widowFirstName2,
|
||
progress: r.nextStepProgress,
|
||
character2: c2 ? {
|
||
id: c2.id,
|
||
age: calcAge(c2.birthdate),
|
||
gender: c2.gender,
|
||
firstName: c2.definedFirstName?.name || 'Unknown',
|
||
nobleTitle: c2.nobleTitle?.labelTr || '',
|
||
mood: c2.mood,
|
||
traits: c2.traits || []
|
||
} : null,
|
||
relationshipType: type ? type.tr : '',
|
||
state: state ? {
|
||
marriageSatisfaction: state.marriageSatisfaction,
|
||
marriagePublicStability: state.marriagePublicStability,
|
||
loverRole: state.loverRole,
|
||
affection: state.affection,
|
||
visibility: state.visibility,
|
||
discretion: state.discretion,
|
||
maintenanceLevel: state.maintenanceLevel,
|
||
statusFit: state.statusFit,
|
||
monthlyBaseCost: state.monthlyBaseCost,
|
||
monthsUnderfunded: state.monthsUnderfunded,
|
||
active: state.active,
|
||
acknowledged: state.acknowledged,
|
||
exclusiveFlag: state.exclusiveFlag,
|
||
} : this.buildDefaultRelationshipState(type ? type.tr : null)
|
||
};
|
||
});
|
||
}
|
||
// Load child relations without FalukantCharacter includes to avoid EagerLoadingError
|
||
const userCharacterIds = (await FalukantCharacter.findAll({
|
||
where: { userId: user.id },
|
||
attributes: ['id']
|
||
})).map(c => c.id);
|
||
const childRels = userCharacterIds.length
|
||
? await ChildRelation.findAll({
|
||
where: {
|
||
[Op.or]: [
|
||
{ fatherCharacterId: { [Op.in]: userCharacterIds } },
|
||
{ motherCharacterId: { [Op.in]: userCharacterIds } }
|
||
]
|
||
},
|
||
attributes: ['childCharacterId', 'nameSet', 'isHeir', 'legitimacy', 'birthContext', 'publicKnown', 'createdAt', 'fatherCharacterId', 'motherCharacterId']
|
||
})
|
||
: [];
|
||
const childCharIds = [...new Set(childRels.map(r => r.childCharacterId))];
|
||
const childChars = childCharIds.length
|
||
? await FalukantCharacter.findAll({
|
||
where: { id: childCharIds },
|
||
attributes: ['id', 'birthdate', 'gender'],
|
||
include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }]
|
||
})
|
||
: [];
|
||
const childCharMap = Object.fromEntries(childChars.map(c => [c.id, c]));
|
||
|
||
const otherParentIds = new Set();
|
||
const relMeta = childRels.map((rel) => {
|
||
const fatherId = rel.fatherCharacterId;
|
||
const motherId = rel.motherCharacterId;
|
||
const userIsFather = userCharacterIds.includes(fatherId);
|
||
const userIsMother = userCharacterIds.includes(motherId);
|
||
let otherParentId = null;
|
||
let playerRole = null;
|
||
if (userIsFather && userIsMother) {
|
||
otherParentId = null;
|
||
playerRole = null;
|
||
} else if (userIsFather) {
|
||
otherParentId = motherId;
|
||
playerRole = 'father';
|
||
} else if (userIsMother) {
|
||
otherParentId = fatherId;
|
||
playerRole = 'mother';
|
||
}
|
||
if (otherParentId != null) {
|
||
otherParentIds.add(otherParentId);
|
||
}
|
||
return { rel, otherParentId, playerRole };
|
||
});
|
||
|
||
const otherParentChars = otherParentIds.size
|
||
? await FalukantCharacter.findAll({
|
||
where: { id: { [Op.in]: [...otherParentIds] } },
|
||
attributes: ['id', 'gender', 'titleOfNobility'],
|
||
include: [
|
||
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
|
||
{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] }
|
||
]
|
||
})
|
||
: [];
|
||
const otherParentMap = Object.fromEntries(
|
||
otherParentChars.map((c) => {
|
||
const plain = c.get({ plain: true });
|
||
return [
|
||
plain.id,
|
||
{
|
||
characterId: plain.id,
|
||
firstName: plain.definedFirstName?.name || 'Unknown',
|
||
gender: plain.gender || 'male',
|
||
nobleTitle: plain.nobleTitle?.labelTr || 'noncivil',
|
||
}
|
||
];
|
||
})
|
||
);
|
||
|
||
const children = relMeta.map(({ rel, otherParentId, playerRole }) => {
|
||
const kid = childCharMap[rel.childCharacterId];
|
||
return {
|
||
childCharacterId: rel.childCharacterId,
|
||
name: kid?.definedFirstName?.name || 'Unknown',
|
||
gender: kid?.gender,
|
||
age: kid?.birthdate ? calcAge(kid.birthdate) : null,
|
||
hasName: rel.nameSet,
|
||
isHeir: rel.isHeir || false,
|
||
legitimacy: rel.legitimacy || 'legitimate',
|
||
birthContext: rel.birthContext || 'marriage',
|
||
publicKnown: !!rel.publicKnown,
|
||
_createdAt: rel.createdAt,
|
||
playerRole,
|
||
otherParent: otherParentId != null ? (otherParentMap[otherParentId] || null) : null,
|
||
};
|
||
});
|
||
// 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 activeRelationships = relationships.filter(r => inProgress.includes(r.relationshipType));
|
||
const activeMarriage = activeRelationships.find(r => r.relationshipType === 'married') || activeRelationships[0] || null;
|
||
const marriageSatisfaction = activeMarriage?.state?.marriageSatisfaction ?? null;
|
||
const marriageState = this.getMarriageStateLabel(marriageSatisfaction);
|
||
const userHouse = await UserHouse.findOne({
|
||
where: { userId: user.id },
|
||
include: [{ model: HouseType, as: 'houseType', attributes: ['id', 'position', 'cost', 'labelTr'] }]
|
||
});
|
||
if (userHouse) {
|
||
userHouse.setDataValue('character', character);
|
||
}
|
||
const lovers = relationships
|
||
.filter(r => r.relationshipType === 'lover')
|
||
.filter(r => (r.state?.active ?? true) !== false)
|
||
.map((r) => {
|
||
const state = r.state || this.buildDefaultRelationshipState('lover');
|
||
const partner = r.character2 || {};
|
||
const monthlyCost = Number(state.monthlyBaseCost || 0);
|
||
return {
|
||
relationshipId: r.id,
|
||
name: partner.firstName || 'Unknown',
|
||
age: partner.age != null ? partner.age : null,
|
||
gender: partner.gender || null,
|
||
title: partner.nobleTitle || '',
|
||
role: state.loverRole || 'lover',
|
||
affection: state.affection,
|
||
visibility: state.visibility,
|
||
discretion: state.discretion,
|
||
maintenanceLevel: state.maintenanceLevel,
|
||
statusFit: state.statusFit,
|
||
monthlyBaseCost: monthlyCost,
|
||
monthlyCost,
|
||
acknowledged: state.acknowledged,
|
||
active: state.active,
|
||
monthsUnderfunded: state.monthsUnderfunded,
|
||
riskState: this.getLoverRiskState(state),
|
||
reputationEffect: null,
|
||
marriageEffect: null,
|
||
canBecomePublic: true,
|
||
character2: partner,
|
||
state,
|
||
};
|
||
});
|
||
const politicalFreeLoverSlots = await sumFreePoliticalLoverSlotsForCharacter(character.id);
|
||
lovers.sort((a, b) => (a.relationshipId || 0) - (b.relationshipId || 0));
|
||
lovers.forEach((lover, idx) => {
|
||
const base = Number(lover.monthlyBaseCost || 0);
|
||
lover.politicalFreeMaintenance = idx < politicalFreeLoverSlots;
|
||
lover.monthlyCost = lover.politicalFreeMaintenance ? 0 : base;
|
||
});
|
||
const derivedHouseholdTension = this.calculateHouseholdTension({
|
||
lovers,
|
||
marriageSatisfaction,
|
||
userHouse,
|
||
children
|
||
});
|
||
const householdTension = {
|
||
score: Number(userHouse?.householdTensionScore ?? derivedHouseholdTension.score),
|
||
reasons: Array.isArray(userHouse?.householdTensionReasonsJson) ? userHouse.householdTensionReasonsJson : derivedHouseholdTension.reasons
|
||
};
|
||
householdTension.label = this.getHouseholdTensionLabel(householdTension.score);
|
||
const family = {
|
||
relationships: activeRelationships.map((r) => ({
|
||
...r,
|
||
marriageSatisfaction: r.state?.marriageSatisfaction ?? null,
|
||
marriageState: this.getMarriageStateLabel(r.state?.marriageSatisfaction ?? null),
|
||
})),
|
||
marriageSatisfaction,
|
||
marriageState,
|
||
householdTension: householdTension.label,
|
||
householdTensionScore: householdTension.score,
|
||
householdTensionReasons: householdTension.reasons,
|
||
debtorsPrison: await this.getDebtorsPrisonStateForUser(user),
|
||
politicalFreeLoverSlots,
|
||
lovers,
|
||
deathPartners: relationships.filter(r => r.relationshipType === 'widowed'),
|
||
children: children.map(({ _createdAt, ...rest }) => rest),
|
||
possiblePartners: [],
|
||
possibleLovers: [],
|
||
pregnancy,
|
||
};
|
||
const ownAge = calcAge(character.birthdate);
|
||
if (ownAge >= 12) {
|
||
family.possibleLovers = await this.getPossibleLovers(character.id);
|
||
}
|
||
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 getPossibleLovers(requestingCharacterId) {
|
||
const requester = await FalukantCharacter.findOne({
|
||
where: { id: requestingCharacterId },
|
||
attributes: ['id', 'regionId', 'birthdate', 'titleOfNobility']
|
||
});
|
||
if (!requester?.id) return [];
|
||
if (calcAge(requester.birthdate) < 12) return [];
|
||
|
||
const existingRelationships = await Relationship.findAll({
|
||
where: {
|
||
[Op.or]: [
|
||
{ character1Id: requestingCharacterId },
|
||
{ character2Id: requestingCharacterId }
|
||
]
|
||
},
|
||
include: [
|
||
{ model: RelationshipType, as: 'relationshipType', attributes: ['tr'] },
|
||
{ model: RelationshipState, as: 'state', required: false }
|
||
],
|
||
attributes: ['character1Id', 'character2Id']
|
||
});
|
||
|
||
const excludedCharacterIds = new Set([requestingCharacterId]);
|
||
for (const rel of existingRelationships) {
|
||
const relationType = rel.relationshipType?.tr;
|
||
const isActiveLover = relationType === 'lover' ? ((rel.state?.active ?? true) !== false) : true;
|
||
if (relationType !== 'widowed' && isActiveLover) {
|
||
excludedCharacterIds.add(rel.character1Id === requestingCharacterId ? rel.character2Id : rel.character1Id);
|
||
}
|
||
}
|
||
|
||
const ownTitle = Number(requester.titleOfNobility || 0);
|
||
const ownAge = calcAge(requester.birthdate);
|
||
const candidates = await FalukantCharacter.findAll({
|
||
where: {
|
||
id: { [Op.notIn]: Array.from(excludedCharacterIds) },
|
||
regionId: requester.regionId,
|
||
health: { [Op.gt]: 0 },
|
||
birthdate: { [Op.lte]: new Date(Date.now() - 12 * 24 * 60 * 60 * 1000) },
|
||
titleOfNobility: { [Op.between]: [Math.max(1, ownTitle - 2), ownTitle + 2] }
|
||
},
|
||
include: [
|
||
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
|
||
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] },
|
||
{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr', 'id'] }
|
||
],
|
||
order: [
|
||
[Sequelize.literal(`ABS("title_of_nobility" - ${ownTitle})`), 'ASC'],
|
||
[Sequelize.literal(`ABS((EXTRACT(EPOCH FROM (NOW() - "birthdate")) / 86400) - ${ownAge})`), 'ASC']
|
||
],
|
||
limit: 6
|
||
});
|
||
|
||
return candidates.map((candidate) => {
|
||
const age = calcAge(candidate.birthdate);
|
||
return {
|
||
characterId: candidate.id,
|
||
name: `${candidate.definedFirstName?.name || ''} ${candidate.definedLastName?.name || ''}`.trim(),
|
||
gender: candidate.gender,
|
||
age,
|
||
title: candidate.nobleTitle?.labelTr || 'noncivil',
|
||
titleId: candidate.nobleTitle?.id || candidate.titleOfNobility || null,
|
||
statusFit: this.calculateLoverStatusFit(ownTitle, candidate.nobleTitle?.id || candidate.titleOfNobility),
|
||
estimatedMonthlyCost: this.calculateLoverBaseCost(ownTitle, candidate.nobleTitle?.id || candidate.titleOfNobility)
|
||
};
|
||
});
|
||
}
|
||
|
||
async createLoverRelationship(hashedUserId, targetCharacterId, loverRole) {
|
||
const parsedTargetCharacterId = Number.parseInt(targetCharacterId, 10);
|
||
if (Number.isNaN(parsedTargetCharacterId)) {
|
||
throw { status: 400, message: 'targetCharacterId is required' };
|
||
}
|
||
|
||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||
if (!user?.character?.id) throw new Error('User or character not found');
|
||
if (user.character.id === parsedTargetCharacterId) {
|
||
throw { status: 400, message: 'Cannot create relationship with self' };
|
||
}
|
||
|
||
const possibleLovers = await this.getPossibleLovers(user.character.id);
|
||
const target = possibleLovers.find((candidate) => candidate.characterId === parsedTargetCharacterId);
|
||
if (!target) {
|
||
throw { status: 409, message: 'Target character is not available for a lover relationship' };
|
||
}
|
||
|
||
const loverType = await RelationshipType.findOne({ where: { tr: 'lover' }, attributes: ['id'] });
|
||
if (!loverType?.id) {
|
||
throw new Error('Relationship type "lover" not found');
|
||
}
|
||
|
||
const relationship = await Relationship.create({
|
||
character1Id: user.character.id,
|
||
character2Id: parsedTargetCharacterId,
|
||
relationshipTypeId: loverType.id
|
||
});
|
||
|
||
const roleConfig = this.getLoverRoleConfig(
|
||
loverRole,
|
||
user.character.titleOfNobility,
|
||
target.titleId
|
||
);
|
||
|
||
await RelationshipState.create({
|
||
relationshipId: relationship.id,
|
||
...this.buildDefaultRelationshipState('lover'),
|
||
...roleConfig,
|
||
maintenanceLevel: 50,
|
||
statusFit: target.statusFit,
|
||
active: true
|
||
});
|
||
await this.refreshHouseholdTensionState(user, user.character);
|
||
|
||
await notifyUser(hashedUserId, 'falukantUpdateFamily', { reason: 'daily' });
|
||
await notifyUser(hashedUserId, 'falukantUpdateStatus', {});
|
||
|
||
return {
|
||
success: true,
|
||
relationshipId: relationship.id,
|
||
targetCharacterId: parsedTargetCharacterId
|
||
};
|
||
}
|
||
|
||
async setLoverMaintenance(hashedUserId, relationshipId, maintenanceLevel) {
|
||
const parsedRelationshipId = Number.parseInt(relationshipId, 10);
|
||
const parsedMaintenance = Number.parseInt(maintenanceLevel, 10);
|
||
if (Number.isNaN(parsedRelationshipId) || Number.isNaN(parsedMaintenance)) {
|
||
throw { status: 400, message: 'relationshipId and maintenanceLevel are required' };
|
||
}
|
||
if (parsedMaintenance < 0 || parsedMaintenance > 100) {
|
||
throw { status: 400, message: 'maintenanceLevel must be between 0 and 100' };
|
||
}
|
||
|
||
const { user, state } = await this.getOwnedLoverRelationState(hashedUserId, parsedRelationshipId);
|
||
await state.update({ maintenanceLevel: parsedMaintenance });
|
||
await this.refreshHouseholdTensionState(user, user.character);
|
||
return {
|
||
success: true,
|
||
relationshipId: parsedRelationshipId,
|
||
maintenanceLevel: state.maintenanceLevel
|
||
};
|
||
}
|
||
|
||
async spendTimeWithSpouse(hashedUserId) {
|
||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||
if (!user?.character?.id) throw new Error('User or character not found');
|
||
|
||
const marriage = await Relationship.findOne({
|
||
where: { character1Id: user.character.id },
|
||
include: [
|
||
{ model: RelationshipType, as: 'relationshipType', where: { tr: 'married' } },
|
||
{ model: RelationshipState, as: 'state', required: false }
|
||
]
|
||
});
|
||
if (!marriage) throw { status: 409, message: 'No active marriage found' };
|
||
|
||
let state = marriage.state;
|
||
if (!state) {
|
||
state = await RelationshipState.create({
|
||
relationshipId: marriage.id,
|
||
...this.buildDefaultRelationshipState('married')
|
||
});
|
||
}
|
||
|
||
const nextSatisfaction = this.clampScore((state.marriageSatisfaction ?? 55) + 2);
|
||
const nextPublicStability = this.clampScore((state.marriagePublicStability ?? 55) + 1);
|
||
await state.update({
|
||
marriageSatisfaction: nextSatisfaction,
|
||
marriagePublicStability: nextPublicStability
|
||
});
|
||
await this.refreshHouseholdTensionState(user, user.character);
|
||
|
||
await notifyUser(hashedUserId, 'falukantUpdateFamily', { reason: 'daily' });
|
||
await notifyUser(hashedUserId, 'falukantUpdateStatus', {});
|
||
return { success: true, marriageSatisfaction: nextSatisfaction };
|
||
}
|
||
|
||
async giftToSpouse(hashedUserId, giftLevel) {
|
||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||
if (!user?.character?.id) throw new Error('User or character not found');
|
||
|
||
const marriage = await Relationship.findOne({
|
||
where: { character1Id: user.character.id },
|
||
include: [
|
||
{ model: RelationshipType, as: 'relationshipType', where: { tr: 'married' } },
|
||
{ model: RelationshipState, as: 'state', required: false }
|
||
]
|
||
});
|
||
if (!marriage) throw { status: 409, message: 'No active marriage found' };
|
||
|
||
const levelConfig = {
|
||
small: { cost: 25, satisfaction: 2, publicStability: 1 },
|
||
decent: { cost: 80, satisfaction: 4, publicStability: 2 },
|
||
lavish: { cost: 180, satisfaction: 7, publicStability: 3 }
|
||
};
|
||
const config = levelConfig[giftLevel] || levelConfig.small;
|
||
if (Number(user.money) < config.cost) throw new Error('notenoughmoney.');
|
||
|
||
let state = marriage.state;
|
||
if (!state) {
|
||
state = await RelationshipState.create({
|
||
relationshipId: marriage.id,
|
||
...this.buildDefaultRelationshipState('married')
|
||
});
|
||
}
|
||
|
||
const nextSatisfaction = this.clampScore((state.marriageSatisfaction ?? 55) + config.satisfaction);
|
||
const nextPublicStability = this.clampScore((state.marriagePublicStability ?? 55) + config.publicStability);
|
||
|
||
await sequelize.transaction(async () => {
|
||
await state.update({
|
||
marriageSatisfaction: nextSatisfaction,
|
||
marriagePublicStability: nextPublicStability
|
||
});
|
||
await updateFalukantUserMoney(user.id, -config.cost, 'marriage_gift', user.id);
|
||
});
|
||
await this.refreshHouseholdTensionState(user, user.character);
|
||
|
||
await notifyUser(hashedUserId, 'falukantUpdateFamily', { reason: 'daily' });
|
||
await notifyUser(hashedUserId, 'falukantUpdateStatus', {});
|
||
return { success: true, marriageSatisfaction: nextSatisfaction, cost: config.cost };
|
||
}
|
||
|
||
async reconcileMarriage(hashedUserId) {
|
||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||
if (!user?.character?.id) throw new Error('User or character not found');
|
||
|
||
const marriage = await Relationship.findOne({
|
||
where: { character1Id: user.character.id },
|
||
include: [
|
||
{ model: RelationshipType, as: 'relationshipType', where: { tr: 'married' } },
|
||
{ model: RelationshipState, as: 'state', required: false }
|
||
]
|
||
});
|
||
if (!marriage) throw { status: 409, message: 'No active marriage found' };
|
||
|
||
let state = marriage.state;
|
||
if (!state) {
|
||
state = await RelationshipState.create({
|
||
relationshipId: marriage.id,
|
||
...this.buildDefaultRelationshipState('married')
|
||
});
|
||
}
|
||
|
||
const nextSatisfaction = this.clampScore((state.marriageSatisfaction ?? 55) + 1);
|
||
const nextPublicStability = this.clampScore((state.marriagePublicStability ?? 55) + 1);
|
||
await state.update({
|
||
marriageSatisfaction: nextSatisfaction,
|
||
marriagePublicStability: nextPublicStability
|
||
});
|
||
|
||
await notifyUser(hashedUserId, 'falukantUpdateFamily', { reason: 'daily' });
|
||
await notifyUser(hashedUserId, 'falukantUpdateStatus', {});
|
||
return { success: true, marriageSatisfaction: nextSatisfaction };
|
||
}
|
||
|
||
async acknowledgeLover(hashedUserId, relationshipId) {
|
||
const parsedRelationshipId = Number.parseInt(relationshipId, 10);
|
||
if (Number.isNaN(parsedRelationshipId)) {
|
||
throw { status: 400, message: 'relationshipId is required' };
|
||
}
|
||
|
||
const { user, state } = await this.getOwnedLoverRelationState(hashedUserId, parsedRelationshipId);
|
||
const updateData = { acknowledged: true };
|
||
if (state.loverRole === 'secret_affair' || !state.loverRole) {
|
||
updateData.loverRole = 'lover';
|
||
}
|
||
await state.update(updateData);
|
||
await this.refreshHouseholdTensionState(user, user.character);
|
||
return {
|
||
success: true,
|
||
relationshipId: parsedRelationshipId,
|
||
acknowledged: true,
|
||
role: state.loverRole
|
||
};
|
||
}
|
||
|
||
async endLoverRelationship(hashedUserId, relationshipId) {
|
||
const parsedRelationshipId = Number.parseInt(relationshipId, 10);
|
||
if (Number.isNaN(parsedRelationshipId)) {
|
||
throw { status: 400, message: 'relationshipId is required' };
|
||
}
|
||
|
||
const { user, state } = await this.getOwnedLoverRelationState(hashedUserId, parsedRelationshipId);
|
||
await state.update({
|
||
active: false,
|
||
acknowledged: false
|
||
});
|
||
await this.refreshHouseholdTensionState(user, user.character);
|
||
return {
|
||
success: true,
|
||
relationshipId: parsedRelationshipId,
|
||
active: false
|
||
};
|
||
}
|
||
|
||
async setHeir(hashedUserId, childCharacterId) {
|
||
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');
|
||
|
||
// Prüfe, ob das Kind wirklich ein Kind des aktuellen Charakters ist
|
||
const childRelation = await ChildRelation.findOne({
|
||
where: {
|
||
childCharacterId: childCharacterId,
|
||
[Op.or]: [
|
||
{ fatherCharacterId: character.id },
|
||
{ motherCharacterId: character.id }
|
||
]
|
||
}
|
||
});
|
||
|
||
if (!childRelation) {
|
||
throw new Error('Child not found or not your child');
|
||
}
|
||
|
||
// Setze alle anderen Kinder des gleichen Elternteils auf isHeir = false
|
||
await ChildRelation.update(
|
||
{ isHeir: false },
|
||
{
|
||
where: {
|
||
[Op.or]: [
|
||
{ fatherCharacterId: character.id },
|
||
{ motherCharacterId: character.id }
|
||
]
|
||
}
|
||
}
|
||
);
|
||
|
||
// Setze das ausgewählte Kind als Erben
|
||
await childRelation.update({ isHeir: true });
|
||
|
||
return { success: true, childCharacterId };
|
||
}
|
||
|
||
async getPotentialHeirs(hashedUserId) {
|
||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||
if (!user) throw new Error('User not found');
|
||
if (user.character?.id) return [];
|
||
|
||
const noncivilTitle = await TitleOfNobility.findOne({
|
||
where: { labelTr: 'noncivil' },
|
||
attributes: ['id']
|
||
});
|
||
if (!noncivilTitle?.id) return [];
|
||
|
||
const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000);
|
||
const mainRegionId = user.mainBranchRegionId || null;
|
||
|
||
const includes = [
|
||
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
|
||
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }
|
||
];
|
||
|
||
const buildWhere = ({ withRegion = true, withYoungAge = true } = {}) => {
|
||
const where = {
|
||
userId: null,
|
||
titleOfNobility: noncivilTitle.id,
|
||
health: { [Op.gt]: 0 }
|
||
};
|
||
if (withRegion && mainRegionId) where.regionId = mainRegionId;
|
||
if (withYoungAge) where.birthdate = { [Op.gte]: tenDaysAgo };
|
||
return where;
|
||
};
|
||
|
||
const loadCandidates = async (where) => FalukantCharacter.findAll({
|
||
where,
|
||
include: includes,
|
||
attributes: ['id', 'birthdate', 'gender'],
|
||
order: sequelize.random(),
|
||
limit: 10
|
||
});
|
||
|
||
let candidates = await loadCandidates(buildWhere({ withRegion: true, withYoungAge: true }));
|
||
if (candidates.length === 0) {
|
||
candidates = await loadCandidates(buildWhere({ withRegion: true, withYoungAge: false }));
|
||
}
|
||
if (candidates.length === 0) {
|
||
candidates = await loadCandidates(buildWhere({ withRegion: false, withYoungAge: false }));
|
||
}
|
||
|
||
return candidates.map(candidate => {
|
||
const plain = candidate.get({ plain: true });
|
||
return {
|
||
...plain,
|
||
age: plain.birthdate ? calcAge(plain.birthdate) : null
|
||
};
|
||
});
|
||
}
|
||
|
||
async selectHeir(hashedUserId, heirId) {
|
||
const parsedHeirId = Number(heirId);
|
||
if (!Number.isInteger(parsedHeirId) || parsedHeirId < 1) {
|
||
throw { status: 400, message: 'Invalid heirId' };
|
||
}
|
||
|
||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||
if (!user) throw new Error('User not found');
|
||
if (user.character?.id) {
|
||
throw { status: 409, message: 'User already has an active character' };
|
||
}
|
||
|
||
const noncivilTitle = await TitleOfNobility.findOne({
|
||
where: { labelTr: 'noncivil' },
|
||
attributes: ['id']
|
||
});
|
||
if (!noncivilTitle?.id) throw new Error('Title "noncivil" not found');
|
||
|
||
const mainRegionId = user.mainBranchRegionId || null;
|
||
const where = {
|
||
id: parsedHeirId,
|
||
userId: null,
|
||
titleOfNobility: noncivilTitle.id,
|
||
health: { [Op.gt]: 0 }
|
||
};
|
||
if (mainRegionId) where.regionId = mainRegionId;
|
||
|
||
const candidate = await FalukantCharacter.findOne({ where, attributes: ['id'] });
|
||
if (!candidate) {
|
||
throw { status: 404, message: 'Selected heir is not available' };
|
||
}
|
||
|
||
await candidate.update({ userId: user.id });
|
||
await FalukantUser.update(
|
||
{ certificateProductionsCountSince: new Date() },
|
||
{ where: { id: user.id } }
|
||
);
|
||
return { success: true, heirId: candidate.id };
|
||
}
|
||
|
||
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 cancelWooing(hashedUserId) {
|
||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||
if (!user || !user.character) {
|
||
throw new Error('User or character not found');
|
||
}
|
||
const relation = await Relationship.findOne({
|
||
where: { character1Id: user.character.id },
|
||
include: [{
|
||
model: RelationshipType,
|
||
as: 'relationshipType',
|
||
where: { tr: 'wooing' }
|
||
}]
|
||
});
|
||
if (!relation) {
|
||
throw new Error('noWooing');
|
||
}
|
||
// Require at least 24h between starting wooing (relation.createdAt) and cancelling
|
||
const earliestCancel = new Date(relation.createdAt.getTime() + 24 * 3600 * 1000);
|
||
if (Date.now() < earliestCancel.getTime()) {
|
||
const err = new PreconditionError('cancelTooSoon');
|
||
err.meta = { retryAt: earliestCancel.toISOString() };
|
||
throw err;
|
||
}
|
||
await relation.destroy();
|
||
return { success: true };
|
||
}
|
||
|
||
async getGifts(hashedUserId) {
|
||
// 1) Mein aktiver Falukant-User & dessen aktueller Charakter
|
||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||
const myChar = user.character;
|
||
if (!myChar) throw new Error('Character not found');
|
||
|
||
// 2) Beziehung finden und „anderen“ Character bestimmen (ohne Include, um EagerLoadingError zu vermeiden)
|
||
const rel = await Relationship.findOne({
|
||
where: {
|
||
[Op.or]: [
|
||
{ character1Id: myChar.id },
|
||
{ character2Id: myChar.id }
|
||
]
|
||
},
|
||
attributes: ['character1Id', 'character2Id']
|
||
});
|
||
if (!rel) return [];
|
||
|
||
const relatedCharId = rel.character1Id === myChar.id ? rel.character2Id : rel.character1Id;
|
||
const relatedChar = await FalukantCharacter.findOne({
|
||
where: { id: relatedCharId },
|
||
attributes: ['id', 'moodId']
|
||
});
|
||
if (!relatedChar) throw new Error('Related character not found');
|
||
const ctRows = await FalukantCharacterTrait.findAll({
|
||
where: { characterId: relatedCharId },
|
||
attributes: ['traitId']
|
||
});
|
||
const traitIds = ctRows.map(r => r.traitId);
|
||
const traits = traitIds.length
|
||
? await CharacterTrait.findAll({ where: { id: traitIds }, attributes: ['id'] })
|
||
: [];
|
||
relatedChar.setDataValue('traits', traits);
|
||
|
||
// 3) Trait-IDs und Mood des relatedChar
|
||
const relatedTraitIds = (relatedChar.traits || []).map(t => t.id);
|
||
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: ['moodId', 'suitability'],
|
||
where: { moodId: relatedMoodId },
|
||
required: false // Gifts ohne Mood-Match bleiben erhalten, haben dann leeres Array
|
||
},
|
||
{
|
||
model: PromotionalGiftCharacterTrait,
|
||
as: 'characterTraits',
|
||
attributes: ['traitId', 'suitability'],
|
||
where: { traitId: 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']] });
|
||
// Sicherstellen, dass wir eine gültige titleOfNobility haben (getFalukantUserByHashedId liefert nicht immer das Feld)
|
||
let characterTitleOfNobility = myChar.titleOfNobility;
|
||
if (characterTitleOfNobility == null && myChar.id) {
|
||
const reloadChar = await FalukantCharacter.findOne({ where: { id: myChar.id }, attributes: ['titleOfNobility'] });
|
||
characterTitleOfNobility = reloadChar?.titleOfNobility ?? lowestTitleOfNobility?.id ?? 1;
|
||
}
|
||
|
||
// Filtere Gifts ohne gültigen 'value' (0 oder fehlend) — solche sollten in der DB korrigiert werden
|
||
const validGifts = gifts.filter(g => Number(g.value) > 0);
|
||
const skipped = gifts.length - validGifts.length;
|
||
if (skipped > 0) {
|
||
console.warn(`getGifts: skipped ${skipped} promotional gifts with invalid value`);
|
||
for (const g of gifts) {
|
||
if (!(Number(g.value) > 0)) console.warn(` skipped gift id=${g.id} name=${g.name} value=${g.value}`);
|
||
}
|
||
}
|
||
|
||
return Promise.all(validGifts.map(async gift => {
|
||
const value = Number(gift.value);
|
||
const cost = await this.getGiftCost(value, characterTitleOfNobility, lowestTitleOfNobility?.id ?? 1);
|
||
return {
|
||
id: gift.id,
|
||
name: gift.name,
|
||
cost,
|
||
// Frontend erwartet snake_case keys (mood_id / trait_id) in these arrays
|
||
moodsAffects: (gift.promotionalgiftmoods || []).map(m => ({
|
||
mood_id: m.moodId ?? m.mood_id ?? m.moodId,
|
||
suitability: m.suitability
|
||
})),
|
||
charactersAffects: (gift.characterTraits || []).map(ct => ({
|
||
trait_id: ct.traitId ?? ct.trait_id ?? ct.traitId,
|
||
suitability: ct.suitability
|
||
}))
|
||
};
|
||
}));
|
||
}
|
||
|
||
async getChildren(hashedUserId) {
|
||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||
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() + FalukantService.WOOING_GIFT_COOLDOWN_MS) > Date.now()) {
|
||
const retryAt = new Date(lastGift.createdAt.getTime() + FalukantService.WOOING_GIFT_COOLDOWN_MS);
|
||
const err = new PreconditionError('tooOften');
|
||
err.meta = { retryAt: retryAt.toISOString() };
|
||
throw err;
|
||
}
|
||
// prepare a safe trait filter: user.character.traits may be undefined
|
||
const userTraitIds = Array.isArray(user.character?.traits) ? user.character.traits.map(t => t.id) : [];
|
||
const traitWhere = userTraitIds.length ? { traitId: { [Op.in]: userTraitIds } } : { traitId: { [Op.in]: [-1] } };
|
||
|
||
const gift = await PromotionalGift.findOne({
|
||
where: { id: giftId },
|
||
include: [
|
||
{
|
||
model: PromotionalGiftCharacterTrait,
|
||
as: 'characterTraits',
|
||
where: traitWhere,
|
||
required: false
|
||
},
|
||
{
|
||
model: PromotionalGiftMood,
|
||
as: 'promotionalgiftmoods',
|
||
where: { moodId: 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 = Array.isArray(gift.characterTraits) ? gift.characterTraits : [];
|
||
// Wenn keine passenden characterTraits gefunden wurden, behandeln wir das als neutralen Wert (0)
|
||
// statt einen Fehler zu werfen. Das erlaubt das Versenden, auch wenn keine Trait-Übereinstimmung vorliegt.
|
||
if (!traits.length) {
|
||
console.warn(`sendGift: no matching characterTraits for user id=${user.id} giftId=${giftId}`);
|
||
}
|
||
// Finde den höchsten Charakterwert (wie im Frontend). Falls keine Traits vorhanden, 0.
|
||
const highestCharacterValue = traits.length ? Math.max(...traits.map(ct => ct.suitability)) : 0;
|
||
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 >= FalukantService.WOOING_PROGRESS_TARGET) {
|
||
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 val = Number(value) || 0;
|
||
const title = Number(titleOfNobility) || 1;
|
||
const lowest = Number(lowestTitleOfNobility) || 1;
|
||
const titleLevel = title - lowest + 1;
|
||
const cost = Math.round(val * Math.pow(1 + titleLevel * 0.3, 1.3) * 100) / 100;
|
||
return Number.isFinite(cost) ? cost : 0;
|
||
}
|
||
|
||
async getTitlesOfNobility() {
|
||
return TitleOfNobility.findAll();
|
||
}
|
||
|
||
async getReputationActions(hashedUserId) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
|
||
// Lade alle Action-Typen
|
||
const actionTypes = await ReputationActionType.findAll({
|
||
order: [['cost', 'ASC']]
|
||
});
|
||
|
||
// Berechne tägliche Nutzung (heute)
|
||
const todayStart = new Date();
|
||
todayStart.setHours(0, 0, 0, 0);
|
||
const todayEnd = new Date();
|
||
todayEnd.setHours(23, 59, 59, 999);
|
||
|
||
const todayActions = await ReputationActionLog.count({
|
||
where: {
|
||
falukantUserId: user.id,
|
||
actionTimestamp: {
|
||
[Op.between]: [todayStart, todayEnd]
|
||
}
|
||
}
|
||
});
|
||
|
||
// Standard-Limits (können später konfigurierbar gemacht werden)
|
||
const dailyCap = 10; // Maximal 10 Actions pro Tag
|
||
const dailyUsed = todayActions;
|
||
const dailyRemaining = Math.max(0, dailyCap - dailyUsed);
|
||
|
||
// Cooldown: 60 Minuten zwischen Actions
|
||
const cooldownMinutes = 60;
|
||
const lastAction = await ReputationActionLog.findOne({
|
||
where: { falukantUserId: user.id },
|
||
order: [['actionTimestamp', 'DESC']],
|
||
attributes: ['actionTimestamp']
|
||
});
|
||
|
||
let cooldownRemainingSec = 0;
|
||
if (lastAction && lastAction.actionTimestamp) {
|
||
const now = new Date();
|
||
const lastActionTime = new Date(lastAction.actionTimestamp);
|
||
const secondsSinceLastAction = Math.floor((now - lastActionTime) / 1000);
|
||
const cooldownSec = cooldownMinutes * 60;
|
||
cooldownRemainingSec = Math.max(0, cooldownSec - secondsSinceLastAction);
|
||
}
|
||
|
||
// Berechne timesUsed für jede Action (basierend auf decay_window_days)
|
||
const actionsWithUsage = await Promise.all(actionTypes.map(async (actionType) => {
|
||
const windowStart = new Date();
|
||
windowStart.setDate(windowStart.getDate() - actionType.decayWindowDays);
|
||
|
||
const usageCount = await ReputationActionLog.count({
|
||
where: {
|
||
falukantUserId: user.id,
|
||
actionTypeId: actionType.id,
|
||
actionTimestamp: {
|
||
[Op.gte]: windowStart
|
||
}
|
||
}
|
||
});
|
||
|
||
// Berechne aktuellen Gewinn basierend auf decay
|
||
let currentGain = actionType.baseGain;
|
||
for (let i = 0; i < usageCount; i++) {
|
||
currentGain = Math.max(
|
||
actionType.minGain,
|
||
Math.floor(currentGain * actionType.decayFactor)
|
||
);
|
||
}
|
||
|
||
return {
|
||
id: actionType.id,
|
||
tr: actionType.tr,
|
||
cost: actionType.cost,
|
||
baseGain: actionType.baseGain,
|
||
currentGain: currentGain,
|
||
timesUsed: usageCount,
|
||
decayFactor: actionType.decayFactor,
|
||
minGain: actionType.minGain,
|
||
decayWindowDays: actionType.decayWindowDays
|
||
};
|
||
}));
|
||
|
||
return {
|
||
actions: actionsWithUsage,
|
||
dailyCap,
|
||
dailyUsed,
|
||
dailyRemaining,
|
||
cooldownMinutes,
|
||
cooldownRemainingSec
|
||
};
|
||
}
|
||
|
||
async executeReputationAction(hashedUserId, actionTypeId) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
const character = await FalukantCharacter.findOne({
|
||
where: { userId: user.id },
|
||
attributes: ['id', 'reputation']
|
||
});
|
||
if (!character) {
|
||
throw new Error('Character not found');
|
||
}
|
||
|
||
const actionType = await ReputationActionType.findByPk(actionTypeId);
|
||
if (!actionType) {
|
||
throw new Error('Action type not found');
|
||
}
|
||
|
||
const todayStart = new Date();
|
||
todayStart.setHours(0, 0, 0, 0);
|
||
const todayEnd = new Date();
|
||
todayEnd.setHours(23, 59, 59, 999);
|
||
const dailyCap = 10;
|
||
const todayActions = await ReputationActionLog.count({
|
||
where: {
|
||
falukantUserId: user.id,
|
||
actionTimestamp: { [Op.between]: [todayStart, todayEnd] }
|
||
}
|
||
});
|
||
if (todayActions >= dailyCap) {
|
||
throw new Error('Daily limit reached');
|
||
}
|
||
|
||
const lastAction = await ReputationActionLog.findOne({
|
||
where: { falukantUserId: user.id },
|
||
order: [['actionTimestamp', 'DESC']],
|
||
attributes: ['actionTimestamp']
|
||
});
|
||
const cooldownMinutes = 60;
|
||
if (lastAction?.actionTimestamp) {
|
||
const cooldownSec = cooldownMinutes * 60;
|
||
const secondsSinceLast = Math.floor((Date.now() - new Date(lastAction.actionTimestamp)) / 1000);
|
||
if (secondsSinceLast < cooldownSec) {
|
||
throw new Error('Cooldown active');
|
||
}
|
||
}
|
||
|
||
const reloadedUser = await FalukantUser.findByPk(user.id, { attributes: ['id', 'money'] });
|
||
if (reloadedUser.money < actionType.cost) {
|
||
throw new Error('notenoughmoney');
|
||
}
|
||
|
||
const windowStart = new Date();
|
||
windowStart.setDate(windowStart.getDate() - actionType.decayWindowDays);
|
||
const timesUsedBefore = await ReputationActionLog.count({
|
||
where: {
|
||
falukantUserId: user.id,
|
||
actionTypeId: actionType.id,
|
||
actionTimestamp: { [Op.gte]: windowStart }
|
||
}
|
||
});
|
||
|
||
let gain = actionType.baseGain;
|
||
for (let i = 0; i < timesUsedBefore; i++) {
|
||
gain = Math.max(actionType.minGain, Math.floor(gain * actionType.decayFactor));
|
||
}
|
||
|
||
const transaction = await sequelize.transaction();
|
||
try {
|
||
await updateFalukantUserMoney(
|
||
user.id,
|
||
-actionType.cost,
|
||
'Reputation action: ' + actionType.tr,
|
||
user.id,
|
||
transaction
|
||
);
|
||
await ReputationActionLog.create(
|
||
{
|
||
falukantUserId: user.id,
|
||
actionTypeId: actionType.id,
|
||
cost: actionType.cost,
|
||
baseGain: actionType.baseGain,
|
||
gain,
|
||
timesUsedBefore
|
||
},
|
||
{ transaction }
|
||
);
|
||
const newReputation = Math.min(100, Math.max(0, (character.reputation ?? 0) + gain));
|
||
await character.update({ reputation: newReputation }, { transaction });
|
||
await transaction.commit();
|
||
} catch (err) {
|
||
await transaction.rollback();
|
||
throw err;
|
||
}
|
||
|
||
return { gain, cost: actionType.cost };
|
||
}
|
||
|
||
async getHouseTypes() {
|
||
// return House
|
||
}
|
||
|
||
async getMoodAffect() {
|
||
return PromotionalGiftMood.findAll();
|
||
}
|
||
|
||
async getCharacterAffect() {
|
||
return PromotionalGiftCharacterTrait.findAll();
|
||
}
|
||
|
||
async getUserHouse(hashedUserId) {
|
||
try {
|
||
const falukantUser = await this.getFalukantUserByHashedId(hashedUserId);
|
||
const userHouse = await UserHouse.findOne({
|
||
where: { userId: falukantUser.id },
|
||
include: [{
|
||
model: HouseType,
|
||
as: 'houseType',
|
||
attributes: ['id', 'position', 'cost', 'labelTr']
|
||
}],
|
||
attributes: [
|
||
'roofCondition',
|
||
'wallCondition',
|
||
'floorCondition',
|
||
'windowCondition',
|
||
'servantCount',
|
||
'servantQuality',
|
||
'servantPayLevel',
|
||
'householdOrder',
|
||
'householdTensionScore',
|
||
'householdTensionReasonsJson',
|
||
'houseTypeId'
|
||
]
|
||
});
|
||
|
||
if (!userHouse) {
|
||
return {
|
||
position: 0,
|
||
roofCondition: 100,
|
||
wallCondition: 100,
|
||
floorCondition: 100,
|
||
windowCondition: 100,
|
||
servantCount: 0,
|
||
servantQuality: 50,
|
||
servantPayLevel: 'normal',
|
||
householdOrder: 55,
|
||
householdTensionScore: 10,
|
||
householdTensionReasonsJson: [],
|
||
servantSummary: this.buildServantSummary(null, falukantUser.character)
|
||
};
|
||
}
|
||
|
||
const plainHouse = userHouse.get({ plain: true });
|
||
plainHouse.servantSummary = this.buildServantSummary(plainHouse, falukantUser.character);
|
||
plainHouse.debtorsPrison = await this.getDebtorsPrisonStateForUser(falukantUser);
|
||
return plainHouse;
|
||
} 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();
|
||
}
|
||
const servantDefaults = this.getInitialServantState(house.houseType, falukantUser.character);
|
||
await UserHouse.create({
|
||
userId: falukantUser.id,
|
||
houseTypeId: house.houseTypeId,
|
||
servantCount: servantDefaults.servantCount,
|
||
servantQuality: servantDefaults.servantQuality,
|
||
servantPayLevel: servantDefaults.servantPayLevel,
|
||
householdOrder: servantDefaults.householdOrder,
|
||
householdTensionScore: 10,
|
||
householdTensionReasonsJson: []
|
||
});
|
||
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);
|
||
}
|
||
|
||
getInitialServantState(houseType, character) {
|
||
const expected = this.getServantExpectation(houseType, character);
|
||
return {
|
||
servantCount: expected.min,
|
||
servantQuality: 50,
|
||
servantPayLevel: 'normal',
|
||
householdOrder: this.calculateHouseholdOrder({
|
||
servantCount: expected.min,
|
||
servantQuality: 50,
|
||
servantPayLevel: 'normal',
|
||
houseType,
|
||
character
|
||
})
|
||
};
|
||
}
|
||
|
||
getPayLevelMultiplier(payLevel) {
|
||
switch (payLevel) {
|
||
case 'low':
|
||
return 0.8;
|
||
case 'high':
|
||
return 1.3;
|
||
default:
|
||
return 1;
|
||
}
|
||
}
|
||
|
||
getPayLevelQualityShift(payLevel) {
|
||
switch (payLevel) {
|
||
case 'low':
|
||
return -6;
|
||
case 'high':
|
||
return 6;
|
||
default:
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
getServantExpectation(houseType, character) {
|
||
const housePosition = Number(houseType?.position || 0);
|
||
const titleLevel = Number(character?.nobleTitle?.level || character?.titleOfNobility?.level || 0);
|
||
|
||
let min = 0;
|
||
let max = 1;
|
||
|
||
if (housePosition >= 6) {
|
||
min = 4;
|
||
max = 8;
|
||
} else if (housePosition === 5) {
|
||
min = 3;
|
||
max = 6;
|
||
} else if (housePosition === 4) {
|
||
min = 2;
|
||
max = 4;
|
||
} else if (housePosition === 3) {
|
||
min = 1;
|
||
max = 2;
|
||
}
|
||
|
||
const titleBonus = Math.max(0, Math.floor(titleLevel / 3));
|
||
return {
|
||
min: min + titleBonus,
|
||
max: max + titleBonus
|
||
};
|
||
}
|
||
|
||
calculateHouseholdOrder({ servantCount, servantQuality, servantPayLevel, houseType, character }) {
|
||
const expectation = this.getServantExpectation(houseType, character);
|
||
const missing = Math.max(0, expectation.min - servantCount);
|
||
const excessive = Math.max(0, servantCount - expectation.max);
|
||
const qualityPart = Math.round((Number(servantQuality || 0) - 50) * 0.35);
|
||
const payPart = this.getPayLevelQualityShift(servantPayLevel);
|
||
const fitPenalty = (missing * 10) + (excessive * 4);
|
||
return Math.max(0, Math.min(100, 55 + qualityPart + payPart - fitPenalty));
|
||
}
|
||
|
||
calculateServantMonthlyCost({ servantCount, servantQuality, servantPayLevel, houseType }) {
|
||
// Falukant läuft stark zeitkomprimiert (1 Tag = 1 Jahr). Der "Monatslohn"
|
||
// ist deshalb bewusst ein abstrahierter Unterhaltswert pro Monatstick und
|
||
// kein realistischer Vollkostenlohn.
|
||
const basePerServant = Math.max(3, Math.round((Number(houseType?.cost || 0) / 10000) + 6));
|
||
const qualityFactor = 1 + ((Number(servantQuality || 50) - 50) / 200);
|
||
const payFactor = this.getPayLevelMultiplier(servantPayLevel);
|
||
return Math.round(servantCount * basePerServant * qualityFactor * payFactor * 100) / 100;
|
||
}
|
||
|
||
buildServantSummary(userHouse, character) {
|
||
const expectation = this.getServantExpectation(userHouse?.houseType, character);
|
||
const servantCount = Number(userHouse?.servantCount || 0);
|
||
const servantQuality = Number(userHouse?.servantQuality ?? 50);
|
||
const servantPayLevel = userHouse?.servantPayLevel || 'normal';
|
||
const householdOrder = Number(
|
||
userHouse?.householdOrder ??
|
||
this.calculateHouseholdOrder({
|
||
servantCount,
|
||
servantQuality,
|
||
servantPayLevel,
|
||
houseType: userHouse?.houseType,
|
||
character
|
||
})
|
||
);
|
||
|
||
let staffingState = 'fitting';
|
||
if (servantCount < expectation.min) staffingState = 'understaffed';
|
||
if (servantCount > expectation.max) staffingState = 'overstaffed';
|
||
|
||
let orderState = 'stable';
|
||
if (householdOrder < 35) orderState = 'chaotic';
|
||
else if (householdOrder < 55) orderState = 'strained';
|
||
else if (householdOrder > 80) orderState = 'excellent';
|
||
|
||
return {
|
||
expectedMin: expectation.min,
|
||
expectedMax: expectation.max,
|
||
monthlyCost: this.calculateServantMonthlyCost({
|
||
servantCount,
|
||
servantQuality,
|
||
servantPayLevel,
|
||
houseType: userHouse?.houseType
|
||
}),
|
||
staffingState,
|
||
orderState
|
||
};
|
||
}
|
||
|
||
async getOwnedUserHouse(hashedUserId) {
|
||
const falukantUser = await this.getFalukantUserByHashedId(hashedUserId);
|
||
const house = await UserHouse.findOne({
|
||
where: { userId: falukantUser.id },
|
||
include: [{ model: HouseType, as: 'houseType', attributes: ['id', 'position', 'cost', 'labelTr'] }]
|
||
});
|
||
if (!house) {
|
||
throw new Error('House not found');
|
||
}
|
||
return { falukantUser, house };
|
||
}
|
||
|
||
async hireServants(hashedUserId, amount = 1) {
|
||
const hireAmount = Math.max(1, Math.min(Number(amount) || 1, 10));
|
||
const { falukantUser, house } = await this.getOwnedUserHouse(hashedUserId);
|
||
const hireCost = Math.round(hireAmount * (40 + ((house.houseType?.cost || 0) / 2000)) * 100) / 100;
|
||
if (Number(falukantUser.money) < hireCost) {
|
||
throw new Error('notenoughmoney.');
|
||
}
|
||
|
||
house.servantCount = Number(house.servantCount || 0) + hireAmount;
|
||
house.servantQuality = Math.min(100, Number(house.servantQuality || 50) + Math.max(1, hireAmount));
|
||
house.householdOrder = this.calculateHouseholdOrder({
|
||
servantCount: house.servantCount,
|
||
servantQuality: house.servantQuality,
|
||
servantPayLevel: house.servantPayLevel,
|
||
houseType: house.houseType,
|
||
character: falukantUser.character
|
||
});
|
||
await house.save();
|
||
await updateFalukantUserMoney(falukantUser.id, -hireCost, 'servants_hired', falukantUser.id);
|
||
await this.refreshHouseholdTensionState(falukantUser, falukantUser.character);
|
||
|
||
const user = await User.findByPk(falukantUser.userId);
|
||
notifyUser(user.hashedId, 'falukantHouseUpdate', {});
|
||
notifyUser(user.hashedId, 'falukantUpdateStatus', {});
|
||
return {
|
||
amount: hireAmount,
|
||
cost: hireCost,
|
||
servantSummary: this.buildServantSummary(house, falukantUser.character)
|
||
};
|
||
}
|
||
|
||
async dismissServants(hashedUserId, amount = 1) {
|
||
const dismissAmount = Math.max(1, Math.min(Number(amount) || 1, 10));
|
||
const { falukantUser, house } = await this.getOwnedUserHouse(hashedUserId);
|
||
const prevCount = Number(house.servantCount || 0);
|
||
if (prevCount <= 0) {
|
||
throw new Error('No servants to dismiss');
|
||
}
|
||
|
||
// Symmetrisch zu hireServants: Qualitätsänderung skaliert mit tatsächlich entlassener Anzahl
|
||
const actualDismissed = Math.min(dismissAmount, prevCount);
|
||
house.servantCount = Math.max(0, prevCount - dismissAmount);
|
||
house.servantQuality = Math.max(
|
||
0,
|
||
Number(house.servantQuality || 50) - Math.max(1, actualDismissed)
|
||
);
|
||
house.householdOrder = this.calculateHouseholdOrder({
|
||
servantCount: house.servantCount,
|
||
servantQuality: house.servantQuality,
|
||
servantPayLevel: house.servantPayLevel,
|
||
houseType: house.houseType,
|
||
character: falukantUser.character
|
||
});
|
||
await house.save();
|
||
await this.refreshHouseholdTensionState(falukantUser, falukantUser.character);
|
||
|
||
const user = await User.findByPk(falukantUser.userId);
|
||
notifyUser(user.hashedId, 'falukantHouseUpdate', {});
|
||
notifyUser(user.hashedId, 'falukantUpdateStatus', {});
|
||
return {
|
||
amount: dismissAmount,
|
||
servantSummary: this.buildServantSummary(house, falukantUser.character)
|
||
};
|
||
}
|
||
|
||
async setServantPayLevel(hashedUserId, payLevel) {
|
||
const normalizedPayLevel = ['low', 'normal', 'high'].includes(payLevel) ? payLevel : 'normal';
|
||
const { falukantUser, house } = await this.getOwnedUserHouse(hashedUserId);
|
||
|
||
const previousPayLevel = house.servantPayLevel || 'normal';
|
||
const oldShift = this.getPayLevelQualityShift(previousPayLevel);
|
||
const newShift = this.getPayLevelQualityShift(normalizedPayLevel);
|
||
const baseQuality = Number(house.servantQuality || 50) - oldShift;
|
||
|
||
house.servantPayLevel = normalizedPayLevel;
|
||
house.servantQuality = Math.max(0, Math.min(100, baseQuality + newShift));
|
||
house.householdOrder = this.calculateHouseholdOrder({
|
||
servantCount: house.servantCount,
|
||
servantQuality: house.servantQuality,
|
||
servantPayLevel: house.servantPayLevel,
|
||
houseType: house.houseType,
|
||
character: falukantUser.character
|
||
});
|
||
await house.save();
|
||
await this.refreshHouseholdTensionState(falukantUser, falukantUser.character);
|
||
|
||
const user = await User.findByPk(falukantUser.userId);
|
||
notifyUser(user.hashedId, 'falukantHouseUpdate', {});
|
||
notifyUser(user.hashedId, 'falukantUpdateStatus', {});
|
||
return {
|
||
payLevel: normalizedPayLevel,
|
||
servantSummary: this.buildServantSummary(house, falukantUser.character)
|
||
};
|
||
}
|
||
|
||
async tidyHousehold(hashedUserId) {
|
||
const { falukantUser, house } = await this.getOwnedUserHouse(hashedUserId);
|
||
const tidyCost = 15;
|
||
if (Number(falukantUser.money) < tidyCost) {
|
||
throw new Error('notenoughmoney.');
|
||
}
|
||
|
||
house.householdOrder = this.clampScore(Number(house.householdOrder || 55) + 3);
|
||
await house.save();
|
||
await updateFalukantUserMoney(falukantUser.id, -tidyCost, 'household_order', falukantUser.id);
|
||
await this.refreshHouseholdTensionState(falukantUser, falukantUser.character);
|
||
|
||
const user = await User.findByPk(falukantUser.userId);
|
||
notifyUser(user.hashedId, 'falukantHouseUpdate', {});
|
||
notifyUser(user.hashedId, 'falukantUpdateFamily', { reason: 'daily' });
|
||
notifyUser(user.hashedId, 'falukantUpdateStatus', {});
|
||
return {
|
||
success: true,
|
||
cost: tidyCost,
|
||
householdOrder: house.householdOrder,
|
||
servantSummary: this.buildServantSummary(house, falukantUser.character)
|
||
};
|
||
}
|
||
|
||
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) {
|
||
const error = new Error('Diese Party wurde bereits innerhalb der letzten 24 Stunden bestellt');
|
||
error.status = 409;
|
||
throw error;
|
||
}
|
||
const [ptype, music, banquette] = await Promise.all([
|
||
PartyType.findByPk(partyTypeId),
|
||
MusicType.findByPk(musicId),
|
||
BanquetteType.findByPk(banquetteId),
|
||
]);
|
||
if (!ptype || !music || !banquette) {
|
||
throw new Error('Ungültige Party-, Musik- oder Bankett-Auswahl');
|
||
}
|
||
const nobilities = nobilityIds && nobilityIds.length
|
||
? await TitleOfNobility.findAll({ where: { id: { [Op.in]: nobilityIds } } })
|
||
: [];
|
||
|
||
// Prüfe, ob alle angegebenen IDs gefunden wurden
|
||
if (nobilityIds && nobilityIds.length > 0 && nobilities.length !== nobilityIds.length) {
|
||
throw new Error('Einige ausgewählte Adelstitel existieren nicht');
|
||
}
|
||
|
||
const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, attributes: ['titleOfNobility'] });
|
||
const partyTypeCost = character && (await isPartyTypeFreeForTitle(character.titleOfNobility, ptype.id, ptype.tr)) ? 0 : (ptype.cost || 0);
|
||
let cost = partyTypeCost + (music.cost || 0) + (banquette.cost || 0);
|
||
cost += (50 / servantRatio - 1) * 1000;
|
||
const nobilityCost = nobilities.reduce((sum, n) => sum + ((n.id ^ 5) * 1000), 0);
|
||
cost += nobilityCost;
|
||
if (Number(falukantUser.money) < cost) {
|
||
throw new Error('Nicht genügend Guthaben für diese Party');
|
||
}
|
||
const moneyResult = await updateFalukantUserMoney(
|
||
falukantUser.id,
|
||
-cost,
|
||
'partyOrder',
|
||
falukantUser.id
|
||
);
|
||
if (!moneyResult.success) {
|
||
throw new Error('Geld konnte nicht abgezogen werden');
|
||
}
|
||
const party = await Party.create({
|
||
partyTypeId,
|
||
falukantUserId: falukantUser.id,
|
||
musicTypeId: musicId,
|
||
banquetteTypeId: banquetteId,
|
||
servantRatio,
|
||
cost: cost
|
||
});
|
||
if (nobilities.length > 0) {
|
||
// Stelle sicher, dass die Party eine ID hat
|
||
if (!party.id) {
|
||
throw new Error('Party wurde erstellt, hat aber keine ID');
|
||
}
|
||
// Verwende die bereits geladenen Objekte
|
||
await party.addInvitedNobilities(nobilities);
|
||
}
|
||
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 {
|
||
// Nutze den aktuell aktiven Charakter (wie in getFalukantUserByHashedId definiert),
|
||
// statt „irgendeinen“ Charakter des Users per userId zu suchen.
|
||
const falukantUser = await this.getFalukantUserByHashedId(hashedUserId);
|
||
const parentCharacter = falukantUser.character;
|
||
if (!parentCharacter) {
|
||
throw new Error('Parent character not found');
|
||
}
|
||
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 (children count) and family view update
|
||
await notifyUser(hashedUserId, 'falukantUpdateStatus', {});
|
||
await notifyUser(hashedUserId, 'familychanged', {});
|
||
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);
|
||
}
|
||
|
||
/** Resolve character id for education: self = user's character, children/director = studentId */
|
||
async _resolveLearningCharacterId(hashedUserId, student, studentId) {
|
||
if (student === 'self') {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
const char = await FalukantCharacter.findOne({ where: { userId: user.id }, attributes: ['id'] });
|
||
return char?.id ?? null;
|
||
}
|
||
return studentId ?? null;
|
||
}
|
||
|
||
async getKnowledgeSingle(hashedUserId, student, studentId, productId) {
|
||
const characterId = await this._resolveLearningCharacterId(hashedUserId, student, studentId);
|
||
if (!characterId) throw new Error('Character not found');
|
||
const row = await Knowledge.findOne({
|
||
where: { characterId, productId },
|
||
attributes: ['knowledge']
|
||
});
|
||
return { knowledge: row?.knowledge ?? 0 };
|
||
}
|
||
|
||
async getKnowledgeForAll(hashedUserId, student, studentId) {
|
||
const characterId = await this._resolveLearningCharacterId(hashedUserId, student, studentId);
|
||
if (!characterId) throw new Error('Character not found');
|
||
const [products, knowledges] = await Promise.all([
|
||
ProductType.findAll({ attributes: ['id'] }),
|
||
Knowledge.findAll({ where: { characterId }, attributes: ['productId', 'knowledge'] })
|
||
]);
|
||
const knowledgeMap = new Map((knowledges || []).map(k => [k.productId, k.knowledge ?? 0]));
|
||
return products.map(p => ({ productId: p.id, knowledge: knowledgeMap.get(p.id) ?? 0 }));
|
||
}
|
||
|
||
async sendToSchool(hashedUserId, item, student, studentId) {
|
||
const falukantUser = await getFalukantUserOrFail(hashedUserId);
|
||
|
||
// 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);
|
||
if (!all.length) percent = 0;
|
||
else {
|
||
const sum = all.reduce((s, k) => s + (k.knowledge || 0), 0);
|
||
percent = sum / all.length;
|
||
}
|
||
} else {
|
||
const single = await this.getKnowledgeSingle(hashedUserId, student, studentId, item);
|
||
percent = single.knowledge ?? 0;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
deriveDebtNextForcedAction(status, daysOverdue) {
|
||
if (status === 'imprisoned') return 'asset_seizure';
|
||
if (daysOverdue >= 3) return 'debtors_prison';
|
||
if (daysOverdue === 2) return 'final_warning';
|
||
if (daysOverdue === 1) return 'reminder';
|
||
return null;
|
||
}
|
||
|
||
calculateCreditworthinessFromDebtState(record) {
|
||
if (!record) return 100;
|
||
|
||
const penalty = Number(record.creditworthinessPenalty || 0);
|
||
const daysOverdue = Number(record.daysOverdue || 0);
|
||
|
||
if (record.status === 'imprisoned') {
|
||
return Math.max(0, 20 - penalty);
|
||
}
|
||
if (record.status === 'delinquent') {
|
||
return Math.max(15, 100 - (daysOverdue * 20) - penalty);
|
||
}
|
||
return Math.max(0, 80 - penalty);
|
||
}
|
||
|
||
serializeDebtorsPrisonRecord(record, totalDebt = 0) {
|
||
if (!record) {
|
||
return {
|
||
active: false,
|
||
inDebtorsPrison: false,
|
||
status: null,
|
||
daysOverdue: 0,
|
||
debtAtEntry: null,
|
||
remainingDebt: Number(totalDebt || 0),
|
||
reason: null,
|
||
creditworthinessPenalty: 0,
|
||
creditworthiness: 100,
|
||
nextForcedAction: null,
|
||
publicKnown: false,
|
||
enteredAt: null,
|
||
releasedAt: null,
|
||
assetsSeized: null
|
||
};
|
||
}
|
||
|
||
const remainingDebt = Number(record.remainingDebt ?? totalDebt ?? 0);
|
||
const debtState = {
|
||
active: record.status !== 'released' && !record.releasedAt,
|
||
inDebtorsPrison: record.status === 'imprisoned' && !record.releasedAt,
|
||
status: record.status || null,
|
||
daysOverdue: Number(record.daysOverdue || 0),
|
||
debtAtEntry: record.debtAtEntry != null ? Number(record.debtAtEntry) : null,
|
||
remainingDebt,
|
||
reason: record.reason || null,
|
||
creditworthinessPenalty: Number(record.creditworthinessPenalty || 0),
|
||
nextForcedAction: record.nextForcedAction || this.deriveDebtNextForcedAction(record.status, Number(record.daysOverdue || 0)),
|
||
publicKnown: !!record.publicKnown,
|
||
enteredAt: record.enteredAt || null,
|
||
releasedAt: record.releasedAt || null,
|
||
assetsSeized: record.assetsSeizedJson || null
|
||
};
|
||
debtState.creditworthiness = this.calculateCreditworthinessFromDebtState(debtState);
|
||
return debtState;
|
||
}
|
||
|
||
async getDebtorsPrisonStateForUser(falukantUser) {
|
||
if (!falukantUser?.id) {
|
||
return this.serializeDebtorsPrisonRecord(null);
|
||
}
|
||
|
||
const character = falukantUser.character || await FalukantCharacter.findOne({
|
||
where: { userId: falukantUser.id },
|
||
attributes: ['id']
|
||
});
|
||
|
||
if (!character?.id) {
|
||
return this.serializeDebtorsPrisonRecord(null);
|
||
}
|
||
|
||
let records = [];
|
||
try {
|
||
records = await DebtorsPrism.findAll({
|
||
where: { characterId: character.id },
|
||
order: [['createdAt', 'DESC']]
|
||
});
|
||
} catch (error) {
|
||
const message = String(error?.original?.message || error?.message || '');
|
||
const missingLegacyDebtColumn =
|
||
message.includes('column') &&
|
||
message.includes('status') &&
|
||
message.includes('debtors_prism');
|
||
|
||
if (!missingLegacyDebtColumn) {
|
||
throw error;
|
||
}
|
||
|
||
// Production can temporarily lag behind the expanded debtors_prism schema.
|
||
// In that case we degrade to "no active debtors prison state" instead of
|
||
// breaking unrelated Falukant endpoints such as family or gifts.
|
||
records = [];
|
||
}
|
||
|
||
const activeRecord = records.find((record) => record.status !== 'released' && !record.releasedAt)
|
||
|| records[0]
|
||
|| null;
|
||
|
||
const totalDebt = await Credit.sum('remaining_amount', {
|
||
where: { falukant_user_id: falukantUser.id }
|
||
}) || 0;
|
||
|
||
return this.serializeDebtorsPrisonRecord(activeRecord, totalDebt);
|
||
}
|
||
|
||
async assertActionAllowedOutsideDebtorsPrison(hashedUserId) {
|
||
const falukantUser = await getFalukantUserOrFail(hashedUserId);
|
||
const debtorsPrison = await this.getDebtorsPrisonStateForUser(falukantUser);
|
||
|
||
if (debtorsPrison.inDebtorsPrison) {
|
||
throw {
|
||
status: 423,
|
||
message: 'Aktion im Schuldturm gesperrt',
|
||
code: 'falukant.debtorsPrison.actionBlocked',
|
||
debtorsPrison
|
||
};
|
||
}
|
||
|
||
return { falukantUser, debtorsPrison };
|
||
}
|
||
|
||
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;
|
||
|
||
const debtorsPrison = await this.getDebtorsPrisonStateForUser(falukantUser);
|
||
|
||
// 5) Maximaler Kredit und verfügbare Linie
|
||
const maxCredit = Math.floor(houseValue + branchValue);
|
||
const availableCreditRaw = maxCredit - totalDebt;
|
||
const availableCredit = debtorsPrison.inDebtorsPrison
|
||
? 0
|
||
: Math.max(0, availableCreditRaw);
|
||
|
||
// 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,
|
||
inDebtorsPrison: debtorsPrison.inDebtorsPrison,
|
||
daysOverdue: debtorsPrison.daysOverdue,
|
||
nextForcedAction: debtorsPrison.nextForcedAction,
|
||
creditworthiness: debtorsPrison.creditworthiness,
|
||
debtorsPrison
|
||
};
|
||
}
|
||
|
||
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.inDebtorsPrison) {
|
||
throw new Error('debtorPrisonBlocksCredit');
|
||
}
|
||
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, highestPoliticalOffice, highestOfficeAny] = await Promise.all([
|
||
TitleOfNobility.findOne({
|
||
where: {
|
||
level: currentTitleLevel + 1
|
||
},
|
||
include: [
|
||
{
|
||
model: TitleRequirement,
|
||
as: 'requirements',
|
||
}
|
||
],
|
||
attributes: ['labelTr']
|
||
}),
|
||
this.getHighestPoliticalOfficeInfo(falukantUser.id),
|
||
this.getHighestOfficeAnyInfo(falukantUser.id)
|
||
]);
|
||
return {
|
||
current: nobility,
|
||
next: nextTitle,
|
||
nextAdvanceAt,
|
||
highestPoliticalOffice,
|
||
highestOfficeAny
|
||
};
|
||
}
|
||
|
||
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) {
|
||
const last = new Date(user.lastNobilityAdvanceAt);
|
||
const retryAt = new Date(last.getTime());
|
||
retryAt.setDate(retryAt.getDate() + 7);
|
||
throw { status: 412, message: 'nobilityTooSoon', retryAt: retryAt.toISOString() };
|
||
}
|
||
}
|
||
const nextTitle = nobility.next.toJSON();
|
||
let cost = 0;
|
||
const unmet = [];
|
||
for (const requirement of nextTitle.requirements) {
|
||
let ok = true;
|
||
switch (requirement.requirementType) {
|
||
case 'money':
|
||
ok = await this.checkMoneyRequirement(user, requirement);
|
||
break;
|
||
case 'cost':
|
||
ok = await this.checkMoneyRequirement(user, requirement);
|
||
cost = requirement.requirementValue;
|
||
break;
|
||
case 'branches':
|
||
ok = await this.checkBranchesRequirement(hashedUserId, requirement);
|
||
break;
|
||
case 'reputation':
|
||
ok = await this.checkReputationRequirement(user, requirement);
|
||
break;
|
||
case 'house_position':
|
||
ok = await this.checkHousePositionRequirement(user, requirement);
|
||
break;
|
||
case 'house_condition':
|
||
ok = await this.checkHouseConditionRequirement(user, requirement);
|
||
break;
|
||
case 'office_rank_any':
|
||
ok = await this.checkOfficeRankAnyRequirement(user, requirement);
|
||
break;
|
||
case 'office_rank_political':
|
||
ok = await this.checkOfficeRankPoliticalRequirement(user, requirement);
|
||
break;
|
||
case 'lover_count_max':
|
||
ok = await this.checkLoverCountMaxRequirement(user, requirement);
|
||
break;
|
||
case 'lover_count_min':
|
||
ok = await this.checkLoverCountMinRequirement(user, requirement);
|
||
break;
|
||
default:
|
||
ok = false;
|
||
}
|
||
if (!ok) {
|
||
unmet.push({
|
||
type: requirement.requirementType,
|
||
required: requirement.requirementValue,
|
||
});
|
||
}
|
||
}
|
||
if (unmet.length > 0) {
|
||
throw { status: 412, message: 'nobilityRequirements', unmet };
|
||
}
|
||
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 checkReputationRequirement(user, requirement) {
|
||
const character = user.character || await FalukantCharacter.findOne({
|
||
where: { userId: user.id },
|
||
attributes: ['reputation']
|
||
});
|
||
return Number(character?.reputation || 0) >= Number(requirement.requirementValue || 0);
|
||
}
|
||
|
||
async checkHousePositionRequirement(user, requirement) {
|
||
const house = await UserHouse.findOne({
|
||
where: { userId: user.id },
|
||
include: [{ model: HouseType, as: 'houseType', attributes: ['position'] }]
|
||
});
|
||
return Number(house?.houseType?.position || 0) >= Number(requirement.requirementValue || 0);
|
||
}
|
||
|
||
async checkHouseConditionRequirement(user, requirement) {
|
||
const house = await UserHouse.findOne({
|
||
where: { userId: user.id },
|
||
attributes: ['roofCondition', 'wallCondition', 'floorCondition', 'windowCondition']
|
||
});
|
||
if (!house) return false;
|
||
const averageCondition = (
|
||
Number(house.roofCondition || 0) +
|
||
Number(house.wallCondition || 0) +
|
||
Number(house.floorCondition || 0) +
|
||
Number(house.windowCondition || 0)
|
||
) / 4;
|
||
return averageCondition >= Number(requirement.requirementValue || 0);
|
||
}
|
||
|
||
async getHighestPoliticalOfficeInfo(userId) {
|
||
const character = await FalukantCharacter.findOne({
|
||
where: { userId },
|
||
attributes: ['id']
|
||
});
|
||
if (!character) return { rank: 0, name: null };
|
||
|
||
const [politicalOffices, politicalHistories] = await Promise.all([
|
||
PoliticalOffice.findAll({
|
||
where: { characterId: character.id },
|
||
include: [{ model: PoliticalOfficeType, as: 'type', attributes: ['name'] }],
|
||
attributes: ['officeTypeId']
|
||
}),
|
||
PoliticalOfficeHistory.findAll({
|
||
where: { characterId: character.id },
|
||
include: [{ model: PoliticalOfficeType, as: 'officeTypeHistory', attributes: ['name'] }],
|
||
attributes: ['officeTypeId']
|
||
})
|
||
]);
|
||
|
||
const candidates = [
|
||
...politicalOffices.map((office) => ({
|
||
rank: POLITICAL_OFFICE_RANKS[office.type?.name] || 0,
|
||
name: office.type?.name || null
|
||
})),
|
||
...politicalHistories.map((history) => ({
|
||
rank: POLITICAL_OFFICE_RANKS[history.officeTypeHistory?.name] || 0,
|
||
name: history.officeTypeHistory?.name || null
|
||
}))
|
||
].sort((a, b) => b.rank - a.rank);
|
||
|
||
return candidates[0] || { rank: 0, name: null };
|
||
}
|
||
|
||
async getHighestOfficeAnyInfo(userId) {
|
||
const character = await FalukantCharacter.findOne({
|
||
where: { userId },
|
||
attributes: ['id']
|
||
});
|
||
if (!character) return { rank: 0, name: null, source: null };
|
||
|
||
const [politicalOffices, politicalHistories, churchOffices] = await Promise.all([
|
||
PoliticalOffice.findAll({
|
||
where: { characterId: character.id },
|
||
include: [{ model: PoliticalOfficeType, as: 'type', attributes: ['name'] }],
|
||
attributes: ['officeTypeId']
|
||
}),
|
||
PoliticalOfficeHistory.findAll({
|
||
where: { characterId: character.id },
|
||
include: [{ model: PoliticalOfficeType, as: 'officeTypeHistory', attributes: ['name'] }],
|
||
attributes: ['officeTypeId']
|
||
}),
|
||
ChurchOffice.findAll({
|
||
where: { characterId: character.id },
|
||
include: [{ model: ChurchOfficeType, as: 'type', attributes: ['name', 'hierarchyLevel'] }],
|
||
attributes: ['officeTypeId']
|
||
})
|
||
]);
|
||
|
||
const candidates = [
|
||
...politicalOffices.map((office) => ({
|
||
rank: POLITICAL_OFFICE_RANKS[office.type?.name] || 0,
|
||
name: office.type?.name || null,
|
||
source: 'political'
|
||
})),
|
||
...politicalHistories.map((history) => ({
|
||
rank: POLITICAL_OFFICE_RANKS[history.officeTypeHistory?.name] || 0,
|
||
name: history.officeTypeHistory?.name || null,
|
||
source: 'political'
|
||
})),
|
||
...churchOffices.map((office) => ({
|
||
rank: Number(office.type?.hierarchyLevel || 0),
|
||
name: office.type?.name || null,
|
||
source: 'church'
|
||
}))
|
||
].sort((a, b) => b.rank - a.rank);
|
||
|
||
return candidates[0] || { rank: 0, name: null, source: null };
|
||
}
|
||
|
||
async getHighestOfficeRankAny(userId) {
|
||
const highest = await this.getHighestOfficeAnyInfo(userId);
|
||
return Number(highest?.rank || 0);
|
||
}
|
||
|
||
async checkOfficeRankAnyRequirement(user, requirement) {
|
||
const highestRank = await this.getHighestOfficeRankAny(user.id);
|
||
return highestRank >= Number(requirement.requirementValue || 0);
|
||
}
|
||
|
||
async checkOfficeRankPoliticalRequirement(user, requirement) {
|
||
const highest = await this.getHighestPoliticalOfficeInfo(user.id);
|
||
return Number(highest?.rank || 0) >= Number(requirement.requirementValue || 0);
|
||
}
|
||
|
||
async checkLoverCountMaxRequirement(user, requirement) {
|
||
const activeLoverCount = await this.getActiveLoverCount(user);
|
||
return activeLoverCount <= Number(requirement.requirementValue || 0);
|
||
}
|
||
|
||
async checkLoverCountMinRequirement(user, requirement) {
|
||
const activeLoverCount = await this.getActiveLoverCount(user);
|
||
return activeLoverCount >= Number(requirement.requirementValue || 0);
|
||
}
|
||
|
||
async getActiveLoverCount(user) {
|
||
const character = user.character || await FalukantCharacter.findOne({
|
||
where: { userId: user.id },
|
||
attributes: ['id']
|
||
});
|
||
if (!character) return 0;
|
||
|
||
const loverType = await RelationshipType.findOne({
|
||
where: { tr: 'lover' },
|
||
attributes: ['id']
|
||
});
|
||
if (!loverType) return 0;
|
||
|
||
const loverRelationships = await Relationship.findAll({
|
||
where: {
|
||
character1Id: character.id,
|
||
relationshipTypeId: loverType.id
|
||
},
|
||
include: [{
|
||
model: RelationshipState,
|
||
as: 'state',
|
||
required: false
|
||
}]
|
||
});
|
||
|
||
return loverRelationships.filter((rel) => (rel.state?.active ?? true) !== false).length;
|
||
}
|
||
|
||
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) {
|
||
// Berechne, wann die nächste Maßnahme möglich ist (24 Stunden nach der letzten)
|
||
const retryAt = new Date(lastHealthActivity.createdAt.getTime() + 24 * 60 * 60 * 1000);
|
||
const err = new PreconditionError('tooClose');
|
||
err.meta = { retryAt: retryAt.toISOString() };
|
||
throw err;
|
||
}
|
||
const activityObject = FalukantService.HEALTH_ACTIVITIES.find((a) => a.tr === activity);
|
||
if (!activityObject) {
|
||
throw new Error('invalid');
|
||
}
|
||
if (user.money - activityObject.cost < 0) {
|
||
throw new Error('no money');
|
||
}
|
||
user.character.health -= activityObject.cost;
|
||
const healthChange = await this[activityObject.method](user);
|
||
await HealthActivity.create({
|
||
characterId: user.character.id,
|
||
activityTr: activity,
|
||
successPercentage: healthChange,
|
||
cost: activityObject.cost
|
||
});
|
||
updateFalukantUserMoney(user.id, -activityObject.cost, 'health.' + activity);
|
||
|
||
// Status-Update Notification senden
|
||
notifyUser(user.user.hashedId, 'falukantUpdateStatus', {});
|
||
|
||
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);
|
||
}
|
||
|
||
politicsBenefitEntriesFromRows(benefitRows, officeName, hierarchyLevelFromType) {
|
||
if (!benefitRows?.length) {
|
||
return [];
|
||
}
|
||
const out = [];
|
||
for (const row of benefitRows) {
|
||
const plain = row.get ? row.get({ plain: true }) : row;
|
||
const tr = plain.benefitDefinition?.tr || '';
|
||
const v = plain.value && typeof plain.value === 'object' ? plain.value : {};
|
||
if (tr === 'tax_exemption') {
|
||
const regions = Array.isArray(v.regions) ? v.regions : [];
|
||
if (regions.includes('*')) {
|
||
out.push({ tr: 'tax_exemption', params: { all: true } });
|
||
} else {
|
||
out.push({ tr: 'tax_exemption', params: { regions } });
|
||
}
|
||
} else if (tr === 'daily_salary' || tr === 'salary') {
|
||
const amount = computePoliticalDailySalaryPayout(v, officeName, hierarchyLevelFromType);
|
||
if (amount > 0) {
|
||
out.push({ tr: 'daily_salary', params: { amount } });
|
||
}
|
||
} else if (tr === 'reputation_periodic' || (tr === 'reputation' && (v.intervalDays != null || v.everyDays != null))) {
|
||
const intervalDays = Math.max(1, Number(v.intervalDays ?? v.everyDays ?? 7));
|
||
const gain = Math.max(1, Number(v.gain ?? v.reputationGain ?? 1));
|
||
out.push({ tr: 'reputation_periodic', params: { intervalDays, gain } });
|
||
} else if (tr === 'appoint_politicians' || (tr === 'influence' && Array.isArray(v.officeTrs) && v.officeTrs.length)) {
|
||
const officeTrs = Array.isArray(v.officeTrs) ? v.officeTrs.filter((x) => typeof x === 'string') : [];
|
||
if (officeTrs.length) {
|
||
out.push({ tr: 'appoint_politicians', params: { officeTrs } });
|
||
}
|
||
} else if (tr === 'set_regional_tax' || tr === 'set_regionl_tax') {
|
||
const scope = typeof v.scope === 'string' && v.scope ? v.scope : 'local';
|
||
out.push({ tr: 'set_regional_tax', params: { scope } });
|
||
} else if (tr === 'free_lover_slots') {
|
||
const count = Math.max(1, Number(v.count ?? 1));
|
||
out.push({ tr: 'free_lover_slots', params: { count } });
|
||
} else if (tr === 'guard_protection') {
|
||
out.push({ tr: 'guard_protection', params: {} });
|
||
} else if (tr === 'court_immunity') {
|
||
out.push({ tr: 'court_immunity', params: {} });
|
||
} else if (tr) {
|
||
out.push({ tr: 'generic_benefit', params: { code: tr } });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
async _maybeGrantPoliticalDailySalary(falukantUser, characterId) {
|
||
const rawLast = falukantUser.lastPoliticalDailySalaryOn;
|
||
const lastStr = rawLast
|
||
? (typeof rawLast === 'string'
|
||
? rawLast.slice(0, 10)
|
||
: new Date(rawLast).toISOString().slice(0, 10))
|
||
: '';
|
||
const todayStr = new Date().toISOString().slice(0, 10);
|
||
if (lastStr === todayStr) {
|
||
return;
|
||
}
|
||
|
||
const held = await PoliticalOffice.findAll({
|
||
where: { characterId },
|
||
include: [{ model: PoliticalOfficeType, as: 'type', attributes: ['name', 'hierarchyLevel'] }]
|
||
});
|
||
if (!held.length) {
|
||
return;
|
||
}
|
||
|
||
const officeTypeIds = [...new Set(held.map((h) => h.officeTypeId))];
|
||
const benefitRows = await PoliticalOfficeBenefit.findAll({
|
||
where: { officeTypeId: { [Op.in]: officeTypeIds } },
|
||
include: [{ model: PoliticalOfficeBenefitType, as: 'benefitDefinition', attributes: ['tr'] }]
|
||
});
|
||
const byType = new Map();
|
||
for (const br of benefitRows) {
|
||
const oid = br.officeTypeId;
|
||
if (!byType.has(oid)) {
|
||
byType.set(oid, []);
|
||
}
|
||
byType.get(oid).push(br);
|
||
}
|
||
|
||
let total = 0;
|
||
for (const h of held) {
|
||
const name = h.type?.name;
|
||
const rows = byType.get(h.officeTypeId) || [];
|
||
for (const row of rows) {
|
||
const tr = row.benefitDefinition?.tr;
|
||
if (tr !== 'daily_salary') {
|
||
continue;
|
||
}
|
||
const v = typeof row.value === 'object' && row.value ? row.value : {};
|
||
const level = h.type?.hierarchyLevel;
|
||
total += computePoliticalDailySalaryPayout(v, name, level);
|
||
}
|
||
}
|
||
|
||
total = Math.round(total * 100) / 100;
|
||
if (total <= 0) {
|
||
return;
|
||
}
|
||
|
||
const moneyResult = await updateFalukantUserMoney(
|
||
falukantUser.id,
|
||
total,
|
||
'Politisches Tagesamtshonorar',
|
||
falukantUser.id
|
||
);
|
||
if (!moneyResult.success) {
|
||
console.error('[getPoliticsOverview] Tageshonorar konnte nicht gebucht werden');
|
||
return;
|
||
}
|
||
await FalukantUser.update(
|
||
{ lastPoliticalDailySalaryOn: todayStr },
|
||
{ where: { id: falukantUser.id } }
|
||
);
|
||
}
|
||
|
||
async getPoliticsOverview(hashedUserId) {
|
||
// Liefert alle aktuell besetzten Ämter im eigenen Gebiet inklusive
|
||
// Inhaber, Vorteile (aus political_office_benefit) 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 [];
|
||
}
|
||
|
||
await this._maybeGrantPoliticalDailySalary(user, character.id);
|
||
|
||
// 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', 'hierarchyLevel']
|
||
},
|
||
{
|
||
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']
|
||
]
|
||
});
|
||
|
||
const officeTypeIds = [...new Set(offices.map((o) => o.officeTypeId))];
|
||
let benefitByType = new Map();
|
||
if (officeTypeIds.length) {
|
||
const allBenefits = await PoliticalOfficeBenefit.findAll({
|
||
where: { officeTypeId: { [Op.in]: officeTypeIds } },
|
||
include: [{ model: PoliticalOfficeBenefitType, as: 'benefitDefinition', attributes: ['tr'] }]
|
||
});
|
||
for (const br of allBenefits) {
|
||
const oid = br.officeTypeId;
|
||
if (!benefitByType.has(oid)) {
|
||
benefitByType.set(oid, []);
|
||
}
|
||
benefitByType.get(oid).push(br);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
const officeName = o.type?.name;
|
||
const benefit = this.politicsBenefitEntriesFromRows(
|
||
benefitByType.get(o.officeTypeId) || [],
|
||
officeName,
|
||
o.type?.hierarchyLevel
|
||
);
|
||
|
||
return {
|
||
id: o.id,
|
||
officeType: {
|
||
name: officeName
|
||
},
|
||
region: {
|
||
name: o.region?.name,
|
||
regionType: o.region?.regionType
|
||
? { labelTr: o.region.regionType.labelTr }
|
||
: undefined
|
||
},
|
||
character: o.holder
|
||
? {
|
||
id: o.holder.id,
|
||
definedFirstName: o.holder.definedFirstName,
|
||
definedLastName: o.holder.definedLastName,
|
||
nobleTitle: o.holder.nobleTitle,
|
||
gender: o.holder.gender
|
||
}
|
||
: null,
|
||
termEnds,
|
||
benefit
|
||
};
|
||
});
|
||
}
|
||
|
||
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 };
|
||
});
|
||
}
|
||
|
||
/** Mindestalter für Bewerbung auf politische Ämter (in Tagen; 16 Tage Realzeit = 16 Spieljahre) */
|
||
static MIN_AGE_POLITICS_DAYS = 16;
|
||
|
||
async getOpenPolitics(hashedUserId) {
|
||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||
const character = user.character;
|
||
const characterId = character.id;
|
||
const ageDays = character.birthdate ? calcAge(character.birthdate) : 0;
|
||
const canApplyByAge = ageDays >= FalukantService.MIN_AGE_POLITICS_DAYS;
|
||
const 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',
|
||
required: false
|
||
},
|
||
{
|
||
model: PoliticalOfficeType, as: 'officeType',
|
||
include: [{ model: PoliticalOfficePrerequisite, as: 'prerequisites' }]
|
||
}
|
||
]
|
||
});
|
||
const titleId = character.titleOfNobility ?? character.nobleTitle?.id;
|
||
const allowedOfficeNames = await getAllowedOfficeTypeNamesByTitle(titleId);
|
||
const result = openPositions
|
||
.filter(election => {
|
||
if (allowedOfficeNames.size > 0 && !allowedOfficeNames.has(election.officeType?.name)) return false;
|
||
return true;
|
||
})
|
||
.filter(election => {
|
||
const prereqs = election.officeType.prerequisites || [];
|
||
return prereqs.some(pr => {
|
||
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,
|
||
canApplyByAge
|
||
};
|
||
})
|
||
.filter(election => !election.alreadyApplied); // Nur Positionen ohne bestehende Bewerbung
|
||
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) Mindestalter 16 (Spieljahre = 16 Tage Realzeit)
|
||
const ageDays = character.birthdate ? calcAge(character.birthdate) : 0;
|
||
if (ageDays < FalukantService.MIN_AGE_POLITICS_DAYS) {
|
||
throw new Error('too_young');
|
||
}
|
||
|
||
// 3) Noncivil‐Titel aussperren
|
||
if (character.nobleTitle.labelTr === 'noncivil') {
|
||
return { applied: [], skipped: electionIds };
|
||
}
|
||
|
||
// 4) Ermittle die offenen Wahlen, auf die er zugreifen darf
|
||
// Verwende getOpenPolitics statt getElections, da getOpenPolitics die gleichen Wahlen
|
||
// zurückgibt, die im Frontend angezeigt werden
|
||
const openPolitics = await this.getOpenPolitics(hashedUserId);
|
||
const allowedIds = new Set(openPolitics.map(e => e.id));
|
||
|
||
// 5) Filter alle electionIds auf gültige/erlaubte
|
||
const toTry = electionIds.filter(id => allowedIds.has(id));
|
||
if (toTry.length === 0) {
|
||
return { applied: [], skipped: electionIds };
|
||
}
|
||
|
||
// 6) Prüfe, auf welche dieser Wahlen der Character bereits als Candidate eingetragen ist
|
||
const existing = await Candidate.findAll({
|
||
where: {
|
||
electionId: { [Op.in]: toTry },
|
||
characterId: character.id
|
||
},
|
||
attributes: ['electionId']
|
||
});
|
||
const alreadyIds = new Set(existing.map(c => c.electionId));
|
||
|
||
// 7) Erstelle Liste der Wahlen, für die er sich noch nicht beworben hat
|
||
const newApplications = toTry.filter(id => !alreadyIds.has(id));
|
||
const skipped = electionIds.filter(id => !newApplications.includes(id));
|
||
|
||
// 8) 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) {
|
||
try {
|
||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||
const character = await FalukantCharacter.findOne({ where: { userId: user.id } });
|
||
if (!character) {
|
||
throw new Error(`No FalukantCharacter found for user with id ${user.id}`);
|
||
}
|
||
|
||
// Produkt abrufen
|
||
const product = await ProductType.findOne({ where: { id: productId } });
|
||
if (!product) {
|
||
throw new Error(`Product not found with id ${productId}`);
|
||
}
|
||
|
||
// Prüfe ob sellCost vorhanden ist
|
||
if (product.sellCost === null || product.sellCost === undefined) {
|
||
throw new Error(`Product ${productId} has no sellCost defined`);
|
||
}
|
||
|
||
// Knowledge für dieses Produkt abrufen
|
||
const knowledge = await Knowledge.findOne({
|
||
where: { characterId: character.id, productId: productId }
|
||
});
|
||
const knowledgeFactor = knowledge?.knowledge || 0;
|
||
|
||
// Verwende die bereits existierende calcRegionalSellPrice Funktion
|
||
const price = await calcRegionalSellPrice(product, knowledgeFactor, regionId);
|
||
|
||
return { price };
|
||
} catch (error) {
|
||
console.error(`[getProductPriceInRegion] Error for productId=${productId}, regionId=${regionId}:`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Regionale Verkaufspreise (Wissen + town_product_worth), für Ertrags-/Gewinn-Tabelle.
|
||
* @param {{ networkWorth?: boolean, branchId?: number|null }} options — bei networkWorth: MAX(worth_percent)
|
||
* über alle Regionen der eigenen Niederlassungen (wie Daemon mit Fahrzeug / Filialnetz).
|
||
*/
|
||
async getAllProductPricesInRegion(hashedUserId, regionId, options = {}) {
|
||
try {
|
||
const networkWorth = Boolean(options.networkWorth);
|
||
const branchId = options.branchId != null ? Number(options.branchId) : null;
|
||
|
||
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}`);
|
||
}
|
||
|
||
let worthRegionIds = [regionId];
|
||
if (networkWorth) {
|
||
if (!branchId || Number.isNaN(branchId)) {
|
||
throw new Error('branchId is required when networkWorth is set');
|
||
}
|
||
const ownBranch = await Branch.findOne({
|
||
where: { id: branchId, falukantUserId: user.id },
|
||
attributes: ['id']
|
||
});
|
||
if (!ownBranch) {
|
||
throw new Error('Branch not found or not owned by user');
|
||
}
|
||
const userBranches = await Branch.findAll({
|
||
where: { falukantUserId: user.id },
|
||
attributes: ['regionId']
|
||
});
|
||
const ids = [...new Set(userBranches.map((b) => b.regionId).filter((id) => id != null))];
|
||
worthRegionIds = ids.length > 0 ? ids : [regionId];
|
||
}
|
||
|
||
const [products, knowledges, townWorths] = await Promise.all([
|
||
ProductType.findAll({ attributes: ['id', 'sellCost'] }),
|
||
Knowledge.findAll({
|
||
where: { characterId: character.id },
|
||
attributes: ['productId', 'knowledge']
|
||
}),
|
||
TownProductWorth.findAll({
|
||
where: { regionId: { [Op.in]: worthRegionIds } },
|
||
attributes: ['productId', 'regionId', 'worthPercent']
|
||
})
|
||
]);
|
||
|
||
const knowledgeMap = new Map(knowledges.map(k => [k.productId, k.knowledge || 0]));
|
||
const maxWorthByProduct = new Map();
|
||
for (const tw of townWorths) {
|
||
const w = tw.worthPercent ?? 50;
|
||
const prev = maxWorthByProduct.get(tw.productId);
|
||
maxWorthByProduct.set(tw.productId, prev == null ? w : Math.max(prev, w));
|
||
}
|
||
|
||
const prices = {};
|
||
for (const product of products) {
|
||
const worthPercent = maxWorthByProduct.get(product.id) ?? 50;
|
||
const knowledgeFactor = knowledgeMap.get(product.id) || 0;
|
||
const price = calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent);
|
||
if (price !== null) prices[product.id] = price;
|
||
}
|
||
return { prices };
|
||
} catch (error) {
|
||
console.error(`[getAllProductPricesInRegion] Error for regionId=${regionId}:`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async getProductPricesInCities(hashedUserId, productId, currentPrice, currentRegionId = null) {
|
||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||
const character = await FalukantCharacter.findOne({ where: { userId: user.id } });
|
||
if (!character) {
|
||
throw new Error(`No FalukantCharacter found for user with id ${user.id}`);
|
||
}
|
||
|
||
const [product, knowledge, cities, townWorths] = await Promise.all([
|
||
ProductType.findOne({ where: { id: productId } }),
|
||
Knowledge.findOne({
|
||
where: { characterId: character.id, productId: productId }
|
||
}),
|
||
RegionData.findAll({
|
||
attributes: ['id', 'name'],
|
||
include: [
|
||
{
|
||
model: RegionType,
|
||
as: 'regionType',
|
||
where: { labelTr: 'city' },
|
||
attributes: ['labelTr']
|
||
},
|
||
{
|
||
model: Branch,
|
||
as: 'branches',
|
||
where: { falukantUserId: user.id },
|
||
include: [
|
||
{
|
||
model: BranchType,
|
||
as: 'branchType',
|
||
attributes: ['labelTr']
|
||
}
|
||
],
|
||
attributes: ['branchTypeId'],
|
||
required: false
|
||
}
|
||
]
|
||
}),
|
||
TownProductWorth.findAll({
|
||
where: { productId: productId },
|
||
attributes: ['regionId', 'worthPercent']
|
||
})
|
||
]);
|
||
|
||
if (!product) {
|
||
throw new Error(`Product not found with id ${productId}`);
|
||
}
|
||
const knowledgeFactor = knowledge?.knowledge || 0;
|
||
const worthMap = new Map(townWorths.map(tw => [tw.regionId, tw.worthPercent]));
|
||
|
||
let currentRegionalPrice = currentPrice;
|
||
if (currentRegionId) {
|
||
const currentWorthPercent = worthMap.get(currentRegionId) || 50;
|
||
currentRegionalPrice = calcRegionalSellPriceSync(product, knowledgeFactor, currentWorthPercent) ?? currentPrice;
|
||
}
|
||
|
||
const results = [];
|
||
const PRICE_TOLERANCE = 0.01;
|
||
for (const city of cities) {
|
||
if (currentRegionId && city.id === currentRegionId) continue;
|
||
const worthPercent = worthMap.get(city.id) || 50;
|
||
const priceInCity = calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent);
|
||
if (priceInCity == null) continue;
|
||
if (priceInCity > currentRegionalPrice - PRICE_TOLERANCE) {
|
||
// 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;
|
||
}
|
||
|
||
/**
|
||
* Batch-Variante: Preise für mehrere Produkte in einem Request.
|
||
* @param {string} hashedUserId
|
||
* @param {Array<{ productId: number, currentPrice: number }>} items
|
||
* @param {number|null} currentRegionId
|
||
* @returns {Promise<Record<number, Array<{ regionId, regionName, price, branchType }>>>}
|
||
*/
|
||
async getProductPricesInCitiesBatch(hashedUserId, items, currentRegionId = null) {
|
||
if (!items || items.length === 0) return {};
|
||
const productIds = [...new Set(items.map(i => i.productId))];
|
||
const priceByProduct = new Map(items.map(i => [i.productId, i.currentPrice]));
|
||
|
||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||
const character = user.character || await FalukantCharacter.findOne({ where: { userId: user.id }, attributes: ['id'] });
|
||
if (!character) {
|
||
throw new Error(`No FalukantCharacter found for user with id ${user.id}`);
|
||
}
|
||
const characterId = character.id;
|
||
|
||
let citiesWithBranchType = FalukantService._citiesBatchCache?.get(user.id);
|
||
const now = Date.now();
|
||
if (citiesWithBranchType && citiesWithBranchType.expires > now) {
|
||
citiesWithBranchType = citiesWithBranchType.data;
|
||
} else {
|
||
const cityRows = await sequelize.query(
|
||
`SELECT r.id, r.name,
|
||
MAX(CASE WHEN bt.label_tr IN ('store','fullstack') THEN 2 WHEN bt.label_tr = 'production' THEN 1 ELSE 0 END) AS branch_type_sort
|
||
FROM falukant_data.region r
|
||
INNER JOIN falukant_type.region rt ON r.region_type_id = rt.id AND rt.label_tr = 'city'
|
||
LEFT JOIN falukant_data.branch b ON b.region_id = r.id AND b.falukant_user_id = :userId
|
||
LEFT JOIN falukant_type.branch bt ON b.branch_type_id = bt.id
|
||
GROUP BY r.id, r.name
|
||
ORDER BY r.id`,
|
||
{ replacements: { userId: user.id }, type: sequelize.QueryTypes.SELECT }
|
||
);
|
||
const branchTypeByCityId = new Map();
|
||
const cities = [];
|
||
for (const row of cityRows) {
|
||
cities.push({ id: row.id, name: row.name });
|
||
const sort = row.branch_type_sort ?? 0;
|
||
branchTypeByCityId.set(row.id, sort === 2 ? 'store' : sort === 1 ? 'production' : null);
|
||
}
|
||
citiesWithBranchType = { cities, branchTypeByCityId };
|
||
if (!FalukantService._citiesBatchCache) FalukantService._citiesBatchCache = new Map();
|
||
FalukantService._citiesBatchCache.set(user.id, { data: citiesWithBranchType, expires: now + 60000 });
|
||
}
|
||
const { cities, branchTypeByCityId } = citiesWithBranchType;
|
||
|
||
const [products, knowledges, townWorths] = await Promise.all([
|
||
ProductType.findAll({ where: { id: { [Op.in]: productIds } }, attributes: ['id', 'sellCost'] }),
|
||
Knowledge.findAll({
|
||
where: { characterId: characterId, productId: { [Op.in]: productIds } },
|
||
attributes: ['productId', 'knowledge']
|
||
}),
|
||
TownProductWorth.findAll({
|
||
where: { productId: { [Op.in]: productIds } },
|
||
attributes: ['productId', 'regionId', 'worthPercent']
|
||
})
|
||
]);
|
||
|
||
const knowledgeByProduct = new Map(knowledges.map(k => [k.productId, k.knowledge || 0]));
|
||
const worthByProductRegion = new Map(townWorths.map(tw => [`${tw.productId}-${tw.regionId}`, tw.worthPercent]));
|
||
|
||
const PRICE_TOLERANCE = 0.01;
|
||
const out = {};
|
||
|
||
for (const product of products) {
|
||
const knowledgeFactor = knowledgeByProduct.get(product.id) || 0;
|
||
const clientPriceRaw = priceByProduct.get(product.id);
|
||
const clientPriceNum = clientPriceRaw != null && clientPriceRaw !== ''
|
||
? Number(clientPriceRaw)
|
||
: NaN;
|
||
let currentRegionalPrice;
|
||
// Wie getProductPricesInCities: bei bekannter Standort-Region immer
|
||
// serverseitigen Verkaufspreis dieser Region als Referenz — nicht den
|
||
// Client-Wert (Ertrags-Tabelle kann MAX-Worth über Filialen nutzen).
|
||
if (currentRegionId) {
|
||
const wp = worthByProductRegion.get(`${product.id}-${currentRegionId}`) ?? 50;
|
||
currentRegionalPrice = calcRegionalSellPriceSync(product, knowledgeFactor, wp)
|
||
?? (!Number.isNaN(clientPriceNum) ? clientPriceNum : Number(product.sellCost) ?? 0);
|
||
} else if (!Number.isNaN(clientPriceNum)) {
|
||
currentRegionalPrice = clientPriceNum;
|
||
} else {
|
||
currentRegionalPrice = Number(product.sellCost) || 0;
|
||
}
|
||
|
||
const results = [];
|
||
for (const city of cities) {
|
||
if (currentRegionId && city.id === currentRegionId) continue;
|
||
const worthPercent = worthByProductRegion.get(`${product.id}-${city.id}`) ?? 50;
|
||
const priceInCity = calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent);
|
||
if (priceInCity == null) continue;
|
||
if (priceInCity <= currentRegionalPrice - PRICE_TOLERANCE) continue;
|
||
results.push({
|
||
regionId: city.id,
|
||
regionName: city.name,
|
||
price: priceInCity,
|
||
branchType: branchTypeByCityId.get(city.id) ?? null
|
||
});
|
||
}
|
||
results.sort((a, b) => b.price - a.price);
|
||
out[product.id] = results;
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
async renovate(hashedUserId, element) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
const house = await UserHouse.findOne({
|
||
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 getRaidTransportRegions(hashedUserId) {
|
||
await getFalukantUserOrFail(hashedUserId);
|
||
|
||
const regions = await RegionData.findAll({
|
||
where: {
|
||
regionTypeId: { [Op.in]: [4, 5] }
|
||
},
|
||
include: [
|
||
{
|
||
model: RegionType,
|
||
as: 'regionType',
|
||
attributes: ['id', 'labelTr']
|
||
}
|
||
],
|
||
attributes: ['id', 'name', 'regionTypeId'],
|
||
order: [['name', 'ASC']]
|
||
});
|
||
|
||
return regions
|
||
.filter((region) => region.regionType?.labelTr !== 'town')
|
||
.map((region) => ({
|
||
id: region.id,
|
||
name: region.name,
|
||
regionTypeId: region.regionTypeId,
|
||
regionTypeLabel: region.regionType?.labelTr || null
|
||
}));
|
||
}
|
||
|
||
|
||
async getNotifications(hashedUserId) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
const notifications = await Notification.findAll({
|
||
where: { userId: user.id, shown: false },
|
||
order: [['createdAt', 'DESC']]
|
||
});
|
||
|
||
// Enrich notifications: parse JSON payloads and resolve character names
|
||
await enrichNotificationsWithCharacterNames(notifications);
|
||
|
||
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,
|
||
});
|
||
|
||
await enrichNotificationsWithCharacterNames(rows);
|
||
|
||
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 };
|
||
}
|
||
|
||
/**
|
||
* Kompakte Daten für das Dashboard-Widget (Charakter-Name, Geschlecht, Alter, Geld, ungelesene Nachrichten, Kinder).
|
||
* @param {string} hashedUserId
|
||
* @returns {Promise<{ characterName: string, gender: string|null, age: number|null, money: number, unreadNotificationsCount: number, childrenCount: number }>}
|
||
*/
|
||
async getDashboardWidget(hashedUserId) {
|
||
const falukantUser = await FalukantUser.findOne({
|
||
include: [
|
||
{ model: User, as: 'user', attributes: [], where: { hashedId: hashedUserId } },
|
||
{
|
||
model: FalukantCharacter,
|
||
as: 'character',
|
||
attributes: ['id', 'birthdate', 'gender'],
|
||
include: [
|
||
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
|
||
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] },
|
||
{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] }
|
||
]
|
||
}
|
||
],
|
||
attributes: ['id', 'money']
|
||
});
|
||
if (!falukantUser || !falukantUser.character) {
|
||
throw new Error('No Falukant character found for this user');
|
||
}
|
||
const character = falukantUser.character;
|
||
const firstName = character.definedFirstName?.name ?? '';
|
||
const lastName = character.definedLastName?.name ?? '';
|
||
const titleLabelTr = character.nobleTitle?.labelTr ?? '';
|
||
const nameWithoutTitle = [firstName, lastName].filter(Boolean).join(' ') || '—';
|
||
const characterName = titleLabelTr ? [titleLabelTr, firstName, lastName].filter(Boolean).join(' ') : nameWithoutTitle;
|
||
const age = character.birthdate ? calcAge(character.birthdate) : null;
|
||
|
||
const [unreadNotificationsCount, childrenCount, debtorsPrison] = await Promise.all([
|
||
Notification.count({ where: { userId: falukantUser.id, shown: false } }),
|
||
ChildRelation.count({
|
||
where: {
|
||
[Op.or]: [
|
||
{ fatherCharacterId: character.id },
|
||
{ motherCharacterId: character.id }
|
||
]
|
||
}
|
||
}),
|
||
this.getDebtorsPrisonStateForUser(falukantUser)
|
||
]);
|
||
|
||
return {
|
||
characterName,
|
||
titleLabelTr: titleLabelTr || null,
|
||
nameWithoutTitle,
|
||
gender: character.gender ?? null,
|
||
age,
|
||
money: Number(falukantUser.money ?? 0),
|
||
unreadNotificationsCount,
|
||
childrenCount,
|
||
debtorsPrison
|
||
};
|
||
}
|
||
|
||
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();
|
||
const POLITICAL_TAX_EXEMPTIONS = {
|
||
council: ['city'],
|
||
taxman: ['city', 'county'],
|
||
treasurer: ['city', 'county', 'shire'],
|
||
'super-state-administrator': ['city', 'county', 'shire', 'markgrave', 'duchy'],
|
||
chancellor: ['*']
|
||
};
|
||
|
||
histories.forEach(h => {
|
||
const c = h.holder;
|
||
if (c && c.id && !map.has(c.id)) {
|
||
const officeName = h.type?.name;
|
||
const benefit = POLITICAL_TAX_EXEMPTIONS[officeName] || [];
|
||
map.set(c.id, {
|
||
id: c.id,
|
||
name: `${c.definedFirstName.name} ${c.definedLastName.name}`,
|
||
title: c.nobleTitle.labelTr,
|
||
officeType: officeName,
|
||
gender: c.gender,
|
||
benefit
|
||
});
|
||
}
|
||
});
|
||
|
||
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 => ({
|
||
characterId: c.id,
|
||
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, regionId, bandSize } = 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) Typ-spezifische Validierung
|
||
const undergroundType = await UndergroundType.findByPk(typeId);
|
||
if (!undergroundType) {
|
||
throw new Error('Invalid underground type');
|
||
}
|
||
|
||
let victimChar = null;
|
||
if (undergroundType.tr !== 'raid_transport') {
|
||
victimChar = await FalukantCharacter.findOne({
|
||
include: [
|
||
{
|
||
model: FalukantUser,
|
||
as: 'user',
|
||
required: true,
|
||
attributes: [],
|
||
include: [
|
||
{
|
||
model: User,
|
||
as: 'user',
|
||
required: true,
|
||
where: { username: victimUsername },
|
||
attributes: []
|
||
}
|
||
]
|
||
}
|
||
]
|
||
});
|
||
|
||
if (!victimChar) {
|
||
throw new PreconditionError('Victim character not found');
|
||
}
|
||
|
||
if (victimChar.id === performerChar.id) {
|
||
throw new PreconditionError('Cannot target yourself');
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
if (undergroundType.tr === 'investigate_affair') {
|
||
if (!goal || !['expose', 'blackmail'].includes(goal)) {
|
||
throw new PreconditionError('Affair investigation goal missing');
|
||
}
|
||
}
|
||
|
||
if (undergroundType.tr === 'raid_transport') {
|
||
const parsedRegionId = Number.parseInt(regionId, 10);
|
||
const parsedBandSize = Math.max(1, Number.parseInt(bandSize, 10) || 0);
|
||
|
||
if (!parsedRegionId) {
|
||
throw new PreconditionError('Raid region missing');
|
||
}
|
||
if (!parsedBandSize) {
|
||
throw new PreconditionError('Band size missing');
|
||
}
|
||
|
||
const validRegion = await RegionData.findOne({
|
||
where: {
|
||
id: parsedRegionId,
|
||
regionTypeId: { [Op.in]: [4, 5] }
|
||
},
|
||
include: [
|
||
{
|
||
model: RegionType,
|
||
as: 'regionType',
|
||
attributes: ['labelTr']
|
||
}
|
||
],
|
||
attributes: ['id', 'name']
|
||
});
|
||
|
||
if (!validRegion || validRegion.regionType?.labelTr === 'town') {
|
||
throw new PreconditionError('Invalid raid region');
|
||
}
|
||
}
|
||
|
||
const defaultResult = undergroundType.tr === 'raid_transport'
|
||
? {
|
||
status: 'pending',
|
||
outcome: null,
|
||
attempts: 0,
|
||
successes: 0,
|
||
lastTargetTransportId: null,
|
||
lastLoot: null,
|
||
lastOutcome: null
|
||
}
|
||
: {
|
||
status: 'pending',
|
||
outcome: null,
|
||
discoveries: null,
|
||
visibilityDelta: 0,
|
||
reputationDelta: 0,
|
||
blackmailAmount: 0
|
||
};
|
||
|
||
const newEntry = await Underground.create({
|
||
undergroundTypeId: typeId,
|
||
performerId: performerChar.id,
|
||
victimId: victimChar?.id || null,
|
||
result: defaultResult,
|
||
parameters: {
|
||
target: target || null,
|
||
goal: goal || null,
|
||
politicalTargets: politicalTargets || null,
|
||
regionId: regionId || null,
|
||
bandSize: bandSize || null
|
||
}
|
||
});
|
||
|
||
return newEntry;
|
||
}
|
||
|
||
async getUndergroundActivities(hashedUserId) {
|
||
const falukantUser = await getFalukantUserOrFail(hashedUserId);
|
||
const character = await FalukantCharacter.findOne({
|
||
where: { userId: falukantUser.id }
|
||
});
|
||
if (!character) throw new Error('Character not found');
|
||
|
||
const activities = await Underground.findAll({
|
||
where: { performerId: character.id },
|
||
include: [
|
||
{
|
||
model: FalukantCharacter,
|
||
as: 'victim',
|
||
include: [
|
||
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
|
||
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }
|
||
],
|
||
attributes: ['id', 'gender']
|
||
},
|
||
{
|
||
model: UndergroundType,
|
||
as: 'undergroundType',
|
||
attributes: ['tr', 'cost']
|
||
}
|
||
],
|
||
order: [['createdAt', 'DESC']]
|
||
});
|
||
|
||
const regionIds = [...new Set(
|
||
activities
|
||
.map((activity) => Number.parseInt(activity.parameters?.regionId, 10))
|
||
.filter((id) => !Number.isNaN(id) && id > 0)
|
||
)];
|
||
const regions = regionIds.length
|
||
? await RegionData.findAll({
|
||
where: { id: regionIds },
|
||
attributes: ['id', 'name']
|
||
})
|
||
: [];
|
||
const regionMap = new Map(regions.map((region) => [region.id, region.name]));
|
||
|
||
return activities.map((activity) => {
|
||
const result = activity.result || {};
|
||
const status = result.status || (result.outcome ? 'resolved' : 'pending');
|
||
return {
|
||
id: activity.id,
|
||
type: activity.undergroundType?.tr || null,
|
||
cost: activity.undergroundType?.cost || null,
|
||
victimName: `${activity.victim?.definedFirstName?.name || ''} ${activity.victim?.definedLastName?.name || ''}`.trim() || '—',
|
||
createdAt: activity.createdAt,
|
||
status,
|
||
success: result.outcome === 'success',
|
||
target: activity.parameters?.target || null,
|
||
goal: activity.parameters?.goal || null,
|
||
regionName: regionMap.get(Number.parseInt(activity.parameters?.regionId, 10)) || null,
|
||
additionalInfo: {
|
||
discoveries: result.discoveries || null,
|
||
visibilityDelta: result.visibilityDelta ?? null,
|
||
reputationDelta: result.reputationDelta ?? null,
|
||
blackmailAmount: result.blackmailAmount ?? null,
|
||
bandSize: activity.parameters?.bandSize ?? null,
|
||
attempts: result.attempts ?? null,
|
||
successes: result.successes ?? null,
|
||
lastOutcome: result.lastOutcome ?? null
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
|
||
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
|
||
};
|
||
}
|
||
|
||
async getChurchOverview(hashedUserId) {
|
||
try {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
const character = await FalukantCharacter.findOne({
|
||
where: { userId: user.id },
|
||
attributes: ['id', 'regionId']
|
||
});
|
||
if (!character) {
|
||
console.log('[getChurchOverview] No character found for user', user.id);
|
||
return [];
|
||
}
|
||
|
||
// Alle relevanten Regionen (Region + Eltern) laden
|
||
const relevantRegionIds = await this.getRegionAndParentIds(character.regionId);
|
||
console.log('[getChurchOverview] Relevant region IDs:', relevantRegionIds);
|
||
|
||
// Aktuell besetzte Kirchenämter in diesen Regionen laden
|
||
const offices = await ChurchOffice.findAll({
|
||
where: {
|
||
regionId: {
|
||
[Op.in]: relevantRegionIds
|
||
}
|
||
},
|
||
include: [
|
||
{
|
||
model: ChurchOfficeType,
|
||
as: 'type',
|
||
attributes: ['name', 'hierarchyLevel']
|
||
},
|
||
{
|
||
model: RegionData,
|
||
as: 'region',
|
||
attributes: ['name']
|
||
},
|
||
{
|
||
model: FalukantCharacter,
|
||
as: 'holder',
|
||
attributes: ['id', 'gender'],
|
||
required: false,
|
||
include: [
|
||
{
|
||
model: FalukantPredefineFirstname,
|
||
as: 'definedFirstName',
|
||
attributes: ['name'],
|
||
required: false
|
||
},
|
||
{
|
||
model: FalukantPredefineLastname,
|
||
as: 'definedLastName',
|
||
attributes: ['name'],
|
||
required: false
|
||
},
|
||
{
|
||
model: TitleOfNobility,
|
||
as: 'nobleTitle',
|
||
attributes: ['labelTr'],
|
||
required: false
|
||
}
|
||
]
|
||
},
|
||
{
|
||
model: FalukantCharacter,
|
||
as: 'supervisor',
|
||
attributes: ['id', 'gender'],
|
||
required: false,
|
||
include: [
|
||
{
|
||
model: FalukantPredefineFirstname,
|
||
as: 'definedFirstName',
|
||
attributes: ['name'],
|
||
required: false
|
||
},
|
||
{
|
||
model: FalukantPredefineLastname,
|
||
as: 'definedLastName',
|
||
attributes: ['name'],
|
||
required: false
|
||
}
|
||
]
|
||
}
|
||
],
|
||
order: [
|
||
[{ model: ChurchOfficeType, as: 'type' }, 'hierarchyLevel', 'DESC'],
|
||
[{ model: RegionData, as: 'region' }, 'name', 'ASC']
|
||
]
|
||
});
|
||
|
||
console.log('[getChurchOverview] Found', offices.length, 'offices');
|
||
return offices.map(office => {
|
||
const o = office.get({ plain: true });
|
||
return {
|
||
id: o.id,
|
||
officeType: {
|
||
name: o.type?.name
|
||
},
|
||
region: {
|
||
name: o.region?.name
|
||
},
|
||
character: o.holder
|
||
? {
|
||
id: o.holder.id,
|
||
name: `${o.holder.definedFirstName?.name || ''} ${o.holder.definedLastName?.name || ''}`.trim(),
|
||
gender: o.holder.gender,
|
||
title: o.holder.nobleTitle?.labelTr
|
||
}
|
||
: null,
|
||
supervisor: o.supervisor
|
||
? {
|
||
id: o.supervisor.id,
|
||
name: `${o.supervisor.definedFirstName?.name || ''} ${o.supervisor.definedLastName?.name || ''}`.trim()
|
||
}
|
||
: null
|
||
};
|
||
});
|
||
} catch (error) {
|
||
console.error('[getChurchOverview] Error:', error);
|
||
console.error('[getChurchOverview] Stack:', error.stack);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async getChurchCareerInfo(characterId) {
|
||
const [currentOffices, approvedApplications] = await Promise.all([
|
||
ChurchOffice.findAll({
|
||
where: { characterId },
|
||
include: [{
|
||
model: ChurchOfficeType,
|
||
as: 'type',
|
||
attributes: ['id', 'name', 'hierarchyLevel']
|
||
}]
|
||
}),
|
||
ChurchApplication.findAll({
|
||
where: {
|
||
characterId,
|
||
status: 'approved'
|
||
},
|
||
include: [{
|
||
model: ChurchOfficeType,
|
||
as: 'officeType',
|
||
attributes: ['id', 'name', 'hierarchyLevel']
|
||
}]
|
||
})
|
||
]);
|
||
|
||
const currentLevels = currentOffices.map((office) => Number(office.type?.hierarchyLevel ?? -1));
|
||
const approvedLevels = approvedApplications.map((application) => Number(application.officeType?.hierarchyLevel ?? -1));
|
||
const highestCurrentLevel = currentLevels.length ? Math.max(...currentLevels) : -1;
|
||
const highestEverLevel = [...currentLevels, ...approvedLevels].length
|
||
? Math.max(...currentLevels, ...approvedLevels)
|
||
: -1;
|
||
|
||
const currentHighest = currentOffices
|
||
.map((office) => office.type)
|
||
.filter(Boolean)
|
||
.sort((a, b) => Number(b.hierarchyLevel || 0) - Number(a.hierarchyLevel || 0))[0] || null;
|
||
|
||
const allAttained = [
|
||
...currentOffices.map((office) => office.type),
|
||
...approvedApplications.map((application) => application.officeType)
|
||
]
|
||
.filter(Boolean)
|
||
.sort((a, b) => Number(b.hierarchyLevel || 0) - Number(a.hierarchyLevel || 0));
|
||
|
||
const highestEver = allAttained[0] || null;
|
||
|
||
return {
|
||
highestCurrentLevel,
|
||
highestEverLevel,
|
||
highestCurrentOffice: currentHighest ? {
|
||
id: currentHighest.id,
|
||
name: currentHighest.name,
|
||
hierarchyLevel: currentHighest.hierarchyLevel
|
||
} : null,
|
||
highestEverOffice: highestEver ? {
|
||
id: highestEver.id,
|
||
name: highestEver.name,
|
||
hierarchyLevel: highestEver.hierarchyLevel
|
||
} : null
|
||
};
|
||
}
|
||
|
||
async getAvailableChurchPositions(hashedUserId) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
const character = await FalukantCharacter.findOne({
|
||
where: { userId: user.id },
|
||
attributes: ['id', 'regionId']
|
||
});
|
||
if (!character) {
|
||
return [];
|
||
}
|
||
|
||
const churchCareer = await this.getChurchCareerInfo(character.id);
|
||
|
||
// Prüfe welche Kirchenämter der Charakter bereits innehat
|
||
const heldOffices = await ChurchOffice.findAll({
|
||
where: { characterId: character.id },
|
||
include: [
|
||
{
|
||
model: ChurchOfficeType,
|
||
as: 'type',
|
||
attributes: ['id']
|
||
}
|
||
]
|
||
});
|
||
const heldOfficeTypeIds = heldOffices.map(o => o.type.id);
|
||
|
||
// Prüfe welche Bewerbungen bereits existieren
|
||
const existingApplications = await ChurchApplication.findAll({
|
||
where: {
|
||
characterId: character.id,
|
||
status: 'pending'
|
||
},
|
||
attributes: ['officeTypeId', 'regionId']
|
||
});
|
||
const appliedPositions = new Set(
|
||
existingApplications.map(a => `${a.officeTypeId}-${a.regionId}`)
|
||
);
|
||
|
||
// Alle relevanten Regionen (Region + Eltern) laden
|
||
const relevantRegionIds = await this.getRegionAndParentIds(character.regionId);
|
||
|
||
// Alle Kirchenamt-Typen mit Voraussetzungen laden
|
||
const officeTypes = await ChurchOfficeType.findAll({
|
||
include: [
|
||
{
|
||
model: ChurchOfficeRequirement,
|
||
as: 'requirements',
|
||
required: false,
|
||
attributes: ['id', 'officeTypeId', 'prerequisiteOfficeTypeId']
|
||
}
|
||
],
|
||
order: [['hierarchyLevel', 'ASC']]
|
||
});
|
||
|
||
console.log(`[getAvailableChurchPositions] Loaded ${officeTypes.length} office types. Held offices:`, heldOfficeTypeIds);
|
||
// Debug: Zeige alle geladenen Voraussetzungen
|
||
officeTypes.forEach(ot => {
|
||
if (ot.requirements && ot.requirements.length > 0) {
|
||
console.log(` - ${ot.name} (id=${ot.id}): prerequisiteOfficeTypeId=${ot.requirements[0].prerequisiteOfficeTypeId}`);
|
||
} else {
|
||
console.log(` - ${ot.name} (id=${ot.id}): NO REQUIREMENT DEFINED`);
|
||
}
|
||
});
|
||
|
||
const availablePositions = [];
|
||
|
||
for (const officeType of officeTypes) {
|
||
// Prüfe Voraussetzungen: Maßgeblich ist die höchste bisherige Kirchenlaufbahn,
|
||
// nicht nur das aktuell gehaltene Amt.
|
||
const requirement = officeType.requirements?.[0];
|
||
|
||
console.log(`[getAvailableChurchPositions] Checking ${officeType.name} (id=${officeType.id}, hierarchyLevel=${officeType.hierarchyLevel}):`, {
|
||
hasRequirement: !!requirement,
|
||
prerequisiteOfficeTypeId: requirement?.prerequisiteOfficeTypeId,
|
||
heldOfficeTypeIds: heldOfficeTypeIds
|
||
});
|
||
|
||
// Prüfe Voraussetzungen
|
||
if (requirement) {
|
||
// Wenn eine Voraussetzung definiert ist
|
||
const prerequisiteId = requirement.prerequisiteOfficeTypeId;
|
||
if (prerequisiteId !== null && prerequisiteId !== undefined) {
|
||
const prerequisiteOffice = await ChurchOfficeType.findByPk(prerequisiteId, {
|
||
attributes: ['id', 'hierarchyLevel']
|
||
});
|
||
const requiredLevel = Number(prerequisiteOffice?.hierarchyLevel ?? (officeType.hierarchyLevel - 1));
|
||
if (churchCareer.highestEverLevel < requiredLevel) {
|
||
console.log(`[getAvailableChurchPositions] Skipping ${officeType.name}: User career too low for prerequisite level ${requiredLevel}. Highest ever:`, churchCareer.highestEverLevel);
|
||
continue; // Voraussetzung nicht erfüllt - User hat das erforderliche Amt nicht
|
||
}
|
||
}
|
||
// Wenn prerequisiteOfficeTypeId === null, dann keine Voraussetzung = Einstiegsposition, OK
|
||
} else {
|
||
// Wenn keine Voraussetzung in der DB definiert ist, bedeutet das:
|
||
// - Entweder ist es eine Einstiegsposition (hierarchyLevel 0)
|
||
// - Oder die Voraussetzung wurde noch nicht initialisiert
|
||
// Sicherheitshalber: Nur Einstiegspositionen ohne Voraussetzung erlauben
|
||
if (officeType.hierarchyLevel !== 0) {
|
||
console.log(`[getAvailableChurchPositions] Skipping ${officeType.name}: No requirement defined in DB and hierarchyLevel > 0 (${officeType.hierarchyLevel}). This might be a configuration issue.`);
|
||
continue; // Keine Voraussetzung definiert für höheres Amt = vermutlich Konfigurationsfehler
|
||
}
|
||
}
|
||
|
||
// Prüfe ob der User bereits dieses Amt innehat
|
||
if (heldOfficeTypeIds.includes(officeType.id)) {
|
||
continue; // User hat bereits dieses Amt
|
||
}
|
||
|
||
// Finde den RegionType für diesen officeType
|
||
const regionType = await RegionType.findOne({
|
||
where: { labelTr: officeType.regionType }
|
||
});
|
||
if (!regionType) continue;
|
||
|
||
// Finde alle Regionen dieses Typs in den relevanten Regionen
|
||
const regions = await RegionData.findAll({
|
||
where: {
|
||
id: { [Op.in]: relevantRegionIds },
|
||
regionTypeId: regionType.id
|
||
},
|
||
attributes: ['id', 'name']
|
||
});
|
||
|
||
for (const region of regions) {
|
||
// Prüfe ob bereits eine Bewerbung für diese Position existiert
|
||
const applicationKey = `${officeType.id}-${region.id}`;
|
||
if (appliedPositions.has(applicationKey)) {
|
||
continue; // Bereits beworben
|
||
}
|
||
|
||
// Prüfe ob der User bereits dieses Amt in dieser Region innehat
|
||
const hasOfficeInRegion = await ChurchOffice.findOne({
|
||
where: {
|
||
characterId: character.id,
|
||
officeTypeId: officeType.id,
|
||
regionId: region.id
|
||
}
|
||
});
|
||
if (hasOfficeInRegion) {
|
||
continue; // User hat bereits dieses Amt in dieser Region
|
||
}
|
||
|
||
// Zähle besetzte Positionen dieses Typs in dieser Region
|
||
const occupiedCount = await ChurchOffice.count({
|
||
where: {
|
||
officeTypeId: officeType.id,
|
||
regionId: region.id
|
||
}
|
||
});
|
||
|
||
const availableSeats = officeType.seatsPerRegion - occupiedCount;
|
||
|
||
if (availableSeats > 0) {
|
||
// Finde den Supervisor (höheres Amt in derselben Region oder Eltern-Region)
|
||
let supervisor = null;
|
||
let decisionMode = officeType.hierarchyLevel === 0 ? 'entry' : 'interim';
|
||
const higherOfficeTypeIds = await ChurchOfficeType.findAll({
|
||
where: {
|
||
hierarchyLevel: { [Op.gt]: officeType.hierarchyLevel }
|
||
},
|
||
attributes: ['id']
|
||
}).then(types => types.map(t => t.id));
|
||
|
||
if (higherOfficeTypeIds.length > 0) {
|
||
const supervisorOffice = await ChurchOffice.findOne({
|
||
where: {
|
||
regionId: region.id,
|
||
officeTypeId: { [Op.in]: higherOfficeTypeIds }
|
||
},
|
||
include: [
|
||
{
|
||
model: ChurchOfficeType,
|
||
as: 'type',
|
||
attributes: ['hierarchyLevel']
|
||
},
|
||
{
|
||
model: FalukantCharacter,
|
||
as: 'holder',
|
||
attributes: ['id', 'userId'],
|
||
include: [
|
||
{
|
||
model: FalukantPredefineFirstname,
|
||
as: 'definedFirstName',
|
||
attributes: ['name'],
|
||
required: false
|
||
},
|
||
{
|
||
model: FalukantPredefineLastname,
|
||
as: 'definedLastName',
|
||
attributes: ['name'],
|
||
required: false
|
||
}
|
||
]
|
||
}
|
||
],
|
||
order: [
|
||
[{ model: ChurchOfficeType, as: 'type' }, 'hierarchyLevel', 'ASC']
|
||
],
|
||
limit: 1
|
||
});
|
||
|
||
if (supervisorOffice && supervisorOffice.holder) {
|
||
supervisor = {
|
||
id: supervisorOffice.holder.id,
|
||
name: `${supervisorOffice.holder.definedFirstName?.name || ''} ${supervisorOffice.holder.definedLastName?.name || ''}`.trim() || 'Supervisor',
|
||
controlledBy: supervisorOffice.holder.userId ? 'player' : 'npc'
|
||
};
|
||
decisionMode = supervisor.controlledBy;
|
||
}
|
||
}
|
||
|
||
availablePositions.push({
|
||
id: officeType.id, // Verwende officeTypeId als ID für die Frontend-Identifikation
|
||
officeType: {
|
||
name: officeType.name
|
||
},
|
||
region: {
|
||
name: region.name,
|
||
id: region.id
|
||
},
|
||
regionId: region.id,
|
||
availableSeats: availableSeats,
|
||
supervisor: supervisor,
|
||
decisionMode
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return availablePositions;
|
||
}
|
||
|
||
async getSupervisedApplications(hashedUserId) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
const character = await FalukantCharacter.findOne({
|
||
where: { userId: user.id },
|
||
attributes: ['id']
|
||
});
|
||
if (!character) {
|
||
return [];
|
||
}
|
||
|
||
// Finde alle Kirchenämter, die dieser Charakter hält
|
||
const heldOffices = await ChurchOffice.findAll({
|
||
where: { characterId: character.id },
|
||
include: [
|
||
{
|
||
model: ChurchOfficeType,
|
||
as: 'type',
|
||
attributes: ['id', 'hierarchyLevel']
|
||
}
|
||
]
|
||
});
|
||
|
||
if (heldOffices.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
// Finde alle niedrigeren Ämter, die dieser Charakter superviden kann
|
||
const maxHierarchyLevel = Math.max(...heldOffices.map(o => o.type.hierarchyLevel));
|
||
const supervisedOfficeTypeIds = await ChurchOfficeType.findAll({
|
||
where: {
|
||
hierarchyLevel: { [Op.lt]: maxHierarchyLevel }
|
||
},
|
||
attributes: ['id']
|
||
}).then(types => types.map(t => t.id));
|
||
|
||
// Finde alle Bewerbungen für diese Ämter, bei denen dieser Charakter Supervisor ist
|
||
const applications = await ChurchApplication.findAll({
|
||
where: {
|
||
supervisorId: character.id,
|
||
status: 'pending',
|
||
officeTypeId: { [Op.in]: supervisedOfficeTypeIds }
|
||
},
|
||
include: [
|
||
{
|
||
model: ChurchOfficeType,
|
||
as: 'officeType',
|
||
attributes: ['name']
|
||
},
|
||
{
|
||
model: RegionData,
|
||
as: 'region',
|
||
attributes: ['name']
|
||
},
|
||
{
|
||
model: FalukantCharacter,
|
||
as: 'applicant',
|
||
attributes: ['id', 'gender', 'age'],
|
||
include: [
|
||
{
|
||
model: FalukantPredefineFirstname,
|
||
as: 'definedFirstName',
|
||
attributes: ['name']
|
||
},
|
||
{
|
||
model: FalukantPredefineLastname,
|
||
as: 'definedLastName',
|
||
attributes: ['name']
|
||
},
|
||
{
|
||
model: TitleOfNobility,
|
||
as: 'nobleTitle',
|
||
attributes: ['labelTr']
|
||
}
|
||
]
|
||
}
|
||
],
|
||
order: [['createdAt', 'DESC']]
|
||
});
|
||
|
||
return applications.map(app => {
|
||
const a = app.get({ plain: true });
|
||
return {
|
||
id: a.id,
|
||
officeType: {
|
||
name: a.officeType?.name
|
||
},
|
||
region: {
|
||
name: a.region?.name
|
||
},
|
||
applicant: {
|
||
id: a.applicant.id,
|
||
name: `${a.applicant.definedFirstName?.name || ''} ${a.applicant.definedLastName?.name || ''}`.trim(),
|
||
gender: a.applicant.gender,
|
||
age: a.applicant.age,
|
||
title: a.applicant.nobleTitle?.labelTr
|
||
},
|
||
createdAt: a.createdAt
|
||
};
|
||
});
|
||
}
|
||
|
||
async applyForChurchPosition(hashedUserId, officeTypeId, regionId) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
const character = await FalukantCharacter.findOne({
|
||
where: { userId: user.id },
|
||
attributes: ['id', 'regionId']
|
||
});
|
||
if (!character) {
|
||
throw new Error('falukant.church.available.errors.characterNotFound');
|
||
}
|
||
|
||
const churchCareer = await this.getChurchCareerInfo(character.id);
|
||
|
||
// Prüfe ob Position verfügbar ist
|
||
const officeType = await ChurchOfficeType.findByPk(officeTypeId);
|
||
if (!officeType) {
|
||
throw new Error('falukant.church.available.errors.officeTypeNotFound');
|
||
}
|
||
|
||
const requirement = await ChurchOfficeRequirement.findOne({
|
||
where: { officeTypeId },
|
||
attributes: ['prerequisiteOfficeTypeId']
|
||
});
|
||
if (requirement?.prerequisiteOfficeTypeId) {
|
||
const prerequisiteOffice = await ChurchOfficeType.findByPk(requirement.prerequisiteOfficeTypeId, {
|
||
attributes: ['hierarchyLevel']
|
||
});
|
||
const requiredLevel = Number(prerequisiteOffice?.hierarchyLevel ?? (officeType.hierarchyLevel - 1));
|
||
if (churchCareer.highestEverLevel < requiredLevel) {
|
||
throw new Error('falukant.church.available.errors.churchCareerTooLow');
|
||
}
|
||
} else if (officeType.hierarchyLevel > 0 && churchCareer.highestEverLevel < officeType.hierarchyLevel - 1) {
|
||
throw new Error('falukant.church.available.errors.churchCareerTooLow');
|
||
}
|
||
|
||
const occupiedCount = await ChurchOffice.count({
|
||
where: {
|
||
officeTypeId: officeTypeId,
|
||
regionId: regionId
|
||
}
|
||
});
|
||
|
||
if (occupiedCount >= officeType.seatsPerRegion) {
|
||
throw new Error('falukant.church.available.errors.noAvailableSeats');
|
||
}
|
||
|
||
// Finde Supervisor (nur wenn es nicht die niedrigste Position ist)
|
||
let supervisorId = null;
|
||
if (officeType.hierarchyLevel > 0) {
|
||
const higherOfficeTypeIds = await ChurchOfficeType.findAll({
|
||
where: {
|
||
hierarchyLevel: { [Op.gt]: officeType.hierarchyLevel }
|
||
},
|
||
attributes: ['id']
|
||
}).then(types => types.map(t => t.id));
|
||
|
||
if (higherOfficeTypeIds.length > 0) {
|
||
const supervisorOffice = await ChurchOffice.findOne({
|
||
where: {
|
||
regionId: regionId,
|
||
officeTypeId: { [Op.in]: higherOfficeTypeIds }
|
||
},
|
||
include: [
|
||
{
|
||
model: ChurchOfficeType,
|
||
as: 'type',
|
||
attributes: ['id', 'name', 'hierarchyLevel']
|
||
},
|
||
{
|
||
model: FalukantCharacter,
|
||
as: 'holder',
|
||
attributes: ['id']
|
||
}
|
||
],
|
||
order: [
|
||
[{ model: ChurchOfficeType, as: 'type' }, 'hierarchyLevel', 'ASC']
|
||
],
|
||
limit: 1
|
||
});
|
||
|
||
if (supervisorOffice?.holder) {
|
||
supervisorId = supervisorOffice.holder.id;
|
||
} else {
|
||
supervisorId = null;
|
||
}
|
||
} else {
|
||
supervisorId = null;
|
||
}
|
||
}
|
||
// Für Einstiegspositionen (hierarchyLevel 0) ist kein Supervisor erforderlich
|
||
|
||
// Prüfe ob bereits eine Bewerbung existiert
|
||
const existingApplication = await ChurchApplication.findOne({
|
||
where: {
|
||
characterId: character.id,
|
||
officeTypeId: officeTypeId,
|
||
regionId: regionId,
|
||
status: 'pending'
|
||
}
|
||
});
|
||
|
||
if (existingApplication) {
|
||
throw new Error('falukant.church.available.errors.applicationAlreadyExists');
|
||
}
|
||
|
||
// Erstelle Bewerbung
|
||
await ChurchApplication.create({
|
||
officeTypeId: officeTypeId,
|
||
characterId: character.id,
|
||
regionId: regionId,
|
||
supervisorId: supervisorId, // Kann null sein für Einstiegspositionen
|
||
status: 'pending'
|
||
});
|
||
|
||
return { success: true };
|
||
}
|
||
|
||
async decideOnChurchApplication(hashedUserId, applicationId, decision) {
|
||
const user = await getFalukantUserOrFail(hashedUserId);
|
||
const character = await FalukantCharacter.findOne({
|
||
where: { userId: user.id },
|
||
attributes: ['id']
|
||
});
|
||
if (!character) {
|
||
throw new Error('Character not found');
|
||
}
|
||
|
||
const application = await ChurchApplication.findOne({
|
||
where: {
|
||
id: applicationId,
|
||
supervisorId: character.id,
|
||
status: 'pending'
|
||
},
|
||
include: [
|
||
{
|
||
model: ChurchOfficeType,
|
||
as: 'officeType',
|
||
attributes: ['id', 'seatsPerRegion']
|
||
}
|
||
]
|
||
});
|
||
|
||
if (!application) {
|
||
throw new Error('Application not found or not authorized');
|
||
}
|
||
|
||
if (decision === 'approve') {
|
||
// Prüfe ob noch Platz verfügbar ist
|
||
const occupiedCount = await ChurchOffice.count({
|
||
where: {
|
||
officeTypeId: application.officeTypeId,
|
||
regionId: application.regionId
|
||
}
|
||
});
|
||
|
||
if (occupiedCount >= application.officeType.seatsPerRegion) {
|
||
throw new Error('No available seats');
|
||
}
|
||
|
||
// Erstelle Kirchenamt
|
||
await ChurchOffice.create({
|
||
officeTypeId: application.officeTypeId,
|
||
characterId: application.characterId,
|
||
regionId: application.regionId,
|
||
supervisorId: application.supervisorId
|
||
});
|
||
}
|
||
|
||
// Aktualisiere Bewerbung
|
||
application.status = decision === 'approve' ? 'approved' : 'rejected';
|
||
application.decisionDate = new Date();
|
||
await application.save();
|
||
|
||
return { success: true };
|
||
}
|
||
}
|
||
|
||
export default new FalukantService();
|
||
|
||
// Helper: parse notifications for character references and attach characterName
|
||
async function enrichNotificationsWithCharacterNames(notifications) {
|
||
if (!Array.isArray(notifications) || notifications.length === 0) return;
|
||
|
||
const charIds = new Set();
|
||
|
||
// recursive collector that extracts any character id fields
|
||
function collectIds(obj) {
|
||
if (!obj) return;
|
||
if (Array.isArray(obj)) {
|
||
for (const it of obj) collectIds(it);
|
||
return;
|
||
}
|
||
if (typeof obj !== 'object') return;
|
||
for (const [k, v] of Object.entries(obj)) {
|
||
if (!v) continue;
|
||
if (k === 'character_id' || k === 'characterId') {
|
||
charIds.add(Number(v));
|
||
continue;
|
||
}
|
||
if (k === 'character' && typeof v === 'object') {
|
||
if (v.id) charIds.add(Number(v.id));
|
||
if (v.character_id) charIds.add(Number(v.character_id));
|
||
if (v.characterId) charIds.add(Number(v.characterId));
|
||
collectIds(v);
|
||
continue;
|
||
}
|
||
collectIds(v);
|
||
}
|
||
}
|
||
|
||
// First pass: collect all referenced character ids from notifications
|
||
for (const n of notifications) {
|
||
// parse n.tr if it's JSON
|
||
try {
|
||
if (typeof n.tr === 'string' && n.tr.trim().startsWith('{')) {
|
||
const parsed = JSON.parse(n.tr);
|
||
collectIds(parsed);
|
||
}
|
||
} catch (err) { /* ignore */ }
|
||
|
||
// parse n.effects if present
|
||
try {
|
||
if (n.effects) {
|
||
const eff = typeof n.effects === 'string' && n.effects.trim().startsWith('{') ? JSON.parse(n.effects) : n.effects;
|
||
collectIds(eff);
|
||
}
|
||
} catch (err) { /* ignore */ }
|
||
|
||
// top-level fields
|
||
if (n.character_id) charIds.add(Number(n.character_id));
|
||
if (n.characterId) charIds.add(Number(n.characterId));
|
||
}
|
||
|
||
const ids = Array.from(charIds).filter(Boolean);
|
||
if (!ids.length) return;
|
||
|
||
// Batch load characters and their display names
|
||
const characters = await FalukantCharacter.findAll({
|
||
where: { id: { [Op.in]: ids } },
|
||
include: [
|
||
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
|
||
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }
|
||
],
|
||
attributes: ['id']
|
||
});
|
||
|
||
const nameMap = new Map();
|
||
for (const c of characters) {
|
||
const first = c.definedFirstName?.name || '';
|
||
const last = c.definedLastName?.name || '';
|
||
const display = `${first} ${last}`.trim() || null;
|
||
nameMap.set(Number(c.id), display || `#${c.id}`);
|
||
}
|
||
|
||
// helper to find first character id in an object
|
||
function findFirstId(obj) {
|
||
if (!obj) return null;
|
||
if (Array.isArray(obj)) {
|
||
for (const it of obj) {
|
||
const r = findFirstId(it);
|
||
if (r) return r;
|
||
}
|
||
return null;
|
||
}
|
||
if (typeof obj !== 'object') return null;
|
||
for (const [k, v] of Object.entries(obj)) {
|
||
if (!v) continue;
|
||
if (k === 'character_id' || k === 'characterId') return Number(v);
|
||
if (k === 'character' && typeof v === 'object') {
|
||
if (v.id) return Number(v.id);
|
||
const r = findFirstId(v);
|
||
if (r) return r;
|
||
}
|
||
const r = findFirstId(v);
|
||
if (r) return r;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Attach resolved name to notifications (set character_name; characterName is a getter that reads from it)
|
||
for (const n of notifications) {
|
||
let foundId = null;
|
||
try {
|
||
if (typeof n.tr === 'string' && n.tr.trim().startsWith('{')) {
|
||
const parsed = JSON.parse(n.tr);
|
||
foundId = findFirstId(parsed) || foundId;
|
||
}
|
||
} catch (err) { /* ignore */ }
|
||
|
||
try {
|
||
if (n.effects) {
|
||
const eff = typeof n.effects === 'string' && n.effects.trim().startsWith('{') ? JSON.parse(n.effects) : n.effects;
|
||
foundId = findFirstId(eff) || foundId;
|
||
}
|
||
} catch (err) { /* ignore */ }
|
||
|
||
if (!foundId && n.character_id) foundId = Number(n.character_id);
|
||
if (!foundId && n.characterId) foundId = Number(n.characterId);
|
||
|
||
if (foundId && nameMap.has(Number(foundId))) {
|
||
const resolved = nameMap.get(Number(foundId));
|
||
// Set character_name directly (characterName is a getter that reads from character_name)
|
||
n.character_name = resolved;
|
||
}
|
||
}
|
||
}
|