feat: Füge Debug-Overlay und Alters-/Geschlechtsanpassungen für 3D-Charaktere hinzu
All checks were successful
Deploy to production / deploy (push) Successful in 1m55s

This commit is contained in:
Torsten Schulz (local)
2026-05-21 09:55:11 +02:00
parent 3df7abe628
commit ad0ccd0281
11 changed files with 364 additions and 24 deletions

View File

@@ -4601,16 +4601,26 @@ class FalukantService extends BaseService {
if (!marriedType) {
throw new Error('Relationship type "married" not found');
}
await Relationship.create({
const newRel = await Relationship.create({
character1Id: proposal.requesterCharacterId,
character2Id: proposal.proposedCharacterId,
relationshipTypeId: marriedType.id,
});
await MarriageProposal.destroy({
where: { requesterCharacterId: character.id },
})
;
return { success: true, message: 'Marriage proposal accepted' };
await MarriageProposal.destroy({ where: { requesterCharacterId: character.id } });
// Ensure both households are marked as unburdened upon marriage creation
try {
const otherChar = await FalukantCharacter.findByPk(proposal.proposedCharacterId, { attributes: ['userId'] });
const otherFUserId = otherChar ? otherChar.userId : null;
const affectedUserIds = [user.id];
if (otherFUserId) affectedUserIds.push(otherFUserId);
await UserHouse.update({ householdTensionScore: 0, householdTensionReasonsJson: [] }, { where: { userId: affectedUserIds } });
} catch (err) {
console.error('Failed to reset household tension on marriage:', err);
}
return { success: true, message: 'Marriage proposal accepted', relationshipId: newRel.id };
}
async cancelWooing(hashedUserId) {

View File

@@ -7,6 +7,16 @@
:src="fallbackImageSrc"
:alt="`Character ${actualGender}`"
/>
<div v-if="isDev" class="character-debug-overlay">
<div><strong>3D Debug</strong></div>
<div>model: {{ debugInfo.modelType || '—' }}</div>
<div>path: {{ debugInfo.modelPath || '—' }}</div>
<div>age: {{ debugInfo.actualAge || '—' }} ({{ debugInfo.ageGroup || '—' }})</div>
<div>camera: <span v-if="debugInfo.camera">{{ debugInfo.camera.x.toFixed(2) }}, {{ debugInfo.camera.y.toFixed(2) }}, {{ debugInfo.camera.z.toFixed(2) }}</span><span v-else></span></div>
<div>camDist: {{ debugInfo.cameraDistance || '—' }}</div>
<div>lightweight: {{ debugInfo.lightweight }}</div>
<div>lazy: {{ debugInfo.lazy }}</div>
</div>
</div>
</template>
@@ -16,6 +26,46 @@ import { getApiBaseURL } from '@/utils/axios.js';
/** Backend-Route: GET /api/models/3d/falukant/characters/:filename (Proxy mit Draco-Optimierung) */
const MODELS_API_PATH = '/api/models/3d/falukant/characters';
// Optional: zusätzliche Namenskonventionen, die vor dem Standardpfad ausprobiert werden.
// Hier werden mögliche Alternativ-Dateinamen aufgeführt, die für bestimmte Altersstufen
// (z.B. weibliche Modelle für 1-4 Jahre) existieren könnten. Trage hier die tatsächlichen
// Dateinamen ein, falls dein Backend solche Varianten enthält.
const AGE_ALTERNATIVE_SUFFIXES = [
'_alt.glb',
'_special.glb',
'_v2.glb'
];
// Per-age camera offsets applied after auto-fit. Values are additive (meters).
// Set `y` to raise/lower the camera, `z` to move it closer/further.
// The mapping supports per-age overrides; if an exact age key is missing the
// gender `default` entry will be used. Tune these numbers visually.
const AGE_CAMERA_OVERRIDES = {
female: {
default: { y: 0.35, z: 0.35 },
'1': { y: 0.0, z: 0.0 },
'2': { y: 0.0, z: 0.0 },
'5': { y: 0.0, z: 0.0 }
},
male: {
default: { y: 0.25, z: 0.25 }
}
};
// Per-age model position overrides (y in meters). Positive raises the model,
// negative moves it down. Defaults move most characters slightly down to match
// the visual baseline; specific ages (e.g. female 1/2/5) keep no offset.
const AGE_MODEL_POSITION_OVERRIDES = {
female: {
default: { y: -1 },
'1': { y: 0 },
'2': { y: 0 },
'5': { y: 0 }
},
male: {
default: { y: -1 }
}
};
let threeRuntimePromise = null;
let threeLoadersPromise = null;
let threeModelRuntimePromise = null;
@@ -89,8 +139,23 @@ export default {
,resizeObserver: null
,intersectionObserver: null
,isVisible: false
,debugInfo: {
modelPath: null,
modelType: null,
camera: null,
cameraDistance: null,
ageGroup: null,
actualAge: null,
lightweight: false,
lazy: false
}
};
},
computed: {
isDev() {
return !import.meta.env.PROD;
}
},
computed: {
actualGender() {
if (this.gender) {
@@ -157,6 +222,9 @@ export default {
},
async mounted() {
const container = this.$refs.container;
// expose runtime props for debug overlay
this.debugInfo.lightweight = !!this.lightweight;
this.debugInfo.lazy = !!this.lazy;
if (this.lazy && typeof window !== 'undefined' && 'IntersectionObserver' in window && container) {
// Defer initialization until the element is in viewport
this.intersectionObserver = new window.IntersectionObserver((entries) => {
@@ -375,25 +443,49 @@ export default {
const ageGroupPath = this.modelPath;
const fallbackPath = `${prefix}/${this.actualGender}.glb`;
// Build a list of candidate paths. For every age and gender we first try
// possible alternate filenames for the exact-age model (e.g. female_1y_alt.glb),
// then the exact-age file, then the age-group file, and finally the base gender
// fallback. This makes the age-specific model the primary source and the
// age-group only a later fallback.
const candidates = [];
const modelAge = this.actualAge;
// Try exact-age variants with configured suffixes first (works for any gender/age)
for (const sfx of AGE_ALTERNATIVE_SUFFIXES) {
candidates.push(`${prefix}/${this.actualGender}_${modelAge}y${sfx}`);
}
// Standard preference order: exact age, age group, fallback
candidates.push(exactAgePath);
candidates.push(ageGroupPath);
candidates.push(fallbackPath);
let gltf;
try {
if (this.lightweight) {
try {
gltf = await loader.loadAsync(ageGroupPath);
} catch (ageGroupError) {
gltf = await loader.loadAsync(fallbackPath);
}
} else {
try {
gltf = await loader.loadAsync(exactAgePath);
} catch (exactAgeError) {
let chosenPath = null;
// Try candidates in order until one loads
for (const candidate of candidates) {
try {
gltf = await loader.loadAsync(ageGroupPath);
} catch (ageGroupError) {
gltf = await loader.loadAsync(fallbackPath);
chosenPath = candidate;
gltf = await loader.loadAsync(candidate);
// loaded successfully
break;
} catch (err) {
// try next candidate
}
}
}
if (!gltf) {
throw new Error('No model candidate could be loaded');
}
// store for debug overlay
this.debugInfo.modelPath = chosenPath;
// determine modelType
if (chosenPath === exactAgePath) this.debugInfo.modelType = 'exactAge';
else if (chosenPath === ageGroupPath) this.debugInfo.modelType = 'ageGroup';
else if (chosenPath === fallbackPath) this.debugInfo.modelType = 'fallback';
else this.debugInfo.modelType = 'alternate';
} finally {
dracoLoader.dispose();
}
@@ -435,7 +527,20 @@ export default {
const baseY = -scaledBox.min.y; // Auf Boden setzen (y=0 entspricht dem unteren Rand)
this.model.position.set(-scaledCenter.x, baseY, -scaledCenter.z);
this.baseYPosition = baseY; // Basisposition für Animation speichern
// Apply per-age/gender model position overrides (vertical shift)
try {
const genderMap = AGE_MODEL_POSITION_OVERRIDES[this.actualGender] || {};
const specific = genderMap[String(this.actualAge)];
const def = genderMap.default;
const used = specific || def;
if (used && typeof used.y === 'number') {
this.model.position.y += used.y;
this.debugInfo.modelOffset = { y: used.y };
}
} catch (e) {
// ignore position override errors
}
this.scene.add(this.model);
// Auto-Kamera-Anpassung: Abstand so setzen, dass die modell-Höhe gut ins Bild passt
@@ -446,11 +551,33 @@ export default {
const desiredHeight = Math.max(scaledSize.y, scaledSize.z) * 1.15; // etwas Margin
let distance = desiredHeight / (2 * Math.tan(fovRad / 2));
if (!isFinite(distance) || distance <= 0) distance = 3;
const camY = scaledCenter.y + Math.max(0.5, scaledSize.y * 0.15);
const camZ = distance + 0.6; // kleiner Offset, damit etwas Raum vor dem Modell ist
let camY = scaledCenter.y + Math.max(0.5, scaledSize.y * 0.15);
let camZ = distance + 0.6; // kleiner Offset, damit etwas Raum vor dem Modell ist
// Apply per-age/gender camera overrides if configured
try {
const genderMap = AGE_CAMERA_OVERRIDES[this.actualGender] || {};
const specific = genderMap[String(this.actualAge)];
const def = genderMap.default;
const used = specific || def;
if (used) {
camY += (used.y || 0);
camZ += (used.z || 0);
// expose applied offsets for easier debugging
this.debugInfo.cameraOffset = { y: (used.y || 0), z: (used.z || 0) };
}
} catch (e) {
// ignore override errors
}
this.camera.position.set(0, camY, camZ);
this.camera.lookAt(scaledCenter.x, scaledCenter.y, scaledCenter.z);
this.onWindowResize();
// debug info
this.debugInfo.camera = { x: this.camera.position.x, y: this.camera.position.y, z: this.camera.position.z };
this.debugInfo.cameraDistance = camZ;
this.debugInfo.ageGroup = this.ageGroup;
this.debugInfo.actualAge = this.actualAge;
}
} catch (e) {
// ignore camera auto-fit errors
@@ -582,4 +709,19 @@ export default {
object-fit: contain;
object-position: center bottom;
}
.character-debug-overlay {
position: absolute;
right: 6px;
top: 6px;
background: rgba(0,0,0,0.6);
color: #fff;
font-size: 11px;
padding: 6px 8px;
border-radius: 6px;
line-height: 1.2;
z-index: 1000;
pointer-events: none;
}
.character-debug-overlay div { opacity: 0.95 }
</style>

View File

@@ -12,6 +12,7 @@
>
<div v-if="child" class="child-details">
<table class="details-table">
<tbody>
<tr>
<td>{{ $t('falukant.family.children.name') }}</td>
<td>
@@ -42,6 +43,7 @@
<span v-else>{{ $t('falukant.family.children.notHeir') }}</span>
</td>
</tr>
</tbody>
</table>
<div v-if="!child.hasName" class="baptism-notice">
<p>{{ $t('falukant.family.children.baptismNotice') }}</p>

View File

@@ -910,6 +910,10 @@
"maintenance": "Suporta bulanan",
"scandalExtraDailyPct": "Dugang risgo sa iskandalo/adlaw",
"monthlyCost": "Gasto kada bulan",
"titleEffects": {
"label": "Epekto sa titulo",
"help": "Giunsa pag-usab sa usa ka titulo sa nobility sa pamatasan o visibility sa usa ka uyab."
},
"politicalFreeSlotsHint": "Ang mga politikal nga opisina naghatag og {count} ka affair slot nga walay bulan nga suporta (ang barato nga relasyon una).",
"politicalFreeMaintenance": "Opisina (libre)",
"statusFit": "Angay sa kahimtang",
@@ -1036,6 +1040,7 @@
},
"householdTension": {
"label": "Balayhold Kakulba",
"noReasons": "Walay espesipikong hinungdan nga gi-report.",
"score": "Kakulba score",
"reasonsLabel": "Current causes",
"low": "Calm",
@@ -1052,6 +1057,18 @@
"tooFewServants": "Too few sulugoons",
"marriageCrisis": "Ubos nga kasal satisfaction (makapasakit sa kalinaw sa balay)"
}
,
"reasonsDetail": {
"visibleLover": "Ang usa ka hayag nga relasyon makapahugno sa dungog sa balay.",
"noticeableLover": "Ang relasyon mabantayan ug mosangpot ug tsismis o panagbangi.",
"underfundedLover": "Kulang ang suporta sa relasyon, mosangpot sa kalagot ug risgo sa iskandalo.",
"acknowledgedAffair": "Ang relasyon giila sa publiko ug naghimo ug adlaw-adlaw nga tensiyon.",
"statusMismatch": "Nagkalain-lain nga sosyal nga ranggo nagdala ug panagbangi ug selos.",
"loverChild": "Ang bata gikan sa relasyon magdala ug komplikasyon ug rivalidad sa pamilya.",
"disorder": "Ang kagubot sa balay makadaut sa adlaw-adlaw nga kinabuhi.",
"tooFewServants": "Kulang ang mga sulugoon, nagdugang trabaho ug kasuko sa balay.",
"marriageCrisis": "Kahimtang sa kasal nga dili maayo nagapaubos sa kalinaw sa balay ug makapahimo ug kasuko."
}
},
"marriageActions": {
"title": "Support ang kasal",

View File

@@ -687,6 +687,7 @@
},
"householdTension": {
"label": "Hausfrieden",
"noReasons": "Keine spezifischen Spannungsgründe.",
"score": "Spannungswert",
"reasonsLabel": "Aktuelle Ursachen",
"low": "Ruhig",
@@ -703,6 +704,18 @@
"tooFewServants": "Zu wenig Diener",
"marriageCrisis": "Niedrige Ehezufriedenheit (belastet den Hausfrieden)"
}
,
"reasonsDetail": {
"visibleLover": "Ein öffentlich sichtbares Verhältnis belastet das Ansehen im Haushalt.",
"noticeableLover": "Die Beziehung ist auffällig und führt zu Gerede oder Konflikten.",
"underfundedLover": "Die Beziehung erhält zu wenig Unterhalt, was Unzufriedenheit und Skandalisierung fördert.",
"acknowledgedAffair": "Die Beziehung ist öffentlich anerkannt und erzeugt Spannungen im Alltag.",
"statusMismatch": "Unterschiedliche soziale Stände führen zu Konflikten und Eifersucht.",
"loverChild": "Ein Kind aus einer Liebschaft verursacht familiäre Komplikationen und Rivalität.",
"disorder": "Unordnung im Haushalt verschlechtert das Zusammenleben.",
"tooFewServants": "Zu wenige Diener erhöhen Arbeitsbelastung und Unzufriedenheit im Haus.",
"marriageCrisis": "Allgemeine Unzufriedenheit in der Ehe verringert den Hausfrieden und kann Verstimmungen auslösen."
}
},
"marriageActions": {
"title": "Ehe pflegen",
@@ -774,6 +787,10 @@
"maintenance": "Unterhalt",
"scandalExtraDailyPct": "Zusatz-Skandalrisiko/Tag",
"monthlyCost": "Monatskosten",
"titleEffects": {
"label": "Titel-Auswirkungen",
"help": "Wie ein Adelstitel das Verhalten oder die Sichtbarkeit eines Liebhabers beeinflussen kann."
},
"politicalFreeSlotsHint": "Politische Ämter gewähren dir {count} Liebschaftsplatz/-plätze ohne monatlichen Unterhalt (die günstigsten Beziehungen zählen zuerst).",
"politicalFreeMaintenance": "Amt (frei)",
"statusFit": "Standespassung",

View File

@@ -927,6 +927,7 @@
},
"householdTension": {
"label": "Household Tension",
"noReasons": "No specific causes reported.",
"score": "Tension score",
"reasonsLabel": "Current causes",
"low": "Calm",
@@ -943,6 +944,18 @@
"tooFewServants": "Too few servants",
"marriageCrisis": "Low marriage satisfaction (strains household peace)"
}
,
"reasonsDetail": {
"visibleLover": "A publicly visible affair affects the household's reputation.",
"noticeableLover": "The relationship is noticeable and leads to gossip or conflicts.",
"underfundedLover": "The affair receives too little maintenance, causing dissatisfaction and scandal risk.",
"acknowledgedAffair": "The relationship is acknowledged publicly and creates daily tensions.",
"statusMismatch": "Different social ranks cause conflicts and jealousy.",
"loverChild": "A child from an affair causes family complications and rivalry.",
"disorder": "Disorder in the household worsens daily life.",
"tooFewServants": "Too few servants increase workload and household dissatisfaction.",
"marriageCrisis": "General marital unhappiness reduces household peace and can cause resentments."
}
},
"marriageActions": {
"title": "Support the marriage",
@@ -973,6 +986,10 @@
"maintenance": "Maintenance",
"scandalExtraDailyPct": "Extra scandal risk/day",
"monthlyCost": "Monthly Cost",
"titleEffects": {
"label": "Title effects",
"help": "How a noble title affects a lover's behavior or visibility."
},
"politicalFreeSlotsHint": "Political offices grant you {count} affair slot(s) with no monthly upkeep (cheapest relationships count first).",
"politicalFreeMaintenance": "Office (free)",
"statusFit": "Status Fit",

View File

@@ -687,6 +687,7 @@
},
"householdTension": {
"label": "Tensión del hogar",
"noReasons": "No hay causas específicas.",
"score": "Valor de tensión",
"reasonsLabel": "Causas actuales",
"low": "Calmo",
@@ -703,6 +704,18 @@
"tooFewServants": "Muy pocos sirvientes",
"marriageCrisis": "Baja satisfacción conyugal (tensa el ambiente en casa)"
}
,
"reasonsDetail": {
"visibleLover": "Una relación visible afecta la reputación del hogar.",
"noticeableLover": "La relación es llamativa y provoca habladurías o conflictos.",
"underfundedLover": "La relación recibe poco mantenimiento, causando descontento y riesgo de escándalo.",
"acknowledgedAffair": "La relación está reconocida públicamente y genera tensiones diarias.",
"statusMismatch": "Diferentes rangos sociales causan conflictos y celos.",
"loverChild": "Un hijo de una relación provoca complicaciones familiares y rivalidades.",
"disorder": "El desorden en la casa empeora la convivencia.",
"tooFewServants": "Muy pocos sirvientes aumentan la carga de trabajo y el descontento.",
"marriageCrisis": "Insatisfacción marital general reduce la paz del hogar y puede causar resentimientos."
}
},
"marriageActions": {
"title": "Cuidar el matrimonio",
@@ -774,6 +787,10 @@
"maintenance": "Mantenimiento",
"scandalExtraDailyPct": "Riesgo extra de escándalo/día",
"monthlyCost": "Coste mensual",
"titleEffects": {
"label": "Efectos del título",
"help": "Cómo un título nobiliario afecta el comportamiento o la visibilidad de un amante."
},
"politicalFreeSlotsHint": "Los cargos políticos te conceden {count} plaza(s) de relación sin mantenimiento mensual (primero cuentan las relaciones más baratas).",
"politicalFreeMaintenance": "Cargo (gratis)",
"statusFit": "Adecuación social",

View File

@@ -685,6 +685,7 @@
},
"householdTension": {
"label": "Paix dans la maison",
"noReasons": "Aucune cause spécifique signalée.",
"score": "Valeur de tension",
"reasonsLabel": "Causes actuelles",
"low": "Calme",
@@ -701,6 +702,18 @@
"tooFewServants": "Trop peu de serviteurs",
"marriageCrisis": "Faible satisfaction du couple (tension au foyer)"
}
,
"reasonsDetail": {
"visibleLover": "Une relation visible porte atteinte à la réputation du foyer.",
"noticeableLover": "La relation est remarquée et provoque des commérages ou des conflits.",
"underfundedLover": "La relation reçoit trop peu d'entretien, entraînant mécontentement et risque de scandale.",
"acknowledgedAffair": "La relation est reconnue publiquement et crée des tensions quotidiennes.",
"statusMismatch": "Des rangs sociaux différents provoquent des conflits et de la jalousie.",
"loverChild": "Un enfant issu d'une relation cause des complications familiales et des rivalités.",
"disorder": "Le désordre dans le foyer dégrade la vie quotidienne.",
"tooFewServants": "Trop peu de domestiques augmentent la charge de travail et le mécontentement.",
"marriageCrisis": "Un malheur conjugal général réduit la paix du foyer et peut provoquer des ressentiments."
}
},
"marriageActions": {
"title": "Maintenir le mariage",
@@ -772,6 +785,10 @@
"maintenance": "Entretien",
"scandalExtraDailyPct": "Risque de scandale supplémentaire/jour",
"monthlyCost": "Coûts mensuels",
"titleEffects": {
"label": "Effets du titre",
"help": "Comment un titre de noblesse influence le comportement ou la visibilité d'un amant."
},
"politicalFreeSlotsHint": "Les bureaux politiques vous accordent {count} intérêts amoureux sans entretien mensuel (les relations les moins chères comptent en premier).",
"politicalFreeMaintenance": "Bureau (vacant)",
"statusFit": "Ajustement de classe",

View File

@@ -3,6 +3,7 @@ const Createview = () => import('../views/falukant/CreateView.vue');
const FalukantOverviewView = () => import('../views/falukant/OverviewView.vue');
const MoneyHistoryView = () => import('../views/falukant/MoneyHistoryView.vue');
const FamilyView = () => import('../views/falukant/FamilyView.vue');
const AllCharactersTest = () => import('../views/falukant/AllCharactersTest.vue');
const HouseView = () => import('../views/falukant/HouseView.vue');
const NobilityView = () => import('../views/falukant/NobilityView.vue');
const ReputationView = () => import('../views/falukant/ReputationView.vue');
@@ -45,6 +46,12 @@ const falukantRoutes = [
component: FamilyView,
meta: { requiresAuth: true }
},
{
path: '/falukant/dev-all-characters',
name: 'FalukantAllCharactersTest',
component: AllCharactersTest,
meta: { requiresAuth: true }
},
{
path: '/falukant/house',
name: 'HouseView',

View File

@@ -0,0 +1,58 @@
<template>
<div class="all-characters-test">
<h1>All 3D Characters (Ages × Genders)</h1>
<div class="controls">
<label><input type="checkbox" v-model="useLightweight"> Use lightweight models</label>
<label><input type="checkbox" v-model="noBackground"> No background</label>
</div>
<div class="grid">
<div v-for="(age, idx) in ages" :key="`age-${age}`" class="card">
<div class="label">Alter: {{ age }}</div>
<div class="row">
<div class="cell">
<div class="mini-shell">
<Character3D :gender="'female'" :age="age" :lightweight="useLightweight" :noBackground="noBackground" />
</div>
<div class="sub">weiblich</div>
</div>
<div class="cell">
<div class="mini-shell">
<Character3D :gender="'male'" :age="age" :lightweight="useLightweight" :noBackground="noBackground" />
</div>
<div class="sub">männlich</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import Character3D from '@/components/Character3D.vue';
export default {
name: 'AllCharactersTest',
components: { Character3D },
data() {
return {
// Show ages 0..17 and some representative adults
ages: [...Array(18).keys()].concat([25, 40, 70]),
useLightweight: true,
noBackground: true
};
}
};
</script>
<style scoped>
.all-characters-test { padding: 16px }
.controls { margin-bottom: 12px }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px }
.card { border: 1px solid #e0e0e0; padding: 8px; border-radius: 6px; background: #fff }
.label { font-weight: 600; margin-bottom: 6px }
.row { display: flex; gap: 8px }
.cell { flex: 1; text-align: center }
.mini-shell { width: 100%; height: 220px }
.sub { margin-top: 6px; font-size: 12px; color: #666 }
</style>

View File

@@ -275,6 +275,23 @@
{{ $t('falukant.family.householdTension.reasons.' + reason) }}
</span>
</div>
<div class="marriage-actions__reason-details" v-if="householdTensionReasons && householdTensionReasons.length">
<ul>
<li v-for="reason in householdTensionReasons" :key="reason">
<strong>{{ $t('falukant.family.householdTension.reasons.' + reason) }}:</strong>
<span>
{{ $t('falukant.family.householdTension.reasonsDetail.' + reason) || $t('falukant.family.householdTension.reasons.' + reason) }}
</span>
</li>
</ul>
</div>
</div>
<div v-else class="marriage-actions__no-reasons">
<p>{{ $t('falukant.family.householdTension.noReasons') || 'Keine spezifischen Spannungsgründe angegeben.' }}</p>
<div v-if="isDev" class="marriage-actions__debug-raw">
<h4>Dev: rohdaten</h4>
<pre>{{ JSON.stringify({ householdTension, householdTensionScore, householdTensionReasons, relationships: relationships && relationships[0] ? { marriageState: relationships[0].marriageState, marriageSatisfaction: relationships[0].marriageSatisfaction, marriageFlags: relationships[0].marriageFlags } : null }, null, 2) }}</pre>
</div>
</div>
</section>
</div>
@@ -449,7 +466,7 @@
<dd>{{ lover.statusFit }}</dd>
</div>
</dl>
<div class="lover-card__meta">
<div class="lover-card__meta">
<span
v-if="lover.acknowledged"
class="lover-meta-badge"
@@ -460,6 +477,24 @@
<span v-if="lover.monthsUnderfunded > 0" class="lover-meta-badge lover-meta-badge--warning">
{{ $t('falukant.family.lovers.underfunded', { count: lover.monthsUnderfunded }) }}
</span>
<!-- Title effects: show which noble titles this lover influences and how -->
<div v-if="lover.titleEffects || lover.titleImpact" class="lover-title-effects">
<strong>{{ $t('falukant.family.lovers.titleEffects.label') || 'Titel-Auswirkungen' }}</strong>
<ul>
<li v-if="Array.isArray(lover.titleEffects)" v-for="(te, idx) in lover.titleEffects" :key="idx">
<span>{{ te.title || te.name || te.key }}: {{ te.effect || te.impact || JSON.stringify(te) }}</span>
</li>
<li v-else v-for="(val, key) in (lover.titleEffects || lover.titleImpact)" :key="key">
<span>{{ key }}: {{ val }}</span>
</li>
</ul>
</div>
<div v-if="isDev" class="lover-card__debug">
<details>
<summary>Dev: Lover raw</summary>
<pre>{{ JSON.stringify(lover, null, 2) }}</pre>
</details>
</div>
</div>
<div class="lover-card__actions">
<button class="button button--secondary" @click="setLoverMaintenance(lover, 25)">
@@ -623,6 +658,7 @@ export default {
},
computed: {
...mapState(['socket', 'daemonSocket', 'user']),
isDev() { return !import.meta.env.PROD },
marriageGiftCosts() {
return MARRIAGE_GIFT_COSTS;
},