diff --git a/backend/controllers/falukantController.js b/backend/controllers/falukantController.js index 226b4f3..a888da3 100644 --- a/backend/controllers/falukantController.js +++ b/backend/controllers/falukantController.js @@ -120,6 +120,12 @@ class FalukantController { 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.spendTimeWithSpouse = this._wrapWithUser((userId) => + this.service.spendTimeWithSpouse(userId)); + this.giftToSpouse = this._wrapWithUser((userId, req) => + this.service.giftToSpouse(userId, req.body?.giftLevel)); + this.reconcileMarriage = this._wrapWithUser((userId) => + this.service.reconcileMarriage(userId)); this.acknowledgeLover = this._wrapWithUser((userId, req) => this.service.acknowledgeLover(userId, req.params.relationshipId)); this.endLoverRelationship = this._wrapWithUser((userId, req) => @@ -149,6 +155,7 @@ class FalukantController { this.hireServants = this._wrapWithUser((userId, req) => this.service.hireServants(userId, req.body?.amount), { successStatus: 201 }); this.dismissServants = this._wrapWithUser((userId, req) => this.service.dismissServants(userId, req.body?.amount)); this.setServantPayLevel = this._wrapWithUser((userId, req) => this.service.setServantPayLevel(userId, req.body?.payLevel)); + this.tidyHousehold = this._wrapWithUser((userId) => this.service.tidyHousehold(userId)); this.getPartyTypes = this._wrapWithUser((userId) => this.service.getPartyTypes(userId)); this.createParty = this._wrapWithUser((userId, req) => { diff --git a/backend/migrations/20260323000000-add-household-tension-to-user-house.cjs b/backend/migrations/20260323000000-add-household-tension-to-user-house.cjs new file mode 100644 index 0000000..f5c1a67 --- /dev/null +++ b/backend/migrations/20260323000000-add-household-tension-to-user-house.cjs @@ -0,0 +1,36 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn( + { schema: 'falukant_data', tableName: 'user_house' }, + 'household_tension_score', + { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 10 + } + ); + + await queryInterface.addColumn( + { schema: 'falukant_data', tableName: 'user_house' }, + 'household_tension_reasons_json', + { + type: Sequelize.JSONB, + allowNull: true + } + ); + }, + + async down(queryInterface) { + await queryInterface.removeColumn( + { schema: 'falukant_data', tableName: 'user_house' }, + 'household_tension_reasons_json' + ); + await queryInterface.removeColumn( + { schema: 'falukant_data', tableName: 'user_house' }, + 'household_tension_score' + ); + } +}; diff --git a/backend/models/falukant/data/user_house.js b/backend/models/falukant/data/user_house.js index 8946243..c33d027 100644 --- a/backend/models/falukant/data/user_house.js +++ b/backend/models/falukant/data/user_house.js @@ -44,6 +44,15 @@ UserHouse.init({ allowNull: false, defaultValue: 55 }, + householdTensionScore: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 10 + }, + householdTensionReasonsJson: { + type: DataTypes.JSONB, + allowNull: true + }, houseTypeId: { type: DataTypes.INTEGER, allowNull: false diff --git a/backend/routers/falukantRouter.js b/backend/routers/falukantRouter.js index 10fd166..1c06974 100644 --- a/backend/routers/falukantRouter.js +++ b/backend/routers/falukantRouter.js @@ -48,6 +48,9 @@ router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageP router.post('/family/cancel-wooing', falukantController.cancelWooing); router.post('/family/set-heir', falukantController.setHeir); router.post('/family/lover', falukantController.createLoverRelationship); +router.post('/family/marriage/spend-time', falukantController.spendTimeWithSpouse); +router.post('/family/marriage/gift', falukantController.giftToSpouse); +router.post('/family/marriage/reconcile', falukantController.reconcileMarriage); router.post('/family/lover/:relationshipId/maintenance', falukantController.setLoverMaintenance); router.post('/family/lover/:relationshipId/acknowledge', falukantController.acknowledgeLover); router.post('/family/lover/:relationshipId/end', falukantController.endLoverRelationship); @@ -68,6 +71,7 @@ router.post('/houses/renovate', falukantController.renovate); router.post('/houses/servants/hire', falukantController.hireServants); router.post('/houses/servants/dismiss', falukantController.dismissServants); router.post('/houses/servants/pay-level', falukantController.setServantPayLevel); +router.post('/houses/order', falukantController.tidyHousehold); router.post('/houses', falukantController.buyUserHouse); router.get('/party/types', falukantController.getPartyTypes); router.post('/party', falukantController.createParty); diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index 99f1dd9..008a92a 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -517,6 +517,167 @@ class FalukantService extends BaseService { return 'stable'; } + getHouseholdTensionLabel(score) { + if (score == null) return null; + if (score >= 60) return 'high'; + if (score >= 25) return 'medium'; + return 'low'; + } + + clampScore(value) { + return Math.max(0, Math.min(100, Math.round(Number(value) || 0))); + } + + calculateHouseholdTension({ lovers = [], marriageSatisfaction = null, userHouse = null, children = [] }) { + let score = 10; + const reasons = []; + + for (const lover of lovers) { + const visibility = Number(lover.visibility || 0); + const monthsUnderfunded = Number(lover.monthsUnderfunded || 0); + const statusFit = Number(lover.statusFit || 0); + + if (visibility >= 60) { + score += 18; + reasons.push('visibleLover'); + } else if (visibility >= 35) { + score += 10; + reasons.push('noticeableLover'); + } else { + score += 4; + } + + if (monthsUnderfunded >= 1) { + score += 6; + reasons.push('underfundedLover'); + } + if (monthsUnderfunded >= 2) score += 6; + + if (lover.acknowledged) { + score += 4; + reasons.push('acknowledgedAffair'); + } + + if (statusFit === -1) score += 3; + if (statusFit <= -2) { + score += 6; + reasons.push('statusMismatch'); + } + } + + for (const child of children) { + if (child.birthContext !== 'lover') continue; + score += child.publicKnown ? 6 : 2; + if (child.publicKnown) reasons.push('loverChild'); + if (child.legitimacy === 'acknowledged_bastard') score += 2; + if (child.legitimacy === 'hidden_bastard') score += 4; + } + + const householdOrder = Number(userHouse?.householdOrder ?? 55); + const servantCount = Number(userHouse?.servantCount ?? 0); + const servantQuality = Number(userHouse?.servantQuality ?? 50); + const servantPayLevel = userHouse?.servantPayLevel || 'normal'; + const expectation = this.getServantExpectation(userHouse?.houseType, userHouse?.character || null); + + if (householdOrder >= 80) score -= 6; + else if (householdOrder >= 65) score -= 3; + if (householdOrder <= 35) { + score += 8; + reasons.push('disorder'); + } else if (householdOrder <= 50) { + score += 4; + } + + if (servantCount < expectation.min) { + score += 5; + reasons.push('tooFewServants'); + } + if (servantPayLevel === 'low') score += 2; + if (servantQuality >= 70 && servantPayLevel === 'high') score -= 3; + + if (marriageSatisfaction != null && marriageSatisfaction <= 35) { + score += 6; + reasons.push('marriageCrisis'); + } + if (marriageSatisfaction != null && marriageSatisfaction >= 75) score -= 2; + + const normalizedScore = this.clampScore(score); + return { + score: normalizedScore, + label: this.getHouseholdTensionLabel(normalizedScore), + reasons: [...new Set(reasons)] + }; + } + + async refreshHouseholdTensionState(falukantUser, character = falukantUser?.character) { + if (!falukantUser?.id || !character?.id) return null; + + const userHouse = await UserHouse.findOne({ + where: { userId: falukantUser.id }, + include: [{ model: HouseType, as: 'houseType', attributes: ['id', 'position', 'cost', 'labelTr'] }] + }); + if (!userHouse) return null; + + const relationshipTypes = await RelationshipType.findAll({ + where: { tr: ['lover', 'married'] }, + attributes: ['id', 'tr'] + }); + const relationshipTypeIds = relationshipTypes.map((type) => type.id); + const relationshipTypeMap = Object.fromEntries(relationshipTypes.map((type) => [type.id, type.tr])); + + const relationships = relationshipTypeIds.length + ? await Relationship.findAll({ + where: { + character1Id: character.id, + relationshipTypeId: relationshipTypeIds + }, + include: [{ model: RelationshipState, as: 'state', required: false }], + attributes: ['relationshipTypeId'] + }) + : []; + + const marriage = relationships.find((rel) => relationshipTypeMap[rel.relationshipTypeId] === 'married') || null; + const lovers = relationships + .filter((rel) => relationshipTypeMap[rel.relationshipTypeId] === 'lover') + .map((rel) => rel.state) + .filter((state) => (state?.active ?? true) !== false) + .map((state) => ({ + visibility: state?.visibility ?? 0, + monthsUnderfunded: state?.monthsUnderfunded ?? 0, + acknowledged: !!state?.acknowledged, + statusFit: state?.statusFit ?? 0 + })); + + const children = await ChildRelation.findAll({ + where: { + [Op.or]: [ + { fatherCharacterId: character.id }, + { motherCharacterId: character.id } + ] + }, + attributes: ['birthContext', 'legitimacy', 'publicKnown'] + }); + + userHouse.setDataValue('character', character); + const householdTension = this.calculateHouseholdTension({ + lovers, + marriageSatisfaction: marriage?.state?.marriageSatisfaction ?? null, + userHouse, + children: children.map((rel) => ({ + birthContext: rel.birthContext, + legitimacy: rel.legitimacy, + publicKnown: !!rel.publicKnown + })) + }); + + await userHouse.update({ + householdTensionScore: householdTension.score, + householdTensionReasonsJson: householdTension.reasons + }); + + return householdTension; + } + getLoverRiskState(state) { if (!state) return 'low'; if ((state.visibility ?? 0) >= 60 || (state.monthsUnderfunded ?? 0) >= 2) return 'high'; @@ -526,7 +687,11 @@ class FalukantService extends BaseService { calculateLoverStatusFit(ownTitleId, targetTitleId) { const diff = Math.abs(Number(ownTitleId || 0) - Number(targetTitleId || 0)); - return Math.max(0, 100 - diff * 20); + if (diff === 0) return 2; + if (diff === 1) return 1; + if (diff === 2) return 0; + if (diff === 3) return -1; + return -2; } calculateLoverBaseCost(ownTitleId, targetTitleId) { @@ -3026,6 +3191,13 @@ class FalukantService extends BaseService { const activeMarriage = activeRelationships.find(r => r.relationshipType === 'married') || activeRelationships[0] || null; const marriageSatisfaction = activeMarriage?.state?.marriageSatisfaction ?? null; const marriageState = this.getMarriageStateLabel(marriageSatisfaction); + const userHouse = await UserHouse.findOne({ + where: { userId: user.id }, + include: [{ model: HouseType, as: 'houseType', attributes: ['id', 'position', 'cost', 'labelTr'] }] + }); + if (userHouse) { + userHouse.setDataValue('character', character); + } const lovers = relationships .filter(r => r.relationshipType === 'lover') .filter(r => (r.state?.active ?? true) !== false) @@ -3057,6 +3229,17 @@ class FalukantService extends BaseService { state, }; }); + const derivedHouseholdTension = this.calculateHouseholdTension({ + lovers, + marriageSatisfaction, + userHouse, + children + }); + const householdTension = { + score: Number(userHouse?.householdTensionScore ?? derivedHouseholdTension.score), + reasons: Array.isArray(userHouse?.householdTensionReasonsJson) ? userHouse.householdTensionReasonsJson : derivedHouseholdTension.reasons + }; + householdTension.label = this.getHouseholdTensionLabel(householdTension.score); const family = { relationships: activeRelationships.map((r) => ({ ...r, @@ -3065,7 +3248,9 @@ class FalukantService extends BaseService { })), marriageSatisfaction, marriageState, - householdTension: lovers.some(l => l.riskState === 'high') ? 'high' : lovers.some(l => l.riskState === 'medium') ? 'medium' : 'low', + householdTension: householdTension.label, + householdTensionScore: householdTension.score, + householdTensionReasons: householdTension.reasons, lovers, deathPartners: relationships.filter(r => r.relationshipType === 'widowed'), children: children.map(({ _createdAt, ...rest }) => rest), @@ -3203,6 +3388,7 @@ class FalukantService extends BaseService { statusFit: target.statusFit, active: true }); + await this.refreshHouseholdTensionState(user, user.character); await notifyUser(hashedUserId, 'falukantUpdateFamily', { reason: 'daily' }); await notifyUser(hashedUserId, 'falukantUpdateStatus', {}); @@ -3224,8 +3410,9 @@ class FalukantService extends BaseService { throw { status: 400, message: 'maintenanceLevel must be between 0 and 100' }; } - const { state } = await this.getOwnedLoverRelationState(hashedUserId, parsedRelationshipId); + const { user, state } = await this.getOwnedLoverRelationState(hashedUserId, parsedRelationshipId); await state.update({ maintenanceLevel: parsedMaintenance }); + await this.refreshHouseholdTensionState(user, user.character); return { success: true, relationshipId: parsedRelationshipId, @@ -3233,6 +3420,119 @@ class FalukantService extends BaseService { }; } + async spendTimeWithSpouse(hashedUserId) { + const user = await this.getFalukantUserByHashedId(hashedUserId); + if (!user?.character?.id) throw new Error('User or character not found'); + + const marriage = await Relationship.findOne({ + where: { character1Id: user.character.id }, + include: [ + { model: RelationshipType, as: 'relationshipType', where: { tr: 'married' } }, + { model: RelationshipState, as: 'state', required: false } + ] + }); + if (!marriage) throw { status: 409, message: 'No active marriage found' }; + + let state = marriage.state; + if (!state) { + state = await RelationshipState.create({ + relationshipId: marriage.id, + ...this.buildDefaultRelationshipState('married') + }); + } + + const nextSatisfaction = this.clampScore((state.marriageSatisfaction ?? 55) + 2); + const nextPublicStability = this.clampScore((state.marriagePublicStability ?? 55) + 1); + await state.update({ + marriageSatisfaction: nextSatisfaction, + marriagePublicStability: nextPublicStability + }); + await this.refreshHouseholdTensionState(user, user.character); + + await notifyUser(hashedUserId, 'falukantUpdateFamily', { reason: 'daily' }); + await notifyUser(hashedUserId, 'falukantUpdateStatus', {}); + return { success: true, marriageSatisfaction: nextSatisfaction }; + } + + async giftToSpouse(hashedUserId, giftLevel) { + const user = await this.getFalukantUserByHashedId(hashedUserId); + if (!user?.character?.id) throw new Error('User or character not found'); + + const marriage = await Relationship.findOne({ + where: { character1Id: user.character.id }, + include: [ + { model: RelationshipType, as: 'relationshipType', where: { tr: 'married' } }, + { model: RelationshipState, as: 'state', required: false } + ] + }); + if (!marriage) throw { status: 409, message: 'No active marriage found' }; + + const levelConfig = { + small: { cost: 25, satisfaction: 2, publicStability: 1 }, + decent: { cost: 80, satisfaction: 4, publicStability: 2 }, + lavish: { cost: 180, satisfaction: 7, publicStability: 3 } + }; + const config = levelConfig[giftLevel] || levelConfig.small; + if (Number(user.money) < config.cost) throw new Error('notenoughmoney.'); + + let state = marriage.state; + if (!state) { + state = await RelationshipState.create({ + relationshipId: marriage.id, + ...this.buildDefaultRelationshipState('married') + }); + } + + const nextSatisfaction = this.clampScore((state.marriageSatisfaction ?? 55) + config.satisfaction); + const nextPublicStability = this.clampScore((state.marriagePublicStability ?? 55) + config.publicStability); + + await sequelize.transaction(async () => { + await state.update({ + marriageSatisfaction: nextSatisfaction, + marriagePublicStability: nextPublicStability + }); + await updateFalukantUserMoney(user.id, -config.cost, 'marriage_gift', user.id); + }); + await this.refreshHouseholdTensionState(user, user.character); + + await notifyUser(hashedUserId, 'falukantUpdateFamily', { reason: 'daily' }); + await notifyUser(hashedUserId, 'falukantUpdateStatus', {}); + return { success: true, marriageSatisfaction: nextSatisfaction, cost: config.cost }; + } + + async reconcileMarriage(hashedUserId) { + const user = await this.getFalukantUserByHashedId(hashedUserId); + if (!user?.character?.id) throw new Error('User or character not found'); + + const marriage = await Relationship.findOne({ + where: { character1Id: user.character.id }, + include: [ + { model: RelationshipType, as: 'relationshipType', where: { tr: 'married' } }, + { model: RelationshipState, as: 'state', required: false } + ] + }); + if (!marriage) throw { status: 409, message: 'No active marriage found' }; + + let state = marriage.state; + if (!state) { + state = await RelationshipState.create({ + relationshipId: marriage.id, + ...this.buildDefaultRelationshipState('married') + }); + } + + const nextSatisfaction = this.clampScore((state.marriageSatisfaction ?? 55) + 1); + const nextPublicStability = this.clampScore((state.marriagePublicStability ?? 55) + 1); + await state.update({ + marriageSatisfaction: nextSatisfaction, + marriagePublicStability: nextPublicStability + }); + + await notifyUser(hashedUserId, 'falukantUpdateFamily', { reason: 'daily' }); + await notifyUser(hashedUserId, 'falukantUpdateStatus', {}); + return { success: true, marriageSatisfaction: nextSatisfaction }; + } + async acknowledgeLover(hashedUserId, relationshipId) { const parsedRelationshipId = Number.parseInt(relationshipId, 10); if (Number.isNaN(parsedRelationshipId)) { @@ -3245,6 +3545,7 @@ class FalukantService extends BaseService { updateData.loverRole = 'lover'; } await state.update(updateData); + await this.refreshHouseholdTensionState(user, user.character); return { success: true, relationshipId: parsedRelationshipId, @@ -3264,6 +3565,7 @@ class FalukantService extends BaseService { active: false, acknowledged: false }); + await this.refreshHouseholdTensionState(user, user.character); return { success: true, relationshipId: parsedRelationshipId, @@ -4031,6 +4333,8 @@ class FalukantService extends BaseService { 'servantQuality', 'servantPayLevel', 'householdOrder', + 'householdTensionScore', + 'householdTensionReasonsJson', 'houseTypeId' ] }); @@ -4046,6 +4350,8 @@ class FalukantService extends BaseService { servantQuality: 50, servantPayLevel: 'normal', householdOrder: 55, + householdTensionScore: 10, + householdTensionReasonsJson: [], servantSummary: this.buildServantSummary(null, falukantUser.character) }; } @@ -4116,7 +4422,9 @@ class FalukantService extends BaseService { servantCount: servantDefaults.servantCount, servantQuality: servantDefaults.servantQuality, servantPayLevel: servantDefaults.servantPayLevel, - householdOrder: servantDefaults.householdOrder + householdOrder: servantDefaults.householdOrder, + householdTensionScore: 10, + householdTensionReasonsJson: [] }); await house.destroy(); await updateFalukantUserMoney(falukantUser.id, -housePrice, "housebuy", falukantUser.id); @@ -4287,6 +4595,7 @@ class FalukantService extends BaseService { }); await house.save(); await updateFalukantUserMoney(falukantUser.id, -hireCost, 'servants_hired', falukantUser.id); + await this.refreshHouseholdTensionState(falukantUser, falukantUser.character); const user = await User.findByPk(falukantUser.userId); notifyUser(user.hashedId, 'falukantHouseUpdate', {}); @@ -4321,6 +4630,7 @@ class FalukantService extends BaseService { character: falukantUser.character }); await house.save(); + await this.refreshHouseholdTensionState(falukantUser, falukantUser.character); const user = await User.findByPk(falukantUser.userId); notifyUser(user.hashedId, 'falukantHouseUpdate', {}); @@ -4350,6 +4660,7 @@ class FalukantService extends BaseService { character: falukantUser.character }); await house.save(); + await this.refreshHouseholdTensionState(falukantUser, falukantUser.character); const user = await User.findByPk(falukantUser.userId); notifyUser(user.hashedId, 'falukantHouseUpdate', {}); @@ -4360,6 +4671,30 @@ class FalukantService extends BaseService { }; } + async tidyHousehold(hashedUserId) { + const { falukantUser, house } = await this.getOwnedUserHouse(hashedUserId); + const tidyCost = 15; + if (Number(falukantUser.money) < tidyCost) { + throw new Error('notenoughmoney.'); + } + + house.householdOrder = this.clampScore(Number(house.householdOrder || 55) + 3); + await house.save(); + await updateFalukantUserMoney(falukantUser.id, -tidyCost, 'household_order', falukantUser.id); + await this.refreshHouseholdTensionState(falukantUser, falukantUser.character); + + const user = await User.findByPk(falukantUser.userId); + notifyUser(user.hashedId, 'falukantHouseUpdate', {}); + notifyUser(user.hashedId, 'falukantUpdateFamily', { reason: 'daily' }); + notifyUser(user.hashedId, 'falukantUpdateStatus', {}); + return { + success: true, + cost: tidyCost, + householdOrder: house.householdOrder, + servantSummary: this.buildServantSummary(house, falukantUser.character) + }; + } + async getPartyTypes(hashedUserId) { const falukantUser = await getFalukantUserOrFail(hashedUserId); const engagedCount = await Relationship.count({ diff --git a/backend/sql/add_household_tension_to_user_house.sql b/backend/sql/add_household_tension_to_user_house.sql new file mode 100644 index 0000000..b0fe179 --- /dev/null +++ b/backend/sql/add_household_tension_to_user_house.sql @@ -0,0 +1,5 @@ +ALTER TABLE falukant_data.user_house +ADD COLUMN IF NOT EXISTS household_tension_score integer NOT NULL DEFAULT 10; + +ALTER TABLE falukant_data.user_house +ADD COLUMN IF NOT EXISTS household_tension_reasons_json jsonb NULL; diff --git a/docs/FALUKANT_MARRIAGE_HOUSEHOLD_CONTROL_SPEC.md b/docs/FALUKANT_MARRIAGE_HOUSEHOLD_CONTROL_SPEC.md new file mode 100644 index 0000000..d27e593 --- /dev/null +++ b/docs/FALUKANT_MARRIAGE_HOUSEHOLD_CONTROL_SPEC.md @@ -0,0 +1,569 @@ +# Falukant: Steuerung von Ehezustand und Hausfrieden + +Dieses Dokument beschreibt: + +- wie Spieler `Ehezustand` und `Hausfrieden` direkt beeinflussen können +- welche Werte dafür im Backend sichtbar und änderbar sein müssen +- was der externe Daemon täglich und monatlich berechnen soll + +Die Datei ist bewusst als gemeinsame Arbeitsgrundlage für UI, Backend und externen Daemon formuliert. + +## 1. Zielbild + +Es soll zwei getrennte, aber gekoppelte Systeme geben: + +- `marriageSatisfaction` + - numerisch `0..100` + - individueller Kernwert der Ehe +- `householdTension` + - aggregierter Haushaltszustand + - nach außen in UI als `low | medium | high` + - intern sinnvollerweise als numerischer Spannungswert `0..100` + +Interpretation: + +- `marriageSatisfaction` beschreibt die Qualität der Paarbeziehung +- `householdTension` beschreibt Spannungen im gesamten Haus + - Liebschaften + - Unterversorgung + - Ordnung + - Kinderkonflikte + - Dienerschaft + +## 2. Werte und Ableitungen + +## 2.1 Ehe + +Bestehend: + +- `relationship_state.marriage_satisfaction` + +Neu sinnvoll: + +- `relationship_state.marriage_public_stability` + - besteht bereits + - soll aktiv genutzt werden +- optional später: + - `last_affection_action_at` + - `last_conflict_action_at` + - `last_shared_time_at` + +UI-Ableitung: + +- `0..19` => `broken` +- `20..39` => `fragile` +- `40..59` => `strained` +- `60..79` => `stable` +- `80..100` => `harmonious` + +## 2.2 Hausfrieden + +Der bisherige reine UI-Helfer + +- `low` +- `medium` +- `high` + +reicht für eine echte Steuerung nicht aus. + +Neu sinnvoll: + +- interner Wert `householdTensionScore` + - Bereich `0..100` + - `0` = sehr ruhig + - `100` = offener Hauskonflikt + +UI-Ableitung: + +- `0..24` => `low` +- `25..59` => `medium` +- `60..100` => `high` + +Falls kein eigener Persistenzwert angelegt werden soll, darf der Daemon den Score auch nur berechnen und als API-Feld zurückgeben. + +## 3. Direkte Spieleraktionen + +Es braucht direkte Spielzüge, die der Spieler bewusst auslösen kann. + +Wichtig: + +- nicht jede Aktion muss sofort große Werte ändern +- direkte Aktionen sollen kleine, klare Effekte haben +- der Daemon übernimmt Drift, Gegenkräfte und Folgewirkungen + +## 3.1 Ehe-Aktionen + +Diese Aktionen gehören fachlich in `FamilyView`. + +### A. Zeit mit Ehepartner verbringen + +Zweck: + +- Standardaktion zur Pflege der Beziehung + +Regel: + +- verfügbar nur bei aktiver Ehe +- Cooldown: `1x pro Tag` +- Kosten: `0` oder sehr klein + +Soforteffekt: + +- `marriageSatisfaction +2` +- `householdTensionScore -1` + +Modifikatoren: + +- wenn aktive sichtbare Liebschaft `visibility >= 45`: nur `+1` +- wenn `householdOrder <= 35`: kein Bonus auf Hausfrieden +- wenn `marriageSatisfaction < 25`: stattdessen nur `+1` + +### B. Geschenk an Ehepartner + +Zweck: + +- Geld gegen schnellere Stabilisierung + +Regel: + +- verfügbar nur bei aktiver Ehe +- Stufen: `small`, `decent`, `lavish` +- Cooldown: `1x pro 3 Tage` + +Soforteffekt: + +- `small`: `marriageSatisfaction +2` +- `decent`: `marriageSatisfaction +4` +- `lavish`: `marriageSatisfaction +7` + +Nebeneffekt: + +- `marriagePublicStability +1/+2/+3` + +Malus: + +- bei gleichzeitig unterfinanzierter Liebschaft halbierter Effekt + +### C. Streit schlichten + +Zweck: + +- gezielte Krisenintervention + +Regel: + +- verfügbar nur wenn `householdTensionScore >= 35` oder `marriageSatisfaction <= 50` +- Cooldown: `1x pro 2 Tage` + +Soforteffekt: + +- `householdTensionScore -4` +- `marriageSatisfaction +1` + +Malus: + +- wenn `visibility` einer aktiven Liebschaft `>= 60`, dann nur `householdTensionScore -2` + +### D. Fest nur für den Haushalt + +Zweck: + +- Hausfrieden über Geld und Repräsentation stützen + +Regel: + +- verfügbar bei vorhandenem Haus +- kleiner interner Hausakt, nicht großes Reputationsfest +- Cooldown: `1x pro Monat` + +Soforteffekt: + +- `householdTensionScore -6` +- `marriageSatisfaction +2` +- `householdOrder +2` + +Malus: + +- bei unterbesetzter Dienerschaft nur halbe Wirkung + +## 3.2 Haus-Aktionen + +Diese Aktionen gehören fachlich in `HouseView`. + +### A. Haus ordnen + +Zweck: + +- kleine direkte Ordnungsmaßnahme + +Regel: + +- Cooldown: `1x pro Tag` +- Kosten: niedrig + +Soforteffekt: + +- `householdOrder +3` +- wenn `householdOrder > 70`: stattdessen nur `+1` + +Indirekter Effekt: + +- besserer Daily-Wert für `householdTensionScore` + +### B. Diener einstellen + +Bereits vorhanden. + +Neue fachliche Wirkung: + +- wenn vorher `servantCount < expectedMin` + - sofort `householdTensionScore -2` + +### C. Diener entlassen + +Bereits vorhanden. + +Neue fachliche Wirkung: + +- wenn danach `servantCount < expectedMin` + - sofort `householdTensionScore +3` + +### D. Bezahlung erhöhen + +Bereits vorhanden. + +Neue fachliche Wirkung: + +- wenn von `low -> normal` oder `normal -> high` + - sofort `householdOrder +2` + - `householdTensionScore -1` + +## 3.3 Familien-/Kinder-Aktionen + +### A. Uneheliches Kind anerkennen + +Zweck: + +- offenere, geordnetere Lösung statt versteckter Konfliktlage + +Soforteffekt: + +- `publicKnown = true` +- `legitimacy = acknowledged_bastard` +- `householdTensionScore -2`, wenn Beziehung bereits öffentlich geordnet +- `householdTensionScore +3`, wenn Ehe schwach und Beziehung skandalös + +Eheeffekt: + +- `marriageSatisfaction -2` bis `-6` je nach Sichtbarkeit und Stand + +### B. Erbenfrage regeln + +Wenn uneheliche Kinder sichtbar werden, kann die UI später eine Handlung +`Erbfolge klären` bekommen. + +Erste Version: + +- nur vorgemerkt +- noch keine direkte Aktion nötig + +## 3.4 Liebschafts-Aktionen mit Einfluss auf Ehe und Haus + +Bestehend: + +- Unterhalt ändern +- Beziehung anerkennen +- Beziehung beenden + +Diese Aktionen sollen explizit folgende Sofortwirkung haben: + +### Unterhalt erhöhen + +- `monthsUnderfunded` baut sich später im Daemon ab +- sofort kein großer Ehebonus +- aber `householdTensionScore -1`, wenn vorher Unterversorgung bestand + +### Beziehung anerkennen + +- `visibility` steigt nicht automatisch hart, aber öffentlicher Charakter nimmt zu +- bei hohen Ständen geordnet eher neutral bis leicht positiv für Ehe-Stabilität +- bei niedrigen Ständen eher negativ + +Sofortregel: + +- Standesgruppe `0-1`: `marriageSatisfaction -3`, `householdTensionScore +2` +- Standesgruppe `2`: `marriageSatisfaction -1`, `householdTensionScore +1` +- Standesgruppe `3`: `marriagePublicStability +1`, `householdTensionScore -1`, wenn Diskretion und Versorgung gut sind + +### Beziehung beenden + +- sofort `householdTensionScore -3`, wenn Liebschaft riskant war +- sofort `marriageSatisfaction +1`, wenn aktive Ehe existiert +- aber bei hoher `affection >= 70` auch möglicher Malus auf Stimmungssystem später + +## 4. Daemon-Berechnung + +## 4.1 Daily-Input + +Der externe Daemon braucht pro Spielerfigur: + +- aktive Ehebeziehung mit `marriageSatisfaction`, `marriagePublicStability` +- aktive Liebschaften mit: + - `loverRole` + - `visibility` + - `discretion` + - `maintenanceLevel` + - `statusFit` + - `monthsUnderfunded` + - `acknowledged` +- Kinderdaten: + - `legitimacy` + - `birthContext` + - `publicKnown` +- Hausdaten: + - `servantCount` + - `servantQuality` + - `servantPayLevel` + - `householdOrder` +- Charakterdaten: + - `titleOfNobility` + - `reputation` + +## 4.2 Daily-Berechnung für Ehe + +Grunddrift: + +```text +marriageDelta = 0 + +if marriageSatisfaction > 55: marriageDelta -= 1 every 3 days +if marriageSatisfaction < 55: marriageDelta += 1 every 5 days +``` + +Liebschaften: + +```text +for each active lover: + if visibility >= 60: marriageDelta -= 2 + else if visibility >= 35: marriageDelta -= 1 + + if monthsUnderfunded >= 2: marriageDelta -= 1 + if acknowledged = true and statusGroup <= 1: marriageDelta -= 1 + if acknowledged = true and statusGroup = 3 and visibility <= 35 and maintenanceLevel >= 60: + marriageDelta += 0 or +1 every few days +``` + +Zu jung: + +```text +if minAge <= 15: marriageDelta -= 1 +if minAge <= 13: marriageDelta -= 2 +``` + +Haus: + +```text +if householdOrder >= 75: marriageDelta += 1 +if householdOrder <= 35: marriageDelta -= 1 +if householdTensionScore >= 60: marriageDelta -= 1 +``` + +Dienerschaft: + +```text +if servantCount < expectedMin: marriageDelta -= 1 +if servantPayLevel = high and servantQuality >= 70 and householdOrder >= 70: + marriageDelta += 1 every 3 days +``` + +Danach: + +- clamp `0..100` + +## 4.3 Daily-Berechnung für Hausfrieden + +Interner Wert: + +```text +householdTensionScore = base +``` + +Empfohlene Berechnung: + +```text +base = 10 + +for each active lover: + if visibility >= 60: base += 18 + else if visibility >= 35: base += 10 + else: base += 4 + + if monthsUnderfunded >= 1: base += 6 + if monthsUnderfunded >= 2: base += 6 + if acknowledged = true: base += 4 + if statusFit = -1: base += 3 + if statusFit = -2: base += 6 +``` + +Kinder: + +```text +for each child where birthContext = 'lover': + if publicKnown = true: base += 6 + else: base += 2 + + if legitimacy = 'acknowledged_bastard': base += 2 + if legitimacy = 'hidden_bastard': base += 4 +``` + +Haus: + +```text +if householdOrder >= 80: base -= 6 +else if householdOrder >= 65: base -= 3 + +if householdOrder <= 35: base += 8 +else if householdOrder <= 50: base += 4 +``` + +Dienerschaft: + +```text +if servantCount < expectedMin: base += 5 +if servantPayLevel = low: base += 2 +if servantQuality >= 70 and servantPayLevel = high: base -= 3 +``` + +Ehe: + +```text +if marriageSatisfaction <= 35: base += 6 +if marriageSatisfaction >= 75: base -= 2 +``` + +Danach: + +- clamp `0..100` +- UI-Ableitung auf `low/medium/high` + +## 4.4 Monthly-Berechnung + +Monatlich soll der Daemon zusätzlich: + +- Dienerkosten abbuchen +- Liebschaftskosten abbuchen +- bei Unterversorgung `householdTensionScore` stärker erhöhen +- langfristige Ordnungs- oder Eheboni addieren + +Empfohlene Zusatzregeln: + +```text +if a lover was underfunded this month: + householdTensionScore += 4 + +if servantCount far below expectedMin for full month: + householdTensionScore += 3 + +if householdOrder >= 80 for full month: + marriageSatisfaction += 1 + +if householdOrder <= 30 for full month: + marriageSatisfaction -= 2 +``` + +## 5. UI-Anforderungen + +## 5.1 FamilyView + +Neu sinnvolle Aktionen: + +- `Zeit miteinander verbringen` +- `Geschenk machen` +- `Streit schlichten` +- `Liebschaft beenden` +- `Uneheliches Kind anerkennen` + +Zusätzlich hilfreiche Anzeige: + +- kurze Ursachenliste für `Hausfrieden` + - z. B. `sichtbare Liebschaft` + - `Unruhe im Haus` + - `zu wenig Diener` + - `anerkanntes uneheliches Kind` + +## 5.2 HouseView + +Neu sinnvolle Aktionen: + +- `Haus ordnen` +- vorhandene Dieneraktionen mit klarer Auswirkungstextzeile + +Anzeige: + +- `Haushaltsordnung` +- `erwartete Dienerzahl` +- `Auswirkung auf Hausfrieden` + +## 6. Backend-Anforderungen + +## 6.1 Direktaktionen + +Dieses Projekt sollte Endpunkte für direkte Einflussaktionen bereitstellen: + +- `POST /api/falukant/family/marriage/spend-time` +- `POST /api/falukant/family/marriage/gift` +- `POST /api/falukant/family/marriage/reconcile` +- `POST /api/falukant/houses/order` +- später optional: + - `POST /api/falukant/family/children/acknowledge` + +## 6.2 API-Rückgabe + +Family-API sollte zusätzlich liefern: + +- `marriageSatisfaction` +- `marriageState` +- `marriagePublicStability` +- `householdTension` +- `householdTensionScore` +- optional: + - `householdTensionReasons[]` + +House-API sollte zusätzlich liefern: + +- `householdOrder` +- `expectedServantsMin` +- `expectedServantsMax` +- `marriageComfortModifier` + +## 7. Priorisierte Umsetzung + +## Phase A + +- `statusFit`-Fehler korrigieren +- direkte Ehe-Aktionen `Zeit`, `Geschenk`, `Streit schlichten` +- direkte Haus-Aktion `Haus ordnen` +- Family-API um `householdTensionScore` erweitern + +## Phase B + +- externer Daemon berechnet Daily-Drift für Ehe und Hausfrieden +- Dienerschaft fließt in Hausfrieden ein +- Liebschaften und Unterversorgung wirken vollständig auf Hausfrieden + +## Phase C + +- uneheliche Kinder als aktiver Konfliktfaktor +- Anerkennungsaktion +- genauere Ursachenlisten in der UI + +## 8. Offene Balancing-Punkte + +Diese Werte sind absichtlich noch nicht final: + +- exakte Geldkosten für Ehe-Aktionen +- Stärke der Boni für hohe Stände +- Stärke des Malus bei sichtbaren Liebschaften +- Stärke der Dienerwirkung auf Ehe und Haus + +Die Struktur sollte jetzt aber stabil genug sein, damit UI und Daemon unabhängig voneinander anfangen können. diff --git a/docs/FALUKANT_MARRIAGE_HOUSEHOLD_DAEMON_HANDOFF.md b/docs/FALUKANT_MARRIAGE_HOUSEHOLD_DAEMON_HANDOFF.md new file mode 100644 index 0000000..ca0a1cc --- /dev/null +++ b/docs/FALUKANT_MARRIAGE_HOUSEHOLD_DAEMON_HANDOFF.md @@ -0,0 +1,137 @@ +# Falukant: Daemon-Handoff für Ehe und Hausfrieden + +Dieses Dokument beschreibt den Stand nach Phase A. + +## 1. Was im Projekt jetzt vorhanden ist + +Backend-/API-seitig vorhanden: + +- `relationship_state.marriage_satisfaction` +- `relationship_state.marriage_public_stability` +- aktive Liebschaften mit: + - `visibility` + - `discretion` + - `maintenance_level` + - `status_fit` + - `months_underfunded` + - `acknowledged` +- `user_house` mit: + - `servant_count` + - `servant_quality` + - `servant_pay_level` + - `household_order` + - `household_tension_score` + - `household_tension_reasons_json` +- Family-API liefert jetzt zusätzlich: + - `householdTension` + - `householdTensionScore` + - `householdTensionReasons` + +Direkte Spieleraktionen vorhanden: + +- `POST /api/falukant/family/marriage/spend-time` +- `POST /api/falukant/family/marriage/gift` +- `POST /api/falukant/family/marriage/reconcile` +- `POST /api/falukant/houses/order` + +## 2. Daily-Input für den externen Daemon + +Pro betroffenem Falukant-User: + +- `falukant_user.id` +- `user.id` / `user.hashed_id` +- aktive Ehe-`relationship` mit `relationship_state` +- aktive Liebschaften mit `relationship_state` +- Kinder mit: + - `birth_context` + - `legitimacy` + - `public_known` +- Haus mit: + - `servant_count` + - `servant_quality` + - `servant_pay_level` + - `household_order` +- Charakter mit: + - `reputation` + - `title_of_nobility` + +## 3. Was der Daemon täglich berechnen soll + +### Ehe + +- Drift von `marriage_satisfaction` +- Drift von `marriage_public_stability` +- Einfluss aus: + - sichtbaren Liebschaften + - unterfinanzierten Liebschaften + - Standesunterschieden + - Dienerschaft / Haushaltsordnung + - zu jungen Liebschaften + +### Hausfrieden + +Der Daemon soll intern einen numerischen Spannungswert pflegen oder berechnen: + +- `householdTensionScore` `0..100` + +Einflussfaktoren: + +- sichtbare Liebschaften +- anerkannte Liebschaften +- unterfinanzierte Liebschaften +- Kinder aus Liebschaften +- Haushaltsordnung +- Dienerschaft +- schwache Ehe + +UI-Ableitung: + +- `0..24` => `low` +- `25..59` => `medium` +- `60..100` => `high` + +## 4. Was der Daemon zurückschreiben soll + +Pflicht: + +- `relationship_state.marriage_satisfaction` +- `relationship_state.marriage_public_stability` +- `user_house.household_tension_score` +- `user_house.household_tension_reasons_json` +- lover-state-Felder bei Änderungen: + - `visibility` + - `discretion` + - `months_underfunded` + - optional `notes_json` / `flags_json` + +## 5. Socket-/Refresh-Verhalten + +Wenn Daily-/Monthly-Verarbeitung Ehe oder Hausfrieden betrifft: + +- `falukantUpdateFamily` mit `reason: "daily"` oder `reason: "monthly"` +- danach `falukantUpdateStatus` + +Wenn ein Sonderereignis entsteht: + +- `reason: "scandal"` zusätzlich + +## 6. Wichtige Phase-A-Regel + +Die neuen Direktaktionen geben nur Sofortimpulse: + +- `spend-time` +- `gift` +- `reconcile` +- `house/order` + +Der Daemon ist weiterhin verantwortlich für: + +- Rückdrift +- Gegenkräfte +- Langzeiteffekte +- Balancing + +Kurz: + +- UI/Backend setzen kleine direkte Impulse +- der Daemon bestimmt die dauerhafte Entwicklung diff --git a/docs/FALUKANT_PRODUCTION_CERTIFICATE_SPEC.md b/docs/FALUKANT_PRODUCTION_CERTIFICATE_SPEC.md new file mode 100644 index 0000000..1c1d19d --- /dev/null +++ b/docs/FALUKANT_PRODUCTION_CERTIFICATE_SPEC.md @@ -0,0 +1,472 @@ +# Falukant: Produktionszertifikate – Fach- und Integrationsspezifikation + +## 1. Ziel + +Das Produktionssystem soll stärker an den tatsächlichen gesellschaftlichen und fachlichen Fortschritt eines Spielers gebunden werden. Ein Spieler darf nur Produkte herstellen, deren Zertifikatsstufe seiner aktuellen Produktionsfreigabe entspricht. + +Die Zertifikatsstufe steigt nicht sofort bei jeder Einzelaktion, sondern wird ausschließlich im externen Daemon einmal täglich neu berechnet. + +Dieses Dokument beschreibt: + +- das fachliche Modell der Produktionszertifikate +- die Faktoren für Aufstieg und Begrenzung +- die Daily-Berechnung im Daemon +- die Kommunikation zwischen Daemon und UI +- die Einbindung in die bestehende Backend-/UI-Struktur + +Wichtig: + +- Die nötigen DB-Grundlagen sind bereits vorhanden. +- Der Daemon muss keine neuen Schemaänderungen erwarten. +- Bestehende Felder wie `falukant_data.falukant_user.certificate` und `falukant_type.product.category` bleiben die führende Basis. + +## 2. Bestehende technische Basis + +Bereits vorhanden: + +- `falukant_data.falukant_user.certificate` + - aktuelle Produktionsfreigabe des Spielers +- `falukant_type.product.category` + - erforderliche Zertifikatsstufe des Produkts +- `falukant_data.knowledge` + - Produktwissen je Charakter und Produkt +- `falukant_data.production` + - Produktionsvorgänge +- `falukant_data.character.reputation` + - Ansehen des Spielercharakters +- `falukant_data.character.title_of_nobility` + - Adelstitel +- `falukant_data.user_house.house_type_id` + - aktuelles Haus +- politische und kirchliche Ämter + - `falukant_data.political_office` + - `falukant_data.church_office` + - `falukant_log.political_office_history` + - `falukant_type.church_office_type.hierarchy_level` + +Bestehende Produktfreigabe im Backend: + +- Produkte werden bereits in `falukantService.getProducts()` über `product.category <= user.certificate` gefiltert. + +Das heißt: + +- Zertifikatslogik muss nicht neu in die Produktionsfreigabe eingebaut werden. +- Es muss nur die Berechnung und Fortschreibung von `falukant_user.certificate` sauber geregelt werden. + +## 3. Fachmodell + +### 3.1 Zertifikatsstufen + +Die Zertifikatsstufe bleibt eine einfache ganze Zahl im Feld `falukant_user.certificate`. + +Empfohlene Bedeutung: + +| Stufe | Bedeutung | +|------|-----------| +| `1` | Grundproduktion, einfache Güter | +| `2` | Gehobene Alltagsproduktion | +| `3` | Fortgeschrittene Manufaktur | +| `4` | Anspruchsvolle Qualitätsproduktion | +| `5` | Hochwertige oder prestigegebundene Produktion | + +Wenn im Typensystem bereits höhere `product.category`-Werte existieren, gilt dieselbe Logik entsprechend weiter. + +### 3.2 Führungsprinzip + +Die Zertifikatsstufe ist kein reines Wissenslevel. + +Sie soll ausdrücken, ob ein Haushalt/Betrieb gesellschaftlich und fachlich als ausreichend etabliert gilt, um komplexere Produktion zu verantworten. + +Darum fließen mehrere Faktoren ein: + +- Durchschnittliches Produktwissen +- Anzahl abgeschlossener Produktionen +- höchstes politisches oder kirchliches Amt +- Adelstitel +- Ansehen +- derzeitiges Haus + +## 4. Berechnungslogik + +## 4.1 Grundidee + +Der Daemon berechnet einmal täglich einen `certificateScore`. + +Aus diesem `certificateScore` wird eine Zielstufe `targetCertificate` abgeleitet. + +Die gespeicherte Stufe `falukant_user.certificate` wird dann höchstens um `+1` pro Tag angehoben. Senkungen sind optional und in der ersten Version nicht vorgesehen. + +Dadurch gilt: + +- Aufstieg ist spürbar, aber nicht sprunghaft +- kurzfristige Schwankungen führen nicht zu hektischen Freischaltungen +- Balancing bleibt später leichter + +## 4.2 Eingangsgrößen + +Für jeden Spielercharakter mit `falukant_user`: + +- `avgKnowledge` + - Durchschnitt aus `falukant_data.knowledge.knowledge` des Spielercharakters +- `completedProductions` + - Anzahl abgeschlossener Produktionen des Spielers +- `highestPoliticalOfficeRank` + - höchster politischer Amtsrang +- `highestChurchOfficeRank` + - höchster kirchlicher Amtsrang +- `highestOfficeRank` + - Maximum aus politischem und kirchlichem Rang +- `nobilityLevel` + - aus `title_of_nobility` +- `reputation` + - aus `character.reputation` +- `housePosition` + - aus `house.position` + +## 4.3 Normalisierung der Faktoren + +### Produktwissen + +`knowledgePoints`: + +- `0`, wenn `avgKnowledge < 20` +- `1`, wenn `avgKnowledge >= 20` +- `2`, wenn `avgKnowledge >= 35` +- `3`, wenn `avgKnowledge >= 50` +- `4`, wenn `avgKnowledge >= 65` +- `5`, wenn `avgKnowledge >= 80` + +### Produktionsmenge + +`productionPoints`: + +- `0`, wenn `completedProductions < 5` +- `1`, wenn `completedProductions >= 5` +- `2`, wenn `completedProductions >= 20` +- `3`, wenn `completedProductions >= 50` +- `4`, wenn `completedProductions >= 100` +- `5`, wenn `completedProductions >= 200` + +### Politische / kirchliche Stellung + +`officePoints`: + +- politische Ämter: + - über definierte Mapping-Tabelle im Daemon von `office_type.name -> rank` +- kirchliche Ämter: + - bevorzugt `church_office_type.hierarchy_level` +- dann: + - `highestOfficeRank = max(highestPoliticalOfficeRank, highestChurchOfficeRank)` + - `officePoints = min(5, highestOfficeRank)` + +Empfehlung für politische Mapping-Tabelle: + +- einfache Kommunalämter: `1` +- regionale Ämter: `2` +- hohe Regionalämter: `3` +- reichs- oder königsnahe Spitzenämter: `4` bis `5` + +Das Mapping lebt im Daemon und kann balanciert werden, ohne DB-Änderungen. + +### Adel + +`nobilityPoints`: + +- aus `title_of_nobility.level` +- `nobilityPoints = clamp(level - 1, 0, 5)` + +### Ansehen + +`reputationPoints`: + +- `0`, wenn `reputation < 20` +- `1`, wenn `reputation >= 20` +- `2`, wenn `reputation >= 40` +- `3`, wenn `reputation >= 60` +- `4`, wenn `reputation >= 75` +- `5`, wenn `reputation >= 90` + +### Haus + +`housePoints`: + +- aus `house.position` +- Vorschlag: + - `0`, wenn `position <= 1` + - `1`, wenn `position >= 2` + - `2`, wenn `position >= 4` + - `3`, wenn `position >= 6` + - `4`, wenn `position >= 8` + - `5`, wenn `position >= 10` + +Die genauen Schwellen können im Balancing später angepasst werden. + +## 4.4 Gesamtwert + +Der Daemon berechnet: + +```text +certificateScore = + knowledgePoints * 0.35 + + productionPoints * 0.20 + + officePoints * 0.15 + + nobilityPoints * 0.10 + + reputationPoints * 0.10 + + housePoints * 0.10 +``` + +Zusätzlich gelten Mindestanforderungen je Stufe. + +## 4.5 Mindestanforderungen je Zertifikatsstufe + +Eine höhere Zielstufe darf nur erreicht werden, wenn neben dem `certificateScore` auch harte Mindestgrenzen erfüllt sind. + +### Für Zertifikat 2 + +- `avgKnowledge >= 25` +- `completedProductions >= 5` + +### Für Zertifikat 3 + +- `avgKnowledge >= 40` +- `completedProductions >= 20` +- mindestens einer der Statusfaktoren erfüllt: + - `officePoints >= 1` + - oder `nobilityPoints >= 1` + - oder `reputationPoints >= 2` + - oder `housePoints >= 1` + +### Für Zertifikat 4 + +- `avgKnowledge >= 55` +- `completedProductions >= 60` +- mindestens zwei Statusfaktoren erfüllt + +### Für Zertifikat 5 + +- `avgKnowledge >= 70` +- `completedProductions >= 150` +- `reputationPoints >= 3` +- mindestens zwei der folgenden: + - `officePoints >= 2` + - `nobilityPoints >= 2` + - `housePoints >= 2` + +## 4.6 Ableitung der Zielstufe + +Vorschlag: + +- `targetCertificate = 1`, wenn `certificateScore < 1.2` +- `targetCertificate = 2`, wenn `certificateScore >= 1.2` +- `targetCertificate = 3`, wenn `certificateScore >= 2.1` +- `targetCertificate = 4`, wenn `certificateScore >= 3.0` +- `targetCertificate = 5`, wenn `certificateScore >= 4.0` + +Danach werden die Mindestanforderungen geprüft. + +Wenn eine Schwelle rechnerisch erreicht ist, die Mindestanforderungen aber fehlen, bleibt der Spieler auf der niedrigeren Zielstufe. + +## 4.7 Fortschreibung + +Daily-Regel: + +- wenn `targetCertificate > currentCertificate` + - dann `newCertificate = currentCertificate + 1` +- sonst + - `newCertificate = currentCertificate` + +Für die erste Version keine automatische Herabstufung. + +Ausnahmen, die bereits im Daemon berücksichtigt werden dürfen: + +- `Bankrott` + - Wenn der Spieler wirtschaftlich zusammenbricht, darf das Zertifikat gesenkt werden. + - Die genaue Definition von Bankrott lebt im Daemon bzw. im Wirtschaftssystem. +- `Tod ohne Kinder` + - Stirbt der Spielercharakter und es gibt keinen erbberechtigten Nachfolger, darf das Zertifikat auf den Grundzustand des Nachfolgesystems bzw. auf eine definierte niedrige Stufe zurückgesetzt werden. + - Dieser Fall darf bereits jetzt im Daemon umgesetzt werden. + +Nicht vorgesehen für die erste Version: + +- Downgrade wegen normaler Alltagsschwankungen +- Downgrade wegen vorübergehend schlechter Werte bei Wissen, Ruf, Haus oder Amt + +## 5. Daemon-Verhalten + +## 5.1 Ausführungszeitpunkt + +Die Zertifikatsprüfung läuft ausschließlich im Daily-Tick. + +Nicht bei: + +- Produktionsstart +- Produktionsende +- Wissensänderung +- Hauswechsel +- Amtswechsel + +Diese Aktionen verändern nur die Eingangsgrößen. Die eigentliche Zertifikatsanpassung erfolgt erst im nächsten Daily-Lauf. + +## 5.2 Daemon-Hinweis + +Für den Daemon gilt ausdrücklich: + +- die relevanten DB-Felder sind bereits vorhanden +- es müssen für diese Funktion keine zusätzlichen Schemaänderungen mehr eingeplant werden +- der Daemon soll direkt mit den vorhandenen Tabellen arbeiten + +## 5.3 Empfohlener Daily-Ablauf + +Für jeden Spieler mit `falukant_user`: + +1. Spielercharakter laden +2. `avgKnowledge` berechnen +3. Anzahl abgeschlossener Produktionen laden +4. höchstes aktives oder historisches politisches Amt ermitteln +5. höchstes aktives kirchliches Amt ermitteln +6. Adelstitel, Ruf und Haus laden +7. `certificateScore` und `targetCertificate` berechnen +8. falls Aufstieg möglich: + - `falukant_user.certificate` um genau `+1` erhöhen +9. Event an UI senden + +## 6. Event-Kommunikation zwischen Daemon und UI + +## 6.1 Neues Event + +Zusätzlich zum allgemeinen `falukantUpdateStatus` wird ein gezieltes Zertifikats-Event empfohlen: + +### `falukantUpdateProductionCertificate` + +Payload: + +```json +{ + "event": "falukantUpdateProductionCertificate", + "user_id": 123, + "reason": "daily_recalculation", + "old_certificate": 2, + "new_certificate": 3 +} +``` + +`reason` ist in der ersten Version fest: + +- `daily_recalculation` + +## 6.2 Wann senden + +Wenn sich die Zertifikatsstufe geändert hat: + +1. `falukantUpdateProductionCertificate` +2. danach `falukantUpdateStatus` + +Wenn sich die Stufe nicht geändert hat: + +- kein spezielles Zertifikats-Event nötig +- normales `falukantUpdateStatus` bleibt anderen Systemen vorbehalten + +## 6.3 UI-Reaktion + +### BranchView + +Bei `falukantUpdateProductionCertificate`: + +- Produkte neu laden +- Produktionsbereich neu laden +- optional kurzer Hinweis: + - „Neues Handelszertifikat erreicht“ + +### OverviewView + +Bei `falukantUpdateProductionCertificate`: + +- Falukant-Status neu laden +- Produktionsüberblick neu laden +- Zertifikatsaufstieg visuell hervorheben + +### StatusBar / DashboardWidget + +- auf denselben Nutzer filtern +- Zertifikat/Produktionsstatus neu laden + +## 6.4 Deduplizierung + +Da direkt nach dem Zertifikats-Event oft `falukantUpdateStatus` folgt, soll die UI wie bei anderen Falukant-Events entprellen: + +- beide Events dürfen denselben kurzen Refresh-Puffer nutzen +- ein Zertifikatsaufstieg darf keinen doppelten Reload-Sturm auslösen + +## 7. API- und UI-Empfehlungen + +## 7.1 Sichtbare Anzeige + +Die UI sollte mittelfristig anzeigen: + +- aktuelle Zertifikatsstufe +- nächste Stufe +- Fortschrittsfaktoren + - Wissen + - Produktionen + - Amt + - Adel + - Ruf + - Haus + +## 7.2 Empfohlene Backend-Ausgabe + +Zusätzlich zur bestehenden User-/Overview-API ist später sinnvoll: + +- `certificate` +- `nextCertificate` +- `certificateFactors` + - `avgKnowledge` + - `completedProductions` + - `highestOfficeRank` + - `nobilityLevel` + - `reputation` + - `housePosition` + +Das ist für die erste Daemon-Integration aber optional. + +## 8. Balancing-Hinweis + +Die genannten Schwellen und Gewichte sind bewusst als Spielregelrahmen zu verstehen, nicht als endgültiges Balancing. + +Für die erste produktive Version gilt: + +- keine zusätzlichen Stufen oder Nebensysteme +- keine normale Herabstufung im Alltagsbetrieb +- Herabstufung nur in Sonderfällen wie `Bankrott` oder `Tod ohne Kinder` +- Daily-Aufstieg maximal `+1` + +Balancing erst nach Live-Erfahrung. + +## 9. Umsetzungsreihenfolge + +### P1 + +- Daemon: Daily-Berechnung von `certificate` +- Event `falukantUpdateProductionCertificate` +- UI: gezielter Refresh in Branch/Overview + +### P2 + +- UI: Sichtbarer Zertifikatsstatus und Aufstiegshinweis +- Backend/API: optionale Faktor-Ausgabe + +### P3 + +- Balancing +- feinere Sonderfallregeln für `Bankrott` +- feinere politische Mapping-Tabelle + +## 10. Done-Kriterien + +Fertig ist die erste Version, wenn: + +- nur Produkte mit `product.category <= falukant_user.certificate` produzierbar sind +- der Daemon die Zertifikatsprüfung genau einmal täglich ausführt +- der Daemon bei Aufstieg das Zertifikat fortschreibt +- die UI auf das Zertifikats-Event gezielt reagiert +- keine neuen DB-Änderungen für diese Funktion nötig sind diff --git a/frontend/src/components/DashboardWidget.vue b/frontend/src/components/DashboardWidget.vue index 11496c1..3236452 100644 --- a/frontend/src/components/DashboardWidget.vue +++ b/frontend/src/components/DashboardWidget.vue @@ -108,7 +108,7 @@ export default { }, setupSocketListeners() { this.teardownSocketListeners(); - const daemonEvents = ['falukantUpdateStatus', 'falukantUpdateFamily', 'children_update', 'stock_change', 'familychanged']; + const daemonEvents = ['falukantUpdateStatus', 'falukantUpdateFamily', 'children_update', 'falukantUpdateProductionCertificate', 'stock_change', 'familychanged']; if (this.daemonSocket) { this._daemonMessageHandler = (event) => { if (event.data === 'ping') return; @@ -129,11 +129,15 @@ export default { this._childrenSocketHandler = (data) => { if (this.matchesCurrentUser(data)) this.queueFetchData(); }; + this._productionCertificateSocketHandler = (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('falukantUpdateProductionCertificate', this._productionCertificateSocketHandler); this.socket.on('falukantBranchUpdate', this._branchSocketHandler); } }, @@ -146,6 +150,7 @@ export default { 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._productionCertificateSocketHandler) this.socket.off('falukantUpdateProductionCertificate', this._productionCertificateSocketHandler); if (this._branchSocketHandler) this.socket.off('falukantBranchUpdate', this._branchSocketHandler); } }, diff --git a/frontend/src/components/falukant/StatusBar.vue b/frontend/src/components/falukant/StatusBar.vue index f453cb0..b5df39d 100644 --- a/frontend/src/components/falukant/StatusBar.vue +++ b/frontend/src/components/falukant/StatusBar.vue @@ -177,12 +177,14 @@ export default { 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._productionCertificateSocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateProductionCertificate', ...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('falukantUpdateProductionCertificate', this._productionCertificateSocketHandler); this.socket.on('stock_change', this._stockSocketHandler); this.socket.on('familychanged', this._familyChangedSocketHandler); }, @@ -191,6 +193,7 @@ export default { 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._productionCertificateSocketHandler) this.socket.off('falukantUpdateProductionCertificate', this._productionCertificateSocketHandler); if (this._stockSocketHandler) this.socket.off('stock_change', this._stockSocketHandler); if (this._familyChangedSocketHandler) this.socket.off('familychanged', this._familyChangedSocketHandler); } @@ -201,7 +204,7 @@ export default { this._daemonHandler = (event) => { try { const data = JSON.parse(event.data); - if (['falukantUpdateStatus', 'falukantUpdateFamily', 'children_update', 'stock_change', 'familychanged'].includes(data.event)) { + if (['falukantUpdateStatus', 'falukantUpdateFamily', 'children_update', 'falukantUpdateProductionCertificate', 'stock_change', 'familychanged'].includes(data.event)) { this.handleEvent(data); } } catch (_) {} @@ -241,6 +244,7 @@ export default { case 'falukantUpdateStatus': case 'falukantUpdateFamily': case 'children_update': + case 'falukantUpdateProductionCertificate': case 'stock_change': case 'familychanged': this.queueStatusRefresh(); diff --git a/frontend/src/dialogues/socialnetwork/UserProfileDialog.vue b/frontend/src/dialogues/socialnetwork/UserProfileDialog.vue index 487cbb6..729cd3b 100644 --- a/frontend/src/dialogues/socialnetwork/UserProfileDialog.vue +++ b/frontend/src/dialogues/socialnetwork/UserProfileDialog.vue @@ -235,7 +235,7 @@ export default { }); }, async fetchImage(image) { - const userId = localStorage.getItem('userid'); + const userId = localStorage.getItem('userid') || sessionStorage.getItem('userid'); try { const response = await apiClient.get(`/api/socialnetwork/image/${image.hash}`, { headers: { diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index 02d3e59..b7ba08a 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -130,7 +130,8 @@ "years": "Jahre", "days": "Tage", "mainbranch": "Heimatstadt", - "nobleTitle": "Stand" + "nobleTitle": "Stand", + "certificate": "Zertifikat" }, "productions": { "title": "Produktionen" @@ -219,6 +220,7 @@ }, "branch": { "title": "Filiale", + "currentCertificate": "Derzeitiges Zertifikat", "tabs": { "director": "Direktor", "inventory": "Inventar", @@ -526,9 +528,34 @@ }, "householdTension": { "label": "Hausfrieden", + "score": "Spannungswert", + "reasonsLabel": "Aktuelle Ursachen", "low": "Ruhig", "medium": "Unruhig", - "high": "Belastet" + "high": "Belastet", + "reasons": { + "visibleLover": "Sichtbare Liebschaft", + "noticeableLover": "Auffällige Liebschaft", + "underfundedLover": "Unterversorgte Liebschaft", + "acknowledgedAffair": "Anerkannte Liebschaft", + "statusMismatch": "Standesunterschied", + "loverChild": "Kind aus Liebschaft", + "disorder": "Unordnung im Haus", + "tooFewServants": "Zu wenig Diener", + "marriageCrisis": "Ehekrise" + } + }, + "marriageActions": { + "title": "Ehe pflegen", + "spendTime": "Zeit miteinander verbringen", + "giftSmall": "Kleines Geschenk", + "giftDecent": "Gutes Geschenk", + "giftLavish": "Großzügiges Geschenk", + "reconcile": "Streit schlichten", + "spendTimeSuccess": "Die gemeinsame Zeit hat die Ehe stabilisiert.", + "giftSuccess": "Das Geschenk hat die Ehe verbessert.", + "reconcileSuccess": "Der Streit wurde fürs Erste geschlichtet.", + "actionError": "Die Aktion konnte nicht ausgeführt werden." }, "relationships": { "name": "Name" @@ -863,12 +890,15 @@ "actions": { "hire": "1 Diener einstellen", "dismiss": "1 Diener entlassen", + "tidy": "Haus ordnen", "hireSuccess": "Die Dienerschaft wurde erweitert.", "hireError": "Die Dienerschaft konnte nicht erweitert werden.", "dismissSuccess": "Ein Diener wurde entlassen.", "dismissError": "Der Diener konnte nicht entlassen werden.", "payLevelSuccess": "Die Bezahlung der Dienerschaft wurde angepasst.", - "payLevelError": "Die Bezahlung konnte nicht angepasst werden." + "payLevelError": "Die Bezahlung konnte nicht angepasst werden.", + "tidySuccess": "Das Haus wurde geordnet.", + "tidyError": "Das Haus konnte nicht geordnet werden." } }, "status": { diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json index 2442cf7..a256719 100644 --- a/frontend/src/i18n/locales/en/falukant.json +++ b/frontend/src/i18n/locales/en/falukant.json @@ -111,7 +111,8 @@ "years": "Years", "days": "Days", "mainbranch": "Home city", - "nobleTitle": "Title" + "nobleTitle": "Title", + "certificate": "Certificate" } }, "health": { @@ -228,12 +229,15 @@ "actions": { "hire": "Hire 1 servant", "dismiss": "Dismiss 1 servant", + "tidy": "Tidy household", "hireSuccess": "The household staff has been expanded.", "hireError": "The staff could not be expanded.", "dismissSuccess": "A servant has been dismissed.", "dismissError": "The servant could not be dismissed.", "payLevelSuccess": "Servant pay has been updated.", - "payLevelError": "Servant pay could not be updated." + "payLevelError": "Servant pay could not be updated.", + "tidySuccess": "The household has been put in order.", + "tidyError": "The household could not be put in order." } }, "status": { @@ -261,6 +265,7 @@ "noProposals": "No director candidates available." }, "branch": { + "currentCertificate": "Current certificate", "selection": { "title": "Branch Selection", "selected": "Selected Branch", @@ -540,9 +545,34 @@ }, "householdTension": { "label": "Household Tension", + "score": "Tension score", + "reasonsLabel": "Current causes", "low": "Calm", "medium": "Uneasy", - "high": "Strained" + "high": "Strained", + "reasons": { + "visibleLover": "Visible affair", + "noticeableLover": "Noticeable affair", + "underfundedLover": "Underfunded affair", + "acknowledgedAffair": "Acknowledged affair", + "statusMismatch": "Status mismatch", + "loverChild": "Child from an affair", + "disorder": "Disorder in the house", + "tooFewServants": "Too few servants", + "marriageCrisis": "Marriage crisis" + } + }, + "marriageActions": { + "title": "Support the marriage", + "spendTime": "Spend time together", + "giftSmall": "Small gift", + "giftDecent": "Decent gift", + "giftLavish": "Lavish gift", + "reconcile": "Reconcile dispute", + "spendTimeSuccess": "The time together has stabilized the marriage.", + "giftSuccess": "The gift has improved the marriage.", + "reconcileSuccess": "The dispute has been eased for now.", + "actionError": "The action could not be completed." }, "lovers": { "title": "Lovers and Mistresses", diff --git a/frontend/src/i18n/locales/es/falukant.json b/frontend/src/i18n/locales/es/falukant.json index a5afa70..92a43b2 100644 --- a/frontend/src/i18n/locales/es/falukant.json +++ b/frontend/src/i18n/locales/es/falukant.json @@ -120,7 +120,8 @@ "age": "Edad", "years": "años", "mainbranch": "Ciudad natal", - "nobleTitle": "Rango" + "nobleTitle": "Rango", + "certificate": "Certificado" }, "productions": { "title": "Producciones" @@ -207,6 +208,7 @@ }, "branch": { "title": "Sucursal", + "currentCertificate": "Certificado actual", "tabs": { "director": "Director", "inventory": "Inventario", @@ -510,9 +512,34 @@ }, "householdTension": { "label": "Tensión del hogar", + "score": "Valor de tensión", + "reasonsLabel": "Causas actuales", "low": "Calmo", "medium": "Inquieto", - "high": "Tenso" + "high": "Tenso", + "reasons": { + "visibleLover": "Relación visible", + "noticeableLover": "Relación llamativa", + "underfundedLover": "Relación infrafinanciada", + "acknowledgedAffair": "Relación reconocida", + "statusMismatch": "Desajuste social", + "loverChild": "Hijo de una relación", + "disorder": "Desorden en la casa", + "tooFewServants": "Muy pocos sirvientes", + "marriageCrisis": "Crisis matrimonial" + } + }, + "marriageActions": { + "title": "Cuidar el matrimonio", + "spendTime": "Pasar tiempo juntos", + "giftSmall": "Regalo pequeño", + "giftDecent": "Buen regalo", + "giftLavish": "Regalo generoso", + "reconcile": "Resolver disputa", + "spendTimeSuccess": "El tiempo compartido ha estabilizado el matrimonio.", + "giftSuccess": "El regalo ha mejorado el matrimonio.", + "reconcileSuccess": "La disputa se ha calmado por ahora.", + "actionError": "No se pudo realizar la acción." }, "relationships": { "name": "Nombre" @@ -829,12 +856,15 @@ "actions": { "hire": "Contratar 1 sirviente", "dismiss": "Despedir 1 sirviente", + "tidy": "Ordenar la casa", "hireSuccess": "Se ha ampliado el servicio doméstico.", "hireError": "No se pudo ampliar el servicio doméstico.", "dismissSuccess": "Se ha despedido a un sirviente.", "dismissError": "No se pudo despedir al sirviente.", "payLevelSuccess": "Se ha ajustado el pago del servicio.", - "payLevelError": "No se pudo ajustar el pago." + "payLevelError": "No se pudo ajustar el pago.", + "tidySuccess": "La casa ha sido ordenada.", + "tidyError": "No se pudo ordenar la casa." } }, "status": { diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index 1b3df2f..0a8ea0b 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -6,12 +6,44 @@ import apiClient from '../utils/axios.js'; import { io } from 'socket.io-client'; import { getDaemonSocketUrl, getSocketIoUrl } from '../utils/appConfig.js'; +const AUTH_KEYS = ['isLoggedIn', 'user', 'userid']; + +function getStoredValue(key) { + return localStorage.getItem(key) ?? sessionStorage.getItem(key); +} + +function getStoredUser() { + const storedUser = getStoredValue('user'); + if (!storedUser) return null; + + try { + return JSON.parse(storedUser); + } catch (_) { + return null; + } +} + +function clearAuthStorage() { + AUTH_KEYS.forEach((key) => { + localStorage.removeItem(key); + sessionStorage.removeItem(key); + }); +} + +function persistAuthStorage(user, rememberMe) { + const targetStorage = rememberMe ? localStorage : sessionStorage; + clearAuthStorage(); + targetStorage.setItem('isLoggedIn', 'true'); + targetStorage.setItem('user', JSON.stringify(user)); + targetStorage.setItem('userid', user?.id || ''); +} + const store = createStore({ state: { - isLoggedIn: localStorage.getItem('isLoggedIn') === 'true', + isLoggedIn: getStoredValue('isLoggedIn') === 'true', connectionStatus: 'disconnected', // 'connected', 'connecting', 'disconnected', 'error' daemonConnectionStatus: 'disconnected', // 'connected', 'connecting', 'disconnected', 'error' - user: JSON.parse(localStorage.getItem('user')) || null, + user: getStoredUser(), // Reconnect state management backendRetryCount: 0, daemonRetryCount: 0, @@ -52,11 +84,12 @@ const store = createStore({ menuNeedsUpdate: false, }, mutations: { - async dologin(state, user) { + async dologin(state, payload) { + const loginPayload = payload?.user ? payload : { user: payload, rememberMe: true }; + const { user, rememberMe = true } = loginPayload; state.isLoggedIn = true; state.user = user; - localStorage.setItem('isLoggedIn', 'true'); - localStorage.setItem('user', JSON.stringify(user)); + persistAuthStorage(user, rememberMe); state.menuNeedsUpdate = true; if (user.param.filter(param => ['birthdate', 'gender'].includes(param.name)).length < 2) { router.push({ path: '/settings/personal' }); @@ -65,8 +98,7 @@ const store = createStore({ async dologout(state) { state.isLoggedIn = false; state.user = null; - localStorage.removeItem('isLoggedIn'); - localStorage.removeItem('user'); + clearAuthStorage(); localStorage.removeItem('menu'); state.menuNeedsUpdate = false; @@ -145,8 +177,8 @@ const store = createStore({ }, }, actions: { - async login({ commit, dispatch }, user) { - await commit('dologin', user); + async login({ commit, dispatch }, payload) { + await commit('dologin', payload); await dispatch('initializeSocket'); await dispatch('initializeDaemonSocket'); const socket = this.getters.socket; diff --git a/frontend/src/views/falukant/BranchView.vue b/frontend/src/views/falukant/BranchView.vue index b62a430..6e094d1 100644 --- a/frontend/src/views/falukant/BranchView.vue +++ b/frontend/src/views/falukant/BranchView.vue @@ -7,6 +7,11 @@ Niederlassung

{{ $t('falukant.branch.title') }}

Produktion, Lager, Verkauf und Transport in einer spielweltbezogenen Steuerflaeche.

+
+ + {{ $t('falukant.branch.currentCertificate') }}: {{ currentCertificate ?? '---' }} + +
@@ -398,11 +403,13 @@ export default { branchTaxes: null, branchTaxesLoading: false, branchTaxesError: null, + currentCertificate: null, + pendingBranchRefresh: null, }; }, computed: { - ...mapState(['socket', 'daemonSocket']), + ...mapState(['socket', 'daemonSocket', 'user']), freeVehiclesByType() { const grouped = {}; for (const v of this.vehicles || []) { @@ -436,6 +443,7 @@ export default { await this.loadBranches(); const branchId = this.$route.params.branchId; + await this.loadCurrentCertificate(); await this.loadProducts(); if (branchId) { @@ -454,6 +462,7 @@ export default { // Live-Socket-Events (Backend Socket.io) if (this.socket) { this.socket.on('falukantUpdateStatus', (data) => this.handleEvent({ event: 'falukantUpdateStatus', ...data })); + this.socket.on('falukantUpdateProductionCertificate', (data) => this.handleEvent({ event: 'falukantUpdateProductionCertificate', ...data })); this.socket.on('falukantBranchUpdate', (data) => this.handleEvent({ event: 'falukantBranchUpdate', ...data })); this.socket.on('transport_arrived', (data) => this.handleEvent({ event: 'transport_arrived', ...data })); this.socket.on('inventory_updated', (data) => this.handleEvent({ event: 'inventory_updated', ...data })); @@ -463,12 +472,17 @@ export default { }, beforeUnmount() { + if (this.pendingBranchRefresh) { + clearTimeout(this.pendingBranchRefresh); + this.pendingBranchRefresh = null; + } // Daemon WebSocket: Listener entfernen (der Socket selbst wird beim Logout geschlossen) if (this.daemonSocket) { this.daemonSocket.removeEventListener('message', this.handleDaemonMessage); } if (this.socket) { this.socket.off('falukantUpdateStatus'); + this.socket.off('falukantUpdateProductionCertificate'); this.socket.off('falukantBranchUpdate'); this.socket.off('transport_arrived'); this.socket.off('inventory_updated'); @@ -493,6 +507,34 @@ export default { }, 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)); + }, + queueBranchRefresh() { + if (this.pendingBranchRefresh) { + clearTimeout(this.pendingBranchRefresh); + } + this.pendingBranchRefresh = setTimeout(async () => { + this.pendingBranchRefresh = null; + this.$refs.statusBar?.fetchStatus(); + await this.loadCurrentCertificate(); + await this.loadProducts(); + this.$refs.productionSection?.loadProductions(); + this.$refs.productionSection?.loadStorage(); + this.$refs.storageSection?.loadStorageData(); + this.$refs.saleSection?.loadInventory(); + if (this.$refs.revenueSection) { + this.$refs.revenueSection.products = this.products; + this.$refs.revenueSection.refresh && this.$refs.revenueSection.refresh(); + } + }, 120); + }, async loadBranches() { try { const result = await apiClient.get('/api/falukant/branches'); @@ -512,6 +554,14 @@ export default { console.error('Error loading branches:', error); } }, + async loadCurrentCertificate() { + try { + const result = await apiClient.get('/api/falukant/user'); + this.currentCertificate = result.data?.certificate ?? null; + } catch (error) { + console.error('Error loading certificate:', error); + } + }, async loadProducts() { try { @@ -771,6 +821,9 @@ export default { }, handleEvent(eventData) { + if (!this.matchesCurrentUser(eventData)) { + return; + } switch (eventData.event) { case 'production_ready': this.$refs.productionSection?.loadProductions(); @@ -798,30 +851,12 @@ export default { this.$refs.productionSection?.loadStorage(); break; case 'falukantUpdateStatus': + case 'falukantUpdateProductionCertificate': case 'falukantBranchUpdate': - if (this.$refs.statusBar) { - this.$refs.statusBar.fetchStatus(); - } - - if (this.$refs.productionSection) { - this.$refs.productionSection.loadProductions(); - this.$refs.productionSection.loadStorage(); - } - - if (this.$refs.storageSection) { - this.$refs.storageSection.loadStorageData(); - } - - if (this.$refs.saleSection) { - this.$refs.saleSection.loadInventory(); - } + this.queueBranchRefresh(); break; case 'knowledge_update': - this.loadProducts(); - if (this.$refs.revenueSection) { - this.$refs.revenueSection.products = this.products; - this.$refs.revenueSection.refresh && this.$refs.revenueSection.refresh(); - } + this.queueBranchRefresh(); break; case 'transport_arrived': // Leerer Transport angekommen - Fahrzeug wurde zurückgeholt @@ -1149,6 +1184,22 @@ export default { color: var(--color-text-secondary); } +.branch-hero__meta { + margin-top: 12px; +} + +.branch-hero__badge { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border: 1px solid rgba(138, 84, 17, 0.16); + border-radius: 999px; + background: rgba(255, 255, 255, 0.7); + color: #7a4b12; + font-size: 0.9rem; + font-weight: 600; +} + .branch-tab-content { margin-top: 16px; padding: 18px; diff --git a/frontend/src/views/falukant/FamilyView.vue b/frontend/src/views/falukant/FamilyView.vue index b4195a1..594de2f 100644 --- a/frontend/src/views/falukant/FamilyView.vue +++ b/frontend/src/views/falukant/FamilyView.vue @@ -149,6 +149,39 @@ {{ $t('falukant.family.householdTension.' + householdTension) }} +
+ {{ $t('falukant.family.householdTension.score') }} + {{ householdTensionScore }} +
+ + +
+

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

+
+ + + + + +
+
+ {{ $t('falukant.family.householdTension.reasonsLabel') }} +
+ + {{ $t('falukant.family.householdTension.reasons.' + reason) }} + +
+
@@ -351,6 +384,8 @@ export default { marriageSatisfaction: null, marriageState: null, householdTension: null, + householdTensionScore: null, + householdTensionReasons: [], selectedChild: null, pendingFamilyRefresh: null } @@ -495,11 +530,46 @@ export default { this.marriageSatisfaction = response.data.marriageSatisfaction; this.marriageState = response.data.marriageState; this.householdTension = response.data.householdTension; + this.householdTensionScore = response.data.householdTensionScore; + this.householdTensionReasons = response.data.householdTensionReasons || []; } catch (error) { console.error('Error loading family data:', error); } }, + async spendTimeWithSpouse() { + try { + await apiClient.post('/api/falukant/family/marriage/spend-time'); + await this.loadFamilyData(); + showSuccess(this, this.$t('falukant.family.marriageActions.spendTimeSuccess')); + } catch (error) { + console.error('Error spending time with spouse:', error); + showError(this, this.$t('falukant.family.marriageActions.actionError')); + } + }, + + async giftToSpouse(giftLevel) { + try { + await apiClient.post('/api/falukant/family/marriage/gift', { giftLevel }); + await this.loadFamilyData(); + showSuccess(this, this.$t('falukant.family.marriageActions.giftSuccess')); + } catch (error) { + console.error('Error gifting spouse:', error); + showError(this, this.$t('falukant.family.marriageActions.actionError')); + } + }, + + async reconcileMarriage() { + try { + await apiClient.post('/api/falukant/family/marriage/reconcile'); + await this.loadFamilyData(); + showSuccess(this, this.$t('falukant.family.marriageActions.reconcileSuccess')); + } catch (error) { + console.error('Error reconciling marriage:', error); + showError(this, this.$t('falukant.family.marriageActions.actionError')); + } + }, + async loadOwnCharacter() { try { const response = await apiClient.get('/api/falukant/user'); @@ -814,6 +884,39 @@ export default { font-size: 0.88rem; } +.marriage-actions { + display: grid; + gap: 12px; + margin-bottom: 18px; + padding: 16px 18px; +} + +.marriage-actions h3 { + margin: 0; +} + +.marriage-actions__buttons { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.marriage-actions__reasons { + display: grid; + gap: 8px; +} + +.marriage-actions__reasons-label { + color: var(--color-text-secondary); + font-size: 0.88rem; +} + +.marriage-actions__reason-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + .inline-status-pill { display: inline-flex; align-items: center; diff --git a/frontend/src/views/falukant/HouseView.vue b/frontend/src/views/falukant/HouseView.vue index b9a8129..e5e47b2 100644 --- a/frontend/src/views/falukant/HouseView.vue +++ b/frontend/src/views/falukant/HouseView.vue @@ -47,6 +47,9 @@ +
@@ -71,6 +74,10 @@ {{ $t('falukant.house.servants.householdOrder') }} {{ userHouse.householdOrder || 0 }} +
+ {{ $t('falukant.family.householdTension.score') }} + {{ userHouse.householdTensionScore ?? 0 }} +
{{ $t('falukant.house.servants.staffingState.label') }} {{ $t(`falukant.house.servants.staffingState.${servantSummary.staffingState || 'fitting'}`) }} @@ -91,6 +98,14 @@ {{ $t(`falukant.house.servants.orderState.${servantSummary.orderState || 'stable'}`) }} +
+ {{ $t('falukant.family.householdTension.reasonsLabel') }} +
+ + {{ $t('falukant.family.householdTension.reasons.' + reason) }} + +
+
@@ -299,6 +314,16 @@ export default { showError(this, this.$t('falukant.house.servants.actions.payLevelError')); } }, + async tidyHousehold() { + try { + await apiClient.post('/api/falukant/houses/order'); + await this.loadData(); + showSuccess(this, this.$t('falukant.house.servants.actions.tidySuccess')); + } catch (err) { + console.error('Error tidying household', err); + showError(this, this.$t('falukant.house.servants.actions.tidyError')); + } + }, handleDaemonMessage(evt) { try { const msg = JSON.parse(evt.data); @@ -344,6 +369,17 @@ export default { display: flex; flex-direction: column; gap: 20px; + /* AppContent gibt dem letzten Kind flex:1 + min-height:0 — sonst schrumpfen + Spalten-Kinder und überlagern sich (z. B. „Dienerschaft“ unter „Kaufe ein Haus“). */ + flex: 0 0 auto; + min-height: min-content; + width: 100%; +} + +.existing-house, +.servants-panel, +.buyable-houses { + flex-shrink: 0; } h2 { @@ -442,6 +478,34 @@ h2 { color: var(--color-text-secondary); } +.servants-reasons { + display: grid; + gap: 8px; + margin-top: 16px; +} + +.servants-reasons__label { + color: var(--color-text-secondary); + font-size: 0.88rem; +} + +.servants-reasons__list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.servants-reasons__badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + background: rgba(188, 84, 61, 0.12); + color: #9a3c26; + font-size: 0.8rem; + font-weight: 700; +} + .buyable-houses { display: flex; flex-direction: column; diff --git a/frontend/src/views/falukant/OverviewView.vue b/frontend/src/views/falukant/OverviewView.vue index 813b52b..5f4f559 100644 --- a/frontend/src/views/falukant/OverviewView.vue +++ b/frontend/src/views/falukant/OverviewView.vue @@ -24,6 +24,11 @@
+
+ {{ $t('falukant.overview.metadata.certificate') }} + {{ falukantUser?.certificate ?? '---' }} +

Bestimmt, welche Produktkategorien du derzeit herstellen darfst.

+
Niederlassungen {{ branchCount }} @@ -109,6 +114,10 @@ {{ $t('falukant.overview.metadata.mainbranch') }} {{ falukantUser?.mainBranchRegion?.name }} +
+ {{ $t('falukant.overview.metadata.certificate') }} + {{ falukantUser?.certificate ?? '---' }} +
@@ -347,6 +356,7 @@ export default { this.socket.off("falukantUserUpdated", this.fetchFalukantUser); this.socket.off("falukantUpdateStatus"); this.socket.off("falukantUpdateFamily"); + this.socket.off("falukantUpdateProductionCertificate"); this.socket.off("children_update"); this.socket.off("falukantBranchUpdate"); this.socket.off("stock_change"); @@ -362,6 +372,9 @@ export default { this.socket.on("falukantUpdateFamily", (data) => { this.handleEvent({ event: 'falukantUpdateFamily', ...data }); }); + this.socket.on("falukantUpdateProductionCertificate", (data) => { + this.handleEvent({ event: 'falukantUpdateProductionCertificate', ...data }); + }); this.socket.on("children_update", (data) => { this.handleEvent({ event: 'children_update', ...data }); }); @@ -428,6 +441,7 @@ export default { switch (eventData.event) { case 'falukantUpdateStatus': case 'falukantUpdateFamily': + case 'falukantUpdateProductionCertificate': case 'children_update': case 'falukantBranchUpdate': this.queueOverviewRefresh(); diff --git a/frontend/src/views/home/NoLoginView.vue b/frontend/src/views/home/NoLoginView.vue index 0f28fbd..a9919e3 100644 --- a/frontend/src/views/home/NoLoginView.vue +++ b/frontend/src/views/home/NoLoginView.vue @@ -57,7 +57,6 @@

{{ $t('home.nologin.login.submit') }}

-
@@ -69,9 +68,12 @@ :title="$t('home.nologin.login.passworddescription')" @keydown.enter="doLogin" ref="passwordInput"> +
@@ -112,6 +114,7 @@ export default { return { username: '', password: '', + rememberMe: true, }; }, components: { @@ -140,7 +143,7 @@ export default { async doLogin() { try { const response = await apiClient.post('/api/auth/login', { username: this.username, password: this.password }); - this.login(response.data); + this.login({ user: response.data, rememberMe: this.rememberMe }); } catch (error) { const errorKey = error?.response?.data?.error || 'network'; this.$root.$refs.errorDialog.open(`tr:error.${errorKey}`); @@ -326,6 +329,11 @@ export default { gap: 0.65rem; } +.login-submit-row { + display: flex; + justify-content: flex-start; +} + .primary-action, .secondary-action { align-self: flex-start; diff --git a/frontend/src/views/minigames/TaxiGame.vue b/frontend/src/views/minigames/TaxiGame.vue index b1d9604..8bb8bef 100644 --- a/frontend/src/views/minigames/TaxiGame.vue +++ b/frontend/src/views/minigames/TaxiGame.vue @@ -3131,7 +3131,7 @@ export default { console.log('User username:', this.$store?.state?.user?.username); const highscoreData = { - userId: this.$store?.state?.user?.id || localStorage.getItem('userid') || 'guest', + userId: this.$store?.state?.user?.id || localStorage.getItem('userid') || sessionStorage.getItem('userid') || 'guest', nickname: this.$store?.state?.user?.nickname || this.$store?.state?.user?.name || this.$store?.state?.user?.username || 'Gast', passengersDelivered: this.passengersDelivered, playtime: playTime, diff --git a/frontend/src/views/social/GalleryView.vue b/frontend/src/views/social/GalleryView.vue index e015a7e..f4420a3 100644 --- a/frontend/src/views/social/GalleryView.vue +++ b/frontend/src/views/social/GalleryView.vue @@ -205,7 +205,7 @@ export default { } }, async fetchImage(image) { - const userId = localStorage.getItem('userid'); + const userId = localStorage.getItem('userid') || sessionStorage.getItem('userid'); try { const response = await apiClient.get(`/api/socialnetwork/image/${image.hash}`, { headers: {