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 }) }} +

@@ -377,7 +380,12 @@
{{ $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.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; +}