Some fixes and additions

This commit is contained in:
Torsten Schulz
2025-07-09 14:28:35 +02:00
parent 5029be81e9
commit fceea5b7fb
32 changed files with 4373 additions and 1294 deletions

View File

@@ -1,6 +1,6 @@
import BaseService from './BaseService.js';
import { Sequelize, Op, where } from 'sequelize';
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';
@@ -47,6 +47,12 @@ import LearnRecipient from '../models/falukant/type/learn_recipient.js';
import Credit from '../models/falukant/data/credit.js';
import TitleRequirement from '../models/falukant/type/title_requirement.js';
import HealthActivity from '../models/falukant/log/health_activity.js';
import Election from '../models/falukant/data/election.js';
import PoliticalOfficeType from '../models/falukant/type/political_office_type.js';
import Candidate from '../models/falukant/data/candidate.js';
import Vote from '../models/falukant/data/vote.js';
import PoliticalOfficePrerequisite from '../models/falukant/predefine/political_office_prerequisite.js';
import PoliticalOfficeHistory from '../models/falukant/log/political_office_history.js';
function calcAge(birthdate) {
const b = new Date(birthdate); b.setHours(0, 0);
@@ -96,13 +102,33 @@ class FalukantService extends BaseService {
all: { min: 400, max: 40000 }
};
static HEALTH_ACTIVITIES = [
{ tr: "barber", method: "healthBarber", cost: 10 },
{ tr: "doctor", method: "healthDoctor", cost: 50 },
{ tr: "witch", method: "healthWitch", cost: 500 },
{ tr: "pill", method: "healthPill", cost: 5000 },
{ tr: "drunkOfLife", method: "healthDruckOfLife", cost:5000000 }
];
{ tr: "barber", method: "healthBarber", cost: 10 },
{ tr: "doctor", method: "healthDoctor", cost: 50 },
{ tr: "witch", method: "healthWitch", cost: 500 },
{ tr: "pill", method: "healthPill", cost: 5000 },
{ tr: "drunkOfLife", method: "healthDruckOfLife", cost: 5000000 }
];
static RECURSIVE_REGION_SEARCH = `
WITH RECURSIVE ancestors AS (
SELECT
r.id,
r.parent_id
FROM falukant_data.region r
join falukant_data."character" c
on c.region_id = r.id
WHERE c.user_id = :user_id
UNION ALL
SELECT
r.id,
r.parent_id
FROM falukant_data.region r
JOIN ancestors a ON r.id = a.parent_id
)
SELECT id
FROM ancestors;
`;
async getFalukantUserByHashedId(hashedId) {
const user = await FalukantUser.findOne({
include: [
@@ -168,9 +194,21 @@ class FalukantService extends BaseService {
attributes: ['name']
}
]
}
},
{
model: UserHouse,
as: 'userHouse',
include: [
{
model: HouseType,
as: 'houseType',
'attributes': ['labelTr', 'position']
},
],
attributes: ['roofCondition'],
},
],
attributes: ['money', 'creditAmount', 'todayCreditTaken']
attributes: ['money', 'creditAmount', 'todayCreditTaken',]
});
if (!u) throw new Error('User not found');
if (u.character?.birthdate) u.character.setDataValue('age', calcAge(u.character.birthdate));
@@ -277,6 +315,38 @@ class FalukantService extends BaseService {
return bs.map(b => ({ ...b.toJSON(), isMainBranch: u.mainBranchRegionId === b.regionId }));
}
async createBranch(hashedUserId, cityId, branchTypeId) {
const user = await getFalukantUserOrFail(hashedUserId);
const branchType = await BranchType.findByPk(branchTypeId);
if (!branchType) {
throw new Error(`Unknown branchTypeId ${branchTypeId}`);
}
const existingCount = await Branch.count({
where: { falukantUserId: user.id }
});
const exponentBase = Math.max(existingCount, 1);
const rawCost = branchType.baseCost * Math.pow(exponentBase, 1.2);
const cost = Math.round(rawCost * 100) / 100;
await updateFalukantUserMoney(
user.id,
-cost,
'create_branch'
);
const branch = await Branch.create({
branchTypeId,
regionId: cityId,
falukantUserId: user.id
});
return branch;
}
async getBranchTypes(hashedUserId) {
const user = await getFalukantUserOrFail(hashedUserId);
const branchTypes = await BranchType.findAll();
return branchTypes;
}
async getBranch(hashedUserId, branchId) {
const u = await getFalukantUserOrFail(hashedUserId);
const br = await Branch.findOne({
@@ -318,6 +388,8 @@ class FalukantService extends BaseService {
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);
@@ -822,9 +894,12 @@ class FalukantService extends BaseService {
await this.deleteExpiredProposals();
const existingProposals = await this.fetchProposals(falukantUserId, regionId);
if (existingProposals.length > 0) {
console.log('Existing proposals:', existingProposals);
return this.formatProposals(existingProposals);
}
console.log('No existing proposals, generating new ones');
await this.generateProposals(falukantUserId, regionId);
console.log('Fetch new proposals');
const newProposals = await this.fetchProposals(falukantUserId, regionId);
return this.formatProposals(newProposals);
}
@@ -867,13 +942,14 @@ class FalukantService extends BaseService {
}
async generateProposals(falukantUserId, regionId) {
const proposalCount = Math.floor(Math.random() * 3) + 3;
for (let i = 0; i < proposalCount; i++) {
const directorCharacter = await FalukantCharacter.findOne({
where: {
regionId,
createdAt: {
[Op.lt]: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000),
try {
const threeWeeksAgo = new Date(Date.now() - 21 * 24 * 60 * 60 * 1000);
const proposalCount = Math.floor(Math.random() * 3) + 3;
for (let i = 0; i < proposalCount; i++) {
const directorCharacter = await FalukantCharacter.findOne({
where: {
regionId,
createdAt: { [Op.lt]: threeWeeksAgo },
},
include: [
{
@@ -881,22 +957,25 @@ class FalukantService extends BaseService {
as: 'nobleTitle',
attributes: ['level'],
},
]
},
order: Sequelize.fn('RANDOM'),
});
if (!directorCharacter) {
throw new Error('No directors available for the region');
],
order: sequelize.literal('RANDOM()'),
});
if (!directorCharacter) {
throw new Error('No directors available for the region');
}
const avgKnowledge = await this.calculateAverageKnowledge(directorCharacter.id);
const proposedIncome = Math.round(
directorCharacter.nobleTitle.level * Math.pow(1.231, avgKnowledge / 1.5)
);
await DirectorProposal.create({
directorCharacterId: directorCharacter.id,
employerUserId: falukantUserId,
proposedIncome,
});
}
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);
}
}
@@ -1394,39 +1473,65 @@ class FalukantService extends BaseService {
}
async getGifts(hashedUserId) {
// 1) Mein User & Character
const user = await this.getFalukantUserByHashedId(hashedUserId);
const character = await FalukantCharacter.findOne({
where: { userId: user.id },
const myChar = await FalukantCharacter.findOne({ where: { userId: user.id } });
if (!myChar) throw new Error('Character not found');
// 2) Beziehung finden und „anderen“ Character bestimmen
const rel = await Relationship.findOne({
where: {
[Op.or]: [
{ character1Id: myChar.id },
{ character2Id: myChar.id }
]
},
include: [
{ model: FalukantCharacter, as: 'character1', include: [{ model: CharacterTrait, as: 'traits' }] },
{ model: FalukantCharacter, as: 'character2', include: [{ model: CharacterTrait, as: 'traits' }] }
]
});
if (!character) {
throw new Error('Character not found');
}
let gifts = await PromotionalGift.findAll({
if (!rel) throw new Error('Beziehung nicht gefunden');
const relatedChar = rel.character1.id === myChar.id ? rel.character2 : rel.character1;
// 3) Trait-IDs und Mood des relatedChar
const relatedTraitIds = relatedChar.traits.map(t => t.id);
const relatedMoodId = relatedChar.moodId;
// 4) Gifts laden aber nur die passenden Moods und Traits als Unter-Arrays
const gifts = await PromotionalGift.findAll({
include: [
{
model: PromotionalGiftMood,
as: 'promotionalgiftmoods',
attributes: ['mood_id', 'suitability']
attributes: ['mood_id', 'suitability'],
where: { mood_id: relatedMoodId },
required: false // Gifts ohne Mood-Match bleiben erhalten, haben dann leeres Array
},
{
model: PromotionalGiftCharacterTrait,
as: 'characterTraits',
attributes: ['trait_id', 'suitability']
attributes: ['trait_id', 'suitability'],
where: { trait_id: relatedTraitIds },
required: false // Gifts ohne Trait-Match bleiben erhalten
}
]
});
const lowestTitleOfNobility = await TitleOfNobility.findOne({
order: [['id', 'ASC']],
});
return await Promise.all(gifts.map(async (gift) => {
return {
id: gift.id,
name: gift.name,
cost: await this.getGiftCost(gift.value, character.titleOfNobility, lowestTitleOfNobility.id),
moodsAffects: gift.promotionalgiftmoods,
charactersAffects: gift.characterTraits,
};
}));
// 5) Rest wie gehabt: Kosten berechnen und zurückgeben
const lowestTitleOfNobility = await TitleOfNobility.findOne({ order: [['id', 'ASC']] });
return Promise.all(gifts.map(async gift => ({
id: gift.id,
name: gift.name,
cost: await this.getGiftCost(
gift.value,
myChar.titleOfNobility,
lowestTitleOfNobility.id
),
moodsAffects: gift.promotionalgiftmoods, // nur Einträge mit relatedMoodId
charactersAffects: gift.characterTraits // nur Einträge mit relatedTraitIds
})));
}
async getChildren(hashedUserId) {
@@ -2199,8 +2304,8 @@ class FalukantService extends BaseService {
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({
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']],
});
@@ -2208,7 +2313,7 @@ class FalukantService extends BaseService {
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 }}),
history: healthHistory.map((activity) => { return { tr: activity.activityTr, cost: activity.cost, createdAt: activity.createdAt, success: activity.successPercentage } }),
};
}
@@ -2232,14 +2337,14 @@ class FalukantService extends BaseService {
if (!activityObject) {
throw new Error('invalid');
}
if (user.money - activityObject.cost < 0) {
if (user.money - activityObject.cost < 0) {
throw new Error('no money');
}
user.character.health -= activityObject.cost;
await HealthActivity.create({
characterId: user.character.id,
activityTr: activity,
successPercentage: await this[activityObject.method](user),
successPercentage: await this[activityObject.method](user),
cost: activityObject.cost
});
updateFalukantUserMoney(user.id, -activityObject.cost, 'health.' + activity);
@@ -2256,32 +2361,424 @@ class FalukantService extends BaseService {
health: Math.min(FalukantService.HEALTH_MAX || 100, Math.max(0, char.health + delta))
});
return delta;
}
async healthBarber(user) {
const raw = Math.floor(Math.random() * 11) - 5;
return this.healthChange(user, raw);
}
async healthDoctor(user) {
const raw = Math.floor(Math.random() * 8) - 2;
return this.healthChange(user, raw);
}
async healthWitch(user) {
const raw = Math.floor(Math.random() * 7) - 1;
return this.healthChange(user, raw);
}
async healthPill(user) {
const raw = Math.floor(Math.random() * 8);
return this.healthChange(user, raw);
}
async healthDrunkOfLife(user) {
const raw = Math.floor(Math.random() * 26);
return this.healthChange(user, raw);
}
}
async healthBarber(user) {
const raw = Math.floor(Math.random() * 11) - 5;
return this.healthChange(user, raw);
}
async healthDoctor(user) {
const raw = Math.floor(Math.random() * 8) - 2;
return this.healthChange(user, raw);
}
async healthWitch(user) {
const raw = Math.floor(Math.random() * 7) - 1;
return this.healthChange(user, raw);
}
async healthPill(user) {
const raw = Math.floor(Math.random() * 8);
return this.healthChange(user, raw);
}
async healthDrunkOfLife(user) {
const raw = Math.floor(Math.random() * 26);
return this.healthChange(user, raw);
}
async getPoliticsOverview(hashedUserId) {
}
async getOpenPolitics(hashedUserId) {
const user = await this.getFalukantUserByHashedId(hashedUserId);
if (!user || user.character.nobleTitle.labelTr === 'noncivil') {
return [];
}
}
async getElections(hashedUserId) {
const user = await this.getFalukantUserByHashedId(hashedUserId);
if (!user || user.character.nobleTitle.labelTr === 'noncivil') {
return [];
}
const rows = await sequelize.query(
FalukantService.RECURSIVE_REGION_SEARCH,
{
replacements: { user_id: user.id },
type: sequelize.QueryTypes.SELECT
}
);
const regionIds = rows.map(r => r.id);
// 3) Zeitbereich "heute"
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const todayEnd = new Date();
todayEnd.setHours(23, 59, 59, 999);
// 4) Wahlen laden (inkl. Kandidaten, Stimmen und Verknüpfungen)
const rawElections = await Election.findAll({
where: {
regionId: { [Op.in]: regionIds },
date: { [Op.between]: [todayStart, todayEnd] }
},
include: [
{
model: RegionData,
as: 'region',
attributes: ['name'],
include: [{
model: RegionType,
as: 'regionType',
attributes: ['labelTr']
}]
},
{
model: PoliticalOfficeType,
as: 'officeType',
attributes: ['name']
},
{
model: Candidate,
as: 'candidates',
attributes: ['id'],
include: [{
model: FalukantCharacter,
as: 'character',
attributes: ['birthdate', 'gender'],
include: [
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] },
{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] }
]
}]
},
{
model: Vote,
as: 'votes',
attributes: ['candidateId'],
where: {
falukantUserId: user.id
},
required: false
}
]
});
return rawElections.map(election => {
const e = election.get({ plain: true });
const voted = Array.isArray(e.votes) && e.votes.length > 0;
const reducedCandidates = (e.candidates || []).map(cand => {
const ch = cand.character || {};
const firstname = ch.definedFirstName?.name || '';
const lastname = ch.definedLastName?.name || '';
return {
id: cand.id,
title: ch.nobleTitle?.labelTr || null,
name: `${firstname} ${lastname}`.trim(),
age: calcAge(ch.birthdate),
gender: ch.gender
};
});
return {
id: e.id,
officeType: { name: e.officeType.name },
region: {
name: e.region.name,
regionType: { labelTr: e.region.regionType.labelTr }
},
date: e.date,
postsToFill: e.postsToFill,
candidates: reducedCandidates,
voted: voted,
votedFor: voted ? e.votes.map(vote => { return vote.candidateId }) : null,
};
});
}
async vote(hashedUserId, votes) {
const elections = await this.getElections(hashedUserId);
if (!Array.isArray(elections) || elections.length === 0) {
throw new Error('No elections found');
}
const user = await this.getFalukantUserByHashedId(hashedUserId);
if (!user) {
throw new Error('User not found');
}
const validElections = votes.filter(voteEntry => {
const e = elections.find(el => el.id === voteEntry.electionId);
return e && !e.voted;
});
if (validElections.length === 0) {
throw new Error('No valid elections to vote for (either nonexistent or already voted)');
}
validElections.forEach(voteEntry => {
const e = elections.find(el => el.id === voteEntry.electionId);
const allowedIds = e.candidates.map(c => c.id);
voteEntry.candidateIds.forEach(cid => {
if (!allowedIds.includes(cid)) {
throw new Error(`Candidate ID ${cid} is not valid for election ${e.id}`);
}
});
if (voteEntry.candidateIds.length > e.postsToFill) {
throw new Error(`Too many candidates selected for election ${e.id}. Allowed: ${e.postsToFill}`);
}
});
return await sequelize.transaction(async (tx) => {
const toCreate = [];
validElections.forEach(voteEntry => {
voteEntry.candidateIds.forEach(candidateId => {
toCreate.push({
electionId: voteEntry.electionId,
candidateId,
falukantUserId: user.id
});
});
});
await Vote.bulkCreate(toCreate, {
transaction: tx,
ignoreDuplicates: true,
returning: false
});
return { success: true };
});
}
async getOpenPolitics(hashedUserId) {
const user = await this.getFalukantUserByHashedId(hashedUserId);
const characterId = user.character.id;
const rows = await sequelize.query(
FalukantService.RECURSIVE_REGION_SEARCH,
{
replacements: { user_id: user.id },
type: sequelize.QueryTypes.SELECT
}
);
const regionIds = rows.map(r => r.id);
const histories = await PoliticalOfficeHistory.findAll({
where: { characterId },
attributes: ['officeTypeId', 'startDate', 'endDate']
});
const heldOfficeTypeIds = histories.map(h => h.officeTypeId);
const allTypes = await PoliticalOfficeType.findAll({ attributes: ['id', 'name'] });
const nameToId = Object.fromEntries(allTypes.map(t => [t.name, t.id]));
const openPositions = await Election.findAll({
where: {
regionId: { [Op.in]: regionIds },
date: { [Op.lt]: new Date() }
},
include: [
{
model: RegionData,
as: 'region',
attributes: ['name'],
include: [
{ model: RegionType, as: 'regionType', attributes: ['labelTr'] }
]
},
{ model: Candidate, as: 'candidates' },
{
model: PoliticalOfficeType, as: 'officeType',
include: [{ model: PoliticalOfficePrerequisite, as: 'prerequisites' }]
}
]
});
const result = openPositions
.filter(election => {
const prereqs = election.officeType.prerequisites || [];
return prereqs.some(pr => {
const jobs = pr.prerequisite.jobs;
if (!Array.isArray(jobs) || jobs.length === 0) return true;
return jobs.some(jobName => {
const reqId = nameToId[jobName];
return heldOfficeTypeIds.includes(reqId);
});
});
})
.map(election => {
const e = election.get({ plain: true });
const jobs = e.officeType.prerequisites[0]?.prerequisite.jobs || [];
const matchingHistory = histories
.filter(h => jobs.includes(allTypes.find(t => t.id === h.officeTypeId)?.name))
.map(h => ({
officeTypeId: h.officeTypeId,
startDate: h.startDate,
endDate: h.endDate
}));
return {
...e,
history: matchingHistory
};
});
return result;
}
async applyForElections(hashedUserId, electionIds) {
// 1) Hole FalukantUser + Character
const user = await this.getFalukantUserByHashedId(hashedUserId);
if (!user) {
throw new Error('User nicht gefunden');
}
const character = user.character;
if (!character) {
throw new Error('Kein Charakter zum User gefunden');
}
// 2) NoncivilTitel aussperren
if (character.nobleTitle.labelTr === 'noncivil') {
return { applied: [], skipped: electionIds };
}
// 3) Ermittle die heute offenen Wahlen, auf die er zugreifen darf
// (getElections liefert id, officeType, region, date, postsToFill, candidates, voted…)
const openElections = await this.getElections(hashedUserId);
const allowedIds = new Set(openElections.map(e => e.id));
// 4) Filter alle electionIds auf gültige/erlaubte
const toTry = electionIds.filter(id => allowedIds.has(id));
if (toTry.length === 0) {
return { applied: [], skipped: electionIds };
}
// 5) Prüfe, auf welche dieser Wahlen der Character bereits als Candidate eingetragen ist
const existing = await Candidate.findAll({
where: {
electionId: { [Op.in]: toTry },
characterId: character.id
},
attributes: ['electionId']
});
const alreadyIds = new Set(existing.map(c => c.electionId));
// 6) Erstelle Liste der Wahlen, für die er sich noch nicht beworben hat
const newApplications = toTry.filter(id => !alreadyIds.has(id));
const skipped = electionIds.filter(id => !newApplications.includes(id));
console.log(newApplications, skipped);
// 7) Bulk-Insert aller neuen Bewerbungen
if (newApplications.length > 0) {
const toInsert = newApplications.map(eid => ({
electionId: eid,
characterId: character.id
}));
await Candidate.bulkCreate(toInsert);
}
return {
applied: newApplications,
skipped: skipped
};
}
async getRegions(hashedUserId) {
const user = await this.getFalukantUserByHashedId(hashedUserId);
const regions = await RegionData.findAll({
attributes: ['id', 'name', 'map'],
include: [
{
model: RegionType,
as: 'regionType',
where: {
labelTr: 'city'
},
attributes: ['labelTr']
},
{
model: Branch,
as: 'branches',
where: {
falukantUserId: user.id
},
include: [
{
model: BranchType,
as: 'branchType',
attributes: ['labelTr'],
},
],
attributes: ['branchTypeId'],
required: false,
}
]
});
return regions;
}
async renovate(hashedUserId, element) {
const user = await getFalukantUserOrFail(hashedUserId);
const house = await UserHouse.findOne({
where: { userId: user.id },
include: [{ model: HouseType, as: 'houseType' }]
});
if (!house) throw new Error('House not found');
const oldValue = house[element];
if (oldValue >= 100) {
return { cost: 0 };
}
const baseCost = house.houseType?.cost || 0;
const cost = this._calculateRenovationCost(baseCost, element, oldValue);
house[element] = 100;
await house.save();
await updateFalukantUserMoney(
user.id,
-cost,
`renovation_${element}`
);
return { cost };
}
_calculateRenovationCost(baseCost, key, currentVal) {
const weights = {
roofCondition: 0.25,
wallCondition: 0.25,
floorCondition: 0.25,
windowCondition: 0.25
};
const weight = weights[key] || 0;
const missing = 100 - currentVal;
const raw = (missing / 100) * baseCost * weight;
return Math.round(raw * 100) / 100;
}
async renovateAll(hashedUserId) {
const user = await getFalukantUserOrFail(hashedUserId);
const house = await UserHouse.findOne({
where: { userId: user.id },
include: [{ model: HouseType, as: 'houseType' }]
});
if (!house) throw new Error('House not found');
const baseCost = house.houseType?.cost || 0;
const keys = ['roofCondition', 'wallCondition', 'floorCondition', 'windowCondition'];
let rawSum = 0;
for (const key of keys) {
const current = house[key];
if (current < 100) {
rawSum += this._calculateRenovationCost(baseCost, key, current);
}
}
const totalCost = Math.round(rawSum * 0.8 * 100) / 100;
for (const key of keys) {
house[key] = 100;
}
await house.save();
await updateFalukantUserMoney(
user.id,
-totalCost,
'renovation_all'
);
return { cost: totalCost };
}
}
export default new FalukantService();