All checks were successful
Deploy to production / deploy (push) Successful in 2m51s
- 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.
533 lines
21 KiB
JavaScript
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();
|