feat(political-benefits): implement political powers and benefits system
All checks were successful
Deploy to production / deploy (push) Successful in 3m3s
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:
@@ -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) => {
|
||||
|
||||
94
backend/jobs/politicalBenefitsTick.js
Normal file
94
backend/jobs/politicalBenefitsTick.js
Normal file
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
56
backend/models/falukant/data/political_appointment.js
Normal file
56
backend/models/falukant/data/political_appointment.js
Normal file
@@ -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;
|
||||
40
backend/models/falukant/data/political_benefit_last_tick.js
Normal file
40
backend/models/falukant/data/political_benefit_last_tick.js
Normal file
@@ -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;
|
||||
46
backend/models/falukant/data/region_tax_history.js
Normal file
46
backend/models/falukant/data/region_tax_history.js
Normal file
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
526
backend/services/falukantPoliticalPowersService.js
Normal file
526
backend/services/falukantPoliticalPowersService.js
Normal file
@@ -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();
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user