diff --git a/backend/controllers/falukantController.js b/backend/controllers/falukantController.js index 90aa6ec..a202285 100644 --- a/backend/controllers/falukantController.js +++ b/backend/controllers/falukantController.js @@ -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 }); diff --git a/backend/migrations/20260320000000-add-relationship-state-and-child-legitimacy.cjs b/backend/migrations/20260320000000-add-relationship-state-and-child-legitimacy.cjs new file mode 100644 index 0000000..957e1c9 --- /dev/null +++ b/backend/migrations/20260320000000-add-relationship-state-and-child-legitimacy.cjs @@ -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; + `); + }, +}; diff --git a/backend/migrations/20260320001000-backfill-relationship-state.cjs b/backend/migrations/20260320001000-backfill-relationship-state.cjs new file mode 100644 index 0000000..a881767 --- /dev/null +++ b/backend/migrations/20260320001000-backfill-relationship-state.cjs @@ -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'); + `); + }, +}; diff --git a/backend/models/associations.js b/backend/models/associations.js index 5f66d66..7237e09 100644 --- a/backend/models/associations.js +++ b/backend/models/associations.js @@ -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' }); } - diff --git a/backend/models/falukant/data/child_relation.js b/backend/models/falukant/data/child_relation.js index 1984246..569b215 100644 --- a/backend/models/falukant/data/child_relation.js +++ b/backend/models/falukant/data/child_relation.js @@ -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, diff --git a/backend/models/falukant/data/relationship_state.js b/backend/models/falukant/data/relationship_state.js new file mode 100644 index 0000000..8006f59 --- /dev/null +++ b/backend/models/falukant/data/relationship_state.js @@ -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; diff --git a/backend/models/index.js b/backend/models/index.js index b39d143..6095739 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -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, diff --git a/backend/routers/falukantRouter.js b/backend/routers/falukantRouter.js index 1623f4a..5af0455 100644 --- a/backend/routers/falukantRouter.js +++ b/backend/routers/falukantRouter.js @@ -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); diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index f2ea61b..a2ddbf7 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -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); diff --git a/backend/sql/add_relationship_state_and_child_legitimacy.sql b/backend/sql/add_relationship_state_and_child_legitimacy.sql new file mode 100644 index 0000000..189fbcc --- /dev/null +++ b/backend/sql/add_relationship_state_and_child_legitimacy.sql @@ -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; diff --git a/backend/sql/add_underground_investigate_affair_type.sql b/backend/sql/add_underground_investigate_affair_type.sql new file mode 100644 index 0000000..b4af9e7 --- /dev/null +++ b/backend/sql/add_underground_investigate_affair_type.sql @@ -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; diff --git a/backend/sql/backfill_relationship_state.sql b/backend/sql/backfill_relationship_state.sql new file mode 100644 index 0000000..a4c499e --- /dev/null +++ b/backend/sql/backfill_relationship_state.sql @@ -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; diff --git a/backend/utils/falukant/initializeFalukantTypes.js b/backend/utils/falukant/initializeFalukantTypes.js index 8143ba6..63b1fdd 100644 --- a/backend/utils/falukant/initializeFalukantTypes.js +++ b/backend/utils/falukant/initializeFalukantTypes.js @@ -659,6 +659,10 @@ const undergroundTypes = [ "tr": "rob", "cost": 500 }, + { + "tr": "investigate_affair", + "cost": 7000 + }, ]; { diff --git a/docs/FALUKANT_LOVERS_CONCEPT.md b/docs/FALUKANT_LOVERS_CONCEPT.md index 0c3dbd9..8f8cd54 100644 --- a/docs/FALUKANT_LOVERS_CONCEPT.md +++ b/docs/FALUKANT_LOVERS_CONCEPT.md @@ -115,9 +115,6 @@ Jede Liebhaber-Beziehung sollte mindestens diese Werte tragen: - `statusFit`: passt die Beziehung zum Stand der Spielfigur - `householdTension`: Spannungen im eigenen Haus - `scandalRisk`: Risiko für Gerüchte, Erpressung oder Entdeckung - -Optional später: - - `fertilityRisk` - `politicalValue` - `churchOffense` diff --git a/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md b/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md new file mode 100644 index 0000000..e73091f --- /dev/null +++ b/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md @@ -0,0 +1,263 @@ +# Falukant: Übergabedokument für den externen Daemon + +## Zweck + +Dieses Dokument ist die technische Übergabe an den externen Daemon, der nicht Teil dieses Projekts ist. + +Es beschreibt: + +- welche Daten der Daemon lesen muss +- welche Regeln er anwenden soll +- welche Felder er zurückschreiben muss +- welche Ereignisse und Nebenwirkungen erwartet werden + +Die fachlichen Regeln selbst stehen in: + +- [FALUKANT_LOVERS_DAEMON_SPEC.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_SPEC.md) + +Die lokale technische Datenbasis dieses Projekts steht in: + +- [FALUKANT_LOVERS_TECHNICAL_CONCEPT.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_TECHNICAL_CONCEPT.md) + +## Architekturgrenze + +Wichtig: + +- dieses Backend hält die Datenstruktur und liefert Family-/UI-Daten +- der eigentliche Tick-Lauf für Kosten, Ansehen, Ehezufriedenheit und Kinder passiert im externen Daemon +- der externe Daemon ist damit zuständig für die periodische Spiellogik + +Dieses Projekt ist nicht zuständig für: + +- die Scheduler-Ausführung +- Tick-Zeitpunkte +- operative Daemon-Laufzeit + +## Datenquelle + +Der externe Daemon arbeitet auf folgenden Tabellen: + +- `falukant_data.relationship` +- `falukant_data.relationship_state` +- `falukant_data.character` +- `falukant_data.child_relation` +- `falukant_data.falukant_user` +- `falukant_type.relationship` +- `falukant_type.title` + +Optional später: + +- Notification-Tabellen +- Frömmigkeits- oder Kirchen-bezogene Tabellen + +## Mindestdatensatz pro Tick + +Für jede aktive Liebschaft muss der Daemon laden: + +- `relationship.id` +- `relationship.character1_id` +- `relationship.character2_id` +- `relationship_type.tr` +- `relationship_state.lover_role` +- `relationship_state.affection` +- `relationship_state.visibility` +- `relationship_state.discretion` +- `relationship_state.maintenance_level` +- `relationship_state.status_fit` +- `relationship_state.monthly_base_cost` +- `relationship_state.months_underfunded` +- `relationship_state.active` +- `relationship_state.acknowledged` +- `relationship_state.last_daily_processed_at` +- `relationship_state.last_monthly_processed_at` + +Zusätzlich pro beteiligter Figur: + +- `character.id` +- `character.user_id` +- `character.gender` +- `character.birthdate` +- `character.reputation` +- `character.title_of_nobility` + +Zusätzlich für Geld: + +- `falukant_user.id` +- `falukant_user.money` + +Zusätzlich für Ehekontext: + +- aktive Beziehung vom Typ `married`, `engaged` oder `wooing` +- `relationship_state.marriage_satisfaction` + +Zusätzlich für Kinderprüfung: + +- bestehende `child_relation` für dieselben Eltern + +## Pflichtlogik Daily Tick + +Der externe Daemon muss täglich: + +1. Sichtbarkeit anpassen +2. Diskretion anpassen +3. Ehezufriedenheit anpassen +4. Ansehen anpassen +5. Skandalchance prüfen +6. Zustände speichern +7. optionale Benachrichtigung oder Log-Einträge erzeugen + +### Daily Input + +- alle aktiven `lover`-Beziehungen +- zugehörige Ehebeziehung, falls vorhanden +- Standesgruppe +- das jüngere Alter der beiden Beteiligten `minAge` + +### Daily Output + +Rückzuschreiben: + +- `relationship_state.visibility` +- `relationship_state.discretion` +- `relationship_state.marriage_satisfaction` der Ehebeziehung +- `character.reputation` +- `relationship_state.last_daily_processed_at` + +Optional: + +- Notification +- Ereignislog + +## Pflichtlogik Monthly Tick + +Der externe Daemon muss monatlich: + +1. Monatskosten berechnen +2. Geld abbuchen +3. Unterversorgung behandeln +4. Kinderchance prüfen +5. ggf. Kind anlegen +6. Folgen auf Ansehen und Ehe anwenden +7. Zustände speichern + +### Monthly Output + +Rückzuschreiben: + +- `falukant_user.money` +- Geldfluss-Log +- `relationship_state.months_underfunded` +- `relationship_state.affection` +- `relationship_state.discretion` +- `relationship_state.visibility` +- `relationship_state.last_monthly_processed_at` +- ggf. `child_relation` +- ggf. neuer Kind-Charakter + +## Formeln + +Die verbindlichen Regeln und Formeln kommen aus: + +- [FALUKANT_LOVERS_DAEMON_SPEC.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_SPEC.md) + +Der externe Daemon soll insbesondere exakt übernehmen: + +- Standesgruppen +- Monatskostenformel +- Unterversorgungsfolgen +- Ehezufriedenheitslogik +- Reputationslogik +- Altersmalus bei zu jungen Liebschaften +- Sichtbarkeits- und Diskretionslogik +- Skandalchance +- Kinderwahrscheinlichkeit + +## Idempotenz + +Der externe Daemon muss idempotent arbeiten. + +Pflicht: + +- Daily Tick nie zweimal für denselben Ingame-Tag auf dieselbe Beziehung anwenden +- Monthly Tick nie zweimal für denselben Ingame-Monat auf dieselbe Beziehung anwenden + +Pflichtfelder dafür: + +- `last_daily_processed_at` +- `last_monthly_processed_at` + +## Transaktionsanforderungen + +Folgende Monthly-Vorgänge müssen atomar laufen: + +- Geldabbuchung +- Statusänderung der Liebschaft +- Kind-Erzeugung +- Folgeänderung an Ansehen oder Ehe + +Empfehlung: + +- pro verarbeiteter Beziehung eine DB-Transaktion + +## Kind-Erzeugung + +Bei erfolgreicher Monatsprüfung auf Kind: + +1. neues Kind in `falukant_data.character` anlegen +2. neue `child_relation` anlegen +3. Felder setzen: + - `birth_context = lover` + - `legitimacy = hidden_bastard` + - `public_known = false` + +Wenn der Daemon Kinder nicht selbst anlegen soll, muss er stattdessen ein klar definiertes Create-Event an dieses Backend oder an ein anderes Backend-Modul senden. Standardempfehlung ist aber direkte DB-Erzeugung im Daemon. + +## Gleichbehandlung der Geschlechter + +Der externe Daemon muss dieselben Regeln für männliche und weibliche Spielfiguren anwenden. + +Das betrifft: + +- Kosten +- Reputationswirkung +- Ehezufriedenheit +- Skandalrisiko +- Status- und Sichtbarkeitslogik + +Unterschiedlich ist nur die biologische Kinderentstehung im aktuellen Modell. + +## Was dieses Backend dafür bereitstellt + +Dieses Projekt stellt aktuell bereit: + +- Datenstruktur für `relationship_state` +- Datenstruktur für `child_relation`-Erweiterungen +- Family-API mit lesbaren Zuständen + +Später kann dieses Backend zusätzlich bereitstellen: + +- Komfort-Endpunkte für Lover-Aktionen +- Admin-/Debug-Ansichten +- eventuelle Helper-Endpoints für den Daemon + +## Erwartete externe Deliverables + +Damit die externe Daemon-Umsetzung vollständig ist, werden dort mindestens benötigt: + +1. Daily-Tick-Job +2. Monthly-Tick-Job +3. SQL- oder ORM-Zugriff auf die Falukant-Tabellen +4. saubere Transaktionslogik +5. Schutz gegen doppelte Verarbeitung +6. Logging oder Monitoring für Tick-Fehler + +## Definition of Done für die Übergabe + +Die Übergabe an den externen Daemon gilt als vollständig, wenn: + +1. Datenfelder und Tabellen eindeutig definiert sind +2. Daily- und Monthly-Inputs beschrieben sind +3. Daily- und Monthly-Outputs beschrieben sind +4. die verbindliche Fachlogik referenziert ist +5. Idempotenz- und Transaktionsanforderungen klar sind +6. Kinder aus Liebschaften technisch beschrieben sind diff --git a/docs/FALUKANT_LOVERS_DAEMON_SPEC.md b/docs/FALUKANT_LOVERS_DAEMON_SPEC.md new file mode 100644 index 0000000..a86320c --- /dev/null +++ b/docs/FALUKANT_LOVERS_DAEMON_SPEC.md @@ -0,0 +1,775 @@ +# Falukant: Daemon-Spezifikation für Liebhaber, Mätressen, Ehezufriedenheit und uneheliche Kinder + +## Zweck + +Dieses Dokument beschreibt die konkrete Server- und Daemon-Logik für außereheliche Beziehungen im Familiensystem von Falukant. Es ergänzt das Grundkonzept in [FALUKANT_LOVERS_CONCEPT.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_CONCEPT.md) um exakte Regeln für: + +- laufende Kosten +- laufende Änderungen von Ansehen +- neues System `Ehe-Zufriedenheit` +- standesabhängige Wirkung von Ehe und Liebschaften +- mögliche Kinder aus Liebschaften +- gendergleiche Behandlung im Regelwerk + +Das Dokument ist bewusst daemon-orientiert, also als Grundlage für periodische Verarbeitung im Backend. + +## Bewusst vertagte Themen + +Zwei Themen werden in dieser Spezifikation ausdrücklich nur vorgemerkt und nicht in die erste Umsetzung gezogen: + +### Dienerschaft + +`Dienerschaft` ist ein interessanter späterer Ausbau, weil sie gut zu Diskretion, Repräsentation, Hausstand und Kosten passt. Für die erste Version wird sie jedoch nicht als eigenes System modelliert. + +Für Phase 1 gilt deshalb: + +- Dienerschaft ist nur indirekt in den Unterhaltskosten enthalten +- keine eigenen Diener-Slots, Rollen oder Haushaltsobjekte +- keine gesonderte Interaktion mit Heimlichkeit oder Hofstatus + +Später kann daraus ein eigenes Hausstands- oder Hofsystem entstehen. + +### Balancing + +Die in diesem Dokument genannten Zahlen sind Regelrahmen für die technische Umsetzung, nicht finale Produktionswerte. + +Für Phase 1 gilt deshalb: + +- Formeln und relative Verhältnisse sind wichtiger als absolute Zahlen +- Kosten-, Ansehens- und Zufriedenheitswerte werden später nach realen Spieltests feinjustiert +- Balancing ist eine eigene Nachphase und kein Blocker für die erste technische Integration + +## Leitprinzipien + +### 1. Gleichbehandlung der Geschlechter + +Die Regeln für Ansehen, Ehezufriedenheit, Unterhalt, Skandal und soziale Bewertung gelten für männliche und weibliche Spielfiguren gleich. + +Das heißt: + +- dieselbe Beziehungsform erzeugt dieselben Grundkosten +- dieselbe Sichtbarkeit erzeugt dieselben Reputationsfolgen +- dieselbe Standeslage erzeugt dieselben Modifikatoren +- dieselbe Untreue erzeugt dieselbe Wirkung auf die Ehe + +Biologische Unterschiede betreffen nur die Frage, ob aus einer konkreten Paarung natürlich ein Kind entstehen kann. Die soziale und spielmechanische Behandlung bleibt gleich. + +### 2. Stand vor Moral + +Das System bewertet nicht abstrakt „Treue“ oder „Untreue“, sondern: + +- wie geordnet die Situation ist +- wie standesgemäß sie geführt wird +- wie sichtbar sie ist +- ob Ehe, Haus und Erbfolge destabilisiert werden + +### 2a. Zu jung ist reputationsschädlich + +Sehr junge Liebschaften sollen im Daemon nie neutral behandelt werden. + +Das heißt: + +- eine Beziehung kann technisch erlaubt sein +- sie kann aber trotzdem das Ansehen zusätzlich belasten +- dieser Malus kommt zusätzlich zu Sichtbarkeit, Skandal und Stand +- der Altersmalus gilt geschlechtsunabhängig + +### 3. Daemon statt Einmal-Effekt + +Kosten, Ehezufriedenheit, Sichtbarkeit und Ansehen werden nicht nur beim Anlegen oder Beenden einer Beziehung verändert, sondern laufend im Daemon fortgeschrieben. + +## Bestehende Anknüpfungspunkte + +Das System passt auf die vorhandenen Strukturen: + +- Beziehungen werden bereits über `Relationship` geführt in [relationship.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/relationship.js) +- Kinder werden bereits über `ChildRelation` geführt in [child_relation.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/child_relation.js) +- Ansehen ist bereits auf dem Charakter vorhanden in [character.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/character.js) +- Stand ist bereits über `titleOfNobility` abbildbar in [title_of_nobility.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/type/title_of_nobility.js) +- `lover` ist bereits ein vorhandener Beziehungstyp + +## Neue Spielwerte + +## A. Pro Spielfigur: Ehezufriedenheit + +Neuer Charakter- oder Ehewert: + +- `marriageSatisfaction` +- Wertebereich `0..100` +- Standardwert bei frischer Ehe: `55` + +Interpretation: + +- `0..19`: offene Ehekrise +- `20..39`: stark belastet +- `40..59`: angespannt bis normal +- `60..79`: stabil +- `80..100`: sehr stabil + +Wichtig: + +- Ehezufriedenheit gehört zur Ehebeziehung, nicht zur Person allein. +- Technisch ist dafür eine eigene Beziehungstabelle oder eine Erweiterung der Ehe-`Relationship` sinnvoll. +- Für eine erste Version kann der Wert aber auf der Ehebeziehung gespeichert werden. + +## B. Pro Liebhaber-Beziehung + +Für jede Beziehung vom Typ `lover` werden zusätzliche Felder benötigt. + +Pflichtfelder: + +- `loverRole` + - `secret_affair` + - `lover` + - `mistress_or_favorite` +- `affection` + - `0..100` +- `visibility` + - `0..100` +- `discretion` + - `0..100` +- `maintenanceLevel` + - `0..100` +- `statusFit` + - `-2..2` +- `monthlyBaseCost` + - integer +- `active` + - boolean +- `acknowledged` + - boolean +- `exclusive` + - boolean optional + +Abgeleitete, nicht zwingend gespeicherte Werte: + +- `monthlyTotalCost` +- `reputationDeltaDaily` +- `marriageDeltaDaily` +- `scandalRiskDaily` +- `pregnancyChanceMonthly` + +## C. Optionaler Kinderwert + +Für Kinder aus Liebschaften wird kein neues Kindmodell benötigt. `ChildRelation` reicht, benötigt aber zusätzlich: + +- `legitimacy` + - `legitimate` + - `acknowledged_bastard` + - `hidden_bastard` +- `birthContext` + - `marriage` + - `lover` +- `publicKnown` + - boolean + +## Standesgruppen + +Für alle Daemon-Berechnungen werden Adelstitel auf vier Gruppen verdichtet: + +### Gruppe 0: niedrige Stände + +- `noncivil` +- `civil` +- `sir` + +### Gruppe 1: wohlhabende Bürger und lokale Oberschicht + +- `townlord` +- `by` +- `landlord` + +### Gruppe 2: niederer und mittlerer Adel + +- `knight` +- `baron` +- `count` +- `palsgrave` +- `margrave` +- `landgrave` + +### Gruppe 3: hoher Adel und Herrschaft + +- `ruler` +- `elector` +- `imperial-prince` +- `duke` +- `grand-duke` +- `prince-regent` +- `king` + +Diese Gruppen steuern: + +- Toleranz gegenüber sichtbaren Liebschaften +- erforderliches Unterhaltsniveau +- Wirkung auf Ehezufriedenheit +- Strafe bei Skandal + +## Taktung im Daemon + +Empfohlene Verarbeitung: + +- `daily tick`: alle 24 Ingame-Stunden +- `monthly tick`: alle 30 Ingame-Tage + +Aufteilung: + +### Daily Tick + +- Sichtbarkeit und Diskretion anpassen +- Ehezufriedenheit anpassen +- Ansehen anpassen +- Skandalrisiko prüfen +- Ereignisse auslösen + +### Monthly Tick + +- Unterhaltskosten abbuchen +- Beziehungskosten neu berechnen +- Kinderchance prüfen +- Statuswechsel prüfen + +## Kostenmodell + +## 1. Grundkosten pro Monat + +### secret_affair + +- Basis: `10` + +### lover + +- Basis: `30` + +### mistress_or_favorite + +- Basis: `80` + +Diese Werte sind Ingame-Basiswerte und sollen relativ zu Falukant-Geldwerten noch feinjustiert werden. + +## 2. Standesmultiplikator + +### Gruppe 0 + +- `x 1.0` + +### Gruppe 1 + +- `x 1.6` + +### Gruppe 2 + +- `x 2.6` + +### Gruppe 3 + +- `x 4.0` + +Begründung: + +- Höhere Stände können sich die Beziehung leisten. +- Gleichzeitig muss sie teurer sein, weil „standesgemäß“ mehr Aufwand verlangt. + +## 3. Unterhaltsfaktor + +`maintenanceFactor = 0.6 + (maintenanceLevel / 100) * 1.2` + +Beispiele: + +- `0` => `0.6` +- `50` => `1.2` +- `100` => `1.8` + +Niedrige Versorgung spart kurzfristig Geld, erhöht aber später Sichtbarkeit, Unzufriedenheit und Skandalrisiko. + +## 4. Status-Fit-Kosten + +Wenn `statusFit < 0`, steigen Kosten für Diskretion und Konfliktpflege: + +- `statusFit = -1` => `+15 %` +- `statusFit = -2` => `+35 %` + +## 5. Monatsformel + +`monthlyTotalCost = round(baseCost * rankMultiplier * maintenanceFactor * statusFitModifier)` + +## 6. Folgen bei Nichtzahlung + +Wenn der Monatsbetrag nicht vollständig gezahlt werden kann: + +- `affection -8` +- `discretion -6` +- `visibility +8` +- `marriageSatisfaction -4` falls verheiratet +- `reputation -1` sofort, falls `visibility >= 40` + +Bei zwei aufeinanderfolgenden Monaten Unterversorgung: + +- tägliches Skandalrisiko zusätzlich `+2 %` + +## Ehezufriedenheit: Grundmodell + +## 1. Basistendenz pro Tag + +Jede bestehende Ehe bewegt sich täglich leicht Richtung `55`, wenn keine besonderen Faktoren wirken. + +Formel: + +- wenn `marriageSatisfaction > 55`: `-1` alle 3 Tage +- wenn `marriageSatisfaction < 55`: `+1` alle 5 Tage + +So bleiben Ehen nicht dauerhaft extrem, wenn nichts passiert. + +## 2. Modifikatoren durch Liebschaften + +Nur wenn eine aktive Ehe und mindestens eine aktive Liebschaft existiert. + +### Gruppe 0 + +- heimliche Liebschaft: `-1` pro Tag +- lover: `-2` pro Tag +- Mätresse/Favorit: `-3` pro Tag + +### Gruppe 1 + +- heimliche Liebschaft: `-1` pro Tag +- lover: `-1` pro Tag +- Mätresse/Favorit: `-2` pro Tag + +### Gruppe 2 + +- heimliche Liebschaft: `0 bis -1` pro Tag +- lover: `-1` pro Tag +- Mätresse/Favorit: `-1` pro Tag + +### Gruppe 3 + +- heimliche Liebschaft: `0` +- lover: `0` +- Mätresse/Favorit: `+1 / 0 / -1` je nach Ordnung + +Für Gruppe 3 gilt: + +- `+1`, wenn: + - `visibility <= 35` + - `maintenanceLevel >= 65` + - `marriageSatisfaction >= 45` + - nur eine aktive Mätresse/Favorit vorhanden +- `0`, wenn die Lage geordnet, aber nicht positiv ist +- `-1`, wenn die Beziehung sichtbar Unruhe erzeugt + +Das bildet den gewünschten Spezialfall ab: + +- Bei einem König kann eine diskrete, geordnete Nebenbeziehung die Ehe sogar entlasten oder stabilisieren. +- Dieselbe Lage kippt ins Negative, wenn sie chaotisch oder öffentlich demütigend wird. + +## 3. Zusätzliche Ehemodifikatoren + +### Positive Faktoren + +- Ehepartner regelmäßig beschenkt: `+1` pro Tag für 5 Tage +- großes Fest oder Hochzeitspflege: `+2..+5` einmalig +- keine aktive Liebschaft und hohe Versorgung des Hauses: `+1` alle 4 Tage + +### Negative Faktoren + +- sichtbare Liebschaft `visibility >= 60`: `-2` pro Tag +- Liebschaft mit `minAge <= 15`: zusätzlich `-1` pro Tag +- Kind aus Liebschaft wird bekannt: `-8` einmalig +- zwei oder mehr aktive Liebschaften: `-2` pro Tag zusätzlich +- Unterhaltsausfall bei Mätresse/Favorit: `-1` pro Tag + +## Ansehen: Grundmodell + +Ansehen wird im Daily Tick pro aktiver Liebschaft angepasst. + +## 1. Basiswert pro Beziehungsform + +### secret_affair + +- `-0.2` pro Tag + +### lover + +- `-0.4` pro Tag + +### mistress_or_favorite + +- `-0.6` pro Tag + +Diese Werte sind Rohwerte vor Modifikatoren. + +## 2. Sichtbarkeitsfaktor + +`visibilityFactor = 0.4 + (visibility / 100) * 1.6` + +Beispiele: + +- `0` => `0.4` +- `50` => `1.2` +- `100` => `2.0` + +## 3. Standesmodifikator auf Reputationsverlust + +### Gruppe 0 + +- `x 1.8` + +### Gruppe 1 + +- `x 1.3` + +### Gruppe 2 + +- `x 1.0` + +### Gruppe 3 + +- `x 0.7` + +Aber: + +- bei Gruppe 3 gilt nur dann `0.7`, wenn die Beziehung geordnet ist +- bei Skandal, Erbfolgedruck oder offener Demütigung springt Gruppe 3 auf `1.5` + +## 4. Ordnungsbonus + +Wenn alle Bedingungen erfüllt sind: + +- `maintenanceLevel >= 65` +- `discretion >= 60` +- `visibility <= 35` +- maximal eine aktive Mätresse/Favorit + +dann: + +- Gruppe 2: `+0.1` Ansehen pro Tag statt Malus bei `mistress_or_favorite` +- Gruppe 3: `+0.2` Ansehen pro Tag statt Malus bei `mistress_or_favorite` + +Das repräsentiert: + +- geordneten Überfluss +- höfische Attraktivität +- kontrollierte Nebenbeziehung ohne Hauschaos + +Für `secret_affair` und normalen `lover` gibt es keinen positiven Reputationswert. + +## 5. Tagesformel + +`dailyReputationDelta = baseValue * visibilityFactor * rankModifier` + +Dann: + +- Ordnungsbonus anwenden, falls aktiv +- auf `[-3, +1]` pro Tag je Beziehung begrenzen + +## 5a. Altersmalus bei zu jungen Liebschaften + +Zusätzlich zur normalen Reputationsformel wird ein eigener Altersmalus berechnet. + +Grundlage ist immer das jüngere Alter der beiden Beteiligten: + +- `minAge <= 13`: `ageReputationDelta = -1.5` pro Tag +- `minAge <= 15`: `ageReputationDelta = -0.8` pro Tag +- `minAge <= 17`: `ageReputationDelta = -0.3` pro Tag +- `minAge >= 18`: `ageReputationDelta = 0` + +Dann gilt: + +`finalDailyReputationDelta = dailyReputationDelta + ageReputationDelta` + +Zusatzregel: + +- wenn `minAge <= 15` und `visibility >= 50`, zusätzlicher Malus `-0.5` pro Tag + +Damit gilt dann: + +`finalDailyReputationDelta = dailyReputationDelta + ageReputationDelta + visibilityYoungPenalty` + +Interpretation: + +- junge Beziehungen sind schon im privaten Bereich reputationsschädlich +- sichtbare junge Beziehungen schaden noch stärker +- der Malus kommt zusätzlich zu allen übrigen Skandal- und Sichtbarkeitsfolgen + +## 6. Harte Malus-Ereignisse + +Zusätzliche Einmal-Effekte: + +- öffentliches Gerücht: `-3` +- kirchlicher Tadel: `-5` +- bekanntes Kind aus Liebschaft: `-6` +- Erbfolgestreit durch uneheliches Kind: `-10` +- zwei sichtbare Liebschaften gleichzeitig: `-4` + +## Sichtbarkeit und Diskretion + +Diese Werte verändern sich täglich. + +## Sichtbarkeit + pro Tag + +- `+1`, wenn `maintenanceLevel < 35` +- `+1`, wenn `affection < 30` +- `+2`, wenn `statusFit = -2` +- `+1`, wenn bereits ein Ehekonflikt aktiv ist + +## Sichtbarkeit - pro Tag + +- `-1`, wenn `discretion >= 60` +- `-1`, wenn `maintenanceLevel >= 70` + +## Diskretion + pro Tag + +- `+1`, wenn `maintenanceLevel >= 70` + +## Diskretion - pro Tag + +- `-1`, wenn `maintenanceLevel < 35` +- `-1`, wenn `visibility > 60` + +Beide Werte bleiben in `0..100`. + +## Skandalrisiko + +Tägliche Grundchance: + +`baseScandalChance = 1 %` + +Modifikatoren: + +- `+ visibility / 25` +- `+2 %`, wenn verheiratet +- `+2 %`, wenn `statusFit = -2` +- `+3 %`, wenn `maintenanceLevel < 25` +- `+3 %`, wenn zwei oder mehr aktive Liebschaften bestehen +- `+6 %`, wenn `minAge <= 13` +- `+3 %`, wenn `minAge <= 15` +- `+1 %`, wenn `minAge <= 17` +- `-2 %`, wenn `discretion >= 75` +- `-2 %`, wenn Gruppe 3 und Beziehung geordnet als Mätresse/Favorit geführt wird + +Deckel: + +- Minimum `0 %` +- Maximum `25 %` pro Tag + +Mögliche Ereignisse: + +- Gerücht +- Ehekrach +- Forderung nach höherem Unterhalt +- kirchlicher Tadel +- Erpressung +- Kind wird bekannt + +## Kinder aus Liebschaften + +## 1. Grundsatz + +Kinder aus Liebschaften sind möglich. + +Sie sollen: + +- nicht die Ehe ersetzen +- das Familiensystem erweitern +- vor allem bei höheren Ständen politische und soziale Reibung erzeugen + +## 2. Technische Bedingung + +Im aktuellen biologischen Modell nur bei gegengeschlechtlicher Paarung. + +Die soziale Behandlung bleibt gleich: + +- weibliche und männliche Spielfiguren erzeugen dieselben Ansehens- und Ehefolgen +- das System bewertet nicht unterschiedlich nach Geschlecht + +## 3. Monatschance auf Kind + +Nur wenn: + +- Beziehung aktiv ist +- beide Figuren im fruchtbaren Altersbereich sind +- `affection >= 45` +- `maintenanceLevel >= 30` +- kein Sperrstatus aktiv + +Empfohlene Monatswahrscheinlichkeit: + +### secret_affair + +- `2 %` + +### lover + +- `4 %` + +### mistress_or_favorite + +- `6 %` + +Modifikatoren: + +- `+2 %`, wenn `affection >= 75` +- `-2 %`, wenn `visibility >= 70` und Beziehung instabil ist +- `-3 %`, wenn eine Figur deutlich über den Fruchtbarkeitsgrenzen liegt + +Deckel: + +- Minimum `0 %` +- Maximum `12 %` + +## 4. Status des Kindes + +Bei Geburt aus Liebschaft: + +- `birthContext = lover` +- `legitimacy = hidden_bastard` standardmäßig + +Wenn die Spielfigur das Kind anerkennt: + +- `legitimacy = acknowledged_bastard` +- `publicKnown = true` + +Das Kind darf nicht automatisch Erbe werden. + +Nur explizite spätere Sonderregeln dürfen Erbfolgedruck erzeugen. + +## 5. Folgen eines Kindes aus Liebschaft + +### Sofortfolgen + +- `marriageSatisfaction -8` +- `reputation -4` + +### Wenn öffentlich bekannt + +- Gruppe 0: zusätzlich `-4` +- Gruppe 1: zusätzlich `-5` +- Gruppe 2: zusätzlich `-6` +- Gruppe 3: zusätzlich `-8` + +### Wenn Kind anerkannt wird + +- laufende Monatskosten `+20..+80` je Stand +- zusätzliche Ereignisse zu Versorgung, Namen und Status + +## Standesabhängigkeit der Ehe + +Die Ehe darf nicht in allen Ständen gleich funktionieren. + +## Gruppe 0 + +- Ehe ist vor allem ökonomische Stabilität +- Liebschaften belasten den Haushalt direkt +- Ehezufriedenheit reagiert stark negativ + +## Gruppe 1 + +- Ehe ist Haus- und Rufgemeinschaft +- diskrete Affären sind denkbar, aber riskant +- sichtbare Liebschaften schaden deutlich + +## Gruppe 2 + +- Ehe ist Hauspolitik und Nachfolgeordnung +- Mätressen können vorkommen, aber nur kontrolliert +- Ehezufriedenheit reagiert weniger moralisch, stärker auf öffentliche Ordnung + +## Gruppe 3 + +- Ehe ist Dynastie, Bündnis und Hofordnung +- eine diskrete Mätresse/Favorit kann den ehelichen Druck senken, solange: + - die offizielle Ehe respektiert bleibt + - keine Erbfolge bedroht wird + - kein öffentlicher Gesichtsverlust entsteht + +Deshalb ist bei hohen Ständen ein positiver Effekt auf die Ehe ausdrücklich zulässig, aber nur in geordneten Fällen. + +## Empfohlene Umsetzung im Daemon + +Reihenfolge pro Daily Tick: + +1. aktive Ehen laden +2. aktive Liebschaften laden +3. Sichtbarkeit und Diskretion fortschreiben +4. Ehezufriedenheit pro Ehe fortschreiben +5. Ansehen pro Charakter fortschreiben +6. Skandalereignisse prüfen +7. Benachrichtigungen schreiben + +Reihenfolge pro Monthly Tick: + +1. Monatskosten je Liebschaft berechnen +2. Geld abbuchen +3. Unterversorgungsfolgen anwenden +4. Kinderchance prüfen +5. neue Kinder aus Liebschaften anlegen +6. Folgeereignisse erzeugen + +## Minimale technische Erweiterungen + +Für eine erste umsetzbare Version werden mindestens benötigt: + +### Beziehungserweiterung + +- Zusatzfelder an `relationship` oder neue Nebentabelle `relationship_state` + +### Ehewert + +- `marriage_satisfaction` an Ehebeziehung + +### Kind-Zusatzfelder + +- `legitimacy` +- `birth_context` +- `public_known` + +### Daemon-Konfiguration + +- täglicher Falukant-Familienjob +- monatlicher Falukant-Familienjob + +## MVP-Empfehlung + +Für die erste produktive Version empfehle ich diesen Schnitt: + +### Enthalten + +- aktive Liebschaften +- Monatskosten +- tägliche Ansehensänderung +- Ehezufriedenheit +- standesabhängige Unterschiede +- Kinderchance aus Liebschaften +- sichtbare Darstellung in FamilyView + +### Noch nicht im MVP + +- Erpressungsketten +- kirchliche Sonderereignisse +- gerichtliche Konflikte +- Sonderrechte unehelicher Kinder +- komplexe Hofintrigen +- Dienerschaft als eigenes System +- finales Balancing aller Zahlenwerte + +## Abnahmekriterien + +Die erste technische Umsetzung gilt als korrekt, wenn: + +1. jede aktive Liebschaft monatlich Kosten erzeugt +2. jede aktive Liebschaft täglich das Ansehen verändert +3. verheiratete Figuren täglich Ehezufriedenheit verändern +4. hohe Stände bei geordneter Mätresse/Favorit einen neutralen oder leicht positiven Eheeffekt haben können +5. sichtbare oder schlecht versorgte Liebschaften zu Ansehensverlust führen +6. Kinder aus Liebschaften entstehen können +7. weibliche und männliche Spielfiguren regelgleich behandelt werden + +## Offene Implementierungsfrage + +Vor dem Coden sollte noch genau entschieden werden: + +- ob die Zusatzwerte direkt in `relationship` landen +- oder in eine neue Tabelle wie `falukant_data.relationship_state` + +Fachlich ist beides möglich. Für Wartbarkeit und spätere Ereignisse ist eine eigene Zustands-/Detailtabelle sauberer. diff --git a/docs/FALUKANT_LOVERS_IMPLEMENTATION_BACKLOG.md b/docs/FALUKANT_LOVERS_IMPLEMENTATION_BACKLOG.md new file mode 100644 index 0000000..2908639 --- /dev/null +++ b/docs/FALUKANT_LOVERS_IMPLEMENTATION_BACKLOG.md @@ -0,0 +1,478 @@ +# Falukant: Implementierungs-Backlog für Liebhaber, Mätressen, Ehezufriedenheit und Kinder aus Liebschaften + +## Zweck + +Dieses Backlog übersetzt die Fach- und Technikdokumente in konkrete Umsetzungspakete. + +Grundlagen: + +- [FALUKANT_LOVERS_CONCEPT.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_CONCEPT.md) +- [FALUKANT_LOVERS_DAEMON_SPEC.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_SPEC.md) +- [FALUKANT_LOVERS_TECHNICAL_CONCEPT.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_TECHNICAL_CONCEPT.md) + +Das Backlog ist absichtlich in Reihenfolge angeordnet. Spätere Pakete bauen auf früheren auf. + +## Rahmen + +Nicht Teil der ersten Umsetzung: + +- eigenes Dienerschaftssystem +- finales Balancing +- große Ereignisketten rund um Kirche, Gericht oder Hofintrigen + +## Paket B1: Datenmodell vorbereiten + +### Ziel + +Die Datenbasis für Ehezufriedenheit, Liebschaftsstatus und Kinder aus Liebschaften anlegen. + +### Aufgaben + +1. Migration für `falukant_data.relationship_state` anlegen +2. Modell [relationship_state.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/relationship_state.js) anlegen +3. Associations in [associations.js](/mnt/share/torsten/Programs/YourPart3/backend/models/associations.js) ergänzen +4. `child_relation` um `legitimacy`, `birth_context`, `public_known` erweitern +5. Modell [child_relation.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/child_relation.js) anpassen + +### Betroffene Dateien + +- [backend/models/associations.js](/mnt/share/torsten/Programs/YourPart3/backend/models/associations.js) +- [backend/models/falukant/data/child_relation.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/child_relation.js) +- neue Migrationen in [backend/migrations](/mnt/share/torsten/Programs/YourPart3/backend/migrations) +- neue Datei [backend/models/falukant/data/relationship_state.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/relationship_state.js) + +### Abhängigkeiten + +- keine + +### Done + +- Datenbank kann die neuen Felder speichern +- Sequelize kann `Relationship` plus `state` laden +- `ChildRelation` kennt neue Legitimitätsfelder + +## Paket B2: Backfill und Defaults + +### Ziel + +Bestehende Ehen und Liebschaften mit Startwerten versorgen. + +### Aufgaben + +1. Backfill-Migration oder Reparaturskript für bestehende `married`-Beziehungen +2. Backfill-Migration oder Reparaturskript für bestehende `lover`-Beziehungen +3. Fallback-Logik im Backend ergänzen, falls für alte Datensätze noch kein State existiert + +### Betroffene Dateien + +- neue Migration oder Tool in [backend/migrations](/mnt/share/torsten/Programs/YourPart3/backend/migrations) oder [backend/tools](/mnt/share/torsten/Programs/YourPart3/backend/tools) +- [backend/services/falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js) + +### Abhängigkeiten + +- B1 + +### Done + +- alle alten `married`- und `lover`-Beziehungen haben nutzbare Zustandswerte +- Family-Lesezugriffe brechen nicht bei fehlendem State + +## Paket B3: Family-Lesepfade erweitern + +### Ziel + +Die bestehenden API-Daten für Familie so erweitern, dass das Frontend sofort lesen und anzeigen kann. + +### Aufgaben + +1. `getFamily()` in [falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js) um `state`-Daten erweitern +2. für Ehebeziehungen `marriageSatisfaction` und `marriageState` liefern +3. für `lovers` Rollen-, Kosten-, Sichtbarkeits- und Risikofelder liefern +4. Hilfsmethoden für Standesgruppe und Vorschauwerte ergänzen + +### Betroffene Dateien + +- [backend/services/falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js) + +### Abhängigkeiten + +- B1 +- B2 + +### Done + +- `GET /api/falukant/family` liefert die neuen Datenfelder +- keine UI-Aktion nötig, aber Daten sind vollständig lesbar + +## Paket B4: Family-UI lesend ausbauen + +### Ziel + +Die neuen Daten im Familienbereich sichtbar machen, ohne schon alle Interaktionen einzubauen. + +### Aufgaben + +1. Ehebereich in [FamilyView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/FamilyView.vue) um `Ehe-Zufriedenheit` ergänzen +2. `lovers`-Bereich mit Rolle, Sichtbarkeit, Diskretion, Unterhalt, Reputationseffekt und Eheeffekt erweitern +3. Kinderkennzeichnung für `legitimate`, `hidden_bastard`, `acknowledged_bastard` ergänzen +4. I18n-Schlüssel in den Falukant-Locales ergänzen + +### Betroffene Dateien + +- [frontend/src/views/falukant/FamilyView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/FamilyView.vue) +- [frontend/src/i18n/locales/de/falukant.json](/mnt/share/torsten/Programs/YourPart3/frontend/src/i18n/locales/de/falukant.json) +- [frontend/src/i18n/locales/en/falukant.json](/mnt/share/torsten/Programs/YourPart3/frontend/src/i18n/locales/en/falukant.json) +- [frontend/src/i18n/locales/es/falukant.json](/mnt/share/torsten/Programs/YourPart3/frontend/src/i18n/locales/es/falukant.json) + +### Abhängigkeiten + +- B3 + +### Done + +- FamilyView zeigt neue Zustände lesbar an +- uneheliche Kinder sind UI-seitig unterscheidbar + +## Paket B5: Berechnungslogik im Service kapseln + +### Ziel + +Alle Formeln in wiederverwendbare Backend-Helfer auslagern, bevor Daemon-Jobs gebaut werden. + +### Aufgaben + +1. `getRankGroup(...)` implementieren +2. `calculateLoverMonthlyCost(...)` implementieren +3. `calculateMarriageDelta(...)` implementieren +4. `calculateReputationDeltaFromLover(...)` implementieren +5. `calculateDailyVisibilityDelta(...)` und `calculateDailyDiscretionDelta(...)` implementieren +6. `calculateDailyScandalChance(...)` implementieren +7. `calculateMonthlyPregnancyChance(...)` implementieren + +### Betroffene Dateien + +- [backend/services/falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js) + +### Abhängigkeiten + +- B1 +- B2 + +### Done + +- Daemon-Jobs können auf zentrale Helper zugreifen +- keine Formel liegt verstreut in mehreren Jobs + +## Paket B6: Daily-Tick-Übergabe an externen Daemon + +### Ziel + +Die tägliche Spiellogik so spezifizieren und übergeben, dass der externe Daemon sie korrekt ausführen kann. + +### Aufgaben + +1. Übergabedokument für den externen Daemon erstellen +2. Daily Input- und Output-Felder festlegen +3. Idempotenzanforderungen für `last_daily_processed_at` festlegen +4. Datenabhängigkeiten für Ehe, Liebschaften und Stand definieren +5. Benachrichtigungs- und Ereignisfolgen beschreiben + +### Betroffene Dateien + +- [docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md) +- [docs/FALUKANT_LOVERS_DAEMON_SPEC.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_SPEC.md) + +### Abhängigkeiten + +- B5 + +### Done + +- der externe Daemon hat eine vollständige Daily-Tick-Übergabe +- Daily-Logik ist ohne Rückfragen implementierbar + +## Paket B7: Monthly-Tick-Übergabe an externen Daemon + +### Ziel + +Die monatliche Spiellogik so spezifizieren und übergeben, dass der externe Daemon sie korrekt ausführen kann. + +### Aufgaben + +1. Monthly Input- und Output-Felder festlegen +2. Geldabbuchung und Moneyflow-Anforderungen beschreiben +3. Unterversorgung und Zustandsänderungen beschreiben +4. Kind-Erzeugung und Folgeeffekte beschreiben +5. Idempotenzanforderungen für `last_monthly_processed_at` festlegen +6. Transaktionsanforderungen definieren + +### Betroffene Dateien + +- [docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md) +- [docs/FALUKANT_LOVERS_DAEMON_SPEC.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_SPEC.md) + +### Abhängigkeiten + +- B5 + +### Done + +- der externe Daemon hat eine vollständige Monthly-Tick-Übergabe +- Monatslogik ist ohne Rückfragen implementierbar + +## Paket B8: Kinder aus Liebschaften technisch ermöglichen + +### Ziel + +Kinder aus aktiven Liebschaften erzeugen und korrekt markieren. + +### Aufgaben + +1. `createChildFromLoverRelationship(...)` implementieren +2. `processLoverBirths(...)` in den Monthly Tick integrieren +3. `ChildRelation` korrekt mit `birthContext = lover` anlegen +4. `legitimacy = hidden_bastard` als Startwert setzen +5. erste Folgeeffekte auf Ansehen und Ehezufriedenheit anwenden + +### Betroffene Dateien + +- [backend/services/falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js) +- [backend/models/falukant/data/child_relation.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/child_relation.js) +- [docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md) + +### Abhängigkeiten + +- B7 + +### Done + +- Kinder aus Liebschaften können entstehen +- sie sind von legitimen Kindern technisch unterscheidbar + +## Paket B9: Notifications und Folgeereignisse MVP + +### Ziel + +Die wichtigsten Ergebnisse für Spieler sichtbar machen. + +### Aufgaben + +1. Notifikationstypen für Kosten, Unterversorgung, Gerücht, Skandal und Kind ergänzen +2. Benachrichtigungstexte definieren +3. Daily- und Monthly-Tick an die Notification-Logik anbinden + +### Betroffene Dateien + +- [docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md) +- bestehende Notification-Modelle oder Services im Backend +- ggf. [frontend/src/views/falukant/OverviewView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/OverviewView.vue) indirekt, falls Benachrichtigungen dort auftauchen + +### Abhängigkeiten + +- B6 +- B7 +- B8 + +### Done + +- Spieler sehen relevante Familienfolgen aktiv + +## Paket B10: Lover-Aktionen im Backend + +### Ziel + +Interaktive Steuerung von Liebschaften serverseitig ermöglichen. + +### Aufgaben + +1. `setLoverMaintenance(...)` +2. `setLoverDiscretionMode(...)` +3. `acknowledgeLover(...)` +4. `endLoverRelationship(...)` +5. `giftLover(...)` +6. Router- und Controller-Anbindung + +### Betroffene Dateien + +- [backend/services/falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js) +- [backend/controllers/falukantController.js](/mnt/share/torsten/Programs/YourPart3/backend/controllers/falukantController.js) +- [backend/routers/falukantRouter.js](/mnt/share/torsten/Programs/YourPart3/backend/routers/falukantRouter.js) + +### Abhängigkeiten + +- B3 +- B5 + +### Done + +- Backend bietet alle Kernaktionen für Lovers an + +## Paket B11: Lover-Aktionen im Frontend + +### Ziel + +Die neuen Interaktionen in `FamilyView` und ggf. Dialogen bedienbar machen. + +### Aufgaben + +1. Action-Buttons in [FamilyView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/FamilyView.vue) ergänzen +2. API-Aufrufe anbinden +3. Feedback- und Confirm-Dialoge integrieren +4. Zustandsänderungen direkt im UI sichtbar machen + +### Betroffene Dateien + +- [frontend/src/views/falukant/FamilyView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/FamilyView.vue) +- ggf. neue API-Helfer in `frontend/src/api` + +### Abhängigkeiten + +- B10 + +### Done + +- Unterhalt, Anerkennung, Diskretion und Beenden sind im UI nutzbar + +## Paket B12: Anerkennung unehelicher Kinder + +### Ziel + +Uneheliche Kinder später sichtbar anerkennen können. + +### Aufgaben + +1. Backend-Methode `acknowledgeLoverChild(...)` +2. Route und Controller +3. UI-Aktion im Familienbereich +4. direkte Folgeeffekte auf Ansehen und Ehe einbauen + +### Betroffene Dateien + +- [backend/services/falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js) +- [backend/controllers/falukantController.js](/mnt/share/torsten/Programs/YourPart3/backend/controllers/falukantController.js) +- [backend/routers/falukantRouter.js](/mnt/share/torsten/Programs/YourPart3/backend/routers/falukantRouter.js) +- [frontend/src/views/falukant/FamilyView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/FamilyView.vue) + +### Abhängigkeiten + +- B8 +- B11 + +### Done + +- uneheliche Kinder können anerkannt werden +- Status und Folgen ändern sich sichtbar + +## Paket B13: Admin- und Testhilfen + +### Ziel + +Die neue Mechanik testbar und debugbar machen. + +### Aufgaben + +1. Admin- oder Tool-Zugriff auf `relationship_state` +2. Debug-Skript für `30 Tage simulieren` +3. Plausibilitätsprüfungen für fehlende States +4. Reparaturskript für inkonsistente Kinderdaten + +### Betroffene Dateien + +- [backend/tools](/mnt/share/torsten/Programs/YourPart3/backend/tools) +- ggf. [backend/services/adminService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/adminService.js) +- [docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md) + +### Abhängigkeiten + +- B6 +- B7 +- B8 + +### Done + +- Entwickler können Systemzustände nachvollziehen und korrigieren + +## Paket B14: QA und Balancing-Vorbereitung + +### Ziel + +Noch kein finales Balancing, aber die technische Basis für spätere Feinjustierung schaffen. + +### Aufgaben + +1. Konfigurationspunkte für Kosten- und Reputationswerte zentralisieren +2. Grundtests für Daily- und Monthly-Tick definieren +3. Testfälle für Standesgruppen definieren +4. Testfälle für weibliche und männliche Spielfiguren spiegeln +5. Testfälle für Kinder aus Liebschaften definieren + +### Betroffene Dateien + +- [docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md) +- ggf. Testverzeichnis im Backend + +### Abhängigkeiten + +- B6 +- B7 +- B8 + +### Done + +- Werte sind zentral auffindbar +- spätere Balancing-Runden können auf Testfällen aufsetzen + +## Empfohlene Reihenfolge + +Für eine saubere erste Lieferung: + +1. B1 +2. B2 +3. B3 +4. B4 +5. B5 +6. B6 +7. B7 +8. B8 +9. B9 +10. B10 +11. B11 +12. B12 +13. B13 +14. B14 + +## MVP-Schnitt + +Wenn eine erste spielbare Version schneller geliefert werden soll, reicht zunächst: + +1. B1 +2. B2 +3. B3 +4. B4 +5. B5 +6. B6 +7. B7 +8. B8 + +Damit wären bereits vorhanden: + +- sichtbare Liebhaber-Details +- Ehezufriedenheit +- laufende Kosten +- laufende Ansehensänderung +- Kinder aus Liebschaften + +Noch nicht enthalten im MVP: + +- volle Interaktionssteuerung +- Anerkennung unehelicher Kinder +- Admin-Tools +- spätere Balancing-Infrastruktur + +## Nächster konkreter Schritt + +Wenn direkt implementiert werden soll, ist der erste technische Einstieg: + +- B1 Datenmodell vorbereiten + +Das ist der sauberste Startpunkt, weil danach alle weiteren Pakete darauf aufbauen können. diff --git a/docs/FALUKANT_LOVERS_TECHNICAL_CONCEPT.md b/docs/FALUKANT_LOVERS_TECHNICAL_CONCEPT.md new file mode 100644 index 0000000..c4bfb92 --- /dev/null +++ b/docs/FALUKANT_LOVERS_TECHNICAL_CONCEPT.md @@ -0,0 +1,503 @@ +# Falukant: Technisches Konzept für Liebhaber, Mätressen, Ehezufriedenheit und Kinder aus Liebschaften + +## Ziel + +Dieses Dokument beschreibt die technische Umsetzung für Backend, Daemon und UI. Es basiert auf: + +- [FALUKANT_LOVERS_CONCEPT.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_CONCEPT.md) +- [FALUKANT_LOVERS_DAEMON_SPEC.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_SPEC.md) + +Es soll als direkte Arbeitsgrundlage für Migrationen, Modelle, Service-Methoden, Daemon-Jobs und Frontend-Anpassungen dienen. + +## Umsetzungsstrategie + +Die Umsetzung sollte in drei technischen Stufen erfolgen: + +### Stufe 1: Datenbasis und Lesepfade + +- neue Beziehungsdetaildaten anlegen +- Ehezufriedenheit technisch einführen +- Family-API erweitern +- UI nur lesend erweitern + +### Stufe 2: Externe Daemon-Logik + +- tägliche und monatliche Falukant-Familienlogik an den externen Daemon übergeben +- Kosten, Ansehen, Sichtbarkeit, Diskretion und Ehezufriedenheit laufen dort automatisch +- Kinder aus Liebschaften werden über den externen Daemon ermöglicht + +### Stufe 3: Interaktive Spielmechanik + +- UI-Aktionen für Unterhalt, Diskretion, Anerkennung, Beenden +- Ereignisse, Warnungen und Benachrichtigungen +- spätere Vertiefung wie Skandal- oder Kirchenereignisse + +## Datenmodell + +## 1. Bestehende Tabellen, die genutzt werden + +- `falukant_data.relationship` +- `falukant_data.child_relation` +- `falukant_data.character` + +## 2. Empfohlene neue Tabelle + +Empfohlen wird eine neue Detailtabelle: + +- `falukant_data.relationship_state` + +Begründung: + +- `relationship` enthält aktuell nur die grobe Beziehung +- Liebhaber-/Ehedaten sind zustandsorientiert +- die Detailwerte wachsen voraussichtlich weiter +- eine Nebentabelle ist sauberer als `relationship` mit vielen Spezialspalten zu überladen + +## 3. Tabelle `relationship_state` + +### Primärbezug + +- `relationship_id` + +### Spalten für Ehe + +- `marriage_satisfaction` integer not null default `55` +- `marriage_public_stability` integer not null default `55` + +`marriage_public_stability` ist optional, aber sinnvoll für spätere Ereignisse. Für MVP kann er schon angelegt, aber noch wenig genutzt werden. + +### Spalten für Liebschaften + +- `lover_role` string nullable + - `secret_affair` + - `lover` + - `mistress_or_favorite` +- `affection` integer not null default `50` +- `visibility` integer not null default `15` +- `discretion` integer not null default `50` +- `maintenance_level` integer not null default `50` +- `status_fit` integer not null default `0` +- `monthly_base_cost` integer not null default `0` +- `months_underfunded` integer not null default `0` +- `active` boolean not null default `true` +- `acknowledged` boolean not null default `false` +- `exclusive_flag` boolean not null default `false` +- `last_monthly_processed_at` date nullable +- `last_daily_processed_at` date nullable + +### Spalten für spätere Erweiterung + +- `notes_json` jsonb nullable +- `flags_json` jsonb nullable + +## 4. Erweiterung `child_relation` + +Neue Spalten: + +- `legitimacy` string not null default `legitimate` + - `legitimate` + - `acknowledged_bastard` + - `hidden_bastard` +- `birth_context` string not null default `marriage` + - `marriage` + - `lover` +- `public_known` boolean not null default `false` + +## 5. Optionale Erweiterung `relationship` + +Für bessere Auswertung kann zusätzlich sinnvoll sein: + +- `ended_at` +- `ended_reason` + +Das ist für MVP nicht zwingend, aber nützlich. + +## Migrationen + +Benötigte Migrationen: + +### Migration 1 + +- neue Tabelle `falukant_data.relationship_state` + +### Migration 2 + +- neue Spalten an `falukant_data.child_relation` + +### Migration 3 optional + +- Backfill für bestehende Beziehungen + +Regeln für Backfill: + +- bei `relationshipType = married` + - `marriage_satisfaction = 55` +- bei `relationshipType = lover` + - `lover_role = lover` + - `affection = 50` + - `visibility = 20` + - `discretion = 45` + - `maintenance_level = 50` + - `status_fit = 0` + - `monthly_base_cost = 30` + +## Sequelize-Modelle + +## 1. Neues Modell + +Neue Datei: + +- [relationship_state.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/relationship_state.js) + +## 2. Associations + +In [associations.js](/mnt/share/torsten/Programs/YourPart3/backend/models/associations.js): + +- `Relationship.hasOne(RelationshipState, { foreignKey: 'relationshipId', as: 'state' })` +- `RelationshipState.belongsTo(Relationship, { foreignKey: 'relationshipId', as: 'relationship' })` + +## 3. ChildRelation-Erweiterung + +In [child_relation.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/child_relation.js): + +- `legitimacy` +- `birthContext` +- `publicKnown` + +## Backend-Service-Konzept + +Hauptort: + +- [falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js) + +## 1. Neue interne Hilfsmethoden + +Empfohlene neue interne Methoden: + +- `getRankGroup(titleLabelTr)` +- `calculateLoverMonthlyCost(relationship, state, character)` +- `calculateMarriageDelta(relationship, state, character, spouseCharacter, context)` +- `calculateReputationDeltaFromLover(relationship, state, character, context)` +- `calculateDailyVisibilityDelta(state, context)` +- `calculateDailyDiscretionDelta(state, context)` +- `calculateDailyScandalChance(relationship, state, character, context)` +- `calculateMonthlyPregnancyChance(relationship, state, charA, charB)` +- `applyLoverMonthlyCosts(transactionDate?)` +- `applyLoverDailyEffects(transactionDate?)` +- `processLoverBirths(transactionDate?)` +- `triggerLoverScandalEvent(...)` + +## 2. Erweiterung `getFamily` + +Die Familienausgabe in [falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js) muss `lovers` detaillierter liefern. + +Zusätzliche API-Felder je Lover: + +- `relationshipId` +- `role` +- `affection` +- `visibility` +- `discretion` +- `maintenanceLevel` +- `statusFit` +- `monthlyCost` +- `reputationEffect` +- `marriageEffect` +- `acknowledged` +- `canBecomePublic` +- `riskState` + +Zusätzliche API-Felder für Ehe: + +- `marriageSatisfaction` +- `marriageState` + - `stable` + - `strained` + - `crisis` + +## 3. Neue Service-Aktionen + +Für spätere UI-Steuerung: + +- `setLoverMaintenance(hashedUserId, relationshipId, maintenanceLevel)` +- `setLoverDiscretionMode(hashedUserId, relationshipId, mode)` +- `acknowledgeLover(hashedUserId, relationshipId)` +- `endLoverRelationship(hashedUserId, relationshipId)` +- `giftLover(hashedUserId, relationshipId, giftType)` + +Diese Methoden müssen in Stufe 1 noch nicht voll sichtbar sein, sollten aber im Konzept vorgesehen werden. + +## API-Konzept + +## 1. Bestehende Family-Route erweitern + +Bestehender Endpunkt: + +- `GET /api/falukant/family` + +Erweitern um: + +- `marriageSatisfaction` +- `lovers` mit Detailfeldern +- `householdTension` optional + +## 2. Neue Endpunkte + +Empfohlene neue Endpunkte: + +- `POST /api/falukant/family/lover/:relationshipId/maintenance` +- `POST /api/falukant/family/lover/:relationshipId/discretion` +- `POST /api/falukant/family/lover/:relationshipId/acknowledge` +- `POST /api/falukant/family/lover/:relationshipId/end` +- `POST /api/falukant/family/lover/:relationshipId/gift` + +## 3. Antwortschema + +Jede mutierende Aktion sollte zurückgeben: + +- aktualisierte Liebhaber-Daten +- aktualisierte Ehe-Zufriedenheit +- aktualisierte Geldmenge +- aktualisiertes Ansehen +- optionale Nachricht für UI + +## Daemon-Integration + +## 1. Tatsächliche Daemon-Lage + +Der operative Daemon ist nicht Teil dieses Projekts. Dieses Projekt stellt daher: + +- Datenmodell +- Backfill +- Family-API +- UI-Anzeige +- Fach- und Übergabedokumente + +Der externe Daemon ist zuständig für: + +- Daily Tick +- Monthly Tick +- Geldabbuchung +- Ansehensänderung +- Ehezufriedenheit +- Kinder aus Liebschaften + +Die operative Übergabe dafür steht in: + +- [FALUKANT_LOVERS_DAEMON_HANDOFF.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md) + +## 2. Neue Benachrichtigungstypen + +Es sollten neue Falukant-Benachrichtigungen eingeführt werden: + +- `loverCostPaid` +- `loverUnderfunded` +- `loverRumor` +- `loverScandal` +- `loverChildHidden` +- `loverChildKnown` +- `marriageCrisis` + +## Kindererzeugung technisch + +## 1. Vorhandene Strukturen nutzen + +Neue Kinder aus Liebschaften sollen dieselbe Charakter- und `ChildRelation`-Logik nutzen wie bestehende Kinder. + +## 2. Erzeugungsort + +Die Kind-Erzeugung soll vom externen Daemon ausgeführt oder angestoßen werden. Dieses Backend muss dafür die Zielstruktur stabil bereitstellen. + +## 3. Anerkennung eines Kindes + +Spätere Service-Methode: + +- `acknowledgeLoverChild(hashedUserId, childCharacterId)` + +Wirkung: + +- `legitimacy = acknowledged_bastard` +- `publicKnown = true` +- Ansehen anpassen +- Ehe-Zufriedenheit anpassen + +## Frontend-Konzept + +Hauptansicht: + +- [FamilyView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/FamilyView.vue) + +## 1. Ehebereich erweitern + +Im Ehe- oder Partnerbereich anzeigen: + +- `Ehe-Zufriedenheit` +- textlicher Status + - `stabil` + - `angespannt` + - `krisenhaft` +- kurzer Hinweis, ob aktive Liebschaften die Ehe belasten oder stabilisieren + +## 2. Lovers-Bereich ausbauen + +Aktuell existiert nur Name plus Zuneigung. Neu anzeigen: + +- Rolle +- Zuneigung +- Sichtbarkeit +- Diskretion +- Unterhaltsniveau +- Monatskosten +- aktueller Einfluss auf Ansehen +- aktueller Einfluss auf Ehe-Zufriedenheit +- Risikostatus + +## 3. Aktionen im UI + +Pro Liebschaft: + +- Unterhalt erhöhen oder senken +- Diskretion priorisieren +- öffentlich anerkennen +- beschenken +- Beziehung beenden + +## 4. Farbliche Zustände + +### Grün + +- geordnet +- geringe Sichtbarkeit +- Ehe stabil oder neutral + +### Gelb + +- steigende Sichtbarkeit +- mittlere Ehebelastung +- Unterhalt knapp + +### Rot + +- Skandalrisiko hoch +- Ehekrise +- unterfinanziert +- Kind öffentlich geworden + +## 5. Kinder aus Liebschaften in FamilyView + +Im Kinderbereich kenntlich machen: + +- legitimes Kind +- uneheliches verborgenes Kind +- anerkanntes uneheliches Kind + +Es braucht keine sensationelle Darstellung, aber klare Kennzeichnung. + +## I18n-Bedarf + +Benötigte neue Übersetzungsbereiche in: + +- [frontend/src/i18n/locales/de/falukant.json](/mnt/share/torsten/Programs/YourPart3/frontend/src/i18n/locales/de/falukant.json) +- [frontend/src/i18n/locales/en/falukant.json](/mnt/share/torsten/Programs/YourPart3/frontend/src/i18n/locales/en/falukant.json) +- [frontend/src/i18n/locales/es/falukant.json](/mnt/share/torsten/Programs/YourPart3/frontend/src/i18n/locales/es/falukant.json) + +Neue Schlüsselgruppen: + +- `falukant.family.marriageSatisfaction.*` +- `falukant.family.lovers.role.*` +- `falukant.family.lovers.visibility` +- `falukant.family.lovers.discretion` +- `falukant.family.lovers.maintenance` +- `falukant.family.lovers.monthlyCost` +- `falukant.family.lovers.reputationEffect` +- `falukant.family.lovers.marriageEffect` +- `falukant.family.lovers.risk.*` +- `falukant.family.children.legitimacy.*` + +## Admin- und Debug-Bedarf + +Für Entwicklung und Balancing später sinnvoll: + +- Admin-Sicht auf `relationship_state` +- Möglichkeit, `marriageSatisfaction`, `visibility`, `discretion`, `maintenanceLevel` zu setzen +- optionales Debug-Tool zum Simulieren von 30 Tagen + +Das sollte nicht Teil des ersten Spieler-UI sein, aber früh mitgedacht werden. + +## Technische Risiken + +### 1. Tick-Duplikate + +Wenn Daily- oder Monthly-Ticks mehrfach laufen, werden Kosten und Ansehen doppelt verrechnet. + +Gegenmaßnahme: + +- `last_daily_processed_at` +- `last_monthly_processed_at` +- idempotente Verarbeitung pro Beziehung und Tag/Monat + +### 2. Dateninkonsistenz + +Eine `lover`-Beziehung ohne `relationship_state` würde Berechnungen brechen. + +Gegenmaßnahme: + +- beim Lesen fehlende States automatisch erzeugen +- oder beim Start ein Reparaturskript + +### 3. Kindererzeugung doppelt + +Bei konkurrierenden Prozessen könnte ein Kind zweimal entstehen. + +Gegenmaßnahme: + +- Transaktion +- Sperre pro Beziehung und Tick +- eindeutige Monatsverarbeitung + +## Empfohlene Implementierungsreihenfolge + +### Paket 1 + +- Migrationen +- Modell `RelationshipState` +- Associations +- Backfill + +### Paket 2 + +- Family-Service erweitert lesen +- API-Felder ausliefern +- UI in `FamilyView` lesend erweitern + +### Paket 3 + +- Übergabe Daily Tick +- Übergabe Monthly Tick +- Abstimmung zu Geldabbuchung +- Abstimmung zu Ansehen und Ehezufriedenheit + +### Paket 4 + +- Kinder aus Liebschaften +- Benachrichtigungen +- UI-Aktionen + +### Paket 5 + +- Ereignisse +- spätere Dienerschaft +- Balancing-Phase + +## Definition of Done + +Die technische Erstumsetzung ist abgeschlossen, wenn: + +1. `lover`-Beziehungen Detailzustand besitzen +2. Ehezufriedenheit technisch existiert +3. Family-API alle neuen Daten ausliefert +4. Daily- und Monthly-Tick für den externen Daemon vollständig beschrieben sind +5. Monatskosten- und Statuslogik extern ausführbar definiert sind +6. Kinder aus Liebschaften technisch entstehen können +7. `FamilyView` die neuen Daten sichtbar macht +8. weibliche und männliche Spielfiguren regelgleich behandelt werden diff --git a/docs/FALUKANT_UI_WEBSOCKET.md b/docs/FALUKANT_UI_WEBSOCKET.md new file mode 100644 index 0000000..b91bcc9 --- /dev/null +++ b/docs/FALUKANT_UI_WEBSOCKET.md @@ -0,0 +1,192 @@ +# Falukant: UI-Anpassung – WebSocket & Familie / Liebschaften + +Dieses Dokument beschreibt die Nachrichten, die der externe Falukant-Daemon für Familien-, Ehe- und Liebschaftsänderungen sendet, damit die UI gezielt reagieren kann. + +Transport: +- Alle Clients erhalten denselben Broadcast. +- Die UI muss nach `user_id` filtern und nur Events für die eingeloggte Session verarbeiten. + +## 1. Übersicht der Events + +| `event` | Pflichtfelder | Typische UI-Reaktion | +|---------|----------------|----------------------| +| `falukantUpdateFamily` | `user_id`, `reason` | Gezielter Refresh von Familie, Liebschaften und je nach `reason` auch Geld oder Ruf | +| `falukantUpdateStatus` | `user_id` | Allgemeiner Falukant-Status-/Spielstands-Refresh | +| `children_update` | `user_id` | Kinderliste und Familienansicht aktualisieren | +| `falukant_family_scandal_hint` | `relationship_id` | Optionaler Hinweis oder Logeintrag; kein `user_id` | + +## 2. JSON-Payloads + +### 2.1 `falukantUpdateFamily` + +```json +{ + "event": "falukantUpdateFamily", + "user_id": 123, + "reason": "daily" +} +``` + +`reason` ist immer genau einer dieser festen Strings: +- `daily` +- `monthly` +- `scandal` +- `lover_birth` + +Es gibt keine weiteren `reason`-Werte. + +### 2.2 `falukantUpdateStatus` + +```json +{ + "event": "falukantUpdateStatus", + "user_id": 123 +} +``` + +Dieses Event wird typischerweise direkt nach `falukantUpdateFamily` mit derselben `user_id` gesendet. + +### 2.3 `children_update` + +```json +{ + "event": "children_update", + "user_id": 123 +} +``` + +Dieses Event tritt bei Geburt aus einer Liebschaft auf, meist zusammen mit: +- `falukantUpdateFamily` mit `reason: "lover_birth"` +- `falukantUpdateStatus` + +### 2.4 `falukant_family_scandal_hint` + +```json +{ + "event": "falukant_family_scandal_hint", + "relationship_id": 456 +} +``` + +Hinweis: +- Dieses Event enthält kein `user_id`. +- Die UI kann es ignorieren oder optional nur für Log-/Toast-Zwecke verwenden. +- Die eigentliche nutzerbezogene Aktualisierung läuft über `falukantUpdateFamily` mit `reason: "scandal"`. + +## 3. Fachliche Bedeutung von `reason` + +### `reason: "daily"` + +`daily` ist der Sammelgrund für tägliche Änderungen im Familien- und Liebschaftssystem. + +Darunter fallen insbesondere: +- tägliche Drift und Änderung der Ehezufriedenheit +- Ehe-Buffs und temporäre Zähler wie Geschenk-, Fest- oder Haus-Effekte +- tägliche Liebschaftslogik für aktive Beziehungen +- Rufverlust bei zwei oder mehr sichtbaren Liebschaften +- Zufalls-Mali wie Gerücht oder Tadel + +Wichtig: +- Es gibt kein separates Event für „nur Ehe-Buff“. +- Es gibt kein separates Event für „nur zwei sichtbare Liebschaften“. +- Es gibt kein separates Event für „nur Gerücht/Tadel“. +- Alles davon erscheint in der UI ausschließlich als `falukantUpdateFamily` mit `reason: "daily"`. + +### `reason: "scandal"` + +`scandal` wird zusätzlich zu einem gelungenen Skandalwurf gesendet. + +Typischer Ablauf: +- optional `falukant_family_scandal_hint` +- `falukantUpdateFamily` mit `reason: "scandal"` +- `falukantUpdateStatus` + +Danach kann für denselben Nutzer am selben Tag zusätzlich noch `daily` folgen. + +### `reason: "monthly"` + +`monthly` steht für Monatsverarbeitung, insbesondere: +- laufende Kosten +- Unterversorgung +- Geldänderungen + +### `reason: "lover_birth"` + +`lover_birth` signalisiert ein neues Kind aus einer Liebschaft. + +Meist folgen zusammen: +- `falukantUpdateFamily` mit `reason: "lover_birth"` +- `children_update` +- `falukantUpdateStatus` + +## 4. Empfohlene Handler-Logik + +```text +onMessage(json): + if json.user_id exists and json.user_id != currentUserId: + return + + switch json.event: + case "falukantUpdateStatus": + refreshPlayerStatus() + return + + case "children_update": + refreshChildrenAndFamilyView() + return + + case "falukantUpdateFamily": + switch json.reason: + case "daily": + refreshFamilyAndRelationships() + refreshReputationIfNeeded() + break + case "monthly": + refreshMoney() + refreshFamilyAndRelationships() + break + case "scandal": + showScandalToastOptional() + refreshFamilyAndRelationships() + refreshReputationIfNeeded() + break + case "lover_birth": + refreshChildrenAndFamilyView() + break + return + + case "falukant_family_scandal_hint": + // optional: nur als Hinweis verarbeiten + return +``` + +## 5. Deduplizierung + +Am selben Tag kann ein Nutzer mehrere relevante Events erhalten, zum Beispiel: +- `scandal` +- danach `daily` +- danach `falukantUpdateStatus` + +Die UI sollte deshalb: +- Refreshes bündeln oder entprellen +- idempotente Reloads verwenden +- nicht davon ausgehen, dass jeder fachliche Effekt einen eigenen `reason` hat + +## 6. Welche Daten sollten neu geladen werden? + +| Situation | Sinnvolle Reaktion | +|-----------|--------------------| +| Jede `falukantUpdateFamily` | Family-/Relationship-Daten neu laden | +| `reason: "monthly"` | Family-Daten plus Geld/Status neu laden | +| `reason: "daily"` | Family-Daten neu laden, bei Bedarf auch Ruf-/Statusdaten | +| `reason: "scandal"` | Family-Daten plus Ruf-/Statusdaten neu laden | +| `children_update` / `lover_birth` | Kinderdaten und FamilyView neu laden | + +## 7. Sonderfälle + +| Fall | Verhalten | +|------|-----------| +| NPC ohne `user_id` | Keine nutzerbezogenen Family-Socket-Events | +| Mehrere Events kurz hintereinander | Normal; UI sollte damit robust umgehen | +| Nur `falukantUpdateStatus` ohne Family-Event | Kann von anderen Falukant-Workern kommen | + diff --git a/docs/FALUKANT_UNDERGROUND_AFFAIR_DAEMON_HANDOFF.md b/docs/FALUKANT_UNDERGROUND_AFFAIR_DAEMON_HANDOFF.md new file mode 100644 index 0000000..154f186 --- /dev/null +++ b/docs/FALUKANT_UNDERGROUND_AFFAIR_DAEMON_HANDOFF.md @@ -0,0 +1,180 @@ +# Falukant: Daemon-Handoff für `investigate_affair` + +## Zweck + +Dieses Dokument beschreibt die Auswertung der Untergrundaktivität `investigate_affair` im externen Daemon. + +Es ergänzt: + +- [FALUKANT_UNDERGROUND_AFFAIR_PLAN.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_UNDERGROUND_AFFAIR_PLAN.md) +- [FALUKANT_LOVERS_DAEMON_SPEC.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_SPEC.md) +- [FALUKANT_LOVERS_DAEMON_HANDOFF.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md) + +## Betroffene Daten + +Der externe Daemon liest: + +- `falukant_data.underground` +- `falukant_type.underground` +- `falukant_data.relationship` +- `falukant_data.relationship_state` +- `falukant_data.character` +- optional `falukant_data.child_relation` + +Relevant ist jeweils: + +- Untergrundaktivität vom Typ `investigate_affair` +- `performer_id` +- `victim_id` +- `parameters.goal` + - `expose` + - `blackmail` +- `result` + +## Erwarteter Input + +Eine Aktivität ist für den Daemon verarbeitbar, wenn: + +- `underground_type.tr = investigate_affair` +- `result.status = pending` + +Der Daemon soll dann beim Opfer prüfen: + +- aktive Liebschaften +- Sichtbarkeit und Diskretion dieser Liebschaften +- evtl. bekannte uneheliche Kinder +- Stand und Ansehen des Opfers + +## Ergebnis-Schema in `underground.result` + +Der Daemon schreibt nach der Auswertung ein JSON mit folgender Struktur: + +```json +{ + "status": "resolved", + "outcome": "success", + "discoveries": { + "relationshipId": 123, + "loverRole": "secret_affair", + "visibility": 42, + "acknowledged": false, + "publicKnownChild": false + }, + "visibilityDelta": 12, + "reputationDelta": -3, + "blackmailAmount": 1500, + "notes": "Affair was uncovered and partially exposed." +} +``` + +Erlaubte Werte: + +- `status` + - `pending` + - `resolved` + - `failed` +- `outcome` + - `success` + - `partial` + - `failure` + +## Auswertung: `goal = expose` + +Ziel: + +- Liebschaft öffentlich machen +- Sichtbarkeit stark erhöhen +- ggf. Skandal auslösen + +Empfohlene Wirkung: + +- `relationship_state.visibility +10..25` +- optional `relationship_state.discretion -5..15` +- sofortiger Reputationsschaden beim Opfer +- bei sehr sichtbarer oder junger Beziehung zusätzliche Skandalchance + +Zusätzliche Empfehlung: + +- wenn die Beziehung bereits fast öffentlich war, darf `outcome = partial` gesetzt werden statt voller Erfolg + +## Auswertung: `goal = blackmail` + +Ziel: + +- belastendes Wissen gewinnen +- keinen sofortigen vollen öffentlichen Effekt erzeugen +- ein Erpressungspotenzial vorbereiten + +Empfohlene Wirkung: + +- `relationship_state.visibility +3..10` +- `blackmailAmount` setzen +- kleiner oder kein sofortiger Reputationsschaden +- optional separates Log oder spätere Forderung + +Wenn ihr noch kein echtes Erpressungssystem habt: + +- `blackmailAmount` trotzdem setzen +- `notes` befüllen +- UI zeigt den Vorgang als abgeschlossen mit Erpressungssumme + +## Mindestregeln für Erfolg + +Erfolgswahrscheinlichkeit sollte steigen bei: + +- hoher vorhandener Sichtbarkeit +- niedriger Diskretion +- mehreren aktiven Liebschaften +- bereits bekannten unehelichen Kindern + +Erfolgswahrscheinlichkeit sollte sinken bei: + +- hoher Diskretion +- niedriger Sichtbarkeit +- standesgemäß geführter Mätresse/Favorit auf hohem Rang + +## Folgewirkungen auf Lovers-System + +Bei Erfolg darf der Daemon auslösen: + +- `falukant_family_scandal_hint` +- `falukantUpdateFamily` mit `reason = scandal` +- zusätzlich normale Status-Updates + +Wenn `goal = expose`, sollte mindestens eine dieser Wirkungen eintreten: + +- Sichtbarkeit steigt +- Ruf sinkt +- Skandalwahrscheinlichkeit steigt + +Wenn `goal = blackmail`, sollte mindestens eine dieser Wirkungen eintreten: + +- `blackmailAmount > 0` +- kleiner Sichtbarkeitsanstieg +- interner Merker für spätere Forderung + +## UI-Erwartung + +Das Frontend dieses Projekts erwartet derzeit: + +- `type` +- `goal` +- `status` +- `additionalInfo.blackmailAmount` + +Optional nutzbar später: + +- `discoveries` +- `visibilityDelta` +- `reputationDelta` +- `notes` + +## Definition of Done + +Die externe Umsetzung gilt als ausreichend, wenn: + +1. `investigate_affair`-Einträge mit `status = pending` verarbeitet werden +2. `result.status` danach nicht mehr `pending` ist +3. `goal = expose` und `goal = blackmail` verschieden behandelt werden +4. mindestens Sichtbarkeit oder Reputationswirkung zurückgeschrieben wird +5. `blackmailAmount` bei Erpressung gesetzt werden kann diff --git a/docs/FALUKANT_UNDERGROUND_AFFAIR_PLAN.md b/docs/FALUKANT_UNDERGROUND_AFFAIR_PLAN.md new file mode 100644 index 0000000..7914512 --- /dev/null +++ b/docs/FALUKANT_UNDERGROUND_AFFAIR_PLAN.md @@ -0,0 +1,67 @@ +# Falukant: Restplan für Liebschafts-Ermittlung im Untergrund + +## Ziel + +Die neue Untergrundaktivität `investigate_affair` soll nicht nur auswählbar sein, sondern einen vollständigen technischen Pfad bekommen: + +- Aktivität anlegen +- Aktivität in der UI sichtbar machen +- Ergebnisstruktur vorbereiten +- externe Daemon-Auswertung eindeutig beschreiben + +## Arbeitspakete + +## UGA1. Aktivitätstyp im System verankern + +Status: abgeschlossen + +- Untergrundtyp `investigate_affair` anlegen +- Ziele `expose` und `blackmail` definieren +- UI-Auswahl in `UndergroundView` ergänzen +- Produktions-SQL für Bestandsdatenbank bereitstellen + +## UGA2. Aktivitätenliste im Frontend nutzbar machen + +Status: abgeschlossen + +- echten GET-Endpunkt für Untergrundaktivitäten bereitstellen +- `UndergroundView.loadActivities()` aktivieren +- Aktivitäten mit Typ, Ziel, Status und Zusatzinformation anzeigen + +## UGA3. Ergebnisstruktur für spätere Auswertung definieren + +Status: abgeschlossen + +- Ergebnisformat für `underground.result` dokumentieren +- Zustände `pending`, `resolved`, `failed` festlegen +- Felder für `discoveries`, `visibilityDelta`, `reputationDelta`, `blackmailAmount` vorbereiten + +## UGA4. Externe Daemon-Übergabe für Liebschafts-Ermittlung + +Status: abgeschlossen + +- Handoff-Dokument für den externen Daemon ergänzen +- beschreiben, wie `investigate_affair` gelesen und aufgelöst wird +- beschreiben, welche Folgewirkungen auf Liebschaften, Ansehen und Erpressung entstehen dürfen + +## UGA5. Spätere Ausbaustufe + +Status: bewusst offen + +- echte Erpressungszustände im Spielmodell +- UI für Forderungen, Schweigegeld, Gegenmaßnahmen +- eigene WebSocket-Events für abgeschlossene Untergrund-Ergebnisse + +## Definition of Done + +Der lokale Teil gilt als fertig, wenn: + +1. `investigate_affair` im Untergrundformular auswählbar ist +2. neue Aktivitäten in der Aktivitätenliste sichtbar sind +3. Typ, Ziel und Status in der UI lesbar sind +4. ein eindeutiges Result-Schema für den externen Daemon dokumentiert ist +5. die externe Daemon-Übergabe die neue Aktivität vollständig beschreibt + +## Restgrenze + +Die tatsächliche Erfolgs-/Misserfolgsberechnung, das Aufdecken von Liebschaften und die Erpressungswirkung werden nicht in diesem Projekt ausgeführt, sondern im externen Daemon. diff --git a/frontend/src/components/DashboardWidget.vue b/frontend/src/components/DashboardWidget.vue index 1a82604..11496c1 100644 --- a/frontend/src/components/DashboardWidget.vue +++ b/frontend/src/components/DashboardWidget.vue @@ -57,11 +57,12 @@ export default { loading: false, error: null, isDragging: false, - _daemonMessageHandler: null + _daemonMessageHandler: null, + pendingFetchTimer: null }; }, computed: { - ...mapState(['socket', 'daemonSocket']), + ...mapState(['socket', 'daemonSocket', 'user']), isFalukantWidget() { return this.endpoint && String(this.endpoint).includes('falukant'); }, @@ -89,25 +90,51 @@ export default { if (this.isFalukantWidget) this.setupSocketListeners(); }, beforeUnmount() { + if (this.pendingFetchTimer) { + clearTimeout(this.pendingFetchTimer); + this.pendingFetchTimer = null; + } if (this.isFalukantWidget) this.teardownSocketListeners(); }, methods: { + matchesCurrentUser(eventData) { + if (eventData?.user_id == null) { + return true; + } + const currentIds = [this.user?.id, this.user?.hashedId] + .filter(Boolean) + .map((value) => String(value)); + return currentIds.includes(String(eventData.user_id)); + }, setupSocketListeners() { this.teardownSocketListeners(); - const daemonEvents = ['falukantUpdateStatus', 'stock_change', 'familychanged']; + const daemonEvents = ['falukantUpdateStatus', 'falukantUpdateFamily', 'children_update', 'stock_change', 'familychanged']; if (this.daemonSocket) { this._daemonMessageHandler = (event) => { if (event.data === 'ping') return; try { const data = JSON.parse(event.data); - if (daemonEvents.includes(data.event)) this.fetchData(); + if (daemonEvents.includes(data.event) && this.matchesCurrentUser(data)) this.queueFetchData(); } catch (_) {} }; this.daemonSocket.addEventListener('message', this._daemonMessageHandler); } if (this.socket) { - this.socket.on('falukantUpdateStatus', () => this.fetchData()); - this.socket.on('falukantBranchUpdate', () => this.fetchData()); + this._statusSocketHandler = (data) => { + if (this.matchesCurrentUser(data)) this.queueFetchData(); + }; + this._familySocketHandler = (data) => { + if (this.matchesCurrentUser(data)) this.queueFetchData(); + }; + this._childrenSocketHandler = (data) => { + if (this.matchesCurrentUser(data)) this.queueFetchData(); + }; + this._branchSocketHandler = () => this.queueFetchData(); + + this.socket.on('falukantUpdateStatus', this._statusSocketHandler); + this.socket.on('falukantUpdateFamily', this._familySocketHandler); + this.socket.on('children_update', this._childrenSocketHandler); + this.socket.on('falukantBranchUpdate', this._branchSocketHandler); } }, teardownSocketListeners() { @@ -116,10 +143,21 @@ export default { this._daemonMessageHandler = null; } if (this.socket) { - this.socket.off('falukantUpdateStatus'); - this.socket.off('falukantBranchUpdate'); + if (this._statusSocketHandler) this.socket.off('falukantUpdateStatus', this._statusSocketHandler); + if (this._familySocketHandler) this.socket.off('falukantUpdateFamily', this._familySocketHandler); + if (this._childrenSocketHandler) this.socket.off('children_update', this._childrenSocketHandler); + if (this._branchSocketHandler) this.socket.off('falukantBranchUpdate', this._branchSocketHandler); } }, + queueFetchData() { + if (this.pendingFetchTimer) { + clearTimeout(this.pendingFetchTimer); + } + this.pendingFetchTimer = setTimeout(() => { + this.pendingFetchTimer = null; + this.fetchData(); + }, 120); + }, async fetchData() { if (!this.endpoint || this.pauseFetch) return; this.loading = true; diff --git a/frontend/src/components/falukant/StatusBar.vue b/frontend/src/components/falukant/StatusBar.vue index dc95f43..f453cb0 100644 --- a/frontend/src/components/falukant/StatusBar.vue +++ b/frontend/src/components/falukant/StatusBar.vue @@ -60,10 +60,11 @@ export default { { key: "children", icon: "👶", value: null }, ], unreadCount: 0, + pendingStatusRefresh: null, }; }, computed: { - ...mapState(["socket", "daemonSocket"]), + ...mapState(["socket", "daemonSocket", "user"]), ...mapGetters(['menu']), }, watch: { @@ -100,6 +101,10 @@ export default { beforeUnmount() { this.teardownSocketListeners(); this.teardownDaemonListeners(); + if (this.pendingStatusRefresh) { + clearTimeout(this.pendingStatusRefresh); + this.pendingStatusRefresh = null; + } EventBus.off('open-falukant-messages', this.openMessages); }, methods: { @@ -169,15 +174,25 @@ export default { setupSocketListeners() { this.teardownSocketListeners(); if (!this.socket) return; - this.socket.on('falukantUpdateStatus', (data) => this.handleEvent({ event: 'falukantUpdateStatus', ...data })); - this.socket.on('stock_change', (data) => this.handleEvent({ event: 'stock_change', ...data })); - this.socket.on('familychanged', (data) => this.handleEvent({ event: 'familychanged', ...data })); + this._statusSocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateStatus', ...data }); + this._familySocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateFamily', ...data }); + this._childrenSocketHandler = (data) => this.handleEvent({ event: 'children_update', ...data }); + this._stockSocketHandler = (data) => this.handleEvent({ event: 'stock_change', ...data }); + this._familyChangedSocketHandler = (data) => this.handleEvent({ event: 'familychanged', ...data }); + + this.socket.on('falukantUpdateStatus', this._statusSocketHandler); + this.socket.on('falukantUpdateFamily', this._familySocketHandler); + this.socket.on('children_update', this._childrenSocketHandler); + this.socket.on('stock_change', this._stockSocketHandler); + this.socket.on('familychanged', this._familyChangedSocketHandler); }, teardownSocketListeners() { if (this.socket) { - this.socket.off('falukantUpdateStatus'); - this.socket.off('stock_change'); - this.socket.off('familychanged'); + if (this._statusSocketHandler) this.socket.off('falukantUpdateStatus', this._statusSocketHandler); + if (this._familySocketHandler) this.socket.off('falukantUpdateFamily', this._familySocketHandler); + if (this._childrenSocketHandler) this.socket.off('children_update', this._childrenSocketHandler); + if (this._stockSocketHandler) this.socket.off('stock_change', this._stockSocketHandler); + if (this._familyChangedSocketHandler) this.socket.off('familychanged', this._familyChangedSocketHandler); } }, setupDaemonListeners() { @@ -186,13 +201,22 @@ export default { this._daemonHandler = (event) => { try { const data = JSON.parse(event.data); - if (['falukantUpdateStatus', 'stock_change', 'familychanged'].includes(data.event)) { + if (['falukantUpdateStatus', 'falukantUpdateFamily', 'children_update', 'stock_change', 'familychanged'].includes(data.event)) { this.handleEvent(data); } } catch (_) {} }; this.daemonSocket.addEventListener('message', this._daemonHandler); }, + matchesCurrentUser(eventData) { + if (eventData?.user_id == null) { + return true; + } + const currentIds = [this.user?.id, this.user?.hashedId] + .filter(Boolean) + .map((value) => String(value)); + return currentIds.includes(String(eventData.user_id)); + }, teardownDaemonListeners() { const sock = this.daemonSocket; if (sock && this._daemonHandler) { @@ -200,12 +224,26 @@ export default { this._daemonHandler = null; } }, + queueStatusRefresh() { + if (this.pendingStatusRefresh) { + clearTimeout(this.pendingStatusRefresh); + } + this.pendingStatusRefresh = setTimeout(async () => { + this.pendingStatusRefresh = null; + await this.fetchStatus(); + }, 120); + }, handleEvent(eventData) { + if (!this.matchesCurrentUser(eventData)) { + return; + } switch (eventData.event) { case 'falukantUpdateStatus': + case 'falukantUpdateFamily': + case 'children_update': case 'stock_change': case 'familychanged': - this.fetchStatus(); + this.queueStatusRefresh(); break; } }, diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index 38eeb3b..24e9fdd 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -486,6 +486,8 @@ "name": "Name", "age": "Alter", "status": "Status", + "marriageSatisfaction": "Ehe-Zufriedenheit", + "marriageState": "Ehezustand", "none": "Kein Ehepartner vorhanden.", "search": "Ehepartner suchen", "found": "Ehepartner gefunden", @@ -517,6 +519,17 @@ "progress": "Zuneigung", "jumpToPartyForm": "Hochzeitsfeier veranstalten (Nötig für Hochzeit und Kinder)" }, + "marriageState": { + "stable": "Stabil", + "strained": "Angespannt", + "crisis": "Krise" + }, + "householdTension": { + "label": "Hausfrieden", + "low": "Ruhig", + "medium": "Unruhig", + "high": "Belastet" + }, "relationships": { "name": "Name" }, @@ -538,14 +551,62 @@ "baptism": "Taufen", "notBaptized": "Noch nicht getauft", "baptismNotice": "Dieses Kind wurde noch nicht getauft und hat daher noch keinen Namen.", + "legitimacy": { + "legitimate": "Ehelich", + "acknowledged_bastard": "Anerkannt unehelich", + "hidden_bastard": "Unehelich" + }, "details": { "title": "Kind-Details" } }, "lovers": { - "title": "Liebhaber", + "title": "Liebhaber und Mätressen", "none": "Keine Liebhaber vorhanden.", - "affection": "Zuneigung" + "affection": "Zuneigung", + "visibility": "Sichtbarkeit", + "discretion": "Diskretion", + "maintenance": "Unterhalt", + "monthlyCost": "Monatskosten", + "statusFit": "Standespassung", + "acknowledged": "Anerkannt", + "underfunded": "{count} Monate unterversorgt", + "role": { + "secret_affair": "Heimliche Liebschaft", + "lover": "Geliebte Beziehung", + "mistress_or_favorite": "Mätresse oder Favorit" + }, + "risk": { + "low": "Geringes Risiko", + "medium": "Mittleres Risiko", + "high": "Hohes Risiko" + }, + "actions": { + "start": "Liebschaft beginnen", + "startSuccess": "Die neue Liebschaft wurde begonnen.", + "startError": "Die Liebschaft konnte nicht begonnen werden.", + "maintenanceLow": "Unterhalt 25", + "maintenanceMedium": "Unterhalt 50", + "maintenanceHigh": "Unterhalt 75", + "maintenanceSuccess": "Der Unterhalt wurde angepasst.", + "maintenanceError": "Der Unterhalt konnte nicht angepasst werden.", + "acknowledge": "Anerkennen", + "acknowledgeSuccess": "Die Beziehung wurde offiziell anerkannt.", + "acknowledgeError": "Die Beziehung konnte nicht anerkannt werden.", + "end": "Beenden", + "endConfirm": "Soll diese Beziehung wirklich beendet werden?", + "endSuccess": "Die Beziehung wurde beendet.", + "endError": "Die Beziehung konnte nicht beendet werden." + }, + "candidates": { + "title": "Mögliche Liebschaften", + "roleLabel": "Form der Beziehung", + "none": "Derzeit gibt es keine passenden neuen Liebschaften." + } + }, + "notifications": { + "scandal": "Ein Familienskandal erschüttert dein Haus.", + "loverBirth": "Aus einer Liebschaft ist ein Kind hervorgegangen." }, "statuses": { "wooing": "In Werbung", @@ -1120,10 +1181,16 @@ "type": "Aktivitätstyp", "victim": "Zielperson", "cost": "Kosten", + "status": "Status", "additionalInfo": "Zusätzliche Informationen", + "blackmailAmount": "Erpressungssumme", + "discoveries": "Erkenntnisse", + "visibilityDelta": "Sichtbarkeit", + "reputationDelta": "Ansehen", "victimPlaceholder": "Benutzername eingeben", "sabotageTarget": "Sabotageziel", - "corruptGoal": "Ziel der Korruption" + "corruptGoal": "Ziel der Korruption", + "affairGoal": "Ziel der Untersuchung" }, "attacks": { "target": "Angreifer", @@ -1136,7 +1203,8 @@ "assassin": "Attentat", "sabotage": "Sabotage", "corrupt_politician": "Korruption", - "rob": "Raub" + "rob": "Raub", + "investigate_affair": "Liebschaft untersuchen" }, "targets": { "house": "Wohnhaus", @@ -1145,8 +1213,15 @@ "goals": { "elect": "Amtseinsetzung", "taxIncrease": "Steuern erhöhen", - "taxDecrease": "Steuern senken" + "taxDecrease": "Steuern senken", + "expose": "Aufdecken", + "blackmail": "Erpressen" + }, + "status": { + "pending": "Ausstehend", + "resolved": "Abgeschlossen", + "failed": "Gescheitert" } } } -} \ No newline at end of file +} diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json index 99982d3..f8c96de 100644 --- a/frontend/src/i18n/locales/en/falukant.json +++ b/frontend/src/i18n/locales/en/falukant.json @@ -434,6 +434,11 @@ "baptism": "Baptize", "notBaptized": "Not yet baptized", "baptismNotice": "This child has not been baptized yet and therefore has no name.", + "legitimacy": { + "legitimate": "Legitimate", + "acknowledged_bastard": "Acknowledged illegitimate", + "hidden_bastard": "Illegitimate" + }, "details": { "title": "Child Details" } @@ -449,6 +454,8 @@ } }, "spouse": { + "marriageSatisfaction": "Marriage Satisfaction", + "marriageState": "Marriage State", "wooing": { "cancel": "Cancel wooing", "cancelConfirm": "Do you really want to cancel wooing? Progress will be lost.", @@ -457,6 +464,65 @@ "cancelTooSoon": "You can only cancel wooing after 24 hours." } }, + "marriageState": { + "stable": "Stable", + "strained": "Strained", + "crisis": "Crisis" + }, + "householdTension": { + "label": "Household Tension", + "low": "Calm", + "medium": "Uneasy", + "high": "Strained" + }, + "lovers": { + "title": "Lovers and Mistresses", + "none": "No lovers present.", + "affection": "Affection", + "visibility": "Visibility", + "discretion": "Discretion", + "maintenance": "Maintenance", + "monthlyCost": "Monthly Cost", + "statusFit": "Status Fit", + "acknowledged": "Acknowledged", + "underfunded": "{count} months underfunded", + "role": { + "secret_affair": "Secret affair", + "lover": "Lover", + "mistress_or_favorite": "Mistress or favorite" + }, + "risk": { + "low": "Low risk", + "medium": "Medium risk", + "high": "High risk" + }, + "actions": { + "start": "Start affair", + "startSuccess": "The new affair has begun.", + "startError": "The affair could not be started.", + "maintenanceLow": "Maintenance 25", + "maintenanceMedium": "Maintenance 50", + "maintenanceHigh": "Maintenance 75", + "maintenanceSuccess": "Maintenance has been updated.", + "maintenanceError": "Maintenance could not be updated.", + "acknowledge": "Acknowledge", + "acknowledgeSuccess": "The relationship has been officially acknowledged.", + "acknowledgeError": "The relationship could not be acknowledged.", + "end": "End", + "endConfirm": "Do you really want to end this relationship?", + "endSuccess": "The relationship has been ended.", + "endError": "The relationship could not be ended." + }, + "candidates": { + "title": "Possible affairs", + "roleLabel": "Relationship form", + "none": "There are currently no suitable new affairs." + } + }, + "notifications": { + "scandal": "A family scandal is shaking your house.", + "loverBirth": "A child has been born from an affair." + }, "sendgift": { "error": { "nogiftselected": "Please select a gift.", @@ -604,6 +670,60 @@ "cost": "Cost", "date": "Date" } + }, + "underground": { + "title": "Underground", + "tabs": { + "activities": "Activities", + "attacks": "Attacks" + }, + "activities": { + "none": "No activities available.", + "create": "Create new activity", + "type": "Activity type", + "victim": "Target person", + "cost": "Cost", + "status": "Status", + "additionalInfo": "Additional information", + "blackmailAmount": "Blackmail amount", + "discoveries": "Discoveries", + "visibilityDelta": "Visibility", + "reputationDelta": "Reputation", + "victimPlaceholder": "Enter username", + "sabotageTarget": "Sabotage target", + "corruptGoal": "Corruption goal", + "affairGoal": "Investigation goal" + }, + "attacks": { + "target": "Attacker", + "date": "Date", + "success": "Success", + "none": "No attacks recorded." + }, + "types": { + "spyin": "Espionage", + "assassin": "Assassination", + "sabotage": "Sabotage", + "corrupt_politician": "Corruption", + "rob": "Robbery", + "investigate_affair": "Investigate affair" + }, + "targets": { + "house": "House", + "storage": "Storage" + }, + "goals": { + "elect": "Appointment", + "taxIncrease": "Raise taxes", + "taxDecrease": "Lower taxes", + "expose": "Expose", + "blackmail": "Blackmail" + }, + "status": { + "pending": "Pending", + "resolved": "Resolved", + "failed": "Failed" + } } } -} \ No newline at end of file +} diff --git a/frontend/src/i18n/locales/es/falukant.json b/frontend/src/i18n/locales/es/falukant.json index 718c656..09e9b13 100644 --- a/frontend/src/i18n/locales/es/falukant.json +++ b/frontend/src/i18n/locales/es/falukant.json @@ -470,6 +470,8 @@ "name": "Nombre", "age": "Edad", "status": "Estado", + "marriageSatisfaction": "Satisfacción matrimonial", + "marriageState": "Estado del matrimonio", "none": "No hay cónyuge.", "search": "Buscar pareja", "found": "Pareja encontrada", @@ -501,6 +503,17 @@ "progress": "Afecto", "jumpToPartyForm": "Organizar banquete de boda (necesario para boda e hijos)" }, + "marriageState": { + "stable": "Estable", + "strained": "Tenso", + "crisis": "Crisis" + }, + "householdTension": { + "label": "Tensión del hogar", + "low": "Calmo", + "medium": "Inquieto", + "high": "Tenso" + }, "relationships": { "name": "Nombre" }, @@ -522,14 +535,62 @@ "baptism": "Bautizar", "notBaptized": "Aún no bautizado", "baptismNotice": "Este niño aún no ha sido bautizado y por lo tanto todavía no tiene nombre.", + "legitimacy": { + "legitimate": "Legítimo", + "acknowledged_bastard": "Ilegítimo reconocido", + "hidden_bastard": "Ilegítimo" + }, "details": { "title": "Detalles del hijo" } }, "lovers": { - "title": "Amantes", + "title": "Amantes y favoritas", "none": "No hay amantes.", - "affection": "Afecto" + "affection": "Afecto", + "visibility": "Visibilidad", + "discretion": "Discreción", + "maintenance": "Mantenimiento", + "monthlyCost": "Coste mensual", + "statusFit": "Adecuación social", + "acknowledged": "Reconocido", + "underfunded": "{count} meses con fondos insuficientes", + "role": { + "secret_affair": "Aventura secreta", + "lover": "Amante", + "mistress_or_favorite": "Favorita o favorito" + }, + "risk": { + "low": "Riesgo bajo", + "medium": "Riesgo medio", + "high": "Riesgo alto" + }, + "actions": { + "start": "Iniciar relación", + "startSuccess": "La nueva relación ha comenzado.", + "startError": "No se pudo iniciar la relación.", + "maintenanceLow": "Mantenimiento 25", + "maintenanceMedium": "Mantenimiento 50", + "maintenanceHigh": "Mantenimiento 75", + "maintenanceSuccess": "Se ha ajustado el mantenimiento.", + "maintenanceError": "No se pudo ajustar el mantenimiento.", + "acknowledge": "Reconocer", + "acknowledgeSuccess": "La relación ha sido reconocida oficialmente.", + "acknowledgeError": "No se pudo reconocer la relación.", + "end": "Finalizar", + "endConfirm": "¿De verdad quieres finalizar esta relación?", + "endSuccess": "La relación ha finalizado.", + "endError": "No se pudo finalizar la relación." + }, + "candidates": { + "title": "Posibles relaciones", + "roleLabel": "Forma de la relación", + "none": "Actualmente no hay nuevas relaciones adecuadas." + } + }, + "notifications": { + "scandal": "Un escándalo familiar sacude tu casa.", + "loverBirth": "Ha nacido un hijo de una relación amorosa." }, "statuses": { "wooing": "En cortejo", @@ -1008,10 +1069,16 @@ "type": "Tipo de actividad", "victim": "Objetivo", "cost": "Coste", + "status": "Estado", "additionalInfo": "Información adicional", + "blackmailAmount": "Suma del chantaje", + "discoveries": "Hallazgos", + "visibilityDelta": "Visibilidad", + "reputationDelta": "Reputación", "victimPlaceholder": "Introduce el nombre de usuario", "sabotageTarget": "Objetivo del sabotaje", - "corruptGoal": "Objetivo de la corrupción" + "corruptGoal": "Objetivo de la corrupción", + "affairGoal": "Objetivo de la investigación" }, "attacks": { "target": "Atacante", @@ -1024,7 +1091,8 @@ "assassin": "Atentado", "sabotage": "Sabotaje", "corrupt_politician": "Corrupción", - "rob": "Robo" + "rob": "Robo", + "investigate_affair": "Investigar relación" }, "targets": { "house": "Vivienda", @@ -1033,7 +1101,14 @@ "goals": { "elect": "Nombramiento", "taxIncrease": "Subir impuestos", - "taxDecrease": "Bajar impuestos" + "taxDecrease": "Bajar impuestos", + "expose": "Exponer", + "blackmail": "Chantajear" + }, + "status": { + "pending": "Pendiente", + "resolved": "Resuelto", + "failed": "Fallido" } } } diff --git a/frontend/src/views/falukant/FamilyView.vue b/frontend/src/views/falukant/FamilyView.vue index 2d753c2..b4195a1 100644 --- a/frontend/src/views/falukant/FamilyView.vue +++ b/frontend/src/views/falukant/FamilyView.vue @@ -37,6 +37,15 @@ {{ $t('falukant.family.spouse.status') }} {{ $t('falukant.family.statuses.' + relationships[0].relationshipType) }} + + {{ $t('falukant.family.spouse.marriageSatisfaction') }} + + {{ relationships[0].marriageSatisfaction }} + + {{ $t('falukant.family.marriageState.' + (relationships[0].marriageState || 'stable')) }} + + + {{ $t('falukant.family.spouse.progress') }} @@ -123,6 +132,25 @@ +
+
+ {{ $t('falukant.family.spouse.marriageSatisfaction') }} + {{ marriageSatisfaction }} +
+
+ {{ $t('falukant.family.spouse.marriageState') }} + + {{ $t('falukant.family.marriageState.' + marriageState) }} + +
+
+ {{ $t('falukant.family.householdTension.label') }} + + {{ $t('falukant.family.householdTension.' + householdTension) }} + +
+
+

