From 9593140e61441d752d5d4a9714b9035a712e62a8 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 28 Aug 2026 11:50:30 +0200 Subject: [PATCH] feat(certificate): add score breakdown and recommendations for certificate progression --- backend/services/falukantService.js | 49 ++++++++++++++++ frontend/src/i18n/locales/de/falukant.json | 6 ++ frontend/src/i18n/locales/fr/falukant.json | 6 ++ frontend/src/views/falukant/OverviewView.vue | 60 ++++++++++++++++++++ 4 files changed, 121 insertions(+) diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index 6fda4d3..42cb85b 100755 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -282,6 +282,22 @@ function getScoreThresholdForCertificate(level) { return 0; } +function buildCertificateScoreComponent(key, current, points, weight, steps) { + const next = steps.find((step) => step.points > points) || null; + return { + key, + current, + points, + maxPoints: 5, + weight, + contribution: Number((points * weight).toFixed(2)), + nextValue: next?.value ?? null, + nextPoints: next?.points ?? null, + gain: next ? Number(((next.points - points) * weight).toFixed(2)) : 0, + maxed: !next, + }; +} + async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null) { // Wenn worthPercent nicht übergeben wurde, hole es aus der Datenbank if (worthPercent === null) { @@ -3122,6 +3138,36 @@ class FalukantService extends BaseService { const nextCertificate = Math.min(5, currentCertificate + 1); const nextThreshold = CERTIFICATE_THRESHOLDS[nextCertificate] || null; const nextScoreThreshold = getScoreThresholdForCertificate(nextCertificate); + const scoreBreakdown = [ + buildCertificateScoreComponent('avgKnowledge', avgKnowledge, knowledgePoints, 0.45, [ + { value: 20, points: 1 }, { value: 35, points: 2 }, { value: 50, points: 3 }, + { value: 65, points: 4 }, { value: 80, points: 5 }, + ]), + buildCertificateScoreComponent('completedProductions', completedProductions, productionPoints, 0.30, [ + { value: 5, points: 1 }, { value: 20, points: 2 }, { value: 50, points: 3 }, + { value: 100, points: 4 }, { value: 200, points: 5 }, + ]), + buildCertificateScoreComponent('officePoints', highestOfficeRank, officePoints, 0.08, [ + { value: 1, points: 1 }, { value: 2, points: 2 }, { value: 3, points: 3 }, + { value: 4, points: 4 }, { value: 5, points: 5 }, + ]), + buildCertificateScoreComponent('reputationPoints', reputation, reputationPoints, 0.07, [ + { value: 20, points: 1 }, { value: 40, points: 2 }, { value: 60, points: 3 }, + { value: 75, points: 4 }, { value: 90, points: 5 }, + ]), + buildCertificateScoreComponent('housePoints', housePosition, housePoints, 0.05, [ + { value: 2, points: 1 }, { value: 4, points: 2 }, { value: 6, points: 3 }, + { value: 8, points: 4 }, { value: 10, points: 5 }, + ]), + buildCertificateScoreComponent('nobilityPoints', nobilityLevel, nobilityPoints, 0.05, [ + { value: 2, points: 1 }, { value: 3, points: 2 }, { value: 4, points: 3 }, + { value: 5, points: 4 }, { value: 6, points: 5 }, + ]), + ]; + const scoreGap = Math.max(0, Number((nextScoreThreshold - score).toFixed(2))); + const scoreRecommendations = scoreBreakdown + .filter((component) => !component.maxed && component.gain > 0) + .sort((a, b) => b.gain - a.gain); const currentValues = { avgKnowledge, @@ -3194,6 +3240,9 @@ class FalukantService extends BaseService { score: Number(score.toFixed(2)), targetCertificate, nextScoreThreshold, + scoreGap, + scoreBreakdown, + scoreRecommendations, currentValues, nextRequirements, statusRequirement, diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index 30884b8..8486267 100755 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -196,6 +196,12 @@ "notReady": "Für Stufe {next} müssen sowohl die Mindestanforderungen als auch die Wertungsgrenze von {threshold} erfüllt sein." }, "scoreHowToRaise": "Die Wertungsgrenze steigt über gewichtete Punkte: Wissen (45%), abgeschlossene Produktionen (30%), Amtsstatus (8%), Ansehensstatus (7%), Hausstatus (5%) und Adelsstatus (5%). Für schnelle Fortschritte sind Wissen + viele abgeschlossene Produktionen die stärksten Hebel.", + "scoreBreakdown": { + "title": "So erreichst du die nächste Wertung", + "gap": "Es fehlen noch {gap} Wertungspunkte bis {threshold}.", + "nextStep": "Nächste Stufe: von {current} auf {next} erhöhen (+{gain} Wertung).", + "maxed": "Bereits ausgeschöpft – weitere Steigerungen erhöhen die Wertung nicht." + }, "factors": "Aktuelle Werte", "requirements": "Bedingungen für die nächste Stufe", "productionsSince": "Nur Produktionen ab {date} zählen (seit letztem Aufstieg, Bankrott oder Erbfolge).", diff --git a/frontend/src/i18n/locales/fr/falukant.json b/frontend/src/i18n/locales/fr/falukant.json index 2f3e982..081ae9f 100755 --- a/frontend/src/i18n/locales/fr/falukant.json +++ b/frontend/src/i18n/locales/fr/falukant.json @@ -195,6 +195,12 @@ "notReady": "Pour le niveau {next}, les exigences minimales et la limite de notation de {threshold} doivent être remplies." }, "scoreHowToRaise": "Le seuil de note monte via des points pondérés : connaissance (45%), productions terminées (30%), statut d'office (8%), statut de réputation (7%), statut de maison (5%) et statut de noblesse (5%). Pour progresser vite, concentrez-vous sur la connaissance et de nombreuses productions terminées.", + "scoreBreakdown": { + "title": "Comment atteindre la prochaine note", + "gap": "Il manque encore {gap} point(s) de note pour atteindre {threshold}.", + "nextStep": "Prochaine étape : passer de {current} à {next} (+{gain} de note).", + "maxed": "Déjà au maximum : d'autres progrès n'augmentent pas la note." + }, "factors": "Valeurs actuelles", "requirements": "Conditions pour la prochaine étape", "productionsSince": "Seules les productions à partir du {date} comptent (depuis la dernière promotion, faillite ou succession).", diff --git a/frontend/src/views/falukant/OverviewView.vue b/frontend/src/views/falukant/OverviewView.vue index 55d4af3..61b62e6 100755 --- a/frontend/src/views/falukant/OverviewView.vue +++ b/frontend/src/views/falukant/OverviewView.vue @@ -123,6 +123,32 @@ {{ $t('falukant.overview.certificate.scoreHowToRaise') }}

+
+

{{ $t('falukant.overview.certificate.scoreBreakdown.title') }}

+

+ {{ $t('falukant.overview.certificate.scoreBreakdown.gap', { + gap: formatCertificateValue(certificateProgress.scoreGap, 2), + threshold: formatCertificateValue(certificateProgress.nextScoreThreshold, 1), + }) }} +

+
+
+
+ {{ certificateRequirementLabel(component.key) }} + {{ component.points }}/{{ component.maxPoints }} · {{ formatCertificateValue(component.contribution, 2) }} +
+

+ {{ $t('falukant.overview.certificate.scoreBreakdown.nextStep', { + current: formatCertificateValue(component.current, 1), + next: formatCertificateValue(component.nextValue, 1), + gain: formatCertificateValue(component.gain, 2), + }) }} +

+

{{ $t('falukant.overview.certificate.scoreBreakdown.maxed') }}

+
+
+
+

{{ $t('falukant.overview.certificate.factors') }}

@@ -1033,6 +1059,40 @@ export default { font-size: 0.92rem; } +.certificate-panel__breakdown { + margin: 0 0 16px; +} + +.certificate-panel__breakdown > p { + margin: 0 0 10px; + color: var(--color-text-secondary); +} + +.certificate-score-components { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.certificate-score-components__item { + padding: 10px; + border: 1px solid rgba(123, 93, 49, 0.16); + border-radius: 8px; + background: rgba(255, 252, 245, 0.72); +} + +.certificate-score-components__item > div { + display: flex; + justify-content: space-between; + gap: 10px; +} + +.certificate-score-components__item p { + margin: 6px 0 0; + color: var(--color-text-secondary); + font-size: 0.86rem; +} + .certificate-panel__productions-since { margin: 6px 0 0; font-size: 0.85rem;