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

@@ -116,6 +116,14 @@ class FalukantController {
console.log('🔍 getGifts called with userId:', userId);
return this.service.getGifts(userId);
});
this.setLoverMaintenance = this._wrapWithUser((userId, req) =>
this.service.setLoverMaintenance(userId, req.params.relationshipId, req.body?.maintenanceLevel));
this.createLoverRelationship = this._wrapWithUser((userId, req) =>
this.service.createLoverRelationship(userId, req.body?.targetCharacterId, req.body?.loverRole), { successStatus: 201 });
this.acknowledgeLover = this._wrapWithUser((userId, req) =>
this.service.acknowledgeLover(userId, req.params.relationshipId));
this.endLoverRelationship = this._wrapWithUser((userId, req) =>
this.service.endLoverRelationship(userId, req.params.relationshipId));
this.getChildren = this._wrapWithUser((userId) => this.service.getChildren(userId));
this.sendGift = this._wrapWithUser(async (userId, req) => {
try {
@@ -234,6 +242,7 @@ class FalukantController {
this.renovateAll = this._wrapWithUser((userId) => this.service.renovateAll(userId));
this.getUndergroundTypes = this._wrapWithUser((userId) => this.service.getUndergroundTypes(userId));
this.getUndergroundActivities = this._wrapWithUser((userId) => this.service.getUndergroundActivities(userId));
this.getNotifications = this._wrapWithUser((userId) => this.service.getNotifications(userId));
this.getAllNotifications = this._wrapWithUser((userId, req) => this.service.getAllNotifications(userId, req.query.page, req.query.size));
this.markNotificationsShown = this._wrapWithUser((userId) => this.service.markNotificationsShown(userId), { successStatus: 202 });

View File

@@ -0,0 +1,122 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS falukant_data.relationship_state (
id serial PRIMARY KEY,
relationship_id integer NOT NULL UNIQUE,
marriage_satisfaction integer NOT NULL DEFAULT 55 CHECK (marriage_satisfaction >= 0 AND marriage_satisfaction <= 100),
marriage_public_stability integer NOT NULL DEFAULT 55 CHECK (marriage_public_stability >= 0 AND marriage_public_stability <= 100),
lover_role text NULL CHECK (lover_role IN ('secret_affair', 'lover', 'mistress_or_favorite')),
affection integer NOT NULL DEFAULT 50 CHECK (affection >= 0 AND affection <= 100),
visibility integer NOT NULL DEFAULT 15 CHECK (visibility >= 0 AND visibility <= 100),
discretion integer NOT NULL DEFAULT 50 CHECK (discretion >= 0 AND discretion <= 100),
maintenance_level integer NOT NULL DEFAULT 50 CHECK (maintenance_level >= 0 AND maintenance_level <= 100),
status_fit integer NOT NULL DEFAULT 0 CHECK (status_fit >= -2 AND status_fit <= 2),
monthly_base_cost integer NOT NULL DEFAULT 0 CHECK (monthly_base_cost >= 0),
months_underfunded integer NOT NULL DEFAULT 0 CHECK (months_underfunded >= 0),
active boolean NOT NULL DEFAULT true,
acknowledged boolean NOT NULL DEFAULT false,
exclusive_flag boolean NOT NULL DEFAULT false,
last_monthly_processed_at timestamp with time zone NULL,
last_daily_processed_at timestamp with time zone NULL,
notes_json jsonb NULL,
flags_json jsonb NULL,
created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT relationship_state_relationship_fk
FOREIGN KEY (relationship_id)
REFERENCES falukant_data.relationship(id)
ON DELETE CASCADE
);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS relationship_state_active_idx
ON falukant_data.relationship_state (active);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS relationship_state_lover_role_idx
ON falukant_data.relationship_state (lover_role);
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
ADD COLUMN IF NOT EXISTS legitimacy text NOT NULL DEFAULT 'legitimate';
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
ADD COLUMN IF NOT EXISTS birth_context text NOT NULL DEFAULT 'marriage';
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
ADD COLUMN IF NOT EXISTS public_known boolean NOT NULL DEFAULT false;
`);
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'child_relation_legitimacy_chk'
) THEN
ALTER TABLE falukant_data.child_relation
ADD CONSTRAINT child_relation_legitimacy_chk
CHECK (legitimacy IN ('legitimate', 'acknowledged_bastard', 'hidden_bastard'));
END IF;
END
$$;
`);
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'child_relation_birth_context_chk'
) THEN
ALTER TABLE falukant_data.child_relation
ADD CONSTRAINT child_relation_birth_context_chk
CHECK (birth_context IN ('marriage', 'lover'));
END IF;
END
$$;
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
DROP CONSTRAINT IF EXISTS child_relation_birth_context_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
DROP CONSTRAINT IF EXISTS child_relation_legitimacy_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
DROP COLUMN IF EXISTS public_known;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
DROP COLUMN IF EXISTS birth_context;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
DROP COLUMN IF EXISTS legitimacy;
`);
await queryInterface.sequelize.query(`
DROP TABLE IF EXISTS falukant_data.relationship_state;
`);
},
};

View File

@@ -0,0 +1,60 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
INSERT INTO falukant_data.relationship_state (
relationship_id,
marriage_satisfaction,
marriage_public_stability,
lover_role,
affection,
visibility,
discretion,
maintenance_level,
status_fit,
monthly_base_cost,
months_underfunded,
active,
acknowledged,
exclusive_flag,
created_at,
updated_at
)
SELECT
r.id,
55,
55,
CASE WHEN rt.tr = 'lover' THEN 'lover' ELSE NULL END,
50,
CASE WHEN rt.tr = 'lover' THEN 20 ELSE 15 END,
CASE WHEN rt.tr = 'lover' THEN 45 ELSE 50 END,
50,
0,
CASE WHEN rt.tr = 'lover' THEN 30 ELSE 0 END,
0,
true,
false,
false,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM falukant_data.relationship r
INNER JOIN falukant_type.relationship rt
ON rt.id = r.relationship_type_id
LEFT JOIN falukant_data.relationship_state rs
ON rs.relationship_id = r.id
WHERE rs.id IS NULL
AND rt.tr IN ('lover', 'wooing', 'engaged', 'married');
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
DELETE FROM falukant_data.relationship_state rs
USING falukant_data.relationship r
INNER JOIN falukant_type.relationship rt
ON rt.id = r.relationship_type_id
WHERE rs.relationship_id = r.id
AND rt.tr IN ('lover', 'wooing', 'engaged', 'married');
`);
},
};

