diff --git a/backend/controllers/falukantController.js b/backend/controllers/falukantController.js
index dddc9ac..9a7e5ab 100644
--- a/backend/controllers/falukantController.js
+++ b/backend/controllers/falukantController.js
@@ -1,4 +1,5 @@
import FalukantService from '../services/falukantService.js';
+import politicalPowersService from '../services/falukantPoliticalPowersService.js';
function extractHashedUserId(req) {
return req.headers?.userid;
@@ -212,6 +213,23 @@ class FalukantController {
this.vote = this._wrapWithUser((userId, req) => this.service.vote(userId, req.body.votes), { blockInDebtorsPrison: true });
this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds), { blockInDebtorsPrison: true });
+ this.getPoliticalMyPowers = this._wrapWithUser((userId) => politicalPowersService.getMyPowers(userId));
+ this.getPoliticalTaxJurisdiction = this._wrapWithUser((userId) => politicalPowersService.getTaxJurisdiction(userId));
+ this.setPoliticalRegionTax = this._wrapWithUser((userId, req) =>
+ politicalPowersService.setRegionTax(userId, parseInt(req.params.regionId, 10), req.body?.percent), { blockInDebtorsPrison: true });
+ this.getPoliticalRegionTaxHistory = this._wrapWithUser((userId, req) =>
+ politicalPowersService.getRegionTaxHistory(userId, parseInt(req.params.regionId, 10), parseInt(req.query.limit || '5', 10)));
+ this.getPoliticalAppointableOffices = this._wrapWithUser((userId) => politicalPowersService.getAppointableOffices(userId));
+ this.createPoliticalAppointment = this._wrapWithUser(
+ (userId, req) =>
+ politicalPowersService.createAppointment(userId, {
+ targetCharacterId: req.body?.targetCharacterId,
+ officeTypeId: req.body?.officeTypeId,
+ regionId: req.body?.regionId
+ }),
+ { successStatus: 201, blockInDebtorsPrison: true }
+ );
+
this.getRegions = this._wrapWithUser((userId) => this.service.getRegions(userId));
this.getBranchTaxes = this._wrapWithUser((userId, req) => this.service.getBranchTaxes(userId, req.params.branchId));
this.getProductPriceInRegion = this._wrapWithUser((userId, req) => {
diff --git a/backend/jobs/politicalBenefitsTick.js b/backend/jobs/politicalBenefitsTick.js
new file mode 100644
index 0000000..3b90d67
--- /dev/null
+++ b/backend/jobs/politicalBenefitsTick.js
@@ -0,0 +1,94 @@
+/**
+ * Periodischer Job: reputation_periodic für politische Amtsinhaber.
+ * Aufruf: systemd-Timer oder FALUKANT_POLITICAL_REPUTATION_JOB=1 (siehe server.js).
+ */
+import PoliticalOffice from '../models/falukant/data/political_office.js';
+import PoliticalOfficeType from '../models/falukant/type/political_office_type.js';
+import PoliticalOfficeBenefit from '../models/falukant/predefine/political_office_benefit.js';
+import PoliticalOfficeBenefitType from '../models/falukant/type/political_office_benefit_type.js';
+import PoliticalBenefitLastTick from '../models/falukant/data/political_benefit_last_tick.js';
+import FalukantCharacter from '../models/falukant/data/character.js';
+import FalukantUser from '../models/falukant/data/user.js';
+import User from '../models/community/user.js';
+import { sequelize } from '../utils/sequelize.js';
+import { notifyUser } from '../utils/socket.js';
+
+export async function runPoliticalReputationTicks() {
+ const offices = await PoliticalOffice.findAll({
+ include: [{ model: PoliticalOfficeType, as: 'type', attributes: ['id', 'name'] }]
+ });
+ const toNotify = new Set();
+ let ticks = 0;
+
+ for (const po of offices) {
+ const characterId = po.characterId;
+ const benefitRows = await PoliticalOfficeBenefit.findAll({
+ where: { officeTypeId: po.officeTypeId },
+ include: [
+ {
+ model: PoliticalOfficeBenefitType,
+ as: 'benefitDefinition',
+ attributes: ['tr'],
+ required: true,
+ where: { tr: 'reputation_periodic' }
+ }
+ ]
+ });
+
+ for (const br of benefitRows) {
+ const v = br.value && typeof br.value === 'object' ? br.value : {};
+ const intervalDays = Math.max(1, Number(v.intervalDays ?? v.everyDays ?? 7));
+ const gain = Math.max(1, Number(v.gain ?? 1));
+
+ const [tickRow, created] = await PoliticalBenefitLastTick.findOrCreate({
+ where: {
+ characterId,
+ politicalOfficeBenefitId: br.id
+ },
+ defaults: {
+ characterId,
+ politicalOfficeBenefitId: br.id,
+ lastTickAt: new Date(po.createdAt),
+ ticksCount: 0
+ }
+ });
+
+ const baseMs = new Date(tickRow.lastTickAt).getTime();
+ const daysSince = Math.floor((Date.now() - baseMs) / 86400000);
+ if (daysSince < intervalDays) continue;
+
+ await sequelize.transaction(async (t) => {
+ const ch = await FalukantCharacter.findByPk(characterId, { transaction: t });
+ if (!ch) return;
+ const nextRep = Math.min(100, (ch.reputation ?? 0) + gain);
+ await ch.update({ reputation: nextRep }, { transaction: t });
+ await PoliticalBenefitLastTick.update(
+ {
+ lastTickAt: new Date(),
+ ticksCount: (tickRow.ticksCount || 0) + 1
+ },
+ { where: { id: tickRow.id }, transaction: t }
+ );
+ });
+
+ ticks += 1;
+ toNotify.add(characterId);
+ console.info(
+ `[PoliticalBenefits] reputation_tick characterId=${characterId} benefitId=${br.id} gain=${gain}`
+ );
+ }
+ }
+
+ for (const characterId of toNotify) {
+ const ch = await FalukantCharacter.findByPk(characterId, { attributes: ['userId'] });
+ if (!ch?.userId) continue;
+ const fu = await FalukantUser.findOne({
+ where: { id: ch.userId },
+ include: [{ model: User, as: 'user', attributes: ['hashedId'] }]
+ });
+ const hid = fu?.user?.hashedId;
+ if (hid) notifyUser(hid, 'falukantUpdateStatus', {});
+ }
+
+ return { processedOffices: offices.length, ticksApplied: ticks };
+}
diff --git a/backend/migrations/20260402180000-political-benefits-mechanics.cjs b/backend/migrations/20260402180000-political-benefits-mechanics.cjs
new file mode 100644
index 0000000..80685da
--- /dev/null
+++ b/backend/migrations/20260402180000-political-benefits-mechanics.cjs
@@ -0,0 +1,61 @@
+'use strict';
+
+/** @param {import('sequelize').QueryInterface} queryInterface */
+module.exports = {
+ async up(queryInterface) {
+ await queryInterface.sequelize.query(`
+ CREATE TABLE IF NOT EXISTS falukant_data.political_benefit_last_tick (
+ id serial PRIMARY KEY,
+ character_id integer NOT NULL
+ REFERENCES falukant_data."character"(id) ON DELETE CASCADE,
+ political_office_benefit_id integer NOT NULL
+ REFERENCES falukant_predefine.political_office_benefit(id) ON DELETE CASCADE,
+ last_tick_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ ticks_count integer NOT NULL DEFAULT 0,
+ CONSTRAINT political_benefit_last_tick_unique UNIQUE (character_id, political_office_benefit_id)
+ );
+ CREATE INDEX IF NOT EXISTS political_benefit_last_tick_character_idx
+ ON falukant_data.political_benefit_last_tick (character_id);
+ `);
+
+ await queryInterface.sequelize.query(`
+ CREATE TABLE IF NOT EXISTS falukant_data.region_tax_history (
+ id serial PRIMARY KEY,
+ region_id integer NOT NULL REFERENCES falukant_data.region(id) ON DELETE CASCADE,
+ old_tax_percent numeric(12,4) NOT NULL,
+ new_tax_percent numeric(12,4) NOT NULL,
+ setter_character_id integer NOT NULL REFERENCES falukant_data."character"(id) ON DELETE CASCADE,
+ political_office_id integer NULL REFERENCES falukant_data.political_office(id) ON DELETE SET NULL,
+ created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE INDEX IF NOT EXISTS region_tax_history_region_idx
+ ON falukant_data.region_tax_history (region_id, created_at DESC);
+ `);
+
+ await queryInterface.sequelize.query(`
+ CREATE TABLE IF NOT EXISTS falukant_data.political_appointment (
+ id serial PRIMARY KEY,
+ appointer_character_id integer NOT NULL REFERENCES falukant_data."character"(id) ON DELETE CASCADE,
+ target_character_id integer NOT NULL REFERENCES falukant_data."character"(id) ON DELETE CASCADE,
+ office_type_id integer NOT NULL REFERENCES falukant_type.political_office_type(id) ON DELETE CASCADE,
+ region_id integer NOT NULL REFERENCES falukant_data.region(id) ON DELETE CASCADE,
+ status varchar(32) NOT NULL DEFAULT 'completed',
+ created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ expires_at timestamptz NULL,
+ completed_political_office_id integer NULL REFERENCES falukant_data.political_office(id) ON DELETE SET NULL
+ );
+ CREATE INDEX IF NOT EXISTS political_appointment_appointer_idx
+ ON falukant_data.political_appointment (appointer_character_id, created_at DESC);
+ CREATE INDEX IF NOT EXISTS political_appointment_target_idx
+ ON falukant_data.political_appointment (target_character_id);
+ `);
+ },
+
+ async down(queryInterface) {
+ await queryInterface.sequelize.query(`
+ DROP TABLE IF EXISTS falukant_data.political_appointment;
+ DROP TABLE IF EXISTS falukant_data.region_tax_history;
+ DROP TABLE IF EXISTS falukant_data.political_benefit_last_tick;
+ `);
+ }
+};
diff --git a/backend/models/associations.js b/backend/models/associations.js
index b4b5556..7ba66c1 100644
--- a/backend/models/associations.js
+++ b/backend/models/associations.js
@@ -94,6 +94,9 @@ import Candidate from './falukant/data/candidate.js';
import Vote from './falukant/data/vote.js';
import PoliticalOfficeType from './falukant/type/political_office_type.js';
import PoliticalOffice from './falukant/data/political_office.js';
+import PoliticalBenefitLastTick from './falukant/data/political_benefit_last_tick.js';
+import RegionTaxHistory from './falukant/data/region_tax_history.js';
+import PoliticalAppointment from './falukant/data/political_appointment.js';
import PoliticalOfficeBenefit from './falukant/predefine/political_office_benefit.js';
import PoliticalOfficeBenefitType from './falukant/type/political_office_benefit_type.js';
import PoliticalOfficeRequirement from './falukant/predefine/political_office_prerequisite.js';
@@ -792,6 +795,48 @@ export default function setupAssociations() {
as: 'heldOffice'
});
+ PoliticalBenefitLastTick.belongsTo(FalukantCharacter, {
+ foreignKey: 'characterId',
+ as: 'character'
+ });
+ FalukantCharacter.hasMany(PoliticalBenefitLastTick, {
+ foreignKey: 'characterId',
+ as: 'politicalBenefitTicks'
+ });
+ PoliticalBenefitLastTick.belongsTo(PoliticalOfficeBenefit, {
+ foreignKey: 'politicalOfficeBenefitId',
+ as: 'officeBenefit'
+ });
+
+ RegionTaxHistory.belongsTo(RegionData, { foreignKey: 'regionId', as: 'region' });
+ RegionData.hasMany(RegionTaxHistory, { foreignKey: 'regionId', as: 'taxHistory' });
+ RegionTaxHistory.belongsTo(FalukantCharacter, {
+ foreignKey: 'setterCharacterId',
+ as: 'setterCharacter'
+ });
+ RegionTaxHistory.belongsTo(PoliticalOffice, {
+ foreignKey: 'politicalOfficeId',
+ as: 'sourceOffice'
+ });
+
+ PoliticalAppointment.belongsTo(FalukantCharacter, {
+ foreignKey: 'appointerCharacterId',
+ as: 'appointer'
+ });
+ PoliticalAppointment.belongsTo(FalukantCharacter, {
+ foreignKey: 'targetCharacterId',
+ as: 'targetCharacter'
+ });
+ PoliticalAppointment.belongsTo(PoliticalOfficeType, {
+ foreignKey: 'officeTypeId',
+ as: 'officeType'
+ });
+ PoliticalAppointment.belongsTo(RegionData, { foreignKey: 'regionId', as: 'region' });
+ PoliticalAppointment.belongsTo(PoliticalOffice, {
+ foreignKey: 'completedPoliticalOfficeId',
+ as: 'completedOffice'
+ });
+
// elections
Election.belongsTo(PoliticalOfficeType, {
foreignKey: 'officeTypeId',
diff --git a/backend/models/falukant/data/political_appointment.js b/backend/models/falukant/data/political_appointment.js
new file mode 100644
index 0000000..9dbec06
--- /dev/null
+++ b/backend/models/falukant/data/political_appointment.js
@@ -0,0 +1,56 @@
+import { Model, DataTypes } from 'sequelize';
+import { sequelize } from '../../../utils/sequelize.js';
+
+class PoliticalAppointment extends Model {}
+
+PoliticalAppointment.init(
+ {
+ appointerCharacterId: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ field: 'appointer_character_id'
+ },
+ targetCharacterId: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ field: 'target_character_id'
+ },
+ officeTypeId: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ field: 'office_type_id'
+ },
+ regionId: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ field: 'region_id'
+ },
+ status: {
+ type: DataTypes.STRING(32),
+ allowNull: false,
+ defaultValue: 'completed'
+ },
+ expiresAt: {
+ type: DataTypes.DATE,
+ allowNull: true,
+ field: 'expires_at'
+ },
+ completedPoliticalOfficeId: {
+ type: DataTypes.INTEGER,
+ allowNull: true,
+ field: 'completed_political_office_id'
+ },
+ },
+ {
+ sequelize,
+ modelName: 'PoliticalAppointment',
+ tableName: 'political_appointment',
+ schema: 'falukant_data',
+ timestamps: true,
+ createdAt: 'created_at',
+ updatedAt: false,
+ underscored: true
+ }
+);
+
+export default PoliticalAppointment;
diff --git a/backend/models/falukant/data/political_benefit_last_tick.js b/backend/models/falukant/data/political_benefit_last_tick.js
new file mode 100644
index 0000000..03e3599
--- /dev/null
+++ b/backend/models/falukant/data/political_benefit_last_tick.js
@@ -0,0 +1,40 @@
+import { Model, DataTypes } from 'sequelize';
+import { sequelize } from '../../../utils/sequelize.js';
+
+class PoliticalBenefitLastTick extends Model {}
+
+PoliticalBenefitLastTick.init(
+ {
+ characterId: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ field: 'character_id'
+ },
+ politicalOfficeBenefitId: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ field: 'political_office_benefit_id'
+ },
+ lastTickAt: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ field: 'last_tick_at'
+ },
+ ticksCount: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ defaultValue: 0,
+ field: 'ticks_count'
+ }
+ },
+ {
+ sequelize,
+ modelName: 'PoliticalBenefitLastTick',
+ tableName: 'political_benefit_last_tick',
+ schema: 'falukant_data',
+ timestamps: false,
+ underscored: true
+ }
+);
+
+export default PoliticalBenefitLastTick;
diff --git a/backend/models/falukant/data/region_tax_history.js b/backend/models/falukant/data/region_tax_history.js
new file mode 100644
index 0000000..b564099
--- /dev/null
+++ b/backend/models/falukant/data/region_tax_history.js
@@ -0,0 +1,46 @@
+import { Model, DataTypes } from 'sequelize';
+import { sequelize } from '../../../utils/sequelize.js';
+
+class RegionTaxHistory extends Model {}
+
+RegionTaxHistory.init(
+ {
+ regionId: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ field: 'region_id'
+ },
+ oldTaxPercent: {
+ type: DataTypes.DECIMAL(12, 4),
+ allowNull: false,
+ field: 'old_tax_percent'
+ },
+ newTaxPercent: {
+ type: DataTypes.DECIMAL(12, 4),
+ allowNull: false,
+ field: 'new_tax_percent'
+ },
+ setterCharacterId: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ field: 'setter_character_id'
+ },
+ politicalOfficeId: {
+ type: DataTypes.INTEGER,
+ allowNull: true,
+ field: 'political_office_id'
+ }
+ },
+ {
+ sequelize,
+ modelName: 'RegionTaxHistory',
+ tableName: 'region_tax_history',
+ schema: 'falukant_data',
+ timestamps: true,
+ createdAt: 'created_at',
+ updatedAt: false,
+ underscored: true
+ }
+);
+
+export default RegionTaxHistory;
diff --git a/backend/models/index.js b/backend/models/index.js
index a19f5f0..353915a 100644
--- a/backend/models/index.js
+++ b/backend/models/index.js
@@ -116,6 +116,9 @@ import PoliticalOfficeRequirement from './falukant/predefine/political_office_pr
import PoliticalOfficeBenefitType from './falukant/type/political_office_benefit_type.js';
import PoliticalOfficeBenefit from './falukant/predefine/political_office_benefit.js';
import PoliticalOffice from './falukant/data/political_office.js';
+import PoliticalBenefitLastTick from './falukant/data/political_benefit_last_tick.js';
+import RegionTaxHistory from './falukant/data/region_tax_history.js';
+import PoliticalAppointment from './falukant/data/political_appointment.js';
import Election from './falukant/data/election.js';
import Candidate from './falukant/data/candidate.js';
import Vote from './falukant/data/vote.js';
@@ -262,6 +265,9 @@ const models = {
PoliticalOfficeBenefitType,
PoliticalOfficeBenefit,
PoliticalOffice,
+ PoliticalBenefitLastTick,
+ RegionTaxHistory,
+ PoliticalAppointment,
Election,
Candidate,
Vote,
diff --git a/backend/routers/falukantRouter.js b/backend/routers/falukantRouter.js
index 2482b39..73336f4 100644
--- a/backend/routers/falukantRouter.js
+++ b/backend/routers/falukantRouter.js
@@ -95,6 +95,12 @@ router.post('/nobility', falukantController.advanceNobility);
router.get('/health', falukantController.getHealth);
router.post('/health', falukantController.healthActivity);
router.get('/politics/overview', falukantController.getPoliticsOverview);
+router.get('/politics/my-powers', falukantController.getPoliticalMyPowers);
+router.get('/politics/tax-jurisdiction', falukantController.getPoliticalTaxJurisdiction);
+router.put('/politics/region/:regionId/tax', falukantController.setPoliticalRegionTax);
+router.get('/politics/region/:regionId/tax-history', falukantController.getPoliticalRegionTaxHistory);
+router.get('/politics/appointable-offices', falukantController.getPoliticalAppointableOffices);
+router.post('/politics/appointments', falukantController.createPoliticalAppointment);
router.get('/politics/open', falukantController.getOpenPolitics);
router.post('/politics/open', falukantController.applyForElections);
router.get('/politics/elections', falukantController.getElections);
diff --git a/backend/server.js b/backend/server.js
index e1de61e..22b53e4 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -46,6 +46,19 @@ if (USE_TLS && TLS_KEY_PATH && TLS_CERT_PATH) {
}
syncDatabase().then(() => {
+ if (process.env.FALUKANT_POLITICAL_REPUTATION_JOB === '1') {
+ const intervalMs = Number.parseInt(process.env.FALUKANT_POLITICAL_REPUTATION_MS || '3600000', 10);
+ import('./jobs/politicalBenefitsTick.js')
+ .then(({ runPoliticalReputationTicks }) => {
+ const run = () =>
+ runPoliticalReputationTicks().catch((e) => console.error('[PoliticalBenefits]', e));
+ run();
+ setInterval(run, intervalMs);
+ console.log(`[PoliticalBenefits] Job aktiv, Intervall ${intervalMs} ms`);
+ })
+ .catch((e) => console.error('[PoliticalBenefits] Job-Import fehlgeschlagen:', e));
+ }
+
// API-Server auf Port 2020 (intern, nur localhost)
httpServer.listen(API_PORT, API_HOST, () => {
console.log(`[API] HTTP-Server läuft auf ${API_HOST}:${API_PORT}`);
diff --git a/backend/services/falukantPoliticalPowersService.js b/backend/services/falukantPoliticalPowersService.js
new file mode 100644
index 0000000..4a558d7
--- /dev/null
+++ b/backend/services/falukantPoliticalPowersService.js
@@ -0,0 +1,526 @@
+/**
+ * Politische Amtsbefugnisse: Steuern setzen, Ernennungen, Meta (Frei-Slots, nächster Ansehens-Tick).
+ * Reputation-Ticks: backend/jobs/politicalBenefitsTick.js
+ */
+import { Op, QueryTypes } from 'sequelize';
+import { differenceInDays } from 'date-fns';
+import { sequelize } from '../utils/sequelize.js';
+import User from '../models/community/user.js';
+import FalukantUser from '../models/falukant/data/user.js';
+import FalukantCharacter from '../models/falukant/data/character.js';
+import TitleOfNobility from '../models/falukant/type/title_of_nobility.js';
+import TitleBenefit from '../models/falukant/type/title_benefit.js';
+import PoliticalOffice from '../models/falukant/data/political_office.js';
+import PoliticalOfficeType from '../models/falukant/type/political_office_type.js';
+import PoliticalOfficeBenefit from '../models/falukant/predefine/political_office_benefit.js';
+import PoliticalOfficeBenefitType from '../models/falukant/type/political_office_benefit_type.js';
+import RegionData from '../models/falukant/data/region.js';
+import RegionType from '../models/falukant/type/region.js';
+import RegionTaxHistory from '../models/falukant/data/region_tax_history.js';
+import PoliticalAppointment from '../models/falukant/data/political_appointment.js';
+import PoliticalOfficeHistory from '../models/falukant/log/political_office_history.js';
+import PoliticalBenefitLastTick from '../models/falukant/data/political_benefit_last_tick.js';
+import { notifyUser } from '../utils/socket.js';
+
+const TAX_MIN = 0;
+const TAX_MAX = 25;
+const MAX_FREE_LOVER_SLOTS = 5;
+const MIN_AGE_POLITICS_DAYS = 16;
+
+function calcAgeDays(birthdate) {
+ const b = new Date(birthdate);
+ b.setHours(0, 0, 0, 0);
+ const now = new Date();
+ now.setHours(0, 0, 0, 0);
+ return differenceInDays(now, b);
+}
+
+async function loadContext(hashedUserId) {
+ const user = await FalukantUser.findOne({
+ include: [{ model: User, as: 'user', attributes: ['hashedId'], where: { hashedId: hashedUserId } }]
+ });
+ if (!user) {
+ const err = new Error('User not found');
+ err.status = 404;
+ throw err;
+ }
+ const character = await FalukantCharacter.findOne({
+ where: { userId: user.id },
+ include: [{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['id', 'labelTr'] }]
+ });
+ if (!character) {
+ const err = new Error('No character');
+ err.status = 404;
+ throw err;
+ }
+ return { user, character };
+}
+
+async function allowedOfficeNamesForTitle(titleId) {
+ const benefits = await TitleBenefit.findAll({
+ where: { titleId, benefitType: 'office_eligibility' },
+ attributes: ['parameters']
+ });
+ const names = new Set();
+ for (const b of benefits) {
+ const arr = b.parameters?.officeTypeNames;
+ if (Array.isArray(arr)) arr.forEach((n) => names.add(n));
+ }
+ return names;
+}
+
+async function getDescendantRegionIds(rootId) {
+ const rows = await sequelize.query(
+ `
+ WITH RECURSIVE sub AS (
+ SELECT id FROM falukant_data.region WHERE id = :root
+ UNION ALL
+ SELECT r.id FROM falukant_data.region r
+ INNER JOIN sub ON r.parent_id = sub.id
+ )
+ SELECT id FROM sub
+ `,
+ { replacements: { root: rootId }, type: QueryTypes.SELECT }
+ );
+ return rows.map((r) => r.id);
+}
+
+async function getAllRegionIds() {
+ const rows = await RegionData.findAll({ attributes: ['id'] });
+ return rows.map((r) => r.id);
+}
+
+async function regionIdsForTaxScope(scope, officeRegionId) {
+ if (scope === 'national') {
+ return getAllRegionIds();
+ }
+ return getDescendantRegionIds(officeRegionId);
+}
+
+async function mergeTaxRegions(heldOffices) {
+ const allowed = new Map();
+ for (const po of heldOffices) {
+ const { officeTypeId, regionId } = po;
+ const benefits = await PoliticalOfficeBenefit.findAll({
+ where: { officeTypeId },
+ include: [
+ {
+ model: PoliticalOfficeBenefitType,
+ as: 'benefitDefinition',
+ attributes: ['tr'],
+ required: true,
+ where: { tr: { [Op.in]: ['set_regional_tax', 'set_regionl_tax'] } }
+ }
+ ]
+ });
+ for (const br of benefits) {
+ const v = br.value && typeof br.value === 'object' ? br.value : {};
+ const scope = typeof v.scope === 'string' ? v.scope : 'local';
+ const ids = scope === 'local' ? [regionId] : await regionIdsForTaxScope(scope, regionId);
+ for (const rid of ids) {
+ const prev = allowed.get(rid) || { scopes: [], officeIds: [] };
+ prev.scopes.push(scope);
+ prev.officeIds.push(po.id);
+ allowed.set(rid, prev);
+ }
+ }
+ }
+ return allowed;
+}
+
+export async function sumFreePoliticalLoverSlotsForCharacter(characterId) {
+ const held = await PoliticalOffice.findAll({
+ where: { characterId },
+ attributes: ['officeTypeId']
+ });
+ if (!held.length) return 0;
+ const typeIds = [...new Set(held.map((h) => h.officeTypeId))];
+ const rows = await PoliticalOfficeBenefit.findAll({
+ where: { officeTypeId: { [Op.in]: typeIds } },
+ include: [
+ {
+ model: PoliticalOfficeBenefitType,
+ as: 'benefitDefinition',
+ attributes: ['tr'],
+ required: true,
+ where: { tr: 'free_lover_slots' }
+ }
+ ]
+ });
+ let sum = 0;
+ for (const r of rows) {
+ const v = r.value && typeof r.value === 'object' ? r.value : {};
+ sum += Math.max(0, Number(v.count ?? 0));
+ }
+ return Math.min(MAX_FREE_LOVER_SLOTS, sum);
+}
+
+class FalukantPoliticalPowersService {
+ async getMyPowers(hashedUserId) {
+ const { character } = await loadContext(hashedUserId);
+ const held = await PoliticalOffice.findAll({
+ where: { characterId: character.id },
+ include: [
+ { model: PoliticalOfficeType, as: 'type', attributes: ['id', 'name'] },
+ { model: RegionData, as: 'region', attributes: ['id', 'name'] }
+ ]
+ });
+ const freeLoverSlots = await sumFreePoliticalLoverSlotsForCharacter(character.id);
+ const reputationPreview = await this._reputationTickPreview(character.id, held);
+ const canSetTax = (await mergeTaxRegions(held)).size > 0;
+ const appointBenefits = await this._collectAppointBenefits(held);
+
+ return {
+ heldOffices: held.map((p) => ({
+ id: p.id,
+ officeTypeName: p.type?.name,
+ regionName: p.region?.name,
+ regionId: p.regionId
+ })),
+ freeLoverSlots,
+ reputationPeriodic: reputationPreview,
+ canSetTax,
+ canAppoint: appointBenefits.length > 0,
+ appointOfficeTrs: [...new Set(appointBenefits.flatMap((x) => x.officeTrs))]
+ };
+ }
+
+ async _reputationTickPreview(characterId, heldOffices) {
+ const out = [];
+ const typeIds = [...new Set(heldOffices.map((h) => h.officeTypeId))];
+ const benefitRows = await PoliticalOfficeBenefit.findAll({
+ where: { officeTypeId: { [Op.in]: typeIds } },
+ include: [
+ {
+ model: PoliticalOfficeBenefitType,
+ as: 'benefitDefinition',
+ attributes: ['tr'],
+ required: true,
+ where: { tr: 'reputation_periodic' }
+ }
+ ]
+ });
+ const byOfficeType = new Map();
+ for (const br of benefitRows) {
+ if (!byOfficeType.has(br.officeTypeId)) byOfficeType.set(br.officeTypeId, []);
+ byOfficeType.get(br.officeTypeId).push(br);
+ }
+ const now = Date.now();
+ for (const po of heldOffices) {
+ const rows = byOfficeType.get(po.officeTypeId) || [];
+ for (const br of rows) {
+ const v = br.value && typeof br.value === 'object' ? br.value : {};
+ const intervalDays = Math.max(1, Number(v.intervalDays ?? v.everyDays ?? 7));
+ const gain = Math.max(1, Number(v.gain ?? 1));
+ let tick = await PoliticalBenefitLastTick.findOne({
+ where: { characterId, politicalOfficeBenefitId: br.id }
+ });
+ const baseTime = tick?.lastTickAt ? new Date(tick.lastTickAt).getTime() : new Date(po.createdAt).getTime();
+ const daysSince = Math.floor((now - baseTime) / 86400000);
+ const daysUntil = Math.max(0, intervalDays - daysSince);
+ out.push({
+ officeTypeName: po.type?.name,
+ intervalDays,
+ gain,
+ daysUntilNext: daysUntil,
+ benefitId: br.id
+ });
+ }
+ }
+ return out;
+ }
+
+ async _collectAppointBenefits(heldOffices) {
+ const list = [];
+ const typeIds = [...new Set(heldOffices.map((h) => h.officeTypeId))];
+ const benefitRows = await PoliticalOfficeBenefit.findAll({
+ where: { officeTypeId: { [Op.in]: typeIds } },
+ include: [
+ {
+ model: PoliticalOfficeBenefitType,
+ as: 'benefitDefinition',
+ attributes: ['tr'],
+ required: true,
+ where: { tr: 'appoint_politicians' }
+ }
+ ]
+ });
+ const byOfficeType = new Map();
+ for (const br of benefitRows) {
+ if (!byOfficeType.has(br.officeTypeId)) byOfficeType.set(br.officeTypeId, []);
+ byOfficeType.get(br.officeTypeId).push(br);
+ }
+ for (const po of heldOffices) {
+ const rows = byOfficeType.get(po.officeTypeId) || [];
+ for (const br of rows) {
+ const v = br.value && typeof br.value === 'object' ? br.value : {};
+ const officeTrs = Array.isArray(v.officeTrs) ? v.officeTrs.filter((x) => typeof x === 'string') : [];
+ if (officeTrs.length) {
+ list.push({ officeTrs, anchorRegionId: po.regionId, sourceOfficeId: po.id });
+ }
+ }
+ }
+ return list;
+ }
+
+ async getTaxJurisdiction(hashedUserId) {
+ const { character } = await loadContext(hashedUserId);
+ const held = await PoliticalOffice.findAll({
+ where: { characterId: character.id },
+ include: [
+ { model: PoliticalOfficeType, as: 'type', attributes: ['name'] },
+ { model: RegionData, as: 'region', attributes: ['id', 'name'], include: [{ model: RegionType, as: 'regionType', attributes: ['labelTr'] }] }
+ ]
+ });
+ const merged = await mergeTaxRegions(held);
+ const regionIds = [...merged.keys()];
+ if (!regionIds.length) return { regions: [], taxMin: TAX_MIN, taxMax: TAX_MAX };
+ const regions = await RegionData.findAll({
+ where: { id: { [Op.in]: regionIds } },
+ include: [{ model: RegionType, as: 'regionType', attributes: ['labelTr'] }],
+ order: [['name', 'ASC']]
+ });
+ return {
+ taxMin: TAX_MIN,
+ taxMax: TAX_MAX,
+ regions: regions.map((r) => ({
+ id: r.id,
+ name: r.name,
+ regionType: r.regionType?.labelTr,
+ taxPercent: Number(r.taxPercent)
+ }))
+ };
+ }
+
+ async setRegionTax(hashedUserId, regionId, percentRaw) {
+ const { character } = await loadContext(hashedUserId);
+ const held = await PoliticalOffice.findAll({ where: { characterId: character.id } });
+ const merged = await mergeTaxRegions(held);
+ if (!merged.has(regionId)) {
+ const err = new Error('no_tax_power_in_region');
+ err.status = 403;
+ throw err;
+ }
+ let percent = Number(percentRaw);
+ if (!Number.isFinite(percent)) {
+ const err = new Error('invalid_percent');
+ err.status = 400;
+ throw err;
+ }
+ percent = Math.round(percent * 100) / 100;
+ if (percent < TAX_MIN || percent > TAX_MAX) {
+ const err = new Error('percent_out_of_range');
+ err.status = 400;
+ throw err;
+ }
+ const region = await RegionData.findByPk(regionId);
+ if (!region) {
+ const err = new Error('region_not_found');
+ err.status = 404;
+ throw err;
+ }
+ const oldVal = Number(region.taxPercent);
+ const meta = merged.get(regionId);
+ const politicalOfficeId = meta?.officeIds?.[0] ?? null;
+ await sequelize.transaction(async (t) => {
+ await region.update({ taxPercent: percent }, { transaction: t });
+ await RegionTaxHistory.create(
+ {
+ regionId,
+ oldTaxPercent: oldVal,
+ newTaxPercent: percent,
+ setterCharacterId: character.id,
+ politicalOfficeId
+ },
+ { transaction: t }
+ );
+ });
+ return { regionId, taxPercent: percent };
+ }
+
+ async getAppointableOffices(hashedUserId) {
+ const { character } = await loadContext(hashedUserId);
+ const held = await PoliticalOffice.findAll({
+ where: { characterId: character.id },
+ include: [{ model: PoliticalOfficeType, as: 'type', attributes: ['name'] }]
+ });
+ const appointBlocks = await this._collectAppointBenefits(held);
+ const allowedTargetNames = new Set(appointBlocks.flatMap((b) => b.officeTrs));
+ if (!allowedTargetNames.size) return [];
+
+ const results = [];
+ for (const block of appointBlocks) {
+ const subtreeIds = await getDescendantRegionIds(block.anchorRegionId);
+ const eligibleRegionIds = new Set([block.anchorRegionId, ...subtreeIds]);
+ for (const officeTr of block.officeTrs) {
+ const officeType = await PoliticalOfficeType.findOne({ where: { name: officeTr } });
+ if (!officeType) continue;
+ for (const rid of eligibleRegionIds) {
+ const cnt = await PoliticalOffice.count({ where: { officeTypeId: officeType.id, regionId: rid } });
+ if (cnt >= officeType.seatsPerRegion) continue;
+ const reg = await RegionData.findByPk(rid, { attributes: ['id', 'name'] });
+ results.push({
+ officeTypeId: officeType.id,
+ officeTypeName: officeTr,
+ regionId: rid,
+ regionName: reg?.name,
+ seatsFree: officeType.seatsPerRegion - cnt,
+ sourceOfficeId: block.sourceOfficeId
+ });
+ }
+ }
+ }
+ const key = (x) => `${x.officeTypeId}-${x.regionId}`;
+ const dedup = new Map();
+ for (const r of results) {
+ if (!dedup.has(key(r)) || r.seatsFree > dedup.get(key(r)).seatsFree) dedup.set(key(r), r);
+ }
+ return [...dedup.values()];
+ }
+
+ async createAppointment(hashedUserId, { targetCharacterId, officeTypeId, regionId }) {
+ const { character: appointer } = await loadContext(hashedUserId);
+ const tid = parseInt(targetCharacterId, 10);
+ const otid = parseInt(officeTypeId, 10);
+ const rid = parseInt(regionId, 10);
+ if ([tid, otid, rid].some((n) => Number.isNaN(n))) {
+ const err = new Error('invalid_payload');
+ err.status = 400;
+ throw err;
+ }
+ if (tid === appointer.id) {
+ const err = new Error('cannot_appoint_self');
+ err.status = 400;
+ throw err;
+ }
+ const target = await FalukantCharacter.findByPk(tid, {
+ include: [{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] }]
+ });
+ if (!target?.userId) {
+ const err = new Error('target_must_be_player');
+ err.status = 400;
+ throw err;
+ }
+ if (target.nobleTitle?.labelTr === 'noncivil') {
+ const err = new Error('target_title_blocked');
+ err.status = 400;
+ throw err;
+ }
+ if (calcAgeDays(target.birthdate) < MIN_AGE_POLITICS_DAYS) {
+ const err = new Error('target_too_young');
+ err.status = 400;
+ throw err;
+ }
+ const officeType = await PoliticalOfficeType.findByPk(otid);
+ if (!officeType) {
+ const err = new Error('office_type_not_found');
+ err.status = 404;
+ throw err;
+ }
+ const allowedNames = await allowedOfficeNamesForTitle(target.titleOfNobility);
+ if (allowedNames.size > 0 && !allowedNames.has(officeType.name)) {
+ const err = new Error('target_title_not_eligible');
+ err.status = 400;
+ throw err;
+ }
+ const held = await PoliticalOffice.findAll({ where: { characterId: appointer.id } });
+ const appointBlocks = await this._collectAppointBenefits(held);
+ let appointAllowed = false;
+ for (const b of appointBlocks) {
+ const subtree = new Set([b.anchorRegionId, ...(await getDescendantRegionIds(b.anchorRegionId))]);
+ for (const tr of b.officeTrs) {
+ if (tr === officeType.name && subtree.has(rid)) {
+ appointAllowed = true;
+ break;
+ }
+ }
+ if (appointAllowed) break;
+ }
+ if (!appointAllowed) {
+ const err = new Error('appoint_not_allowed');
+ err.status = 403;
+ throw err;
+ }
+ const dup = await PoliticalOffice.findOne({
+ where: { characterId: tid, officeTypeId: otid, regionId: rid }
+ });
+ if (dup) {
+ const err = new Error('already_holds_office');
+ err.status = 409;
+ throw err;
+ }
+ const cnt = await PoliticalOffice.count({ where: { officeTypeId: otid, regionId: rid } });
+ if (cnt >= officeType.seatsPerRegion) {
+ const err = new Error('no_seat_available');
+ err.status = 409;
+ throw err;
+ }
+
+ const start = new Date();
+ const end = new Date(start);
+ end.setFullYear(end.getFullYear() + (officeType.termLength || 4));
+
+ let newOffice;
+ await sequelize.transaction(async (t) => {
+ newOffice = await PoliticalOffice.create(
+ { officeTypeId: otid, characterId: tid, regionId: rid },
+ { transaction: t }
+ );
+ await PoliticalOfficeHistory.create(
+ {
+ characterId: tid,
+ officeTypeId: otid,
+ startDate: start,
+ endDate: end
+ },
+ { transaction: t }
+ );
+ await PoliticalAppointment.create(
+ {
+ appointerCharacterId: appointer.id,
+ targetCharacterId: tid,
+ officeTypeId: otid,
+ regionId: rid,
+ status: 'completed',
+ completedPoliticalOfficeId: newOffice.id
+ },
+ { transaction: t }
+ );
+ });
+
+ const targetUser = await FalukantUser.findOne({ where: { id: target.userId }, include: [{ model: User, as: 'user', attributes: ['hashedId'] }] });
+ if (targetUser?.user?.hashedId) {
+ notifyUser(targetUser.user.hashedId, 'falukantUpdateStatus', {});
+ }
+ const appUser = await FalukantUser.findOne({ where: { id: appointer.userId }, include: [{ model: User, as: 'user', attributes: ['hashedId'] }] });
+ if (appUser?.user?.hashedId) {
+ notifyUser(appUser.user.hashedId, 'falukantUpdateStatus', {});
+ }
+
+ return { politicalOfficeId: newOffice.id };
+ }
+
+ async getRegionTaxHistory(hashedUserId, regionId, limit = 5) {
+ const { character } = await loadContext(hashedUserId);
+ const held = await PoliticalOffice.findAll({ where: { characterId: character.id } });
+ const merged = await mergeTaxRegions(held);
+ if (!merged.has(regionId)) {
+ const err = new Error('no_tax_power_in_region');
+ err.status = 403;
+ throw err;
+ }
+ const rows = await RegionTaxHistory.findAll({
+ where: { regionId },
+ order: [['createdAt', 'DESC']],
+ limit,
+ attributes: ['oldTaxPercent', 'newTaxPercent', 'createdAt']
+ });
+ return rows.map((r) => ({
+ oldTaxPercent: Number(r.oldTaxPercent),
+ newTaxPercent: Number(r.newTaxPercent),
+ createdAt: r.createdAt
+ }));
+ }
+}
+
+export default new FalukantPoliticalPowersService();
diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js
index 84ea1a2..ad21583 100644
--- a/backend/services/falukantService.js
+++ b/backend/services/falukantService.js
@@ -85,6 +85,7 @@ import {
KNOWLEDGE_PRICE_FLOOR,
calcRegionalSellPriceSync,
} from '../utils/falukant/falukantProductEconomy.js';
+import { sumFreePoliticalLoverSlotsForCharacter } from './falukantPoliticalPowersService.js';
function calcAge(birthdate) {
const b = new Date(birthdate); b.setHours(0, 0);
@@ -3591,6 +3592,13 @@ class FalukantService extends BaseService {
state,
};
});
+ const politicalFreeLoverSlots = await sumFreePoliticalLoverSlotsForCharacter(character.id);
+ lovers.sort((a, b) => (a.relationshipId || 0) - (b.relationshipId || 0));
+ lovers.forEach((lover, idx) => {
+ const base = Number(lover.monthlyBaseCost || 0);
+ lover.politicalFreeMaintenance = idx < politicalFreeLoverSlots;
+ lover.monthlyCost = lover.politicalFreeMaintenance ? 0 : base;
+ });
const derivedHouseholdTension = this.calculateHouseholdTension({
lovers,
marriageSatisfaction,
@@ -3614,6 +3622,7 @@ class FalukantService extends BaseService {
householdTensionScore: householdTension.score,
householdTensionReasons: householdTension.reasons,
debtorsPrison: await this.getDebtorsPrisonStateForUser(user),
+ politicalFreeLoverSlots,
lovers,
deathPartners: relationships.filter(r => r.relationshipType === 'widowed'),
children: children.map(({ _createdAt, ...rest }) => rest),
@@ -7256,6 +7265,7 @@ ORDER BY r.id`,
const mapped = chars
.map(c => ({
+ characterId: c.id,
username: c.user?.user?.username || null,
firstname: c.definedFirstName?.name || null,
lastname: c.definedLastName?.name || null,
diff --git a/docs/FALUKANT_POLITICAL_BENEFITS_DAEMON_UI_SPEC.md b/docs/FALUKANT_POLITICAL_BENEFITS_DAEMON_UI_SPEC.md
new file mode 100644
index 0000000..3316b9c
--- /dev/null
+++ b/docs/FALUKANT_POLITICAL_BENEFITS_DAEMON_UI_SPEC.md
@@ -0,0 +1,217 @@
+# Falukant: Konzept — Daemon, Backend und UI für politische Amtsvorteile
+
+Dieses Dokument beschreibt die **noch nicht implementierten** Teile zu den Amtsvorteilen (`reputation_periodic`, `appoint_politicians`, `set_regional_tax`, `free_lover_slots`, sowie optional `guard_protection` / `court_immunity` als Spielwirkung). Die **Anzeige** in der Politik-Übersicht existiert bereits; hier geht es um **Ausführung**, **Persistenz** und **Bedienoberfläche**.
+
+---
+
+## 1. Ausgangslage im Code
+
+| Bereich | Stand |
+|--------|--------|
+| **Predefine** | `falukant_predefine.political_office_benefit` + Typen in `falukant_type.political_office_benefit_type`; JSON `value` pro Amt. |
+| **Auslesen / Anzeige** | `FalukantService.politicsBenefitEntriesFromRows`, Frontend `PoliticsView.vue`. |
+| **Bereits mechanisch** | `daily_salary` (beim Laden der Politik-Übersicht, `last_political_daily_salary_on` am `FalukantUser`); Steuerbefreiung in `getCumulativeTaxPercentForCharacter` (SQL mit exempt types). |
+| **Daemon (C++)** | `PoliticsWorker` läuft **täglich nach 03:00** (`src/politics_worker.cpp`): Wahlen, Nachbesetzung, Benachrichtigungen — **kein** Bezug zu den neuen JSON-Vorteilen. |
+| **Node-Daemon** | `backend/daemonServer.js` ist ein **WebSocket-Relay**, kein Spiel-Ticker. |
+| **Steuern im Spiel** | Regionen haben `tax_percent`; kumulativ über Eltern-Regionen; Verkaufsbuchung in `falukantService` (Steueranteil). |
+
+**Empfehlung zur Architektur:** zeitkritische **Batch-Jobs** (täglich, idempotent) entweder im **bestehenden C++-`PoliticsWorker`** direkt nach den bestehenden Schritten ausführen **oder** einen kleinen **Node-Cron-Job** (z. B. `node backend/jobs/politicalBenefitsTick.js` + systemd timer), der Sequelize nutzt. C++ vermeidet doppelte DB-Logik; Node vermeidet C++-Deploy für reine Sequelize-Regeln. Für **synchrone API-Aktionen** (Steuern setzen, Ernennungen) ist **ausschließlich das Node-Backend** sinnvoll.
+
+---
+
+## 2. Zielbild pro Vorteilstyp
+
+### 2.1 `reputation_periodic` (Ansehen alle *x* Tage)
+
+**Ziel:** Charaktere mit aktivem Amt erhalten in einem festen Intervall `gain` Ansehenspunkte (oberhalb 0, Cap z. B. 100), **höchstens einmal pro Intervall pro (Charakter × konkreter Benefit-Zeile)**.
+
+**Persistenz (neu):**
+
+- Tabelle z. B. `falukant_data.political_benefit_last_tick`
+ - `id`, `character_id`, `political_office_benefit_id` (FK auf Predefine-Zeile), `last_tick_at` (timestamptz), optional `ticks_count`.
+ - **Unique** `(character_id, political_office_benefit_id)`.
+
+**Logik:**
+
+1. Alle `political_office`-Zeilen mit `character_id IS NOT NULL` laden (oder seit letztem Lauf geänderte).
+2. Pro Zeile zugehörige `office_type_id` → alle `PoliticalOfficeBenefit` mit `benefitDefinition.tr = 'reputation_periodic'`.
+3. Für jede Kombination: `last_tick_at` lesen; wenn `now - last_tick_at >= intervalDays` (Kalendertage **Spielzeit** oder **UTC-Echtzeit** — festlegen; konsistent mit `daily_salary` ist **UTC-Datum** einfacher), dann `character.reputation` erhöhen, `last_tick_at = now`, Eintrag ins **Money-/Activity-Log** optional (`political_reputation_tick`).
+4. **Mehrere Ämter:** mehrere Benefit-Zeilen = mehrere mögliche Ticks (jede mit eigenem Intervall), so wie konfiguriert.
+5. **Idempotenz:** Pro Lauf nur ein Tick pro Unique-Key; Worker max. 1× täglich wie `PoliticsWorker` reicht, wenn Intervall ≥ 1 Tag; bei `intervalDays < 1` separaten Stunden-Job definieren (nicht empfohlen ohne Bedarf).
+
+**Daemon-Ort:** neuer Schritt `performPoliticalBenefitTicks()` am Ende von `PoliticsWorker::performDailyPoliticsTask()` **oder** Node-Job 03:05 UTC.
+
+**Push:** nach Tick `falukantUpdateStatus` an betroffene User (wie bei anderen Politics-Events).
+
+---
+
+### 2.2 `free_lover_slots` (kostenfreie Liebschaften / Mätressen)
+
+**Ziel:** Die ersten *N* aktiven Liebschaften (Relationship-Typ `lover` + sichtbarer `relationship_state`, Rolle z. B. `lover` oder `mistress_or_favorite`) verursachen **keinen monatlichen Unterhalt** (`monthly_base_cost` effektiv 0 für diese Slots).
+
+**Kein eigener Daemon nötig** — Anwendung beim **bestehenden monatlichen Abrechnungslauf** (C++ `UserCharacterWorker` oder Sequelize-Äquivalent), der `relationship_state` und Kosten verarbeitet.
+
+**Backend-Mechanik:**
+
+1. Hilfsfunktion `getFreePoliticalLoverSlotCount(characterId)`
+ - Aktive `PoliticalOffice` des Charakters → `office_type_id` → Summe `free_lover_slots.count` aus Benefits (max oder Summe — **Spec-Empfehlung: Summe**, Deckel optional `maxTotalFreeSlots: 5` in Config).
+2. Liebschaften nach **Erstellungsdatum** oder fester Priorität sortieren; die ersten `N` erhalten `effectiveMonthlyCost = 0` für diese Abrechnung.
+3. Optional: Flag in `relationship_state.flags_json` (`political_free_slot: true`) setzen/aktualisieren für Transparenz und UI — oder rein berechnet ohne Persistenz.
+
+**Fairness:** Nur Charaktere, die **Inhaber** sind (nicht nur Kandidaten).
+
+---
+
+### 2.3 `appoint_politicians` (Ernennungsrecht)
+
+**Ziel:** Amtsträger dürfen unter definierten Regeln **andere Ämter** in ihrer **Zuständigkeit** besetzen (NPC oder Spieler), ohne vollständige Wahl — oder: **Sonderwahl / Nachrücker** auslösen.
+
+**Varianten (Priorisierung empfohlen):**
+
+| Variante | Beschreibung | Aufwand |
+|----------|--------------|--------|
+| **A — Nachbesetzung** | Bei vakantem `political_office` in erlaubter Region: Appointer wählt **einen Kandidaten** aus Pool (nur Charaktere mit Titel/Alter wie bei Wahl); Server erstellt Eintrag `political_office` + Log. | Mittel |
+| **B — Nur NPC** | Appointer „ernennt“ nur NPCs (bestehende NPC-Erzeugung); kein PvP-Konflikt. | Geringer |
+| **C — Einladung** | `appointment_invite` an Spieler; Annahme erstellt Amt. | Höher |
+
+**Persistenz (neu):**
+
+- `falukant_data.political_appointment`
+ - `id`, `appointer_character_id`, `target_character_id` (nullable bei NPC), `office_type_id`, `region_id`, `status` (`pending`, `accepted`, `rejected`, `expired`, `completed`), `created_at`, `expires_at`, `completed_office_id` (nullable).
+
+**Regeln:**
+
+- Appointer muss **aktuelles** Amt haben, dessen Benefit `officeTrs` das Zielamt enthält.
+- Zielregion muss zur **Kompetenz** passen (z. B. gleiche Stadt/Grafschaft wie Appointer-Amt oder laut `scope` aus JSON erweitert).
+- Anti-Spam: Cooldown, max. offene Ernennungen pro Appointer.
+
+**Daemon:** optional nur **Ablauf** (`expired`) einmal täglich im `PoliticsWorker` oder Node.
+
+---
+
+### 2.4 `set_regional_tax` (Steuersätze festlegen)
+
+**Ziel:** Berechtigte setzen `tax_percent` (oder einen **Aufschlag**) für Regionen innerhalb ihrer Kompetenz (`scope`: `local`, `shire`, `duchy`, `national`).
+
+**Datenmodell:**
+
+- **Option A (minimal):** direkt `falukant_data.region.tax_percent` ändern + **Audit-Tabelle** `region_tax_history` (`region_id`, `old_value`, `new_value`, `setter_character_id`, `office_id`, `created_at`).
+- **Option B (flexibler):** `region_political_tax_modifier` (additiv), Basis bleibt in `region`; effektive Steuer = Basis + Summe gültiger Modifier (mit TTL). Für „Rücknahme nach Amtsende“ einfacher.
+
+**Backend-Mechanik:**
+
+1. `GET /api/falukant/politics/tax-jurisdiction` — Regionen, für die der Charakter nach aktuellem Amt + `set_regional_tax` schreiben darf, inkl. aktuellem Wert und Grenzen.
+2. `PUT /api/falukant/politics/region/:regionId/tax` — Body `{ percent }` oder `{ delta }`; Server prüft:
+ - Charakter hat passendes Amt;
+ - Region liegt in erlaubter Hierarchie (lokal = nur Amtsregion; shire = Teilbaum unter shire; …);
+ - **Clamp** min/max (global config, z. B. 0–25 %).
+3. Verkaufs-/Steuer-SQL **unverändert** nutzen, sofern weiterhin `tax_percent` auf `region` liegt (bei Option B SQL um Modifier erweitern).
+
+**Daemon:** nicht nötig; bei **Amtsende** optional Modifier entfernen oder auf Historie zurücksetzen (täglicher Job oder Trigger auf `political_office` delete).
+
+---
+
+### 2.5 `guard_protection` / `court_immunity` (Spielwirkung)
+
+**Optional, später:**
+
+- **guard_protection:** Reduktion von Untergrund-Erfolgswahrscheinlichkeit gegen den Charakter oder geringerer `visibility`-Anstieg bei Affären (wenn solche Formeln existieren).
+- **court_immunity:** Abschwächung bestimmter Rechts-/Straf-Events (sobald es dafür Services gibt).
+
+Konkrete Zahlen als JSON in `value` (`attackMitigationPercent`, …). **Daemon:** nicht zwingend; eher **bei Event-Auswertung** abfragen.
+
+---
+
+## 3. Daemon — Gesamtüberblick
+
+```
+03:00 (bestehend) PoliticsWorker
+ ├─ … Wahlen, Nachbesetzung, Notifications …
+ ├─ NEU: politicalBenefitReputationTicks()
+ └─ NEU: politicalAppointmentExpiry() [optional]
+
+Monatlich / bestehender Character-Tick
+ └─ relationship monthly cost ← berücksichtigt free_lover_slots
+```
+
+**Idempotenz-Prinzip:** Jeder Batch-Job nutzt klare **Unique-Keys** und **Transaktionen**; bei Crash-Wiederholung keine Doppel-Ticks (Reputation).
+
+**Observability:** strukturierte Logs `[PoliticalBenefits] characterId=… officeBenefitId=… action=reputation_tick`.
+
+---
+
+## 4. Backend-API (Node) — Übersicht
+
+| Endpoint | Zweck |
+|----------|--------|
+| `GET /api/falukant/politics/my-powers` | Für eingeloggten Charakter: Liste **aktionabler** Vorteile inkl. Parameter, nächster `reputation`-Tick (read-only aus `political_benefit_last_tick`), freie Liebschaft-Slots-Zahl. |
+| `GET /api/falukant/politics/tax-jurisdiction` | Regionen + aktuelle Steuer + erlaubter Bereich. |
+| `PUT /api/falukant/politics/region/:id/tax` | Steuer setzen (siehe oben). |
+| `GET /api/falukant/politics/appointable-offices` | Vakante oder ernennbare Posten + erlaubte Ziel-`officeTrs`. |
+| `POST /api/falukant/politics/appointments` | Ernennung anstoßen (Body: `targetCharacterId`, `officeTypeId`, `regionId`). |
+| `POST /api/falukant/politics/appointments/:id/accept` | Optional für Ziel-Spieler. |
+
+**Service-Schicht:** `falukantPoliticalPowersService.js` (oder Erweiterung `falukantService`) mit klaren Guards und Wiederverwendung der Benefit-Loader aus `PoliticalOfficeBenefit`.
+
+**Sicherheit:** Alle Routen mit `getFalukantUserByHashedId` + Charakter-Inhaber-Check; keine Überschreibung fremder Regionen.
+
+---
+
+## 5. UI-Konzept (Vue)
+
+### 5.1 Einstieg
+
+- In **Politik** neuer Unter-Tab oder Sektion **„Amtsbefugnisse“** (sichtbar nur, wenn `my-powers` nicht leer).
+- Kurzinfo in **„Aktuelle Position“**: neben der Vorteilsliste ein Link „Befugnisse ausüben“.
+
+### 5.2 Steuern (`set_regional_tax`)
+
+- **Ansicht:** Tabelle Region (Name, Typ, aktueller Steuersatz, Slider oder Zahleneingabe).
+- **Validierung clientseitig** spiegelt Server-Clamps.
+- **Speichern** ruft `PUT` auf; Erfolgstoast; ggf. Eintrag in lokaler Historie (letzte 5 Änderungen aus `region_tax_history`).
+
+### 5.3 Ernennungen (`appoint_politicians`)
+
+- **Schritt 1:** Amt + Region wählen (nur erlaubte Kombinationen).
+- **Schritt 2:** Ziel-Charakter suchen (Autocomplete über Community/Falukant-Suche) oder „NPC vorschlagen“ (Variante B).
+- **Schritt 3:** Bestätigung, Anzeige der Regeln (Titel, Alter, Cooldown).
+- **Einladungs-Variante:** Posteingang / Notification mit Annehmen/Ablehnen.
+
+### 5.4 `reputation_periodic` / Meta
+
+- Anzeige: **„Nächster Ansehens-Bonus: in X Tagen“** aus `last_tick_at` + `intervalDays` (nur lesend; Ausführung durch Daemon).
+- Optional: Eintrag im **Tagebuch** / Notification nach Tick.
+
+### 5.5 `free_lover_slots`
+
+- In **Familie → Affären** (oder Liebschaften): Badge **„Amt: X Plätze ohne Unterhalt“**; in der Kostenübersicht pro Beziehung Kennzeichnung „politisch freigestellt“.
+- Kein eigener Daemon-Dialog nötig.
+
+### 5.6 Internationalisierung
+
+- Alle neuen Strings unter `falukant.politics.powers.*` (de/en/es/ceb analog zu `benefits`).
+
+---
+
+## 6. Migrations-Reihenfolge (empfohlen)
+
+1. `political_benefit_last_tick` (+ FKs, Indizes).
+2. `region_tax_history` oder Modifier-Tabelle.
+3. `political_appointment` (+ Indizes auf `status`, `appointer_character_id`).
+4. Daemon-/Job-Implementierung Reputation.
+5. API Steuern + UI.
+6. API Ernennungen + UI.
+7. Monatliche Kostenanpassung `free_lover_slots`.
+
+---
+
+## 7. Offene Produktentscheidungen
+
+- **Echtzeit vs. Spielzeit** für `intervalDays` (aktuell Mischung möglich mit `daily_salary` auf UTC-Datum).
+- **Summe vs. Maximum** bei mehreren Ämtern für `free_lover_slots`.
+- **Ernennung:** nur innerhalb der eigenen Region oder auch in Unterregionen?
+- **Steuer:** darf derselbe Region mehrfach von verschiedenen Ämtern gesetzt werden? (Empfehlung: letzte schreibende Instanz mit höherer `scope`-Priorität gewinnt, oder explizite Hierarchie-Regel.)
+
+---
+
+*Dokumentstand: abgestimmt auf Repository mit C++-`PoliticsWorker`, Sequelize-Falukant-Backend und bestehenden `political_office_benefit`-Seeds.*
diff --git a/frontend/src/i18n/locales/ceb/falukant.json b/frontend/src/i18n/locales/ceb/falukant.json
index 361031f..02bc92d 100644
--- a/frontend/src/i18n/locales/ceb/falukant.json
+++ b/frontend/src/i18n/locales/ceb/falukant.json
@@ -427,6 +427,7 @@
"title": "Politika",
"tabs": {
"current": "Karon nga posisyon",
+ "powers": "Katungod sa opisina",
"upcoming": "Umaabot nga mga posisyon",
"elections": "Mga eleksiyon"
},
@@ -452,6 +453,27 @@
"court_immunity": "Limitado nga immune sa korte sa opisina",
"generic": "Benepisyo ({code})"
},
+ "powers": {
+ "none": "Ang imong mga opisina karon walay dugang katungod (buhis, pagtudlo, libre nga slot).",
+ "loadError": "Dili makarga ang mga katungod sa opisina.",
+ "freeLoversTitle": "Mga affair (opisina)",
+ "freeLoversHint": "Adunay ka {count} ka political nga affair slot nga walay bulan nga gasto (tan-awa Pamilya → Affairs).",
+ "reputationTitle": "Reputasyon (awtomatiko)",
+ "reputationLine": "{office}: sunod nga bonus mga {days} ka adlaw (+{gain} reputasyon).",
+ "taxTitle": "Mga rate sa buhis sa rehiyon",
+ "taxRange": "Gitugotan: {min}% hangtod {max}%.",
+ "taxSave": "I-save",
+ "taxSaved": "Na-save ang rate sa buhis.",
+ "taxError": "Dili ma-save ang rate sa buhis.",
+ "appointTitle": "Mga pagtudlo",
+ "appointSlot": "Bakante nga opisina",
+ "appointPick": "— pilia ang opisina —",
+ "appointSearch": "Pangitaa ang mga magdula (ngalan o username)",
+ "appointSelected": "Gipili: {name}",
+ "appointSubmit": "Itudlo",
+ "appointSuccess": "Natuman ang pagtudlo.",
+ "appointError": "Napakyas ang pagtudlo."
+ },
"regionLevels": {
"city": "Siyudad",
"county": "County",
@@ -482,6 +504,53 @@
"partner": "Kapikas ug kasal",
"children": "Mga anak",
"lovers": "Mga affair"
+ },
+ "lovers": {
+ "title": "Mga minyo’g gawas ug mga paborito",
+ "none": "Walay mga affair karon.",
+ "age": "Edad",
+ "affection": "Pagmahal",
+ "visibility": "Klaro ba",
+ "discretion": "Diskreto",
+ "maintenance": "Suporta bulanan",
+ "monthlyCost": "Gasto kada bulan",
+ "politicalFreeSlotsHint": "Ang mga politikal nga opisina naghatag og {count} ka affair slot nga walay bulan nga suporta (ang barato nga relasyon una).",
+ "politicalFreeMaintenance": "Opisina (libre)",
+ "statusFit": "Angay sa kahimtang",
+ "acknowledged": "Giila",
+ "underfunded": "{count} ka bulan kulang ang suporta",
+ "role": {
+ "secret_affair": "Tago nga relasyon",
+ "lover": "Kasintahan",
+ "mistress_or_favorite": "Mistress o paborito"
+ },
+ "risk": {
+ "low": "Ubos nga risgo",
+ "medium": "Tunga-tunga nga risgo",
+ "high": "Taas nga risgo"
+ },
+ "actions": {
+ "start": "Sugdi ang affair",
+ "startSuccess": "Nagsugod na ang bag-ong affair.",
+ "startError": "Dili masugdan ang affair.",
+ "maintenanceLow": "Suporta 25",
+ "maintenanceMedium": "Suporta 50",
+ "maintenanceHigh": "Suporta 75",
+ "maintenanceSuccess": "Na-update ang suporta.",
+ "maintenanceError": "Dili ma-update ang suporta.",
+ "acknowledge": "Iila",
+ "acknowledgeSuccess": "Giila na ang relasyon.",
+ "acknowledgeError": "Dili ma-ila ang relasyon.",
+ "end": "Hunongon",
+ "endConfirm": "Hunongon ba gyud kini nga relasyon?",
+ "endSuccess": "Nahunong na ang relasyon.",
+ "endError": "Dili mahunong ang relasyon."
+ },
+ "candidates": {
+ "title": "Posible nga mga affair",
+ "roleLabel": "Porma sa relasyon",
+ "none": "Walay angay nga bag-ong affair karon."
+ }
}
},
"church": {
diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json
index cae6c30..c04cf10 100644
--- a/frontend/src/i18n/locales/de/falukant.json
+++ b/frontend/src/i18n/locales/de/falukant.json
@@ -711,6 +711,8 @@
"discretion": "Diskretion",
"maintenance": "Unterhalt",
"monthlyCost": "Monatskosten",
+ "politicalFreeSlotsHint": "Politische Ämter gewähren dir {count} Liebschaftsplatz/-plätze ohne monatlichen Unterhalt (die günstigsten Beziehungen zählen zuerst).",
+ "politicalFreeMaintenance": "Amt (frei)",
"statusFit": "Standespassung",
"acknowledged": "Anerkannt",
"underfunded": "{count} Monate unterversorgt",
@@ -1346,6 +1348,7 @@
"title": "Politik",
"tabs": {
"current": "Aktuelle Position",
+ "powers": "Amtsbefugnisse",
"upcoming": "Anstehende Neuwahl-Positionen",
"elections": "Wahlen"
},
@@ -1371,6 +1374,27 @@
"court_immunity": "Eingeschränkte gerichtliche Immunität im Amtsbereich",
"generic": "Vorteil ({code})"
},
+ "powers": {
+ "none": "Für deine aktuellen Ämter liegen keine zusätzlichen Befugnisse vor (Steuern, Ernennungen, Freiplätze).",
+ "loadError": "Amtsbefugnisse konnten nicht geladen werden.",
+ "freeLoversTitle": "Liebschaften (Amt)",
+ "freeLoversHint": "Dir stehen {count} politisch begründete Plätze ohne monatlichen Unterhalt zu (siehe Familie → Affären).",
+ "reputationTitle": "Ansehen (automatisch)",
+ "reputationLine": "{office}: nächster Bonus in ca. {days} Tag(en) (+{gain} Ansehen).",
+ "taxTitle": "Regionale Steuersätze",
+ "taxRange": "Erlaubter Bereich: {min} % bis {max} %.",
+ "taxSave": "Speichern",
+ "taxSaved": "Steuersatz gespeichert.",
+ "taxError": "Steuersatz konnte nicht gespeichert werden.",
+ "appointTitle": "Ernennungen",
+ "appointSlot": "Freier Posten",
+ "appointPick": "— Posten wählen —",
+ "appointSearch": "Spieler suchen (Name oder Benutzername)",
+ "appointSelected": "Gewählt: {name}",
+ "appointSubmit": "Ernennen",
+ "appointSuccess": "Ernennung durchgeführt.",
+ "appointError": "Ernennung fehlgeschlagen."
+ },
"regionLevels": {
"city": "Stadt",
"county": "Landkreis",
diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json
index b54081c..313b3e1 100644
--- a/frontend/src/i18n/locales/en/falukant.json
+++ b/frontend/src/i18n/locales/en/falukant.json
@@ -567,6 +567,7 @@
"title": "Politics",
"tabs": {
"current": "Current Position",
+ "powers": "Office powers",
"upcoming": "Upcoming Positions",
"elections": "Elections"
},
@@ -592,6 +593,27 @@
"court_immunity": "Limited judicial immunity in office matters",
"generic": "Benefit ({code})"
},
+ "powers": {
+ "none": "Your current offices grant no extra powers (taxes, appointments, free slots).",
+ "loadError": "Could not load office powers.",
+ "freeLoversTitle": "Affairs (office)",
+ "freeLoversHint": "You have {count} politically granted affair slot(s) with no monthly upkeep (see Family → Affairs).",
+ "reputationTitle": "Reputation (automatic)",
+ "reputationLine": "{office}: next bonus in about {days} day(s) (+{gain} reputation).",
+ "taxTitle": "Regional tax rates",
+ "taxRange": "Allowed range: {min}% to {max}%.",
+ "taxSave": "Save",
+ "taxSaved": "Tax rate saved.",
+ "taxError": "Could not save tax rate.",
+ "appointTitle": "Appointments",
+ "appointSlot": "Vacant office",
+ "appointPick": "— choose office —",
+ "appointSearch": "Search players (name or username)",
+ "appointSelected": "Selected: {name}",
+ "appointSubmit": "Appoint",
+ "appointSuccess": "Appointment completed.",
+ "appointError": "Appointment failed."
+ },
"regionLevels": {
"city": "City",
"county": "County",
@@ -795,6 +817,8 @@
"discretion": "Discretion",
"maintenance": "Maintenance",
"monthlyCost": "Monthly Cost",
+ "politicalFreeSlotsHint": "Political offices grant you {count} affair slot(s) with no monthly upkeep (cheapest relationships count first).",
+ "politicalFreeMaintenance": "Office (free)",
"statusFit": "Status Fit",
"acknowledged": "Acknowledged",
"underfunded": "{count} months underfunded",
diff --git a/frontend/src/i18n/locales/es/falukant.json b/frontend/src/i18n/locales/es/falukant.json
index 8bd74f3..c66910b 100644
--- a/frontend/src/i18n/locales/es/falukant.json
+++ b/frontend/src/i18n/locales/es/falukant.json
@@ -679,6 +679,8 @@
"discretion": "Discreción",
"maintenance": "Mantenimiento",
"monthlyCost": "Coste mensual",
+ "politicalFreeSlotsHint": "Los cargos políticos te conceden {count} plaza(s) de relación sin mantenimiento mensual (primero cuentan las relaciones más baratas).",
+ "politicalFreeMaintenance": "Cargo (gratis)",
"statusFit": "Adecuación social",
"acknowledged": "Reconocido",
"underfunded": "{count} meses con fondos insuficientes",
@@ -1258,6 +1260,7 @@
"title": "Política",
"tabs": {
"current": "Cargo actual",
+ "powers": "Facultades del cargo",
"upcoming": "Cargos pendientes de (re)elección",
"elections": "Elecciones"
},
@@ -1279,6 +1282,27 @@
"court_immunity": "Inmunidad judicial limitada en asuntos del cargo",
"generic": "Ventaja ({code})"
},
+ "powers": {
+ "none": "Tus cargos actuales no conceden facultades adicionales (impuestos, nombramientos, plazas gratuitas).",
+ "loadError": "No se pudieron cargar las facultades del cargo.",
+ "freeLoversTitle": "Relaciones (cargo)",
+ "freeLoversHint": "Tienes {count} plaza(s) de relación concedida(s) políticamente sin mantenimiento mensual (véase Familia → Relaciones).",
+ "reputationTitle": "Reputación (automática)",
+ "reputationLine": "{office}: próximo bono en unos {days} día(s) (+{gain} reputación).",
+ "taxTitle": "Tipos impositivos regionales",
+ "taxRange": "Rango permitido: del {min} % al {max} %.",
+ "taxSave": "Guardar",
+ "taxSaved": "Tipo impositivo guardado.",
+ "taxError": "No se pudo guardar el tipo impositivo.",
+ "appointTitle": "Nombramientos",
+ "appointSlot": "Cargo vacante",
+ "appointPick": "— elegir cargo —",
+ "appointSearch": "Buscar jugadores (nombre o usuario)",
+ "appointSelected": "Seleccionado: {name}",
+ "appointSubmit": "Nombrar",
+ "appointSuccess": "Nombramiento realizado.",
+ "appointError": "El nombramiento ha fallado."
+ },
"regionLevels": {
"city": "Ciudad",
"county": "Condado",
diff --git a/frontend/src/views/falukant/FamilyView.vue b/frontend/src/views/falukant/FamilyView.vue
index c2e4fb2..95623c8 100644
--- a/frontend/src/views/falukant/FamilyView.vue
+++ b/frontend/src/views/falukant/FamilyView.vue
@@ -342,6 +342,9 @@
{{ $t('falukant.family.lovers.title') }}
+
+ {{ $t('falukant.family.lovers.politicalFreeSlotsHint', { count: politicalFreeLoverSlots }) }}
+
{{ $t('falukant.family.lovers.monthlyCost') }}
- {{ formatCost(lover.monthlyCost || 0) }}
+
+ {{ formatCost(lover.monthlyCost || 0) }}
+
+ {{ $t('falukant.family.lovers.politicalFreeMaintenance') }}
+
+
{{ $t('falukant.family.lovers.statusFit') }}
@@ -476,6 +484,7 @@ export default {
relationships: [],
children: [],
lovers: [],
+ politicalFreeLoverSlots: 0,
possibleLovers: [],
candidateRoles: {},
deathPartners: [],
@@ -655,6 +664,7 @@ export default {
this.relationships = response.data.relationships;
this.children = response.data.children;
this.lovers = response.data.lovers;
+ this.politicalFreeLoverSlots = Number(response.data.politicalFreeLoverSlots) || 0;
this.possibleLovers = response.data.possibleLovers || [];
this.syncCandidateRoles();
this.proposals = response.data.possiblePartners;
@@ -1525,6 +1535,18 @@ export default {
background-color: #f9f9f9;
}
+.lovers-political-hint {
+ font-size: 0.92rem;
+ color: var(--color-text-secondary);
+ margin: 0 0 12px 0;
+}
+.lover-political-free {
+ display: block;
+ font-size: 0.85rem;
+ color: var(--color-text-secondary);
+ margin-top: 4px;
+}
+
.lovers-section {
ul {
list-style: none;
diff --git a/frontend/src/views/falukant/PoliticsView.vue b/frontend/src/views/falukant/PoliticsView.vue
index f252424..3da72d1 100644
--- a/frontend/src/views/falukant/PoliticsView.vue
+++ b/frontend/src/views/falukant/PoliticsView.vue
@@ -44,6 +44,81 @@
{{ $t('falukant.politics.current.none') }}
+
+
{{ $t('loading') }}
+
+ {{ $t('falukant.politics.powers.none') }}
+
+
+ {{ $t('falukant.politics.powers.freeLoversTitle') }}
+ {{ $t('falukant.politics.powers.freeLoversHint', { count: myPowers.freeLoverSlots }) }}
+
+
+ {{ $t('falukant.politics.powers.reputationTitle') }}
+
+ -
+ {{ $t('falukant.politics.powers.reputationLine', {
+ office: $t(`falukant.politics.offices.${r.officeTypeName}`),
+ days: r.daysUntilNext,
+ gain: r.gain
+ }) }}
+
+
+
+
+ {{ $t('falukant.politics.powers.taxTitle') }}
+ {{ $t('falukant.politics.powers.taxRange', { min: taxJurisdiction.taxMin, max: taxJurisdiction.taxMax }) }}
+
+
+ {{ reg.name }}
+ ({{ formatPoliticsRegionLevel(reg.regionType) }})
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('falukant.politics.open.ageRequirement') }}
@@ -150,6 +225,7 @@ export default {
activeTab: 'current',
tabs: [
{ value: 'current', label: 'falukant.politics.tabs.current' },
+ { value: 'powers', label: 'falukant.politics.tabs.powers' },
{ value: 'openPolitics', label: 'falukant.politics.tabs.upcoming' },
{ value: 'elections', label: 'falukant.politics.tabs.elections' }
],
@@ -159,14 +235,37 @@ export default {
selectedCandidates: {},
selectedApplications: [],
ownCharacterId: null,
+ myPowers: null,
+ taxJurisdiction: null,
+ taxDraft: {},
+ appointable: [],
+ selectedAppointKey: '',
+ appointSearch: '',
+ appointSearchResults: [],
+ selectedAppointTarget: null,
+ _appointSearchTimer: null,
loading: {
current: false,
openPolitics: false,
- elections: false
+ elections: false,
+ powers: false
}
};
},
computed: {
+ hasAnyPowers() {
+ const p = this.myPowers;
+ if (!p) return false;
+ return (
+ (p.freeLoverSlots > 0) ||
+ (p.reputationPeriodic && p.reputationPeriodic.length) ||
+ (this.taxJurisdiction?.regions?.length > 0) ||
+ (this.appointable.length > 0)
+ );
+ },
+ canSubmitAppointment() {
+ return Boolean(this.selectedAppointKey && this.selectedAppointTarget?.characterId);
+ },
hasAnySelection() {
return Object.values(this.selectedCandidates)
.some(arr => Array.isArray(arr) && arr.length > 0);
@@ -266,6 +365,9 @@ export default {
if (tab === 'current') {
this.loadCurrentPositions();
}
+ if (tab === 'powers') {
+ this.loadPowers();
+ }
if (tab === 'openPolitics') {
this.loadOpenPolitics();
}
@@ -274,6 +376,95 @@ export default {
}
},
+ async loadPowers() {
+ this.loading.powers = true;
+ try {
+ const [mp, tj, ap] = await Promise.all([
+ apiClient.get('/api/falukant/politics/my-powers'),
+ apiClient.get('/api/falukant/politics/tax-jurisdiction'),
+ apiClient.get('/api/falukant/politics/appointable-offices')
+ ]);
+ this.myPowers = mp.data;
+ this.taxJurisdiction = tj.data;
+ this.appointable = Array.isArray(ap.data) ? ap.data : [];
+ const draft = {};
+ (this.taxJurisdiction?.regions || []).forEach((r) => {
+ draft[r.id] = r.taxPercent;
+ });
+ this.taxDraft = draft;
+ } catch (err) {
+ console.error('[PoliticsView] loadPowers', err);
+ showApiError(this, err, this.$t('falukant.politics.powers.loadError'));
+ } finally {
+ this.loading.powers = false;
+ }
+ },
+
+ appointOptionKey(a) {
+ return `${a.officeTypeId}:${a.regionId}`;
+ },
+
+ parseAppointKey(key) {
+ const [officeTypeId, regionId] = String(key).split(':').map((x) => parseInt(x, 10));
+ if (Number.isNaN(officeTypeId) || Number.isNaN(regionId)) return null;
+ return { officeTypeId, regionId };
+ },
+
+ async saveRegionTax(regionId) {
+ const percent = this.taxDraft[regionId];
+ try {
+ await apiClient.put(`/api/falukant/politics/region/${regionId}/tax`, { percent });
+ showSuccess(this, this.$t('falukant.politics.powers.taxSaved'));
+ await this.loadPowers();
+ } catch (err) {
+ showApiError(this, err, this.$t('falukant.politics.powers.taxError'));
+ }
+ },
+
+ debouncedAppointSearch() {
+ if (this._appointSearchTimer) clearTimeout(this._appointSearchTimer);
+ this._appointSearchTimer = setTimeout(() => this.runAppointSearch(), 350);
+ },
+
+ async runAppointSearch() {
+ const q = (this.appointSearch || '').trim();
+ if (q.length < 2) {
+ this.appointSearchResults = [];
+ return;
+ }
+ try {
+ const { data } = await apiClient.get('/api/falukant/users/search', { params: { q } });
+ this.appointSearchResults = Array.isArray(data) ? data : [];
+ } catch (err) {
+ this.appointSearchResults = [];
+ }
+ },
+
+ selectAppointTarget(u) {
+ this.selectedAppointTarget = u;
+ },
+
+ async submitAppointment() {
+ const parsed = this.parseAppointKey(this.selectedAppointKey);
+ if (!parsed || !this.selectedAppointTarget?.characterId) return;
+ try {
+ await apiClient.post('/api/falukant/politics/appointments', {
+ targetCharacterId: this.selectedAppointTarget.characterId,
+ officeTypeId: parsed.officeTypeId,
+ regionId: parsed.regionId
+ });
+ showSuccess(this, this.$t('falukant.politics.powers.appointSuccess'));
+ this.selectedAppointKey = '';
+ this.selectedAppointTarget = null;
+ this.appointSearch = '';
+ this.appointSearchResults = [];
+ await this.loadPowers();
+ await this.loadCurrentPositions();
+ } catch (err) {
+ showApiError(this, err, this.$t('falukant.politics.powers.appointError'));
+ }
+ },
+
async loadCurrentPositions() {
this.loading.current = true;
try {
@@ -535,4 +726,82 @@ export default {
align-items: flex-start;
}
}
+
+.powers-tab .powers-section {
+ margin-bottom: 1.5rem;
+ padding: 1rem 1.1rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ background: rgba(255, 255, 255, 0.75);
+}
+.powers-tab h3 {
+ margin: 0 0 0.5rem 0;
+ font-size: 1.05rem;
+}
+.powers-hint,
+.politics-powers-empty {
+ color: var(--color-text-secondary);
+ font-size: 0.95rem;
+}
+.powers-list {
+ margin: 0;
+ padding-left: 1.2rem;
+}
+.powers-tax-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 10px 0;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
+}
+.powers-tax-edit {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.powers-tax-edit input {
+ width: 5.5rem;
+ padding: 4px 8px;
+}
+.powers-muted {
+ margin-left: 6px;
+ color: var(--color-text-secondary);
+ font-size: 0.9rem;
+}
+.powers-label {
+ display: block;
+ margin: 10px 0 4px;
+ font-size: 0.92rem;
+}
+.powers-input,
+.powers-select {
+ width: 100%;
+ max-width: 28rem;
+ padding: 6px 10px;
+}
+.powers-search-results {
+ list-style: none;
+ margin: 8px 0 0;
+ padding: 0;
+}
+.powers-linkish {
+ background: none;
+ border: none;
+ padding: 4px 0;
+ cursor: pointer;
+ text-align: left;
+ color: var(--color-primary, #6b4a2a);
+ text-decoration: underline;
+}
+.powers-selected-target {
+ margin: 10px 0 0;
+ font-size: 0.95rem;
+}
+.powers-submit {
+ margin-top: 12px;
+ padding: 6px 14px;
+ cursor: pointer;
+}