feat(political-office): enhance political office benefits and salary computation
All checks were successful
Deploy to production / deploy (push) Successful in 3m6s

- Added a new hierarchyLevel field to PoliticalOfficeType for better categorization of political roles.
- Updated computePoliticalDailySalaryPayout function to incorporate hierarchy level in salary calculations, allowing for more dynamic salary adjustments based on office rank.
- Modified SQL scripts to reflect changes in political office benefits, ensuring compatibility with the new salary structure.
- Enhanced localization files to support updated benefit descriptions and salary formats across multiple languages.
- Improved UI components to display the new salary calculations and benefits accurately in the PoliticsView.
This commit is contained in:
Torsten Schulz (local)
2026-04-02 16:49:18 +02:00
parent e063df5cbe
commit 07ab648143
17 changed files with 233 additions and 53 deletions

View File

@@ -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;
`);
}
};

View File

@@ -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',

View File

@@ -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^(Stufe1).
* 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 {

View File

@@ -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;

View File

@@ -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^(Stufe1); 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 (

View File

@@ -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 (

View File

@@ -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;