{{ $t('falukant.family.children.title') }}

@@ -141,6 +169,9 @@ {{ child.name }} + + {{ $t('falukant.family.children.legitimacy.' + child.legitimacy) }} + + + + + +
+

{{ $t('falukant.family.lovers.none') }}

+
+

{{ $t('falukant.family.lovers.candidates.title') }}

+
+
+
+ {{ $t('falukant.titles.' + candidate.gender + '.' + candidate.title) }} {{ candidate.name }} + {{ $t('falukant.family.spouse.age') }}: {{ candidate.age }} + {{ $t('falukant.family.lovers.statusFit') }}: {{ candidate.statusFit }} + {{ $t('falukant.family.lovers.monthlyCost') }}: {{ formatCost(candidate.estimatedMonthlyCost || 0) }} +
+
+ + + +
+
+
+

{{ $t('falukant.family.lovers.candidates.none') }}

+
@@ -201,7 +321,7 @@ import ChildDetailsDialog from '@/dialogues/falukant/ChildDetailsDialog.vue' import Character3D from '@/components/Character3D.vue' import apiClient from '@/utils/axios.js' -import { confirmAction, showError, showSuccess } from '@/utils/feedback.js' +import { confirmAction, showError, showInfo, showSuccess } from '@/utils/feedback.js' import { mapState } from 'vuex' const WOOING_PROGRESS_TARGET = 70 @@ -218,6 +338,8 @@ export default { relationships: [], children: [], lovers: [], + possibleLovers: [], + candidateRoles: {}, deathPartners: [], proposals: [], selectedProposalId: null, @@ -226,11 +348,25 @@ export default { moodAffects: [], characterAffects: [], ownCharacter: null, - selectedChild: null + marriageSatisfaction: null, + marriageState: null, + householdTension: null, + selectedChild: null, + pendingFamilyRefresh: null } }, computed: { - ...mapState(['socket']) + ...mapState(['socket', 'daemonSocket', 'user']) + }, + watch: { + socket(newVal, oldVal) { + if (oldVal) this.teardownSocketEvents(); + if (newVal) this.setupSocketEvents(); + }, + daemonSocket(newVal, oldVal) { + if (oldVal) this.teardownDaemonListeners(); + if (newVal) this.setupDaemonListeners(); + } }, async mounted() { await this.loadOwnCharacter(); @@ -239,25 +375,110 @@ export default { await this.loadMoodAffects(); await this.loadCharacterAffects(); this.setupSocketEvents(); + this.setupDaemonListeners(); + }, + beforeUnmount() { + this.teardownSocketEvents(); + this.teardownDaemonListeners(); + if (this.pendingFamilyRefresh) { + clearTimeout(this.pendingFamilyRefresh); + this.pendingFamilyRefresh = null; + } }, methods: { setupSocketEvents() { + this.teardownSocketEvents(); if (this.socket) { - this.socket.on('falukantUpdateStatus', (data) => { - this.handleEvent({ event: 'falukantUpdateStatus', ...data }); - }); - this.socket.on('familychanged', (data) => { - this.handleEvent({ event: 'familychanged', ...data }); - }); + this._falukantUpdateStatusHandler = (data) => this.handleEvent({ event: 'falukantUpdateStatus', ...data }); + this._falukantUpdateFamilyHandler = (data) => this.handleEvent({ event: 'falukantUpdateFamily', ...data }); + this._childrenUpdateHandler = (data) => this.handleEvent({ event: 'children_update', ...data }); + this._familyChangedHandler = (data) => this.handleEvent({ event: 'familychanged', ...data }); + + this.socket.on('falukantUpdateStatus', this._falukantUpdateStatusHandler); + this.socket.on('falukantUpdateFamily', this._falukantUpdateFamilyHandler); + this.socket.on('children_update', this._childrenUpdateHandler); + this.socket.on('familychanged', this._familyChangedHandler); } else { setTimeout(() => this.setupSocketEvents(), 1000); } }, + teardownSocketEvents() { + if (!this.socket) return; + if (this._falukantUpdateStatusHandler) this.socket.off('falukantUpdateStatus', this._falukantUpdateStatusHandler); + if (this._falukantUpdateFamilyHandler) this.socket.off('falukantUpdateFamily', this._falukantUpdateFamilyHandler); + if (this._childrenUpdateHandler) this.socket.off('children_update', this._childrenUpdateHandler); + if (this._familyChangedHandler) this.socket.off('familychanged', this._familyChangedHandler); + }, + setupDaemonListeners() { + this.teardownDaemonListeners(); + if (!this.daemonSocket) return; + this._daemonFamilyHandler = (event) => { + if (event.data === 'ping') return; + try { + const message = JSON.parse(event.data); + if ([ + 'falukantUpdateStatus', + 'falukantUpdateFamily', + 'children_update', + 'familychanged', + 'falukant_family_scandal_hint' + ].includes(message.event)) { + this.handleEvent(message); + } + } catch (_) {} + }; + this.daemonSocket.addEventListener('message', this._daemonFamilyHandler); + }, + teardownDaemonListeners() { + if (this.daemonSocket && this._daemonFamilyHandler) { + this.daemonSocket.removeEventListener('message', this._daemonFamilyHandler); + this._daemonFamilyHandler = null; + } + }, + matchesCurrentUser(eventData) { + if (eventData?.user_id == null) { + return true; + } + + const currentIds = [this.user?.id, this.user?.hashedId] + .filter(Boolean) + .map((value) => String(value)); + + return currentIds.includes(String(eventData.user_id)); + }, + queueFamilyRefresh({ reloadCharacter = false } = {}) { + if (this.pendingFamilyRefresh) { + clearTimeout(this.pendingFamilyRefresh); + } + + this.pendingFamilyRefresh = setTimeout(async () => { + this.pendingFamilyRefresh = null; + await this.loadFamilyData(); + if (reloadCharacter) { + await this.loadOwnCharacter(); + } + }, 120); + }, handleEvent(eventData) { + if (!this.matchesCurrentUser(eventData)) { + return; + } + switch (eventData.event) { case 'falukantUpdateStatus': case 'familychanged': - this.loadFamilyData(); + this.queueFamilyRefresh({ reloadCharacter: true }); + break; + case 'children_update': + this.queueFamilyRefresh({ reloadCharacter: false }); + break; + case 'falukantUpdateFamily': + if (eventData.reason === 'scandal') { + showInfo(this, this.$t('falukant.family.notifications.scandal')); + } else if (eventData.reason === 'lover_birth') { + showInfo(this, this.$t('falukant.family.notifications.loverBirth')); + } + this.queueFamilyRefresh({ reloadCharacter: eventData.reason === 'monthly' || eventData.reason === 'daily' }); break; } }, @@ -267,8 +488,13 @@ export default { this.relationships = response.data.relationships; this.children = response.data.children; this.lovers = response.data.lovers; + this.possibleLovers = response.data.possibleLovers || []; + this.syncCandidateRoles(); this.proposals = response.data.possiblePartners; this.deathPartners = response.data.deathPartners; + this.marriageSatisfaction = response.data.marriageSatisfaction; + this.marriageState = response.data.marriageState; + this.householdTension = response.data.householdTension; } catch (error) { console.error('Error loading family data:', error); } @@ -305,6 +531,73 @@ export default { } }, + async setLoverMaintenance(lover, maintenanceLevel) { + try { + await apiClient.post(`/api/falukant/family/lover/${lover.relationshipId}/maintenance`, { + maintenanceLevel + }); + await this.loadFamilyData(); + showSuccess(this, this.$t('falukant.family.lovers.actions.maintenanceSuccess')); + } catch (error) { + console.error('Error updating lover maintenance:', error); + showError(this, this.$t('falukant.family.lovers.actions.maintenanceError')); + } + }, + + async acknowledgeLover(lover) { + try { + await apiClient.post(`/api/falukant/family/lover/${lover.relationshipId}/acknowledge`); + await this.loadFamilyData(); + showSuccess(this, this.$t('falukant.family.lovers.actions.acknowledgeSuccess')); + } catch (error) { + console.error('Error acknowledging lover:', error); + showError(this, this.$t('falukant.family.lovers.actions.acknowledgeError')); + } + }, + + async endLoverRelationship(lover) { + const confirmed = await confirmAction(this, { + title: this.$t('falukant.family.lovers.actions.end'), + message: this.$t('falukant.family.lovers.actions.endConfirm') + }); + if (!confirmed) return; + + try { + await apiClient.post(`/api/falukant/family/lover/${lover.relationshipId}/end`); + await this.loadFamilyData(); + showSuccess(this, this.$t('falukant.family.lovers.actions.endSuccess')); + } catch (error) { + console.error('Error ending lover relationship:', error); + showError(this, this.$t('falukant.family.lovers.actions.endError')); + } + }, + + async createLoverRelationship(candidate) { + try { + await apiClient.post('/api/falukant/family/lover', { + targetCharacterId: candidate.characterId, + loverRole: this.getCandidateRole(candidate.characterId) + }); + await this.loadFamilyData(); + showSuccess(this, this.$t('falukant.family.lovers.actions.startSuccess')); + } catch (error) { + console.error('Error creating lover relationship:', error); + showError(this, this.$t('falukant.family.lovers.actions.startError')); + } + }, + + syncCandidateRoles() { + const nextRoles = {}; + for (const candidate of this.possibleLovers) { + nextRoles[candidate.characterId] = this.candidateRoles[candidate.characterId] || 'secret_affair'; + } + this.candidateRoles = nextRoles; + }, + + getCandidateRole(characterId) { + return this.candidateRoles[characterId] || 'secret_affair'; + }, + formatCost(value) { return new Intl.NumberFormat(navigator.language, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value); }, @@ -442,16 +735,6 @@ export default { }); }, - handleDaemonMessage(event) { - if (event.data === 'ping') { - return; - } - const message = JSON.parse(event.data); - if (message.event === 'children_update') { - this.loadFamilyData(); - } - }, - getEffect(gift) { // aktueller Partner const partner = this.relationships[0].character2; @@ -512,6 +795,196 @@ export default { color: var(--color-text-secondary); } +.marriage-overview { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 18px; + padding: 16px 18px; +} + +.marriage-overview__item { + display: flex; + flex-direction: column; + gap: 6px; +} + +.marriage-overview__label { + color: var(--color-text-secondary); + font-size: 0.88rem; +} + +.inline-status-pill { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 700; + width: fit-content; +} + +.inline-status-pill--stable, +.inline-status-pill--low { + background: rgba(66, 140, 87, 0.16); + color: #2e6b42; +} + +.inline-status-pill--strained, +.inline-status-pill--medium { + background: rgba(230, 172, 52, 0.18); + color: #875e08; +} + +.inline-status-pill--crisis, +.inline-status-pill--high { + background: rgba(188, 84, 61, 0.16); + color: #9a3c26; +} + +.child-origin-badge { + margin-left: 8px; + padding: 2px 8px; + border-radius: 999px; + background: rgba(131, 104, 73, 0.14); + color: #6a4d2f; + font-size: 0.72rem; + font-weight: 700; +} + +.lovers-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 14px; +} + +.lover-card { + padding: 16px 18px; +} + +.lover-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + margin-bottom: 12px; +} + +.lover-card__role { + margin-top: 4px; + color: var(--color-text-secondary); + font-size: 0.88rem; +} + +.lover-card__stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 16px; + margin: 0; +} + +.lover-card__stats div { + display: flex; + flex-direction: column; + gap: 3px; +} + +.lover-card__stats dt { + color: var(--color-text-secondary); + font-size: 0.78rem; +} + +.lover-card__stats dd { + margin: 0; + font-weight: 700; +} + +.lover-card__meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +.lover-card__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +.lover-card__actions .button { + min-width: 0; +} + +.lover-candidates { + margin-top: 18px; + padding: 16px 18px; +} + +.lover-candidates__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px; + margin-top: 12px; +} + +.lover-candidate-card { + display: flex; + justify-content: space-between; + align-items: center; + gap: 14px; + padding: 14px 16px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: rgba(255, 250, 243, 0.88); +} + +.lover-candidate-card__main { + display: flex; + flex-direction: column; + gap: 4px; +} + +.lover-candidate-card__main span { + color: var(--color-text-secondary); + font-size: 0.88rem; +} + +.lover-candidate-card__actions { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 180px; +} + +.lover-candidate-card__label { + color: var(--color-text-secondary); + font-size: 0.8rem; + font-weight: 600; +} + +.lover-candidate-card__select { + width: 100%; + min-height: 40px; +} + +.lover-meta-badge { + display: inline-flex; + align-items: center; + padding: 3px 9px; + border-radius: 999px; + background: rgba(66, 140, 87, 0.14); + color: #2e6b42; + font-size: 0.76rem; + font-weight: 700; +} + +.lover-meta-badge--warning { + background: rgba(188, 84, 61, 0.14); + color: #9a3c26; +} + .self-character-3d { width: 250px; height: 350px; diff --git a/frontend/src/views/falukant/OverviewView.vue b/frontend/src/views/falukant/OverviewView.vue index 16d7717..813b52b 100644 --- a/frontend/src/views/falukant/OverviewView.vue +++ b/frontend/src/views/falukant/OverviewView.vue @@ -213,10 +213,11 @@ export default { productions: [], potentialHeirs: [], loadingHeirs: false, + pendingOverviewRefresh: null, }; }, computed: { - ...mapState(['socket', 'daemonSocket']), + ...mapState(['socket', 'daemonSocket', 'user']), getAvatarStyle() { if (!this.falukantUser || !this.falukantUser.character) return {}; const { gender, age } = this.falukantUser.character; @@ -335,12 +336,18 @@ export default { } }, beforeUnmount() { + if (this.pendingOverviewRefresh) { + clearTimeout(this.pendingOverviewRefresh); + this.pendingOverviewRefresh = null; + } if (this.daemonSocket) { this.daemonSocket.removeEventListener('message', this.handleDaemonMessage); } if (this.socket) { this.socket.off("falukantUserUpdated", this.fetchFalukantUser); this.socket.off("falukantUpdateStatus"); + this.socket.off("falukantUpdateFamily"); + this.socket.off("children_update"); this.socket.off("falukantBranchUpdate"); this.socket.off("stock_change"); } @@ -352,6 +359,12 @@ export default { this.socket.on("falukantUpdateStatus", (data) => { this.handleEvent({ event: 'falukantUpdateStatus', ...data }); }); + this.socket.on("falukantUpdateFamily", (data) => { + this.handleEvent({ event: 'falukantUpdateFamily', ...data }); + }); + this.socket.on("children_update", (data) => { + this.handleEvent({ event: 'children_update', ...data }); + }); this.socket.on("falukantBranchUpdate", (data) => { this.handleEvent({ event: 'falukantBranchUpdate', ...data }); }); @@ -387,16 +400,37 @@ export default { console.error('Overview: Error processing daemon message:', err); } }, + matchesCurrentUser(eventData) { + if (eventData?.user_id == null) { + return true; + } + const currentIds = [this.user?.id, this.user?.hashedId] + .filter(Boolean) + .map((value) => String(value)); + return currentIds.includes(String(eventData.user_id)); + }, + queueOverviewRefresh() { + if (this.pendingOverviewRefresh) { + clearTimeout(this.pendingOverviewRefresh); + } + this.pendingOverviewRefresh = setTimeout(async () => { + this.pendingOverviewRefresh = null; + await this.fetchFalukantUser(); + if (this.falukantUser?.character) { + await this.fetchProductions(); + await this.fetchAllStock(); + } + }, 120); + }, async handleEvent(eventData) { if (!this.falukantUser?.character) return; + if (!this.matchesCurrentUser(eventData)) return; switch (eventData.event) { case 'falukantUpdateStatus': + case 'falukantUpdateFamily': + case 'children_update': case 'falukantBranchUpdate': - await this.fetchFalukantUser(); - if (this.falukantUser?.character) { - await this.fetchProductions(); - await this.fetchAllStock(); - } + this.queueOverviewRefresh(); break; case 'production_ready': case 'production_started': diff --git a/frontend/src/views/falukant/UndergroundView.vue b/frontend/src/views/falukant/UndergroundView.vue index be3e3b8..0eb9267 100644 --- a/frontend/src/views/falukant/UndergroundView.vue +++ b/frontend/src/views/falukant/UndergroundView.vue @@ -80,6 +80,18 @@ + + @@ -96,6 +108,7 @@ {{ $t('falukant.underground.activities.type') }} {{ $t('falukant.underground.activities.victim') }} {{ $t('falukant.underground.activities.cost') }} + {{ $t('falukant.underground.activities.status') }} {{ $t('falukant.underground.activities.additionalInfo') }} @@ -105,16 +118,73 @@ {{ act.victimName }} {{ formatCost(act.cost) }} - - +
+ + {{ $t(`falukant.underground.status.${act.status}`) }} + +
+ + +
+
+ + + +
+ + +
- {{ $t('falukant.underground.activities.none') }} + {{ $t('falukant.underground.activities.none') }} @@ -179,7 +249,8 @@ export default { victimSearchTimeout: null, newPoliticalTargets: [], newSabotageTarget: 'house', - newCorruptGoal: 'elect' + newCorruptGoal: 'elect', + newAffairGoal: 'expose' }; }, computed: { @@ -202,6 +273,12 @@ export default { ) { return false; } + if ( + this.selectedType?.tr === 'investigate_affair' && + !this.newAffairGoal + ) { + return false; + } return true; } }, @@ -234,7 +311,6 @@ export default { } }, async searchVictims(q) { - console.log('Searching victims for:', q); try { const { data } = await apiClient.get('/api/falukant/users/search', { params: { q } @@ -264,6 +340,9 @@ export default { payload.politicalTargets = this.newPoliticalTargets; } } + if (this.selectedType.tr === 'investigate_affair') { + payload.goal = this.newAffairGoal; + } try { await apiClient.post( '/api/falukant/underground/activities', @@ -273,6 +352,7 @@ export default { this.newPoliticalTargets = []; this.newSabotageTarget = 'house'; this.newCorruptGoal = 'elect'; + this.newAffairGoal = 'expose'; await this.loadActivities(); } catch (err) { console.error('Error creating activity', err); @@ -287,8 +367,6 @@ export default { }, async loadActivities() { - return; // TODO: Aktivierung der Methode geplant - /* Temporär deaktiviert: this.loading.activities = true; try { const { data } = await apiClient.get( @@ -298,7 +376,6 @@ export default { } finally { this.loading.activities = false; } - */ }, async loadAttacks() { @@ -328,6 +405,48 @@ export default { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(v); + }, + + hasNumericValue(value) { + return typeof value === 'number' && !Number.isNaN(value); + }, + + formatSignedNumber(value) { + if (!this.hasNumericValue(value)) return '0'; + return new Intl.NumberFormat(navigator.language, { + minimumFractionDigits: 0, + maximumFractionDigits: 1, + signDisplay: 'always' + }).format(value); + }, + + getAffairDiscoveries(activity) { + const discoveries = activity?.additionalInfo?.discoveries; + if (Array.isArray(discoveries)) { + return discoveries.filter(Boolean).map(entry => String(entry)); + } + if (discoveries && typeof discoveries === 'object') { + return Object.entries(discoveries) + .filter(([, value]) => value !== null && value !== undefined && value !== '') + .map(([key, value]) => `${key}: ${value}`); + } + if (typeof discoveries === 'string' && discoveries.trim()) { + return [discoveries.trim()]; + } + return []; + }, + + hasAffairImpact(activity) { + const info = activity?.additionalInfo || {}; + return ( + this.hasNumericValue(info.visibilityDelta) || + this.hasNumericValue(info.reputationDelta) || + (typeof info.blackmailAmount === 'number' && info.blackmailAmount > 0) + ); + }, + + hasAffairDetails(activity) { + return this.getAffairDiscoveries(activity).length > 0 || this.hasAffairImpact(activity); } } }; @@ -407,6 +526,7 @@ h2 { padding: 8px; border: 1px solid #ddd; text-align: left; + vertical-align: top; } .suggestions { @@ -432,4 +552,72 @@ h2 { .suggestions li:hover { background: #eee; } + +.activity-status__badge { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.55rem; + border-radius: 999px; + font-size: 0.88rem; + font-weight: 600; + background: #ececec; + color: #333; +} + +.activity-status__badge.is-pending { + background: #fff2cc; + color: #7a5600; +} + +.activity-status__badge.is-resolved { + background: #dff3e2; + color: #25613a; +} + +.activity-status__badge.is-failed { + background: #f8d7da; + color: #8a2632; +} + +.activity-details { + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.activity-details__summary { + font-weight: 600; +} + +.activity-details__block { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.activity-details__label { + font-size: 0.85rem; + font-weight: 600; + color: #666; +} + +.activity-details__list { + margin: 0; + padding-left: 1.1rem; +} + +.activity-details__metrics { + flex-direction: row; + flex-wrap: wrap; + gap: 0.5rem; +} + +.activity-metric { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.55rem; + border-radius: 999px; + background: #f0f0f0; + font-size: 0.88rem; +}