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 {