View File

@@ -68,6 +68,7 @@ import PromotionalGiftCharacterTrait from './falukant/predefine/promotional_gift
import PromotionalGiftMood from './falukant/predefine/promotional_gift_mood.js';
import RelationshipType from './falukant/type/relationship.js';
import Relationship from './falukant/data/relationship.js';
import RelationshipState from './falukant/data/relationship_state.js';
import PromotionalGiftLog from './falukant/log/promotional_gift.js';
import HouseType from './falukant/type/house.js';
import BuyableHouse from './falukant/data/buyable_house.js';
@@ -460,6 +461,8 @@ export default function setupAssociations() {
Relationship.belongsTo(FalukantCharacter, { foreignKey: 'character2Id', as: 'character2', });
FalukantCharacter.hasMany(Relationship, { foreignKey: 'character1Id', as: 'relationshipsAsCharacter1', });
FalukantCharacter.hasMany(Relationship, { foreignKey: 'character2Id', as: 'relationshipsAsCharacter2', });
Relationship.hasOne(RelationshipState, { foreignKey: 'relationshipId', as: 'state' });
RelationshipState.belongsTo(Relationship, { foreignKey: 'relationshipId', as: 'relationship' });
PromotionalGiftLog.belongsTo(PromotionalGift, { foreignKey: 'giftId', as: 'gift' });
PromotionalGift.hasMany(PromotionalGiftLog, { foreignKey: 'giftId', as: 'logs' });
@@ -1095,4 +1098,3 @@ export default function setupAssociations() {
CalendarEvent.belongsTo(User, { foreignKey: 'userId', as: 'user' });
User.hasMany(CalendarEvent, { foreignKey: 'userId', as: 'calendarEvents' });
}

View File

@@ -27,7 +27,25 @@ ChildRelation.init(
isHeir: {
type: DataTypes.BOOLEAN,
allowNull: true,
default: false}
default: false},
legitimacy: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'legitimate',
validate: {
isIn: [['legitimate', 'acknowledged_bastard', 'hidden_bastard']]
}},
birthContext: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'marriage',
validate: {
isIn: [['marriage', 'lover']]
}},
publicKnown: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false}
},
{
sequelize,

View File

@@ -0,0 +1,141 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
class RelationshipState extends Model {}
RelationshipState.init(
{
relationshipId: {
type: DataTypes.INTEGER,
allowNull: false,
unique: true,
},
marriageSatisfaction: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 55,
validate: {
min: 0,
max: 100,
},
},
marriagePublicStability: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 55,
validate: {
min: 0,
max: 100,
},
},
loverRole: {
type: DataTypes.STRING,
allowNull: true,
validate: {
isIn: [[null, 'secret_affair', 'lover', 'mistress_or_favorite'].filter(Boolean)],
},
},
affection: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 50,
validate: {
min: 0,
max: 100,
},
},
visibility: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 15,
validate: {
min: 0,
max: 100,
},
},
discretion: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 50,
validate: {
min: 0,
max: 100,
},
},
maintenanceLevel: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 50,
validate: {
min: 0,
max: 100,
},
},
statusFit: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
validate: {
min: -2,
max: 2,
},
},
monthlyBaseCost: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
validate: {
min: 0,
},
},
monthsUnderfunded: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
validate: {
min: 0,
},
},
active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
acknowledged: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
exclusiveFlag: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
lastMonthlyProcessedAt: {
type: DataTypes.DATE,
allowNull: true,
},
lastDailyProcessedAt: {
type: DataTypes.DATE,
allowNull: true,
},
notesJson: {
type: DataTypes.JSONB,
allowNull: true,
},
flagsJson: {
type: DataTypes.JSONB,
allowNull: true,
},
},
{
sequelize,
modelName: 'RelationshipState',
tableName: 'relationship_state',
schema: 'falukant_data',
timestamps: true,
underscored: true,
}
);
export default RelationshipState;

