diff --git a/backend/migrations/20260403120000-political-office-hierarchy-level.cjs b/backend/migrations/20260403120000-political-office-hierarchy-level.cjs new file mode 100644 index 0000000..9aebf52 --- /dev/null +++ b/backend/migrations/20260403120000-political-office-hierarchy-level.cjs @@ -0,0 +1,50 @@ +'use strict'; + +/** Stufe pro politischem Amt (Tageshonorar: base + perRank × hierarchy_level). */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE falukant_type.political_office_type + ADD COLUMN IF NOT EXISTS hierarchy_level INTEGER NOT NULL DEFAULT 1; + `); + + await queryInterface.sequelize.query(` + UPDATE falukant_type.political_office_type AS pot + SET hierarchy_level = sub.lvl + FROM (VALUES + ('assessor', 1), + ('councillor', 1), + ('council', 2), + ('beadle', 2), + ('town-clerk', 2), + ('mayor', 3), + ('master-builder', 2), + ('village-major', 2), + ('judge', 3), + ('bailif', 3), + ('taxman', 2), + ('sheriff', 3), + ('consultant', 3), + ('treasurer', 4), + ('hangman', 2), + ('territorial-council', 3), + ('territorial-council-speaker', 4), + ('ruler-consultant', 4), + ('state-administrator', 4), + ('super-state-administrator', 5), + ('governor', 5), + ('ministry-helper', 4), + ('minister', 5), + ('chancellor', 6) + ) AS sub(name, lvl) + WHERE pot.name = sub.name; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE falukant_type.political_office_type + DROP COLUMN IF EXISTS hierarchy_level; + `); + } +}; diff --git a/backend/models/falukant/type/political_office_type.js b/backend/models/falukant/type/political_office_type.js index 7adecdd..60400df 100644 --- a/backend/models/falukant/type/political_office_type.js +++ b/backend/models/falukant/type/political_office_type.js @@ -17,10 +17,18 @@ PoliticalOfficeType.init({ regionType: { type: DataTypes.STRING, allowNull: false}, - termLength: { + termLength: { type: DataTypes.INTEGER, allowNull: false, - defaultValue: 0}}, { + defaultValue: 0}, + /** Stufe für Tageshonorar (base + perRank × level) und Sortierung; 1 = niedrigstes politisches Level */ + hierarchyLevel: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 1, + field: 'hierarchy_level' + } +}, { sequelize, modelName: 'PoliticalOfficeType', tableName: 'political_office_type', diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index ad21583..d532abe 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -134,15 +134,53 @@ const POLITICAL_OFFICE_RANKS = { chancellor: 6 }; -function computePoliticalDailySalaryPayout(value, officeName) { +const _envPolSalaryStart = Number(process.env.POLITICAL_DAILY_SALARY_START); +const _envPolSalaryGrowth = Number(process.env.POLITICAL_DAILY_SALARY_GROWTH); +/** Tageshonorar niedrigste Stufe (Default ~50; per ENV überschreibbar). */ +const POLITICAL_DAILY_SALARY_START_DEFAULT = + Number.isFinite(_envPolSalaryStart) && _envPolSalaryStart > 0 ? _envPolSalaryStart : 50; +/** Pro hierarchy_level leicht exponentiell (Default 1,22; per ENV überschreibbar). */ +const POLITICAL_DAILY_SALARY_GROWTH_DEFAULT = + Number.isFinite(_envPolSalaryGrowth) && _envPolSalaryGrowth > 1 ? _envPolSalaryGrowth : 1.22; + +/** + * Tageshonorar: value.dailyAmount fest, sonst salaryStart × salaryGrowth^(Stufe−1). + * Stufe: value.rank (JSON), sonst hierarchy_level, sonst POLITICAL_OFFICE_RANKS[name]. + * Optional im JSON: salaryStart, salaryGrowth (pro Amt); ENV POLITICAL_DAILY_SALARY_* (global). + * Legacy linear: nur bei salaryFormula === 'linear' mit base und perRank. + */ +function computePoliticalDailySalaryPayout(value, officeName, hierarchyLevelFromType) { const v = value && typeof value === 'object' ? value : {}; if (v.dailyAmount != null && Number.isFinite(Number(v.dailyAmount))) { return Math.round(Number(v.dailyAmount) * 100) / 100; } - const rank = POLITICAL_OFFICE_RANKS[officeName] ?? 0; - const base = Number(v.base ?? 0); - const perRank = Number(v.perRank ?? v.per_rank ?? 0); - return Math.round((base + perRank * rank) * 100) / 100; + const nameKey = typeof officeName === 'string' ? officeName.trim() : officeName; + let rank = 0; + if (v.rank != null && Number.isFinite(Number(v.rank))) { + rank = Math.max(0, Number(v.rank)); + } else { + const fromDb = Number(hierarchyLevelFromType); + if (Number.isFinite(fromDb) && fromDb > 0) { + rank = fromDb; + } else { + rank = POLITICAL_OFFICE_RANKS[nameKey] ?? 0; + } + } + if (rank < 1) { + return 0; + } + if (v.salaryFormula === 'linear') { + const base = Number(v.base ?? 0); + const perRank = Number(v.perRank ?? v.per_rank ?? 0); + return Math.round((base + perRank * rank) * 100) / 100; + } + const startRaw = Number(v.salaryStart ?? v.salary_start); + const growthRaw = Number(v.salaryGrowth ?? v.salary_growth); + const start = Number.isFinite(startRaw) && startRaw > 0 ? startRaw : POLITICAL_DAILY_SALARY_START_DEFAULT; + const growth = + Number.isFinite(growthRaw) && growthRaw > 1 ? growthRaw : POLITICAL_DAILY_SALARY_GROWTH_DEFAULT; + const amount = start * growth ** (rank - 1); + return Math.round(amount * 100) / 100; } const CERTIFICATE_THRESHOLDS = { @@ -6089,7 +6127,7 @@ class FalukantService extends BaseService { return this.healthChange(user, delta); } - politicsBenefitEntriesFromRows(benefitRows, officeName) { + politicsBenefitEntriesFromRows(benefitRows, officeName, hierarchyLevelFromType) { if (!benefitRows?.length) { return []; } @@ -6106,7 +6144,7 @@ class FalukantService extends BaseService { out.push({ tr: 'tax_exemption', params: { regions } }); } } else if (tr === 'daily_salary' || tr === 'salary') { - const amount = computePoliticalDailySalaryPayout(v, officeName); + const amount = computePoliticalDailySalaryPayout(v, officeName, hierarchyLevelFromType); if (amount > 0) { out.push({ tr: 'daily_salary', params: { amount } }); } @@ -6150,7 +6188,7 @@ class FalukantService extends BaseService { const held = await PoliticalOffice.findAll({ where: { characterId }, - include: [{ model: PoliticalOfficeType, as: 'type', attributes: ['name'] }] + include: [{ model: PoliticalOfficeType, as: 'type', attributes: ['name', 'hierarchyLevel'] }] }); if (!held.length) { return; @@ -6180,7 +6218,8 @@ class FalukantService extends BaseService { continue; } const v = typeof row.value === 'object' && row.value ? row.value : {}; - total += computePoliticalDailySalaryPayout(v, name); + const level = h.type?.hierarchyLevel; + total += computePoliticalDailySalaryPayout(v, name, level); } } @@ -6235,7 +6274,7 @@ class FalukantService extends BaseService { { model: PoliticalOfficeType, as: 'type', - attributes: ['name', 'termLength'] + attributes: ['name', 'termLength', 'hierarchyLevel'] }, { model: RegionData, @@ -6311,7 +6350,8 @@ class FalukantService extends BaseService { const officeName = o.type?.name; const benefit = this.politicsBenefitEntriesFromRows( benefitByType.get(o.officeTypeId) || [], - officeName + officeName, + o.type?.hierarchyLevel ); return { diff --git a/backend/sql/add_political_office_hierarchy_level.sql b/backend/sql/add_political_office_hierarchy_level.sql new file mode 100644 index 0000000..4fafe58 --- /dev/null +++ b/backend/sql/add_political_office_hierarchy_level.sql @@ -0,0 +1,35 @@ +-- Stufe pro politischem Amt (Tageshonorar: exponentielles Modell nach Stufe im Backend). +-- Entspricht Migration 20260403120000-political-office-hierarchy-level.cjs + +ALTER TABLE falukant_type.political_office_type + ADD COLUMN IF NOT EXISTS hierarchy_level INTEGER NOT NULL DEFAULT 1; + +UPDATE falukant_type.political_office_type AS pot +SET hierarchy_level = sub.lvl +FROM (VALUES + ('assessor', 1), + ('councillor', 1), + ('council', 2), + ('beadle', 2), + ('town-clerk', 2), + ('mayor', 3), + ('master-builder', 2), + ('village-major', 2), + ('judge', 3), + ('bailif', 3), + ('taxman', 2), + ('sheriff', 3), + ('consultant', 3), + ('treasurer', 4), + ('hangman', 2), + ('territorial-council', 3), + ('territorial-council-speaker', 4), + ('ruler-consultant', 4), + ('state-administrator', 4), + ('super-state-administrator', 5), + ('governor', 5), + ('ministry-helper', 4), + ('minister', 5), + ('chancellor', 6) +) AS sub(name, lvl) +WHERE pot.name = sub.name; diff --git a/backend/sql/falukant_political_office_benefits.sql b/backend/sql/falukant_political_office_benefits.sql index 61fd693..d926051 100644 --- a/backend/sql/falukant_political_office_benefits.sql +++ b/backend/sql/falukant_political_office_benefits.sql @@ -21,8 +21,11 @@ -- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f backend/sql/falukant_political_office_benefits.sql -- -- JSON value daily_salary: --- • dailyAmount: fester Tagesbetrag (überschreibt base/perRank) --- • base + perRank: Auszahlung = base + (Amtsrang * perRank) +-- • dailyAmount: fester Tagesbetrag (überschreibt alles) +-- • rank: optional feste Stufe in JSON (überschreibt DB-Stufe) +-- • Standard: salaryStart × salaryGrowth^(Stufe−1); Stufe = hierarchy_level (s. Migration political-office-hierarchy-level) +-- • Optional: salaryStart, salaryGrowth pro Amt; ENV POLITICAL_DAILY_SALARY_START / POLITICAL_DAILY_SALARY_GROWTH +-- • Legacy linear: salaryFormula = 'linear' und base, perRank -- JSON value tax_exemption: -- • regions: Text-Array (z. B. "city") oder "*" für alle Ebenen -- ============================================================================= @@ -145,9 +148,9 @@ WHERE ot.name = 'chancellor' WHERE x.office_type_id = ot.id AND x.benefit_type_id = bt.id ); --- Tageslohn: base + Amtsrang * perRank (Rang in App: POLITICAL_OFFICE_RANKS) +-- Tageslohn: Betrag aus App-Formel (exponentiell nach hierarchy_level); value leer = Defaults INSERT INTO falukant_predefine.political_office_benefit (office_type_id, benefit_type_id, value) -SELECT ot.id, bt.id, '{"base":4,"perRank":11}'::jsonb +SELECT ot.id, bt.id, '{}'::jsonb FROM falukant_type.political_office_type ot JOIN falukant_type.political_office_benefit_type bt ON bt.tr = 'daily_salary' WHERE ot.name IN ( diff --git a/backend/sql/falukant_political_office_benefits_steps.sql b/backend/sql/falukant_political_office_benefits_steps.sql index dcc8d86..5ceb95b 100644 --- a/backend/sql/falukant_political_office_benefits_steps.sql +++ b/backend/sql/falukant_political_office_benefits_steps.sql @@ -125,7 +125,7 @@ WHERE ot.name = 'chancellor' -- ========== Schritt 7 ========== INSERT INTO falukant_predefine.political_office_benefit (office_type_id, benefit_type_id, value) -SELECT ot.id, bt.id, '{"base":4,"perRank":11}'::jsonb +SELECT ot.id, bt.id, '{}'::jsonb FROM falukant_type.political_office_type ot JOIN falukant_type.political_office_benefit_type bt ON bt.tr = 'daily_salary' WHERE ot.name IN ( diff --git a/backend/utils/falukant/initializeFalukantTypes.js b/backend/utils/falukant/initializeFalukantTypes.js index bbc031b..e2fdf24 100644 --- a/backend/utils/falukant/initializeFalukantTypes.js +++ b/backend/utils/falukant/initializeFalukantTypes.js @@ -421,30 +421,30 @@ const politicalOfficeExtraBenefitSeeds = [ ]; const politicalOffices = [ - { tr: "assessor", seatsPerRegion: 10, regionType: "city", termLength: 5 }, - { tr: "councillor", seatsPerRegion: 7, regionType: "city", termLength: 7 }, - { tr: "council", seatsPerRegion: 5, regionType: "city", termLength: 4 }, - { tr: "beadle", seatsPerRegion: 1, regionType: "city", termLength: 6 }, - { tr: "town-clerk", seatsPerRegion: 3, regionType: "city", termLength: 10 }, - { tr: "mayor", seatsPerRegion: 1, regionType: "city", termLength: 3 }, - { tr: "master-builder", seatsPerRegion: 10, regionType: "county", termLength: 10 }, - { tr: "village-major", seatsPerRegion: 6, regionType: "county", termLength: 5 }, - { tr: "judge", seatsPerRegion: 3, regionType: "county", termLength: 8 }, - { tr: "bailif", seatsPerRegion: 1, regionType: "county", termLength: 4 }, - { tr: "taxman", seatsPerRegion: 8, regionType: "shire", termLength: 5 }, - { tr: "sheriff", seatsPerRegion: 5, regionType: "shire", termLength: 8 }, - { tr: "consultant", seatsPerRegion: 3, regionType: "shire", termLength: 9 }, - { tr: "treasurer", seatsPerRegion: 1, regionType: "shire", termLength: 7 }, - { tr: "hangman", seatsPerRegion: 9, regionType: "markgravate", termLength: 5 }, - { tr: "territorial-council", seatsPerRegion: 6, regionType: "markgravate", termLength: 6 }, - { tr: "territorial-council-speaker", seatsPerRegion: 4, regionType: "markgravate", termLength: 8 }, - { tr: "ruler-consultant", seatsPerRegion: 1, regionType: "markgravate", termLength: 3 }, - { tr: "state-administrator", seatsPerRegion: 7, regionType: "duchy", termLength: 3 }, - { tr: "super-state-administrator", seatsPerRegion: 5, regionType: "duchy", termLength: 6 }, - { tr: "governor", seatsPerRegion: 1, regionType: "duchy", termLength: 5 }, - { tr: "ministry-helper", seatsPerRegion: 12, regionType: "country", termLength: 4 }, - { tr: "minister", seatsPerRegion: 3, regionType: "country", termLength: 4 }, - { tr: "chancellor", seatsPerRegion: 1, regionType: "country", termLength: 4 } + { tr: "assessor", seatsPerRegion: 10, regionType: "city", termLength: 5, hierarchyLevel: 1 }, + { tr: "councillor", seatsPerRegion: 7, regionType: "city", termLength: 7, hierarchyLevel: 1 }, + { tr: "council", seatsPerRegion: 5, regionType: "city", termLength: 4, hierarchyLevel: 2 }, + { tr: "beadle", seatsPerRegion: 1, regionType: "city", termLength: 6, hierarchyLevel: 2 }, + { tr: "town-clerk", seatsPerRegion: 3, regionType: "city", termLength: 10, hierarchyLevel: 2 }, + { tr: "mayor", seatsPerRegion: 1, regionType: "city", termLength: 3, hierarchyLevel: 3 }, + { tr: "master-builder", seatsPerRegion: 10, regionType: "county", termLength: 10, hierarchyLevel: 2 }, + { tr: "village-major", seatsPerRegion: 6, regionType: "county", termLength: 5, hierarchyLevel: 2 }, + { tr: "judge", seatsPerRegion: 3, regionType: "county", termLength: 8, hierarchyLevel: 3 }, + { tr: "bailif", seatsPerRegion: 1, regionType: "county", termLength: 4, hierarchyLevel: 3 }, + { tr: "taxman", seatsPerRegion: 8, regionType: "shire", termLength: 5, hierarchyLevel: 2 }, + { tr: "sheriff", seatsPerRegion: 5, regionType: "shire", termLength: 8, hierarchyLevel: 3 }, + { tr: "consultant", seatsPerRegion: 3, regionType: "shire", termLength: 9, hierarchyLevel: 3 }, + { tr: "treasurer", seatsPerRegion: 1, regionType: "shire", termLength: 7, hierarchyLevel: 4 }, + { tr: "hangman", seatsPerRegion: 9, regionType: "markgravate", termLength: 5, hierarchyLevel: 2 }, + { tr: "territorial-council", seatsPerRegion: 6, regionType: "markgravate", termLength: 6, hierarchyLevel: 3 }, + { tr: "territorial-council-speaker", seatsPerRegion: 4, regionType: "markgravate", termLength: 8, hierarchyLevel: 4 }, + { tr: "ruler-consultant", seatsPerRegion: 1, regionType: "markgravate", termLength: 3, hierarchyLevel: 4 }, + { tr: "state-administrator", seatsPerRegion: 7, regionType: "duchy", termLength: 3, hierarchyLevel: 4 }, + { tr: "super-state-administrator", seatsPerRegion: 5, regionType: "duchy", termLength: 6, hierarchyLevel: 5 }, + { tr: "governor", seatsPerRegion: 1, regionType: "duchy", termLength: 5, hierarchyLevel: 5 }, + { tr: "ministry-helper", seatsPerRegion: 12, regionType: "country", termLength: 4, hierarchyLevel: 4 }, + { tr: "minister", seatsPerRegion: 3, regionType: "country", termLength: 4, hierarchyLevel: 5 }, + { tr: "chancellor", seatsPerRegion: 1, regionType: "country", termLength: 4, hierarchyLevel: 6 } ]; const politicalOfficePrerequisites = [ @@ -1063,14 +1063,22 @@ export const initializePoliticalOfficeBenefitTypes = async () => { export const initializePoliticalOfficeTypes = async () => { for (const po of politicalOffices) { - await PoliticalOfficeType.findOrCreate({ + const level = Number(po.hierarchyLevel) >= 1 ? Number(po.hierarchyLevel) : 1; + const [row, created] = await PoliticalOfficeType.findOrCreate({ where: { name: po.tr }, defaults: { seatsPerRegion: po.seatsPerRegion, regionType: po.regionType, - termLength: po.termLength + termLength: po.termLength, + hierarchyLevel: level } }); + if (!created) { + await PoliticalOfficeType.update( + { hierarchyLevel: level }, + { where: { id: row.id } } + ); + } } }; @@ -1141,7 +1149,7 @@ export const initializePoliticalOfficeBenefits = async () => { defaults: { officeTypeId: office.id, benefitTypeId: dailyType.id, - value: { base: 4, perRank: 11 } + value: {} } }); if (wasCreated) dailyCreated += 1; diff --git a/frontend/src/i18n/locales/ceb/falukant.json b/frontend/src/i18n/locales/ceb/falukant.json index 02bc92d..3dcd4ec 100644 --- a/frontend/src/i18n/locales/ceb/falukant.json +++ b/frontend/src/i18n/locales/ceb/falukant.json @@ -436,7 +436,7 @@ "voteAllError": "Sayop sa paghatag sa mga boto", "applyError": "Dili mapadala ang aplikasyon.", "benefits": { - "daily_salary": "Adlaw-adlaw nga suhol (usa ra kada adlaw): mga {amount}", + "daily_salary": "Adlaw-adlaw nga suhol (usa ra kada adlaw): {amount}", "tax_exemption": "Wa’y buhis: {regions}", "tax_exemption_all": "Wa’y buhis: tanang lebel sa rehiyon", "reputation_periodic": "+{gain} reputasyon matag {days} ka adlaw (benepisyo sa opisina)", diff --git a/frontend/src/i18n/locales/ceb/socialnetwork.json b/frontend/src/i18n/locales/ceb/socialnetwork.json index 5b249a5..82e6e91 100644 --- a/frontend/src/i18n/locales/ceb/socialnetwork.json +++ b/frontend/src/i18n/locales/ceb/socialnetwork.json @@ -391,6 +391,8 @@ "reviewPriorityTitle": "Hinay-hinay nga gisagol ang balik-balik", "reviewPriorityIntro": "Sa sinugdan, ang pokus anaa sa bag-ong mga pulong niining leksiyona. Samtang mopadayon ka, hinay-hinay nga masagol ang daan nga bokabularyo.", "exerciseLockTitle": "Naka-lock pa ang chapter test", + "exerciseUnlockHintTrainerCore": "Ma-unlock ang chapter test kung natuman ang tulo ka kondisyon: labing menos {newTarget} ka pangutana bahin sa bag-ong sulod (tan-awa ang “Bag-ong sulod”), mga {attempts} ka pangutana sa trainer sa kinatibuk-an, ug dili ubos sa {rate}% nga success rate.", + "exerciseUnlockHintTrainerMixSuffix": "Ang daan nga bokabularyo hinay-hinay nga gisagol.", "trainerStartWithReview": "Sugdi sa bag-ong bokabularyo niining leksiyona. Samtang nagpraktis ka, awtomatikong isagol sa trainer ang angay nga balik-balik.", "startLesson": "Sugdi ang leksiyon", "trainerProgressNewContent": "Bag-ong sulod: {current}/{target}", diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index c04cf10..0b95d46 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -1357,7 +1357,7 @@ "voteAllError": "Fehler beim Abgeben der Stimmen", "applyError": "Bewerbung konnte nicht eingereicht werden.", "benefits": { - "daily_salary": "Tagesamtshonorar (einmal pro Tag): ca. {amount}", + "daily_salary": "Tagesamtshonorar (einmal pro Tag): {amount}", "tax_exemption": "Steuerbefreiung: {regions}", "tax_exemption_all": "Steuerbefreiung: alle Regionsebenen", "reputation_periodic": "+{gain} Ansehen alle {days} Tage (Amtsbonus)", diff --git a/frontend/src/i18n/locales/de/socialnetwork.json b/frontend/src/i18n/locales/de/socialnetwork.json index b37015c..130bf6b 100644 --- a/frontend/src/i18n/locales/de/socialnetwork.json +++ b/frontend/src/i18n/locales/de/socialnetwork.json @@ -746,6 +746,8 @@ "reviewPriorityTitle": "Wiederholung läuft schrittweise mit", "reviewPriorityIntro": "Zuerst liegt der Fokus auf den neuen Begriffen dieser Lektion. Mit deinem Fortschritt fließen ältere Vokabeln dann zunehmend mit ein.", "exerciseLockTitle": "Kapitel-Prüfung noch gesperrt", + "exerciseUnlockHintTrainerCore": "Die Kapitel-Prüfung wird freigeschaltet, wenn alle drei Bedingungen erfüllt sind: mindestens {newTarget} Fragen zu den neuen Inhalten (Zeile „Neue Inhalte“), insgesamt etwa {attempts} Trainerfragen und mindestens {rate} % Erfolgsrate.", + "exerciseUnlockHintTrainerMixSuffix": "Ältere Vokabeln fließen dabei schrittweise mit ein.", "trainerStartWithReview": "Starte mit den neuen Vokabeln dieser Lektion. Mit fortschreitendem Üben mischt der Trainer automatisch passende Wiederholungen ein.", "startLesson": "Lektion starten", "trainerProgressNewContent": "Neue Inhalte: {current}/{target}", diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json index 313b3e1..6e45e8b 100644 --- a/frontend/src/i18n/locales/en/falukant.json +++ b/frontend/src/i18n/locales/en/falukant.json @@ -576,7 +576,7 @@ "voteAllError": "Error while submitting the votes", "applyError": "Application could not be submitted.", "benefits": { - "daily_salary": "Daily office stipend (once per day): about {amount}", + "daily_salary": "Daily office stipend (once per day): {amount}", "tax_exemption": "Tax exemption: {regions}", "tax_exemption_all": "Tax exemption: all regional levels", "reputation_periodic": "+{gain} reputation every {days} days (office bonus)", diff --git a/frontend/src/i18n/locales/en/socialnetwork.json b/frontend/src/i18n/locales/en/socialnetwork.json index d024f5e..aafad72 100644 --- a/frontend/src/i18n/locales/en/socialnetwork.json +++ b/frontend/src/i18n/locales/en/socialnetwork.json @@ -746,6 +746,8 @@ "reviewPriorityTitle": "Review is mixed in step by step", "reviewPriorityIntro": "The focus starts on the new terms of this lesson. As you progress, older vocabulary is gradually mixed in.", "exerciseLockTitle": "Chapter test still locked", + "exerciseUnlockHintTrainerCore": "The chapter test unlocks when all three conditions are met: at least {newTarget} questions on new content (see “New content”), about {attempts} trainer questions in total, and a success rate of at least {rate}%.", + "exerciseUnlockHintTrainerMixSuffix": "Older vocabulary is mixed in gradually.", "trainerStartWithReview": "Start with the new vocabulary from this lesson. As you practice, the trainer will automatically mix in fitting review items.", "startLesson": "Start lesson", "trainerProgressNewContent": "New content: {current}/{target}", diff --git a/frontend/src/i18n/locales/es/falukant.json b/frontend/src/i18n/locales/es/falukant.json index c66910b..0619a54 100644 --- a/frontend/src/i18n/locales/es/falukant.json +++ b/frontend/src/i18n/locales/es/falukant.json @@ -1265,7 +1265,7 @@ "elections": "Elecciones" }, "benefits": { - "daily_salary": "Estipendio diario (una vez al día): unos {amount}", + "daily_salary": "Estipendio diario (una vez al día): {amount}", "tax_exemption": "Exención fiscal: {regions}", "tax_exemption_all": "Exención fiscal: todos los niveles regionales", "reputation_periodic": "+{gain} reputación cada {days} días (bono de cargo)", diff --git a/frontend/src/i18n/locales/es/socialnetwork.json b/frontend/src/i18n/locales/es/socialnetwork.json index 21a80ab..0a387de 100644 --- a/frontend/src/i18n/locales/es/socialnetwork.json +++ b/frontend/src/i18n/locales/es/socialnetwork.json @@ -744,6 +744,8 @@ "reviewPriorityTitle": "El repaso se mezcla paso a paso", "reviewPriorityIntro": "Primero el foco está en los términos nuevos de esta lección. Con tu progreso se van mezclando cada vez más vocablos anteriores.", "exerciseLockTitle": "La prueba del capítulo sigue bloqueada", + "exerciseUnlockHintTrainerCore": "La prueba del capítulo se desbloquea cuando se cumplen las tres condiciones: al menos {newTarget} preguntas sobre el contenido nuevo (línea «Contenido nuevo»), unas {attempts} preguntas del entrenador en total y una tasa de aciertos de al menos {rate}%.", + "exerciseUnlockHintTrainerMixSuffix": "El vocabulario anterior se mezcla poco a poco.", "trainerStartWithReview": "Empieza con el vocabulario nuevo de esta lección. A medida que avances, el entrenador mezclará automáticamente repasos adecuados.", "startLesson": "Empezar lección", "trainerProgressNewContent": "Contenido nuevo: {current}/{target}", diff --git a/frontend/src/views/falukant/PoliticsView.vue b/frontend/src/views/falukant/PoliticsView.vue index c73aeea..616c975 100644 --- a/frontend/src/views/falukant/PoliticsView.vue +++ b/frontend/src/views/falukant/PoliticsView.vue @@ -335,7 +335,9 @@ export default { return this.$t('falukant.politics.benefits.tax_exemption', { regions: labels }); } if (b.tr === 'daily_salary') { - return this.$t('falukant.politics.benefits.daily_salary', { amount: b.params?.amount ?? '—' }); + return this.$t('falukant.politics.benefits.daily_salary', { + amount: this.formatPoliticsMoney(b.params?.amount) + }); } if (b.tr === 'reputation_periodic') { return this.$t('falukant.politics.benefits.reputation_periodic', { @@ -586,6 +588,18 @@ export default { }); }, + /** Geldbetrag wie in Familie/Bank (exakter Wert, kein „ca.“). */ + formatPoliticsMoney(value) { + const n = Number(value); + if (!Number.isFinite(n)) { + return '—'; + } + return new Intl.NumberFormat(this.$i18n.locale, { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(n); + }, + async loadOwnCharacterId() { try { const { data } = await apiClient.get('/api/falukant/info'); diff --git a/frontend/src/views/social/VocabLessonView.vue b/frontend/src/views/social/VocabLessonView.vue index a756288..7ccee39 100644 --- a/frontend/src/views/social/VocabLessonView.vue +++ b/frontend/src/views/social/VocabLessonView.vue @@ -874,6 +874,8 @@ import apiClient from '@/utils/axios.js'; const debugLog = () => {}; const LESSON_STATE_VERSION = 1; const VOCAB_REPEAT_INTERVALS = [1, 2, 4]; +/** Mindest-Erfolgsquote im Vokabeltrainer (gesamt), damit die Kapitel-Prüfung freigeschaltet wird. */ +const EXERCISE_UNLOCK_MIN_SUCCESS_PERCENT = 70; export default { name: 'VocabLessonView', @@ -991,6 +993,9 @@ export default { const unlockTarget = this.trainerNewFocusTarget + Math.ceil((this.effectiveExercises?.length || 0) * 0.25); return Math.max(6, Math.min(140, unlockTarget)); }, + exerciseUnlockMinSuccessPercent() { + return EXERCISE_UNLOCK_MIN_SUCCESS_PERCENT; + }, currentReviewShare() { if (!this.hasPreviousVocab) { return 0; @@ -1018,10 +1023,15 @@ export default { if (this.trainableLessonVocab.length === 0 && this.prepItems.length > 0) { return this.$t('socialnetwork.vocab.courses.exerciseUnlockHintAfterPrep'); } + const core = this.$t('socialnetwork.vocab.courses.exerciseUnlockHintTrainerCore', { + newTarget: this.trainerNewFocusTarget, + attempts: this.trainerExerciseUnlockAttempts, + rate: this.exerciseUnlockMinSuccessPercent + }); if (this.hasPreviousVocab) { - return `Lerne zuerst die neuen Inhalte der Lektion und arbeite dich durch ungefähr ${this.trainerExerciseUnlockAttempts} Trainerfragen. Ältere Vokabeln werden dabei nach und nach zugemischt.`; + return `${core} ${this.$t('socialnetwork.vocab.courses.exerciseUnlockHintTrainerMixSuffix')}`; } - return `Arbeite zuerst durch ungefähr ${this.trainerExerciseUnlockAttempts} Trainerfragen aus dieser Lektion. Danach wird die Kapitel-Prüfung freigeschaltet.`; + return core; }, /** Für Wiederholungslektionen: Übungen aus vorherigen Lektionen (Kapitelprüfung). Sonst: eigene Grammatik-Übungen. */ effectiveExercises() { @@ -1697,7 +1707,11 @@ export default { : 0; const currentLessonReady = this.vocabTrainerCurrentAttempts >= this.trainerNewFocusTarget; - if (currentLessonReady && this.vocabTrainerTotalAttempts >= minimumAttempts && successRate >= 70) { + if ( + currentLessonReady + && this.vocabTrainerTotalAttempts >= minimumAttempts + && successRate >= EXERCISE_UNLOCK_MIN_SUCCESS_PERCENT + ) { this.exercisePreparationCompleted = true; } },