Implement lover relationship management features: Add endpoints for creating, acknowledging, and managing lover relationships in the FalukantController. Enhance backend models with RelationshipState for tracking relationship statuses. Update frontend components to display and manage lover details, including marriage satisfaction and household tension. Improve localization for new features in multiple languages.

This commit is contained in:
Torsten Schulz (local)
2026-03-20 11:37:46 +01:00
parent c7d33525ff
commit 2977b152a2
29 changed files with 4551 additions and 86 deletions

View File

@@ -29,6 +29,7 @@ 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';
@@ -419,6 +420,158 @@ class FalukantService extends BaseService {
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';
}
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));
return Math.max(0, 100 - diff * 20);
}
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: [
@@ -2748,7 +2901,7 @@ class FalukantService extends BaseService {
// Load relationships without includes to avoid EagerLoadingError
const relRows = await Relationship.findAll({
where: { character1Id: character.id },
attributes: ['createdAt', 'widowFirstName2', 'nextStepProgress', 'character2Id', 'relationshipTypeId']
attributes: ['id', 'createdAt', 'widowFirstName2', 'nextStepProgress', 'character2Id', 'relationshipTypeId']
});
let relationships;
if (relRows.length === 0) {
@@ -2770,6 +2923,7 @@ class FalukantService extends BaseService {
]);
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']
@@ -2791,7 +2945,9 @@ class FalukantService extends BaseService {
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,
@@ -2804,7 +2960,22 @@ class FalukantService extends BaseService {
mood: c2.mood,
traits: c2.traits || []
} : null,
relationshipType: type ? type.tr : ''
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)
};
});
}
@@ -2821,7 +2992,7 @@ class FalukantService extends BaseService {
{ motherCharacterId: { [Op.in]: userCharacterIds } }
]
},
attributes: ['childCharacterId', 'nameSet', 'isHeir', 'createdAt']
attributes: ['childCharacterId', 'nameSet', 'isHeir', 'legitimacy', 'birthContext', 'publicKnown', 'createdAt']
})
: [];
const childCharIds = [...new Set(childRels.map(r => r.childCharacterId))];
@@ -2842,20 +3013,69 @@ class FalukantService extends BaseService {
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,
};
});
// 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 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',
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 family = {
relationships: relationships.filter(r => inProgress.includes(r.relationshipType)),
lovers: relationships.filter(r => r.relationshipType === 'lover'),
relationships: activeRelationships.map((r) => ({
...r,
marriageSatisfaction: r.state?.marriageSatisfaction ?? null,
marriageState: this.getMarriageStateLabel(r.state?.marriageSatisfaction ?? null),
})),
marriageSatisfaction,
marriageState,
householdTension: lovers.some(l => l.riskState === 'high') ? 'high' : lovers.some(l => l.riskState === 'medium') ? 'medium' : 'low',
lovers,
deathPartners: relationships.filter(r => r.relationshipType === 'widowed'),
children: children.map(({ _createdAt, ...rest }) => rest),
possiblePartners: []
possiblePartners: [],
possibleLovers: []
};
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) {
@@ -2872,6 +3092,185 @@ class FalukantService extends BaseService {
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 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 { state } = await this.getOwnedLoverRelationState(hashedUserId, parsedRelationshipId);
await state.update({ maintenanceLevel: parsedMaintenance });
return {
success: true,
relationshipId: parsedRelationshipId,
maintenanceLevel: state.maintenanceLevel
};
}
async acknowledgeLover(hashedUserId, relationshipId) {
const parsedRelationshipId = Number.parseInt(relationshipId, 10);
if (Number.isNaN(parsedRelationshipId)) {
throw { status: 400, message: 'relationshipId is required' };
}
const { state } = await this.getOwnedLoverRelationState(hashedUserId, parsedRelationshipId);
const updateData = { acknowledged: true };
if (state.loverRole === 'secret_affair' || !state.loverRole) {
updateData.loverRole = 'lover';
}
await state.update(updateData);
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 { state } = await this.getOwnedLoverRelationState(hashedUserId, parsedRelationshipId);
await state.update({
active: false,
acknowledged: false
});
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');
@@ -5471,12 +5870,25 @@ ORDER BY r.id`,
// 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');
}
}
// 5) Eintrag anlegen (optional: in Transaction)
const newEntry = await Underground.create({
undergroundTypeId: typeId,
performerId: performerChar.id,
victimId: victimChar.id,
result: null,
result: {
status: 'pending',
outcome: null,
discoveries: null,
visibilityDelta: 0,
reputationDelta: 0,
blackmailAmount: 0
},
parameters: {
target: target || null,
goal: goal || null,
@@ -5487,6 +5899,57 @@ ORDER BY r.id`,
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']]
});
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,
additionalInfo: {
discoveries: result.discoveries || null,
visibilityDelta: result.visibilityDelta ?? null,
reputationDelta: result.reputationDelta ?? null,
blackmailAmount: result.blackmailAmount ?? null
}
};
});
}
async getUndergroundAttacks(hashedUserId) {
const falukantUser = await getFalukantUserOrFail(hashedUserId);