/** * 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();