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

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

View File

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

View File

@@ -213,10 +213,11 @@ export default {
productions: [],
potentialHeirs: [],
loadingHeirs: false,
pendingOverviewRefresh: null,
};
},
computed: {
...mapState(['socket', 'daemonSocket']),
...mapState(['socket', 'daemonSocket', 'user']),
getAvatarStyle() {
if (!this.falukantUser || !this.falukantUser.character) return {};
const { gender, age } = this.falukantUser.character;
@@ -335,12 +336,18 @@ export default {
}
},
beforeUnmount() {
if (this.pendingOverviewRefresh) {
clearTimeout(this.pendingOverviewRefresh);
this.pendingOverviewRefresh = null;
}
if (this.daemonSocket) {
this.daemonSocket.removeEventListener('message', this.handleDaemonMessage);
}
if (this.socket) {
this.socket.off("falukantUserUpdated", this.fetchFalukantUser);
this.socket.off("falukantUpdateStatus");
this.socket.off("falukantUpdateFamily");
this.socket.off("children_update");
this.socket.off("falukantBranchUpdate");
this.socket.off("stock_change");
}
@@ -352,6 +359,12 @@ export default {
this.socket.on("falukantUpdateStatus", (data) => {
this.handleEvent({ event: 'falukantUpdateStatus', ...data });
});
this.socket.on("falukantUpdateFamily", (data) => {
this.handleEvent({ event: 'falukantUpdateFamily', ...data });
});
this.socket.on("children_update", (data) => {
this.handleEvent({ event: 'children_update', ...data });
});
this.socket.on("falukantBranchUpdate", (data) => {
this.handleEvent({ event: 'falukantBranchUpdate', ...data });
});
@@ -387,16 +400,37 @@ export default {
console.error('Overview: Error processing daemon message:', err);
}
},
matchesCurrentUser(eventData) {
if (eventData?.user_id == null) {
return true;
}
const currentIds = [this.user?.id, this.user?.hashedId]
.filter(Boolean)
.map((value) => String(value));
return currentIds.includes(String(eventData.user_id));
},
queueOverviewRefresh() {
if (this.pendingOverviewRefresh) {
clearTimeout(this.pendingOverviewRefresh);
}
this.pendingOverviewRefresh = setTimeout(async () => {
this.pendingOverviewRefresh = null;
await this.fetchFalukantUser();
if (this.falukantUser?.character) {
await this.fetchProductions();
await this.fetchAllStock();
}
}, 120);
},
async handleEvent(eventData) {
if (!this.falukantUser?.character) return;
if (!this.matchesCurrentUser(eventData)) return;
switch (eventData.event) {
case 'falukantUpdateStatus':
case 'falukantUpdateFamily':
case 'children_update':
case 'falukantBranchUpdate':
await this.fetchFalukantUser();
if (this.falukantUser?.character) {
await this.fetchProductions();
await this.fetchAllStock();
}
this.queueOverviewRefresh();
break;
case 'production_ready':
case 'production_started':

View File

@@ -80,6 +80,18 @@
</select>
</label>
<label v-if="selectedType && selectedType.tr === 'investigate_affair'" class="form-label">
{{ $t('falukant.underground.activities.affairGoal') }}
<select v-model="newAffairGoal" class="form-control">
<option value="expose">
{{ $t('falukant.underground.goals.expose') }}
</option>
<option value="blackmail">
{{ $t('falukant.underground.goals.blackmail') }}
</option>
</select>
</label>
<button class="btn-create-activity" :disabled="!canCreate" @click="createActivity">
{{ $t('falukant.underground.activities.create') }}
</button>
@@ -96,6 +108,7 @@
<th>{{ $t('falukant.underground.activities.type') }}</th>
<th>{{ $t('falukant.underground.activities.victim') }}</th>
<th>{{ $t('falukant.underground.activities.cost') }}</th>
<th>{{ $t('falukant.underground.activities.status') }}</th>
<th>{{ $t('falukant.underground.activities.additionalInfo') }}</th>
</tr>
</thead>
@@ -105,16 +118,73 @@
<td>{{ act.victimName }}</td>
<td>{{ formatCost(act.cost) }}</td>
<td>
<template v-if="act.type === 'sabotage'">
{{ $t(`falukant.underground.targets.${act.target}`) }}
</template>
<template v-else-if="act.type === 'corrupt_politician'">
{{ $t(`falukant.underground.goals.${act.goal}`) }}
</template>
<div class="activity-status">
<span class="activity-status__badge" :class="`is-${act.status}`">
{{ $t(`falukant.underground.status.${act.status}`) }}
</span>
</div>
</td>
<td>
<div class="activity-details">
<div class="activity-details__summary">
<template v-if="act.type === 'sabotage'">
{{ $t(`falukant.underground.targets.${act.target}`) }}
</template>
<template v-else-if="act.type === 'corrupt_politician'">
{{ $t(`falukant.underground.goals.${act.goal}`) }}
</template>
<template v-else-if="act.type === 'investigate_affair'">
{{ $t(`falukant.underground.goals.${act.goal}`) }}
</template>
</div>
<template v-if="act.type === 'investigate_affair' && hasAffairDetails(act)">
<div
v-if="getAffairDiscoveries(act).length"
class="activity-details__block"
>
<div class="activity-details__label">
{{ $t('falukant.underground.activities.discoveries') }}
</div>
<ul class="activity-details__list">
<li v-for="entry in getAffairDiscoveries(act)" :key="entry">
{{ entry }}
</li>
</ul>
</div>
<div
v-if="hasAffairImpact(act)"
class="activity-details__block activity-details__metrics"
>
<span
v-if="hasNumericValue(act.additionalInfo?.visibilityDelta)"
class="activity-metric"
>
{{ $t('falukant.underground.activities.visibilityDelta') }}:
{{ formatSignedNumber(act.additionalInfo.visibilityDelta) }}
</span>
<span
v-if="hasNumericValue(act.additionalInfo?.reputationDelta)"
class="activity-metric"
>
{{ $t('falukant.underground.activities.reputationDelta') }}:
{{ formatSignedNumber(act.additionalInfo.reputationDelta) }}
</span>
<span
v-if="act.additionalInfo?.blackmailAmount > 0"
class="activity-metric"
>
{{ $t('falukant.underground.activities.blackmailAmount') }}:
{{ formatCost(act.additionalInfo.blackmailAmount) }}
</span>
</div>
</template>
</div>
</td>
</tr>
<tr v-if="!activities.length">
<td colspan="4">{{ $t('falukant.underground.activities.none') }}</td>
<td colspan="5">{{ $t('falukant.underground.activities.none') }}</td>
</tr>
</tbody>
</table>
@@ -179,7 +249,8 @@ export default {
victimSearchTimeout: null,
newPoliticalTargets: [],
newSabotageTarget: 'house',
newCorruptGoal: 'elect'
newCorruptGoal: 'elect',
newAffairGoal: 'expose'
};
},
computed: {
@@ -202,6 +273,12 @@ export default {
) {
return false;
}
if (
this.selectedType?.tr === 'investigate_affair' &&
!this.newAffairGoal
) {
return false;
}
return true;
}
},
@@ -234,7 +311,6 @@ export default {
}
},
async searchVictims(q) {
console.log('Searching victims for:', q);
try {
const { data } = await apiClient.get('/api/falukant/users/search', {
params: { q }
@@ -264,6 +340,9 @@ export default {
payload.politicalTargets = this.newPoliticalTargets;
}
}
if (this.selectedType.tr === 'investigate_affair') {
payload.goal = this.newAffairGoal;
}
try {
await apiClient.post(
'/api/falukant/underground/activities',
@@ -273,6 +352,7 @@ export default {
this.newPoliticalTargets = [];
this.newSabotageTarget = 'house';
this.newCorruptGoal = 'elect';
this.newAffairGoal = 'expose';
await this.loadActivities();
} catch (err) {
console.error('Error creating activity', err);
@@ -287,8 +367,6 @@ export default {
},
async loadActivities() {
return; // TODO: Aktivierung der Methode geplant
/* Temporär deaktiviert:
this.loading.activities = true;
try {
const { data } = await apiClient.get(
@@ -298,7 +376,6 @@ export default {
} finally {
this.loading.activities = false;
}
*/
},
async loadAttacks() {
@@ -328,6 +405,48 @@ export default {
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(v);
},
hasNumericValue(value) {
return typeof value === 'number' && !Number.isNaN(value);
},
formatSignedNumber(value) {
if (!this.hasNumericValue(value)) return '0';
return new Intl.NumberFormat(navigator.language, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
signDisplay: 'always'
}).format(value);
},
getAffairDiscoveries(activity) {
const discoveries = activity?.additionalInfo?.discoveries;
if (Array.isArray(discoveries)) {
return discoveries.filter(Boolean).map(entry => String(entry));
}
if (discoveries && typeof discoveries === 'object') {
return Object.entries(discoveries)
.filter(([, value]) => value !== null && value !== undefined && value !== '')
.map(([key, value]) => `${key}: ${value}`);
}
if (typeof discoveries === 'string' && discoveries.trim()) {
return [discoveries.trim()];
}
return [];
},
hasAffairImpact(activity) {
const info = activity?.additionalInfo || {};
return (
this.hasNumericValue(info.visibilityDelta) ||
this.hasNumericValue(info.reputationDelta) ||
(typeof info.blackmailAmount === 'number' && info.blackmailAmount > 0)
);
},
hasAffairDetails(activity) {
return this.getAffairDiscoveries(activity).length > 0 || this.hasAffairImpact(activity);
}
}
};
@@ -407,6 +526,7 @@ h2 {
padding: 8px;
border: 1px solid #ddd;
text-align: left;
vertical-align: top;
}
.suggestions {
@@ -432,4 +552,72 @@ h2 {
.suggestions li:hover {
background: #eee;
}
.activity-status__badge {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.88rem;
font-weight: 600;
background: #ececec;
color: #333;
}
.activity-status__badge.is-pending {
background: #fff2cc;
color: #7a5600;
}
.activity-status__badge.is-resolved {
background: #dff3e2;
color: #25613a;
}
.activity-status__badge.is-failed {
background: #f8d7da;
color: #8a2632;
}
.activity-details {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.activity-details__summary {
font-weight: 600;
}
.activity-details__block {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.activity-details__label {
font-size: 0.85rem;
font-weight: 600;
color: #666;
}
.activity-details__list {
margin: 0;
padding-left: 1.1rem;
}
.activity-details__metrics {
flex-direction: row;
flex-wrap: wrap;
gap: 0.5rem;
}
.activity-metric {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: #f0f0f0;
font-size: 0.88rem;
}
</style>