Files
yourpart3/backend/services/falukantPoliticalPowersService.js
Torsten Schulz (local) 60ef98283f
All checks were successful
Deploy to production / deploy (push) Successful in 2m51s
fix(falukant): validate office term length and adjust term end calculation
- Added validation for office term length to ensure it is a positive finite number, throwing an error for invalid values.
- Updated the calculation of the term end date to use the term length in days instead of years, aligning with the expected data format.
2026-04-10 16:08:50 +02:00

533 lines
21 KiB
JavaScript

/**
* 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 termDays = Number(officeType.termLength);
if (!Number.isFinite(termDays) || termDays <= 0) {
const err = new Error('invalid_office_term_length');
err.status = 500;
throw err;
}
const end = new Date(start);
end.setDate(end.getDate() + termDays);
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();