feat(political-benefits): implement political powers and benefits system
All checks were successful
Deploy to production / deploy (push) Successful in 3m3s

- Added new political powers and benefits functionalities, including reputation ticks, tax jurisdiction management, and appointment capabilities.
- Introduced a new job for periodic reputation updates and created necessary database tables for tracking political benefits.
- Enhanced the FalukantController and services to support new endpoints for managing political powers and appointments.
- Updated localization files to reflect new features and improve user experience across multiple languages.
- Modified the UI to display new political powers and benefits, ensuring accurate representation in the PoliticsView.
This commit is contained in:
Torsten Schulz (local)
2026-04-02 16:00:29 +02:00
parent 5d06d97737
commit 56be4b76c0
19 changed files with 1572 additions and 2 deletions

View File

@@ -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 minyog 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": {

View File

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

View File

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

View File

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

View File

@@ -342,6 +342,9 @@
<!-- Liebhaber / Geliebte -->
<div class="lovers-section">
<h3>{{ $t('falukant.family.lovers.title') }}</h3>
<p v-if="politicalFreeLoverSlots > 0" class="lovers-political-hint">
{{ $t('falukant.family.lovers.politicalFreeSlotsHint', { count: politicalFreeLoverSlots }) }}
</p>
<div v-if="lovers && lovers.length > 0" class="lovers-grid">
<article v-for="lover in lovers" :key="lover.relationshipId" class="lover-card surface-card">
<div class="lover-card__header">
@@ -377,7 +380,12 @@
</div>
<div>
<dt>{{ $t('falukant.family.lovers.monthlyCost') }}</dt>
<dd>{{ formatCost(lover.monthlyCost || 0) }}</dd>
<dd>
{{ formatCost(lover.monthlyCost || 0) }}
<span v-if="lover.politicalFreeMaintenance" class="lover-political-free">
{{ $t('falukant.family.lovers.politicalFreeMaintenance') }}
</span>
</dd>
</div>
<div>
<dt>{{ $t('falukant.family.lovers.statusFit') }}</dt>
@@ -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;

View File

@@ -44,6 +44,81 @@
<p v-else class="loading">{{ $t('falukant.politics.current.none') }}</p>
</div>
<div v-else-if="activeTab === 'powers'" class="tab-pane powers-tab">
<div v-if="loading.powers" class="loading">{{ $t('loading') }}</div>
<template v-else-if="myPowers">
<p v-if="!hasAnyPowers" class="politics-powers-empty">{{ $t('falukant.politics.powers.none') }}</p>
<template v-else>
<section v-if="myPowers.freeLoverSlots > 0" class="powers-section">
<h3>{{ $t('falukant.politics.powers.freeLoversTitle') }}</h3>
<p>{{ $t('falukant.politics.powers.freeLoversHint', { count: myPowers.freeLoverSlots }) }}</p>
</section>
<section v-if="myPowers.reputationPeriodic && myPowers.reputationPeriodic.length" class="powers-section">
<h3>{{ $t('falukant.politics.powers.reputationTitle') }}</h3>
<ul class="powers-list">
<li v-for="(r, i) in myPowers.reputationPeriodic" :key="i">
{{ $t('falukant.politics.powers.reputationLine', {
office: $t(`falukant.politics.offices.${r.officeTypeName}`),
days: r.daysUntilNext,
gain: r.gain
}) }}
</li>
</ul>
</section>
<section v-if="taxJurisdiction && taxJurisdiction.regions && taxJurisdiction.regions.length" class="powers-section">
<h3>{{ $t('falukant.politics.powers.taxTitle') }}</h3>
<p class="powers-hint">{{ $t('falukant.politics.powers.taxRange', { min: taxJurisdiction.taxMin, max: taxJurisdiction.taxMax }) }}</p>
<div v-for="reg in taxJurisdiction.regions" :key="reg.id" class="powers-tax-row">
<div>
<strong>{{ reg.name }}</strong>
<span class="powers-muted">({{ formatPoliticsRegionLevel(reg.regionType) }})</span>
</div>
<div class="powers-tax-edit">
<input
type="number"
:min="taxJurisdiction.taxMin"
:max="taxJurisdiction.taxMax"
step="0.1"
v-model.number="taxDraft[reg.id]"
/>
<button type="button" @click="saveRegionTax(reg.id)">{{ $t('falukant.politics.powers.taxSave') }}</button>
</div>
</div>
</section>
<section v-if="appointable.length" class="powers-section">
<h3>{{ $t('falukant.politics.powers.appointTitle') }}</h3>
<label class="powers-label">{{ $t('falukant.politics.powers.appointSlot') }}</label>
<select v-model="selectedAppointKey" class="powers-select">
<option value="">{{ $t('falukant.politics.powers.appointPick') }}</option>
<option v-for="a in appointable" :key="appointOptionKey(a)" :value="appointOptionKey(a)">
{{ $t(`falukant.politics.offices.${a.officeTypeName}`) }} {{ a.regionName }} ({{ a.seatsFree }})
</option>
</select>
<label class="powers-label">{{ $t('falukant.politics.powers.appointSearch') }}</label>
<input v-model="appointSearch" type="text" class="powers-input" @input="debouncedAppointSearch" />
<ul v-if="appointSearchResults.length" class="powers-search-results">
<li v-for="u in appointSearchResults" :key="u.characterId">
<button type="button" class="powers-linkish" @click="selectAppointTarget(u)">
{{ u.username }} {{ u.firstname }} {{ u.lastname }} ({{ u.town }})
</button>
</li>
</ul>
<p v-if="selectedAppointTarget" class="powers-selected-target">
{{ $t('falukant.politics.powers.appointSelected', { name: selectedAppointTarget.username }) }}
</p>
<button
type="button"
class="powers-submit"
:disabled="!canSubmitAppointment"
@click="submitAppointment"
>
{{ $t('falukant.politics.powers.appointSubmit') }}
</button>
</section>
</template>
</template>
</div>
<!-- OPEN Tab: hier zeigen wir 'openPolitics' -->
<div v-else-if="activeTab === 'openPolitics'" class="tab-pane">
<p class="politics-age-requirement">{{ $t('falukant.politics.open.ageRequirement') }}</p>
@@ -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;
}
</style>