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.

This commit is contained in:
Torsten Schulz (local)
2026-03-25 11:59:43 +01:00
parent b61a533eac
commit 44991743d2
5 changed files with 520 additions and 2 deletions

View File

@@ -14,6 +14,7 @@ import TitleBenefit from '../models/falukant/type/title_benefit.js';
import Branch from '../models/falukant/data/branch.js'; import Branch from '../models/falukant/data/branch.js';
import BranchType from '../models/falukant/type/branch.js'; import BranchType from '../models/falukant/type/branch.js';
import Production from '../models/falukant/data/production.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 ProductType from '../models/falukant/type/product.js';
import Knowledge from '../models/falukant/data/product_knowledge.js'; import Knowledge from '../models/falukant/data/product_knowledge.js';
import Inventory from '../models/falukant/data/inventory.js'; import Inventory from '../models/falukant/data/inventory.js';
@@ -138,6 +139,83 @@ const POLITICAL_OFFICE_RANKS = {
chancellor: 6 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) { async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null) {
// Wenn worthPercent nicht übergeben wurde, hole es aus der Datenbank // Wenn worthPercent nicht übergeben wurde, hole es aus der Datenbank
if (worthPercent === null) { if (worthPercent === null) {
@@ -823,9 +901,9 @@ class FalukantService extends BaseService {
include: [ include: [
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
{ model: FalukantPredefineLastname, as: 'definedLastName', 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, model: RegionData,
@@ -870,6 +948,7 @@ class FalukantService extends BaseService {
} }
if (userHouse) u.setDataValue('userHouse', userHouse); if (userHouse) u.setDataValue('userHouse', userHouse);
if (u.character?.birthdate) u.character.setDataValue('age', calcAge(u.character.birthdate)); 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)); u.setDataValue('debtorsPrison', await this.getDebtorsPrisonStateForUser(u));
return u; return u;
} }
@@ -2795,6 +2874,151 @@ class FalukantService extends BaseService {
return parseFloat(averageKnowledge[0]?.avgKnowledge || 0); 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) { formatProposals(proposals) {
return proposals.map((proposal) => { return proposals.map((proposal) => {
const age = Math.floor((Date.now() - new Date(proposal.character.birthdate)) / (24 * 60 * 60 * 1000)); const age = Math.floor((Date.now() - new Date(proposal.character.birthdate)) / (24 * 60 * 60 * 1000));

View File

@@ -138,6 +138,34 @@
"nobleTitle": "Stand", "nobleTitle": "Stand",
"certificate": "Zertifikat" "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": { "productions": {
"title": "Produktionen" "title": "Produktionen"
}, },

View File

@@ -118,6 +118,34 @@
"mainbranch": "Home city", "mainbranch": "Home city",
"nobleTitle": "Title", "nobleTitle": "Title",
"certificate": "Certificate" "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": { "health": {

View File

@@ -128,6 +128,34 @@
"nobleTitle": "Rango", "nobleTitle": "Rango",
"certificate": "Certificado" "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": { "productions": {
"title": "Producciones" "title": "Producciones"
}, },

View File

@@ -97,6 +97,99 @@
</article> </article>
</section> </section>
<section v-if="certificateProgress" class="certificate-panel surface-card">
<div class="certificate-panel__header">
<div>
<h3>{{ $t('falukant.overview.certificate.title') }}</h3>
<p>{{ $t('falukant.overview.certificate.description') }}</p>
</div>
<div class="certificate-panel__badges">
<span class="summary-card__label">{{ $t('falukant.overview.certificate.current') }}: {{ certificateProgress.currentCertificate }}</span>
<span class="summary-card__label">{{ $t('falukant.overview.certificate.next') }}: {{ certificateProgress.nextCertificate }}</span>
</div>
</div>
<div class="certificate-panel__score">
<span>{{ $t('falukant.overview.certificate.score') }}</span>
<strong>{{ certificateProgress.score }}</strong>
<span class="certificate-panel__state" :class="{ 'is-ready': certificateProgress.readyForNextCertificate }">
{{ certificateProgress.readyForNextCertificate
? $t('falukant.overview.certificate.ready')
: $t('falukant.overview.certificate.notReady') }}
</span>
</div>
<div class="certificate-panel__grid">
<article class="certificate-panel__block">
<h4>{{ $t('falukant.overview.certificate.factors') }}</h4>
<div class="detail-list">
<div class="detail-list__item">
<span>{{ $t('falukant.overview.certificate.factor.avgKnowledge') }}</span>
<strong>{{ formatCertificateValue(certificateProgress.currentValues.avgKnowledge, 1) }}</strong>
</div>
<div class="detail-list__item">
<span>{{ $t('falukant.overview.certificate.factor.completedProductions') }}</span>
<strong>{{ certificateProgress.currentValues.completedProductions }}</strong>
</div>
<div class="detail-list__item">
<span>{{ $t('falukant.overview.certificate.factor.reputation') }}</span>
<strong>{{ certificateProgress.currentValues.reputation }}</strong>
</div>
<div class="detail-list__item">
<span>{{ $t('falukant.overview.certificate.factor.housePosition') }}</span>
<strong>{{ certificateProgress.currentValues.housePosition }}</strong>
</div>
<div class="detail-list__item">
<span>{{ $t('falukant.overview.certificate.factor.highestPoliticalOfficeRank') }}</span>
<strong>{{ certificateProgress.currentValues.highestPoliticalOfficeRank }}</strong>
</div>
<div class="detail-list__item">
<span>{{ $t('falukant.overview.certificate.factor.highestChurchOfficeRank') }}</span>
<strong>{{ certificateProgress.currentValues.highestChurchOfficeRank }}</strong>
</div>
<div class="detail-list__item">
<span>{{ $t('falukant.overview.certificate.factor.nobilityLevel') }}</span>
<strong>{{ certificateProgress.currentValues.nobilityLevel }}</strong>
</div>
</div>
</article>
<article class="certificate-panel__block">
<h4>{{ $t('falukant.overview.certificate.requirements') }}</h4>
<div class="certificate-requirements">
<div
v-for="requirement in certificateProgress.nextRequirements"
:key="requirement.type"
class="certificate-requirement"
:class="{ 'is-met': requirement.met }"
>
<span>{{ certificateRequirementLabel(requirement.type) }}</span>
<strong>{{ formatCertificateRequirement(requirement.current, requirement.required) }}</strong>
</div>
<div
v-if="certificateProgress.statusRequirement"
class="certificate-requirement"
:class="{ 'is-met': certificateProgress.statusRequirement.fulfilled }"
>
<span>{{ certificateStatusRequirementLabel(certificateProgress.statusRequirement.mode) }}</span>
<strong>{{ certificateProgress.statusRequirement.metCount }}/{{ certificateProgress.statusRequirement.requiredCount }}</strong>
</div>
</div>
<ul v-if="certificateProgress.statusRequirement?.options?.length" class="certificate-status-options">
<li
v-for="option in certificateProgress.statusRequirement.options"
:key="option.type"
:class="{ 'is-met': option.met }"
>
{{ certificateRequirementLabel(option.type) }}:
{{ formatCertificateRequirement(option.current, option.required) }}
</li>
</ul>
</article>
</div>
</section>
<!-- Erben-Auswahl wenn kein Charakter vorhanden --> <!-- Erben-Auswahl wenn kein Charakter vorhanden -->
<div v-if="!falukantUser?.character" class="heir-selection-container"> <div v-if="!falukantUser?.character" class="heir-selection-container">
<h3>{{ $t('falukant.overview.heirSelection.title') }}</h3> <h3>{{ $t('falukant.overview.heirSelection.title') }}</h3>
@@ -313,6 +406,9 @@ export default {
stockEntryCount() { stockEntryCount() {
return this.allStock.length; return this.allStock.length;
}, },
certificateProgress() {
return this.falukantUser?.certificateProgress || null;
},
routineActions() { routineActions() {
return [ return [
{ {
@@ -553,6 +649,24 @@ export default {
formatDate(timestamp) { formatDate(timestamp) {
return new Date(timestamp).toLocaleString(); 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() { async fetchPotentialHeirs() {
this.loadingHeirs = true; this.loadingHeirs = true;
try { try {
@@ -649,6 +763,94 @@ export default {
grid-template-columns: repeat(4, minmax(0, 1fr)); 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, .summary-card,
.routine-card { .routine-card {
padding: 18px; padding: 18px;
@@ -936,9 +1138,17 @@ export default {
@media (max-width: 900px) { @media (max-width: 900px) {
.falukant-summary-grid, .falukant-summary-grid,
.falukant-routine-grid, .falukant-routine-grid,
.certificate-panel__grid,
.overviewcontainer { .overviewcontainer {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.certificate-panel__header,
.certificate-panel__score,
.certificate-requirement {
flex-direction: column;
align-items: flex-start;
}
} }
.select-heir-button { .select-heir-button {