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

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

View File

@@ -37,6 +37,15 @@
<td>{{ $t('falukant.family.spouse.status') }}</td>
<td>{{ $t('falukant.family.statuses.' + relationships[0].relationshipType) }}</td>
</tr>
<tr v-if="relationships[0].marriageSatisfaction != null">
<td>{{ $t('falukant.family.spouse.marriageSatisfaction') }}</td>
<td>
{{ relationships[0].marriageSatisfaction }}
<span class="inline-status-pill" :class="`inline-status-pill--${relationships[0].marriageState || 'stable'}`">
{{ $t('falukant.family.marriageState.' + (relationships[0].marriageState || 'stable')) }}
</span>
</td>
</tr>
<tr v-if="relationships[0].relationshipType === 'wooing'">
<td>{{ $t('falukant.family.spouse.progress') }}</td>
<td>
@@ -123,6 +132,25 @@
</div>
</div>
<section v-if="marriageSatisfaction != null || householdTension" class="marriage-overview surface-card">
<div class="marriage-overview__item" v-if="marriageSatisfaction != null">
<span class="marriage-overview__label">{{ $t('falukant.family.spouse.marriageSatisfaction') }}</span>
<strong>{{ marriageSatisfaction }}</strong>
</div>
<div class="marriage-overview__item" v-if="marriageState">
<span class="marriage-overview__label">{{ $t('falukant.family.spouse.marriageState') }}</span>
<span class="inline-status-pill" :class="`inline-status-pill--${marriageState}`">
{{ $t('falukant.family.marriageState.' + marriageState) }}
</span>
</div>
<div class="marriage-overview__item" v-if="householdTension">
<span class="marriage-overview__label">{{ $t('falukant.family.householdTension.label') }}</span>
<span class="inline-status-pill" :class="`inline-status-pill--${householdTension}`">
{{ $t('falukant.family.householdTension.' + householdTension) }}
</span>
</div>
</section>
<div class="children-section">
<h3>{{ $t('falukant.family.children.title') }}</h3>
<div v-if="children && children.length > 0" class="children-container">
@@ -141,6 +169,9 @@
<tr v-for="(child, index) in children" :key="index">
<td v-if="child.hasName">
{{ child.name }}
<span v-if="child.legitimacy && child.legitimacy !== 'legitimate'" class="child-origin-badge">
{{ $t('falukant.family.children.legitimacy.' + child.legitimacy) }}
</span>
</td>
<td v-else>
<button @click="jumpToChurchForm">{{ $t('falukant.family.children.baptism')
@@ -177,17 +208,106 @@
<!-- Liebhaber / Geliebte -->
<div class="lovers-section">
<h3>{{ $t('falukant.family.lovers.title') }}</h3>
<div v-if="lovers && lovers.length > 0">
<ul>
<li v-for="(lover, idx) in lovers" :key="idx">
{{ $t('falukant.titles.' + lover.gender + '.' + lover.title) }} {{ lover.name }}
({{ $t('falukant.family.lovers.affection') }}: {{ lover.affection }})
</li>
</ul>
<div v-if="lovers && lovers.length > 0" class="lovers-grid">
<article v-for="lover in lovers" :key="lover.relationshipId" class="lover-card surface-card">
<div class="lover-card__header">
<div>
<strong>{{ $t('falukant.titles.' + lover.gender + '.' + lover.title) }} {{ lover.name }}</strong>
<div class="lover-card__role">
{{ $t('falukant.family.lovers.role.' + (lover.role || 'lover')) }}
</div>
</div>
<span class="inline-status-pill" :class="`inline-status-pill--${lover.riskState || 'low'}`">
{{ $t('falukant.family.lovers.risk.' + (lover.riskState || 'low')) }}
</span>
</div>
<dl class="lover-card__stats">
<div>
<dt>{{ $t('falukant.family.lovers.affection') }}</dt>
<dd>{{ lover.affection }}</dd>
</div>
<div>
<dt>{{ $t('falukant.family.lovers.visibility') }}</dt>
<dd>{{ lover.visibility }}</dd>
</div>
<div>
<dt>{{ $t('falukant.family.lovers.discretion') }}</dt>
<dd>{{ lover.discretion }}</dd>
</div>
<div>
<dt>{{ $t('falukant.family.lovers.maintenance') }}</dt>
<dd>{{ lover.maintenanceLevel }}</dd>
</div>
<div>
<dt>{{ $t('falukant.family.lovers.monthlyCost') }}</dt>
<dd>{{ formatCost(lover.monthlyCost || 0) }}</dd>
</div>
<div>
<dt>{{ $t('falukant.family.lovers.statusFit') }}</dt>
<dd>{{ lover.statusFit }}</dd>
</div>
</dl>
<div class="lover-card__meta">
<span v-if="lover.acknowledged" class="lover-meta-badge">
{{ $t('falukant.family.lovers.acknowledged') }}
</span>
<span v-if="lover.monthsUnderfunded > 0" class="lover-meta-badge lover-meta-badge--warning">
{{ $t('falukant.family.lovers.underfunded', { count: lover.monthsUnderfunded }) }}
</span>
</div>
<div class="lover-card__actions">
<button class="button button--secondary" @click="setLoverMaintenance(lover, 25)">
{{ $t('falukant.family.lovers.actions.maintenanceLow') }}
</button>
<button class="button button--secondary" @click="setLoverMaintenance(lover, 50)">
{{ $t('falukant.family.lovers.actions.maintenanceMedium') }}
</button>
<button class="button button--secondary" @click="setLoverMaintenance(lover, 75)">
{{ $t('falukant.family.lovers.actions.maintenanceHigh') }}
</button>
<button
v-if="!lover.acknowledged"
class="button button--secondary"
@click="acknowledgeLover(lover)"
>
{{ $t('falukant.family.lovers.actions.acknowledge') }}
</button>
<button class="button button--danger" @click="endLoverRelationship(lover)">
{{ $t('falukant.family.lovers.actions.end') }}
</button>
</div>
</article>
</div>
<div v-else>
<p>{{ $t('falukant.family.lovers.none') }}</p>
</div>
<div class="lover-candidates surface-card">
<h4>{{ $t('falukant.family.lovers.candidates.title') }}</h4>
<div v-if="possibleLovers && possibleLovers.length > 0" class="lover-candidates__grid">
<article v-for="candidate in possibleLovers" :key="candidate.characterId" class="lover-candidate-card">
<div class="lover-candidate-card__main">
<strong>{{ $t('falukant.titles.' + candidate.gender + '.' + candidate.title) }} {{ candidate.name }}</strong>
<span>{{ $t('falukant.family.spouse.age') }}: {{ candidate.age }}</span>
<span>{{ $t('falukant.family.lovers.statusFit') }}: {{ candidate.statusFit }}</span>
<span>{{ $t('falukant.family.lovers.monthlyCost') }}: {{ formatCost(candidate.estimatedMonthlyCost || 0) }}</span>
</div>
<div class="lover-candidate-card__actions">
<label class="lover-candidate-card__label">
{{ $t('falukant.family.lovers.candidates.roleLabel') }}
</label>
<select class="lover-candidate-card__select" v-model="candidateRoles[candidate.characterId]">
<option value="secret_affair">{{ $t('falukant.family.lovers.role.secret_affair') }}</option>
<option value="lover">{{ $t('falukant.family.lovers.role.lover') }}</option>
<option value="mistress_or_favorite">{{ $t('falukant.family.lovers.role.mistress_or_favorite') }}</option>
</select>
<button class="button button--secondary" @click="createLoverRelationship(candidate)">
{{ $t('falukant.family.lovers.actions.start') }}
</button>
</div>
</article>
</div>
<p v-else>{{ $t('falukant.family.lovers.candidates.none') }}</p>
</div>
</div>
</div>
</div>
@@ -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;