View File

@@ -67,6 +67,7 @@ import Notification from './falukant/log/notification.js';
import MarriageProposal from './falukant/data/marriage_proposal.js';
import RelationshipType from './falukant/type/relationship.js';
import Relationship from './falukant/data/relationship.js';
import RelationshipState from './falukant/data/relationship_state.js';
import CharacterTrait from './falukant/type/character_trait.js';
import FalukantCharacterTrait from './falukant/data/falukant_character_trait.js';
import Mood from './falukant/type/mood.js';
@@ -219,6 +220,7 @@ const models = {
MarriageProposal,
RelationshipType,
Relationship,
RelationshipState,
CharacterTrait,
FalukantCharacterTrait,
Mood,

View File

@@ -47,6 +47,10 @@ router.get('/dashboard-widget', falukantController.getDashboardWidget);
router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal);
router.post('/family/cancel-wooing', falukantController.cancelWooing);
router.post('/family/set-heir', falukantController.setHeir);
router.post('/family/lover', falukantController.createLoverRelationship);
router.post('/family/lover/:relationshipId/maintenance', falukantController.setLoverMaintenance);
router.post('/family/lover/:relationshipId/acknowledge', falukantController.acknowledgeLover);
router.post('/family/lover/:relationshipId/end', falukantController.endLoverRelationship);
router.get('/heirs/potential', falukantController.getPotentialHeirs);
router.post('/heirs/select', falukantController.selectHeir);
router.get('/family/gifts', falukantController.getGifts);
@@ -101,6 +105,7 @@ router.post('/transports', falukantController.createTransport);
router.get('/transports/route', falukantController.getTransportRoute);
router.get('/transports/branch/:branchId', falukantController.getBranchTransports);
router.get('/underground/types', falukantController.getUndergroundTypes);
router.get('/underground/activities', falukantController.getUndergroundActivities);
router.get('/notifications', falukantController.getNotifications);
router.get('/notifications/all', falukantController.getAllNotifications);
router.post('/notifications/mark-shown', falukantController.markNotificationsShown);

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);

View File

