From 44991743d2c203a01918d96639e1f500c948291f Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 25 Mar 2026 11:59:43 +0100 Subject: [PATCH] Implement certificate progress feature in FalukantService and frontend: Add methods to calculate and retrieve certificate progress based on user attributes. Update localization files for English, German, and Spanish to include new terms related to certificate progress. Enhance OverviewView to display certificate details and requirements, improving user experience and clarity. --- backend/services/falukantService.js | 228 ++++++++++++++++++- frontend/src/i18n/locales/de/falukant.json | 28 +++ frontend/src/i18n/locales/en/falukant.json | 28 +++ frontend/src/i18n/locales/es/falukant.json | 28 +++ frontend/src/views/falukant/OverviewView.vue | 210 +++++++++++++++++ 5 files changed, 520 insertions(+), 2 deletions(-) diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index 5febd83..a6d2482 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -14,6 +14,7 @@ import TitleBenefit from '../models/falukant/type/title_benefit.js'; import Branch from '../models/falukant/data/branch.js'; import BranchType from '../models/falukant/type/branch.js'; import Production from '../models/falukant/data/production.js'; +import DayProduction from '../models/falukant/log/dayproduction.js'; import ProductType from '../models/falukant/type/product.js'; import Knowledge from '../models/falukant/data/product_knowledge.js'; import Inventory from '../models/falukant/data/inventory.js'; @@ -138,6 +139,83 @@ const POLITICAL_OFFICE_RANKS = { chancellor: 6 }; +const CERTIFICATE_THRESHOLDS = { + 2: { avgKnowledge: 15, completedProductions: 4, statusMode: 'none', statusRequiredCount: 0 }, + 3: { avgKnowledge: 28, completedProductions: 15, statusMode: 'none', statusRequiredCount: 0 }, + 4: { + avgKnowledge: 45, + completedProductions: 45, + statusMode: 'one_of', + statusRequiredCount: 1, + options: [ + { type: 'officePoints', required: 1 }, + { type: 'nobilityPoints', required: 1 }, + { type: 'reputationPoints', required: 2 }, + { type: 'housePoints', required: 2 }, + ], + }, + 5: { + avgKnowledge: 60, + completedProductions: 110, + reputationPoints: 2, + statusMode: 'two_of', + statusRequiredCount: 2, + options: [ + { type: 'officePoints', required: 2 }, + { type: 'nobilityPoints', required: 1 }, + { type: 'housePoints', required: 2 }, + ], + }, +}; + +function getKnowledgePoints(avgKnowledge) { + if (avgKnowledge >= 80) return 5; + if (avgKnowledge >= 65) return 4; + if (avgKnowledge >= 50) return 3; + if (avgKnowledge >= 35) return 2; + if (avgKnowledge >= 20) return 1; + return 0; +} + +function getProductionPoints(completedProductions) { + if (completedProductions >= 200) return 5; + if (completedProductions >= 100) return 4; + if (completedProductions >= 50) return 3; + if (completedProductions >= 20) return 2; + if (completedProductions >= 5) return 1; + return 0; +} + +function getReputationPoints(reputation) { + if (reputation >= 90) return 5; + if (reputation >= 75) return 4; + if (reputation >= 60) return 3; + if (reputation >= 40) return 2; + if (reputation >= 20) return 1; + return 0; +} + +function getHousePoints(housePosition) { + if (housePosition >= 10) return 5; + if (housePosition >= 8) return 4; + if (housePosition >= 6) return 3; + if (housePosition >= 4) return 2; + if (housePosition >= 2) return 1; + return 0; +} + +function getNobilityPoints(nobilityLevel) { + return Math.max(0, Math.min(5, (nobilityLevel || 0) - 1)); +} + +function getTargetCertificateByScore(score) { + if (score >= 3.8) return 5; + if (score >= 2.8) return 4; + if (score >= 1.8) return 3; + if (score >= 0.9) return 2; + return 1; +} + async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null) { // Wenn worthPercent nicht übergeben wurde, hole es aus der Datenbank if (worthPercent === null) { @@ -823,9 +901,9 @@ class FalukantService extends BaseService { include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }, - { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] } + { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr', 'level'] } ], - attributes: ['birthdate', 'gender'] + attributes: ['id', 'birthdate', 'gender', 'reputation', 'titleOfNobility'] }, { model: RegionData, @@ -870,6 +948,7 @@ class FalukantService extends BaseService { } if (userHouse) u.setDataValue('userHouse', userHouse); if (u.character?.birthdate) u.character.setDataValue('age', calcAge(u.character.birthdate)); + u.setDataValue('certificateProgress', await this.buildCertificateProgress(u)); u.setDataValue('debtorsPrison', await this.getDebtorsPrisonStateForUser(u)); return u; } @@ -2795,6 +2874,151 @@ class FalukantService extends BaseService { return parseFloat(averageKnowledge[0]?.avgKnowledge || 0); } + async getHighestChurchOfficeInfo(userId) { + const character = await FalukantCharacter.findOne({ + where: { userId }, + attributes: ['id'] + }); + if (!character) return { rank: 0, name: null }; + + const churchOffices = await ChurchOffice.findAll({ + where: { characterId: character.id }, + include: [{ model: ChurchOfficeType, as: 'type', attributes: ['name', 'hierarchyLevel'] }], + attributes: ['officeTypeId'] + }); + + const candidates = churchOffices + .map((office) => ({ + rank: Number(office.type?.hierarchyLevel || 0), + name: office.type?.name || null, + })) + .sort((a, b) => b.rank - a.rank); + + return candidates[0] || { rank: 0, name: null }; + } + + async buildCertificateProgress(user) { + const character = user?.character || await FalukantCharacter.findOne({ + where: { userId: user.id }, + attributes: ['id', 'reputation', 'titleOfNobility'] + }); + if (!character?.id) { + return null; + } + + const [avgKnowledge, completedProductions, highestPoliticalOffice, highestChurchOffice, house, title] = await Promise.all([ + this.calculateAverageKnowledge(character.id), + DayProduction.count({ where: { producerId: user.id } }), + this.getHighestPoliticalOfficeInfo(user.id), + this.getHighestChurchOfficeInfo(user.id), + UserHouse.findOne({ + where: { userId: user.id }, + include: [{ model: HouseType, as: 'houseType', attributes: ['position', 'labelTr'] }], + attributes: ['houseTypeId'] + }), + TitleOfNobility.findOne({ + where: { id: character.titleOfNobility }, + attributes: ['level', 'labelTr'] + }), + ]); + + const reputation = Number(character.reputation || 0); + const housePosition = Number(house?.houseType?.position || 0); + const highestPoliticalOfficeRank = Number(highestPoliticalOffice?.rank || 0); + const highestChurchOfficeRank = Number(highestChurchOffice?.rank || 0); + const highestOfficeRank = Math.max(highestPoliticalOfficeRank, highestChurchOfficeRank); + const nobilityLevel = Number(title?.level || 0); + + const knowledgePoints = getKnowledgePoints(avgKnowledge); + const productionPoints = getProductionPoints(completedProductions); + const officePoints = Math.min(5, highestOfficeRank); + const nobilityPoints = getNobilityPoints(nobilityLevel); + const reputationPoints = getReputationPoints(reputation); + const housePoints = getHousePoints(housePosition); + + const score = ( + knowledgePoints * 0.45 + + productionPoints * 0.30 + + officePoints * 0.08 + + nobilityPoints * 0.05 + + reputationPoints * 0.07 + + housePoints * 0.05 + ); + + const currentCertificate = Number(user.certificate ?? 1); + const targetCertificate = getTargetCertificateByScore(score); + const nextCertificate = Math.min(5, currentCertificate + 1); + const nextThreshold = CERTIFICATE_THRESHOLDS[nextCertificate] || null; + + const currentValues = { + avgKnowledge, + completedProductions, + highestPoliticalOfficeRank, + highestChurchOfficeRank, + highestOfficeRank, + nobilityLevel, + reputation, + housePosition, + knowledgePoints, + productionPoints, + officePoints, + nobilityPoints, + reputationPoints, + housePoints, + }; + + let statusRequirement = null; + if (nextThreshold?.options?.length) { + const options = nextThreshold.options.map((option) => ({ + ...option, + current: Number(currentValues[option.type] || 0), + met: Number(currentValues[option.type] || 0) >= Number(option.required || 0), + })); + const metCount = options.filter((option) => option.met).length; + statusRequirement = { + mode: nextThreshold.statusMode, + requiredCount: nextThreshold.statusRequiredCount, + metCount, + fulfilled: metCount >= nextThreshold.statusRequiredCount, + options, + }; + } + + const nextRequirements = nextThreshold ? [ + { + type: 'avgKnowledge', + current: avgKnowledge, + required: nextThreshold.avgKnowledge, + met: avgKnowledge >= nextThreshold.avgKnowledge, + }, + { + type: 'completedProductions', + current: completedProductions, + required: nextThreshold.completedProductions, + met: completedProductions >= nextThreshold.completedProductions, + }, + ...(typeof nextThreshold.reputationPoints === 'number' ? [{ + type: 'reputationPoints', + current: reputationPoints, + required: nextThreshold.reputationPoints, + met: reputationPoints >= nextThreshold.reputationPoints, + }] : []), + ] : []; + + return { + currentCertificate, + nextCertificate, + score: Number(score.toFixed(2)), + targetCertificate, + currentValues, + nextRequirements, + statusRequirement, + readyForNextCertificate: nextCertificate <= targetCertificate + && nextRequirements.every((requirement) => requirement.met) + && (!statusRequirement || statusRequirement.fulfilled), + }; + } + formatProposals(proposals) { return proposals.map((proposal) => { const age = Math.floor((Date.now() - new Date(proposal.character.birthdate)) / (24 * 60 * 60 * 1000)); diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index b91b8d5..a1b25a0 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -138,6 +138,34 @@ "nobleTitle": "Stand", "certificate": "Zertifikat" }, + "certificate": { + "title": "Zertifikatsfortschritt", + "description": "Zeigt die aktuelle Stufe und die Bedingungen für den nächsten Aufstieg.", + "current": "Aktuell", + "next": "Nächste Stufe", + "score": "Wertung", + "ready": "Für den nächsten Aufstieg bereit", + "notReady": "Bedingungen noch nicht erfüllt", + "factors": "Aktuelle Werte", + "requirements": "Bedingungen für die nächste Stufe", + "factor": { + "avgKnowledge": "Durchschnittliches Wissen", + "completedProductions": "Abgeschlossene Produktionen", + "reputation": "Ansehen", + "housePosition": "Hausstufe", + "highestPoliticalOfficeRank": "Höchstes politisches Amt", + "highestChurchOfficeRank": "Höchstes kirchliches Amt", + "nobilityLevel": "Adelsstufe", + "officePoints": "Amtsstatus", + "nobilityPoints": "Adelsstatus", + "reputationPoints": "Ansehensstatus", + "housePoints": "Hausstatus" + }, + "statusMode": { + "one_of": "Mindestens eine Statusbedingung", + "two_of": "Mindestens zwei Statusbedingungen" + } + }, "productions": { "title": "Produktionen" }, diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json index 4a80708..f77b87c 100644 --- a/frontend/src/i18n/locales/en/falukant.json +++ b/frontend/src/i18n/locales/en/falukant.json @@ -118,6 +118,34 @@ "mainbranch": "Home city", "nobleTitle": "Title", "certificate": "Certificate" + }, + "certificate": { + "title": "Certificate progress", + "description": "Shows your current level and the requirements for the next promotion.", + "current": "Current", + "next": "Next level", + "score": "Score", + "ready": "Ready for the next promotion", + "notReady": "Requirements not met yet", + "factors": "Current values", + "requirements": "Requirements for the next level", + "factor": { + "avgKnowledge": "Average knowledge", + "completedProductions": "Completed productions", + "reputation": "Reputation", + "housePosition": "House level", + "highestPoliticalOfficeRank": "Highest political office", + "highestChurchOfficeRank": "Highest church office", + "nobilityLevel": "Nobility level", + "officePoints": "Office status", + "nobilityPoints": "Nobility status", + "reputationPoints": "Reputation status", + "housePoints": "House status" + }, + "statusMode": { + "one_of": "At least one status condition", + "two_of": "At least two status conditions" + } } }, "health": { diff --git a/frontend/src/i18n/locales/es/falukant.json b/frontend/src/i18n/locales/es/falukant.json index f973fea..051506e 100644 --- a/frontend/src/i18n/locales/es/falukant.json +++ b/frontend/src/i18n/locales/es/falukant.json @@ -128,6 +128,34 @@ "nobleTitle": "Rango", "certificate": "Certificado" }, + "certificate": { + "title": "Progreso del certificado", + "description": "Muestra tu nivel actual y las condiciones para el siguiente ascenso.", + "current": "Actual", + "next": "Siguiente nivel", + "score": "Puntuación", + "ready": "Listo para el siguiente ascenso", + "notReady": "Condiciones aún no cumplidas", + "factors": "Valores actuales", + "requirements": "Condiciones para el siguiente nivel", + "factor": { + "avgKnowledge": "Conocimiento medio", + "completedProductions": "Producciones completadas", + "reputation": "Reputación", + "housePosition": "Nivel de la casa", + "highestPoliticalOfficeRank": "Cargo político más alto", + "highestChurchOfficeRank": "Cargo eclesiástico más alto", + "nobilityLevel": "Nivel nobiliario", + "officePoints": "Estado de cargo", + "nobilityPoints": "Estado nobiliario", + "reputationPoints": "Estado de reputación", + "housePoints": "Estado de la casa" + }, + "statusMode": { + "one_of": "Al menos una condición de estatus", + "two_of": "Al menos dos condiciones de estatus" + } + }, "productions": { "title": "Producciones" }, diff --git a/frontend/src/views/falukant/OverviewView.vue b/frontend/src/views/falukant/OverviewView.vue index a7251df..80c5ec5 100644 --- a/frontend/src/views/falukant/OverviewView.vue +++ b/frontend/src/views/falukant/OverviewView.vue @@ -96,6 +96,99 @@ + +
+
+
+

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

+

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

+
+
+ {{ $t('falukant.overview.certificate.current') }}: {{ certificateProgress.currentCertificate }} + {{ $t('falukant.overview.certificate.next') }}: {{ certificateProgress.nextCertificate }} +
+
+ +
+ {{ $t('falukant.overview.certificate.score') }} + {{ certificateProgress.score }} + + {{ certificateProgress.readyForNextCertificate + ? $t('falukant.overview.certificate.ready') + : $t('falukant.overview.certificate.notReady') }} + +
+ +
+
+

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

+
+
+ {{ $t('falukant.overview.certificate.factor.avgKnowledge') }} + {{ formatCertificateValue(certificateProgress.currentValues.avgKnowledge, 1) }} +
+
+ {{ $t('falukant.overview.certificate.factor.completedProductions') }} + {{ certificateProgress.currentValues.completedProductions }} +
+
+ {{ $t('falukant.overview.certificate.factor.reputation') }} + {{ certificateProgress.currentValues.reputation }} +
+
+ {{ $t('falukant.overview.certificate.factor.housePosition') }} + {{ certificateProgress.currentValues.housePosition }} +
+
+ {{ $t('falukant.overview.certificate.factor.highestPoliticalOfficeRank') }} + {{ certificateProgress.currentValues.highestPoliticalOfficeRank }} +
+
+ {{ $t('falukant.overview.certificate.factor.highestChurchOfficeRank') }} + {{ certificateProgress.currentValues.highestChurchOfficeRank }} +
+
+ {{ $t('falukant.overview.certificate.factor.nobilityLevel') }} + {{ certificateProgress.currentValues.nobilityLevel }} +
+
+
+ +
+

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

+
+
+ {{ certificateRequirementLabel(requirement.type) }} + {{ formatCertificateRequirement(requirement.current, requirement.required) }} +
+
+ {{ certificateStatusRequirementLabel(certificateProgress.statusRequirement.mode) }} + {{ certificateProgress.statusRequirement.metCount }}/{{ certificateProgress.statusRequirement.requiredCount }} +
+
+ +
    +
  • + {{ certificateRequirementLabel(option.type) }}: + {{ formatCertificateRequirement(option.current, option.required) }} +
  • +
+
+
+
@@ -313,6 +406,9 @@ export default { stockEntryCount() { return this.allStock.length; }, + certificateProgress() { + return this.falukantUser?.certificateProgress || null; + }, routineActions() { return [ { @@ -553,6 +649,24 @@ export default { formatDate(timestamp) { return new Date(timestamp).toLocaleString(); }, + formatCertificateValue(value, digits = 0) { + if (value == null) return '---'; + return new Intl.NumberFormat(this.locale, { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(value); + }, + formatCertificateRequirement(current, required) { + const currentDigits = typeof current === 'number' && !Number.isInteger(current) ? 1 : 0; + const requiredDigits = typeof required === 'number' && !Number.isInteger(required) ? 1 : 0; + return `${this.formatCertificateValue(current, currentDigits)} / ${this.formatCertificateValue(required, requiredDigits)}`; + }, + certificateRequirementLabel(type) { + return this.$t(`falukant.overview.certificate.factor.${type}`); + }, + certificateStatusRequirementLabel(mode) { + return this.$t(`falukant.overview.certificate.statusMode.${mode}`); + }, async fetchPotentialHeirs() { this.loadingHeirs = true; try { @@ -649,6 +763,94 @@ export default { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.certificate-panel { + margin-bottom: 16px; + padding: 18px; +} + +.certificate-panel__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 14px; +} + +.certificate-panel__header p { + margin: 6px 0 0; + color: var(--color-text-secondary); +} + +.certificate-panel__badges { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.certificate-panel__score { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.certificate-panel__state { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + background: rgba(185, 99, 24, 0.12); + color: #8d5412; + font-weight: 700; +} + +.certificate-panel__state.is-ready { + background: rgba(68, 138, 86, 0.14); + color: #2f6b3d; +} + +.certificate-panel__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.certificate-panel__block h4 { + margin: 0 0 10px; +} + +.certificate-requirements { + display: grid; + gap: 10px; +} + +.certificate-requirement { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + border-radius: var(--radius-md); + background: rgba(185, 99, 24, 0.08); +} + +.certificate-requirement.is-met { + background: rgba(68, 138, 86, 0.12); +} + +.certificate-status-options { + margin: 12px 0 0; + padding-left: 18px; +} + +.certificate-status-options li { + margin-bottom: 6px; +} + +.certificate-status-options li.is-met { + color: #2f6b3d; + font-weight: 600; +} + .summary-card, .routine-card { padding: 18px; @@ -936,9 +1138,17 @@ export default { @media (max-width: 900px) { .falukant-summary-grid, .falukant-routine-grid, + .certificate-panel__grid, .overviewcontainer { grid-template-columns: 1fr; } + + .certificate-panel__header, + .certificate-panel__score, + .certificate-requirement { + flex-direction: column; + align-items: flex-start; + } } .select-heir-button {