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.description') }}{{ $t('falukant.overview.certificate.title') }}
+ {{ $t('falukant.overview.certificate.factors') }}
+ {{ $t('falukant.overview.certificate.requirements') }}
+