@@ -0,0 +1,88 @@
-- PostgreSQL-only migration script.
-- Dieses Projekt-Backend nutzt Schemas, JSONB und PostgreSQL-Datentypen.
-- Nicht auf MariaDB/MySQL ausführen.
BEGIN;
CREATE TABLE IF NOT EXISTS falukant_data.relationship_state (
id serial PRIMARY KEY,
relationship_id integer NOT NULL UNIQUE,
marriage_satisfaction integer NOT NULL DEFAULT 55 CHECK (marriage_satisfaction >= 0 AND marriage_satisfaction <= 100),
marriage_public_stability integer NOT NULL DEFAULT 55 CHECK (marriage_public_stability >= 0 AND marriage_public_stability <= 100),
lover_role text NULL CHECK (lover_role IN ('secret_affair', 'lover', 'mistress_or_favorite')),
affection integer NOT NULL DEFAULT 50 CHECK (affection >= 0 AND affection <= 100),
visibility integer NOT NULL DEFAULT 15 CHECK (visibility >= 0 AND visibility <= 100),
discretion integer NOT NULL DEFAULT 50 CHECK (discretion >= 0 AND discretion <= 100),
maintenance_level integer NOT NULL DEFAULT 50 CHECK (maintenance_level >= 0 AND maintenance_level <= 100),
status_fit integer NOT NULL DEFAULT 0 CHECK (status_fit >= -2 AND status_fit <= 2),
monthly_base_cost integer NOT NULL DEFAULT 0 CHECK (monthly_base_cost >= 0),
months_underfunded integer NOT NULL DEFAULT 0 CHECK (months_underfunded >= 0),
active boolean NOT NULL DEFAULT true,
acknowledged boolean NOT NULL DEFAULT false,
exclusive_flag boolean NOT NULL DEFAULT false,
last_monthly_processed_at timestamp with time zone NULL,
last_daily_processed_at timestamp with time zone NULL,
notes_json jsonb NULL,
flags_json jsonb NULL,
created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT relationship_state_relationship_fk
FOREIGN KEY (relationship_id)
REFERENCES falukant_data.relationship(id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS relationship_state_active_idx
ON falukant_data.relationship_state (active);
CREATE INDEX IF NOT EXISTS relationship_state_lover_role_idx
ON falukant_data.relationship_state (lover_role);
ALTER TABLE IF EXISTS falukant_data.child_relation
ADD COLUMN IF NOT EXISTS legitimacy text NOT NULL DEFAULT 'legitimate';
ALTER TABLE IF EXISTS falukant_data.child_relation
ADD COLUMN IF NOT EXISTS birth_context text NOT NULL DEFAULT 'marriage';
ALTER TABLE IF EXISTS falukant_data.child_relation
ADD COLUMN IF NOT EXISTS public_known boolean NOT NULL DEFAULT false;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'child_relation_legitimacy_chk'
) THEN
ALTER TABLE falukant_data.child_relation
ADD CONSTRAINT child_relation_legitimacy_chk
CHECK (legitimacy IN ('legitimate', 'acknowledged_bastard', 'hidden_bastard'));
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'child_relation_birth_context_chk'
) THEN
ALTER TABLE falukant_data.child_relation
ADD CONSTRAINT child_relation_birth_context_chk
CHECK (birth_context IN ('marriage', 'lover'));
END IF;
END
$$;
COMMIT;
-- Rollback separat bei Bedarf:
-- BEGIN;
-- ALTER TABLE falukant_data.child_relation DROP CONSTRAINT IF EXISTS child_relation_birth_context_chk;
-- ALTER TABLE falukant_data.child_relation DROP CONSTRAINT IF EXISTS child_relation_legitimacy_chk;
-- ALTER TABLE falukant_data.child_relation DROP COLUMN IF EXISTS public_known;
-- ALTER TABLE falukant_data.child_relation DROP COLUMN IF EXISTS birth_context;
-- ALTER TABLE falukant_data.child_relation DROP COLUMN IF EXISTS legitimacy;
-- DROP TABLE IF EXISTS falukant_data.relationship_state;
-- COMMIT;

View File

@@ -0,0 +1,5 @@
-- PostgreSQL-only
INSERT INTO falukant_type.underground (tr, cost)
VALUES ('investigate_affair', 7000)
ON CONFLICT (tr) DO UPDATE
SET cost = EXCLUDED.cost;

View File

@@ -0,0 +1,50 @@
-- PostgreSQL-only backfill script.
-- Dieses Projekt-Backend nutzt Schemas und PostgreSQL-spezifische SQL-Strukturen.
-- Nicht auf MariaDB/MySQL ausführen.
BEGIN;
INSERT INTO falukant_data.relationship_state (
relationship_id,
marriage_satisfaction,
marriage_public_stability,
lover_role,
affection,
visibility,
discretion,
maintenance_level,
status_fit,
monthly_base_cost,
months_underfunded,
active,
acknowledged,
exclusive_flag,
created_at,
updated_at
)
SELECT
r.id,
55,
55,
CASE WHEN rt.tr = 'lover' THEN 'lover' ELSE NULL END,
50,
CASE WHEN rt.tr = 'lover' THEN 20 ELSE 15 END,
CASE WHEN rt.tr = 'lover' THEN 45 ELSE 50 END,
50,
0,
CASE WHEN rt.tr = 'lover' THEN 30 ELSE 0 END,
0,
true,
false,
false,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM falukant_data.relationship r
INNER JOIN falukant_type.relationship rt
ON rt.id = r.relationship_type_id
LEFT JOIN falukant_data.relationship_state rs
ON rs.relationship_id = r.id
WHERE rs.id IS NULL
AND rt.tr IN ('lover', 'wooing', 'engaged', 'married');
COMMIT;

View File

@@ -659,6 +659,10 @@ const undergroundTypes = [
"tr": "rob",
"cost": 500
},
{
"tr": "investigate_affair",
"cost": 7000
},
];
{