Add NPC creation and titles retrieval functionality in Admin module

- Implemented createNPCs method in AdminController to handle NPC creation with specified parameters including region, age, title, and count.
- Added getTitlesOfNobility method in AdminController to retrieve available titles for users.
- Updated adminRouter to include new routes for creating NPCs and fetching titles.
- Enhanced navigationController and frontend localization files to support new NPC creation feature.
- Introduced corresponding UI components and routes for NPC management in the admin interface.
This commit is contained in:
Torsten Schulz (local)
2026-01-07 16:45:39 +01:00
parent 511df52c3c
commit bb91c2bbe5
11 changed files with 549 additions and 19 deletions

View File

@@ -43,6 +43,8 @@ class AdminController {
this.getRegionDistances = this.getRegionDistances.bind(this);
this.upsertRegionDistance = this.upsertRegionDistance.bind(this);
this.deleteRegionDistance = this.deleteRegionDistance.bind(this);
this.createNPCs = this.createNPCs.bind(this);
this.getTitlesOfNobility = this.getTitlesOfNobility.bind(this);
}
async getOpenInterests(req, res) {
@@ -383,6 +385,38 @@ class AdminController {
}
}
async createNPCs(req, res) {
try {
const { userid: userId } = req.headers;
const { regionIds, minAge, maxAge, minTitleId, maxTitleId, count } = req.body;
const result = await AdminService.createNPCs(userId, {
regionIds: regionIds && regionIds.length > 0 ? regionIds : null,
minAge: parseInt(minAge) || 0,
maxAge: parseInt(maxAge) || 100,
minTitleId: parseInt(minTitleId) || 1,
maxTitleId: parseInt(maxTitleId) || 19,
count: parseInt(count) || 1
});
res.status(200).json(result);
} catch (error) {
console.log(error);
const status = error.message === 'noaccess' ? 403 : 500;
res.status(status).json({ error: error.message });
}
}
async getTitlesOfNobility(req, res) {
try {
const { userid: userId } = req.headers;
const titles = await AdminService.getTitlesOfNobility(userId);
res.status(200).json(titles);
} catch (error) {
console.log(error);
const status = error.message === 'noaccess' ? 403 : 500;
res.status(status).json({ error: error.message });
}
}
async getRoomTypes(req, res) {
try {
const userId = req.headers.userid;

View File

@@ -280,6 +280,10 @@ const menuStructure = {
visible: ["mainadmin", "falukant"],
path: "/admin/falukant/map"
},
createNPC: {
visible: ["mainadmin", "falukant"],
path: "/admin/falukant/create-npc"
},
}
},
minigames: {

View File

@@ -46,6 +46,8 @@ router.put('/falukant/regions/:id/map', authenticate, adminController.updateFalu
router.get('/falukant/region-distances', authenticate, adminController.getRegionDistances);
router.post('/falukant/region-distances', authenticate, adminController.upsertRegionDistance);
router.delete('/falukant/region-distances/:id', authenticate, adminController.deleteRegionDistance);
router.post('/falukant/npcs/create', authenticate, adminController.createNPCs);
router.get('/falukant/titles', authenticate, adminController.getTitlesOfNobility);
// --- Minigames Admin ---
router.get('/minigames/match3/campaigns', authenticate, adminController.getMatch3Campaigns);

View File

@@ -10,7 +10,7 @@ import UserParamType from "../models/type/user_param.js";
import ContactMessage from "../models/service/contactmessage.js";
import ContactService from "./ContactService.js";
import { sendAnswerEmail } from './emailService.js';
import { Op } from 'sequelize';
import { Op, Sequelize } from 'sequelize';
import FalukantUser from "../models/falukant/data/user.js";
import FalukantCharacter from "../models/falukant/data/character.js";
import FalukantPredefineFirstname from "../models/falukant/predefine/firstname.js";
@@ -24,6 +24,8 @@ import BranchType from "../models/falukant/type/branch.js";
import RegionDistance from "../models/falukant/data/region_distance.js";
import Room from '../models/chat/room.js';
import UserParam from '../models/community/user_param.js';
import TitleOfNobility from "../models/falukant/type/title_of_nobility.js";
import { sequelize } from '../utils/sequelize.js';
class AdminService {
async hasUserAccess(userId, section) {
@@ -321,6 +323,17 @@ class AdminService {
return regions;
}
async getTitlesOfNobility(userId) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess');
}
const titles = await TitleOfNobility.findAll({
order: [['id', 'ASC']],
attributes: ['id', 'labelTr', 'level']
});
return titles;
}
async updateFalukantRegionMap(userId, regionId, map) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess');
@@ -1085,6 +1098,136 @@ class AdminService {
ageGroups
};
}
async createNPCs(userId, options) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess');
}
const {
regionIds, // Array von Region-IDs oder null für alle Städte
minAge, // Mindestalter in Jahren
maxAge, // Maximalalter in Jahren
minTitleId, // Minimale Title-ID
maxTitleId, // Maximale Title-ID
count // Anzahl der zu erstellenden NPCs
} = options;
// Hole alle Städte, wenn keine spezifischen Regionen angegeben
let targetRegions = [];
if (regionIds && regionIds.length > 0) {
targetRegions = await RegionData.findAll({
where: {
id: { [Op.in]: regionIds }
},
include: [{
model: RegionType,
as: 'regionType',
where: { labelTr: 'city' }
}]
});
} else {
targetRegions = await RegionData.findAll({
include: [{
model: RegionType,
as: 'regionType',
where: { labelTr: 'city' }
}]
});
}
if (targetRegions.length === 0) {
throw new Error('No cities found');
}
// Hole alle Titles im Bereich
const titles = await TitleOfNobility.findAll({
where: {
id: {
[Op.between]: [minTitleId, maxTitleId]
}
},
order: [['id', 'ASC']]
});
if (titles.length === 0) {
throw new Error('No titles found in specified range');
}
const genders = ['male', 'female'];
const createdNPCs = [];
// Erstelle NPCs in einer Transaktion
await sequelize.transaction(async (t) => {
for (let i = 0; i < count; i++) {
// Zufällige Region
const region = targetRegions[Math.floor(Math.random() * targetRegions.length)];
// Zufälliges Geschlecht
const gender = genders[Math.floor(Math.random() * genders.length)];
// Zufälliger Vorname
const firstName = await FalukantPredefineFirstname.findAll({
where: { gender },
order: sequelize.fn('RANDOM'),
limit: 1,
transaction: t
});
if (firstName.length === 0) {
throw new Error(`No first names found for gender: ${gender}`);
}
const fnObj = firstName[0];
// Zufälliger Nachname
const lastName = await FalukantPredefineLastname.findAll({
order: sequelize.fn('RANDOM'),
limit: 1,
transaction: t
});
if (lastName.length === 0) {
throw new Error('No last names found');
}
const lnObj = lastName[0];
// Zufälliges Alter (in Jahren, wird in Tage umgerechnet)
const randomAge = Math.floor(Math.random() * (maxAge - minAge + 1)) + minAge;
const birthdate = new Date();
birthdate.setDate(birthdate.getDate() - randomAge); // 5 Tage = 5 Jahre alt
// Zufälliger Title
const title = titles[Math.floor(Math.random() * titles.length)];
// Erstelle den NPC-Charakter (ohne userId = NPC)
const npc = await FalukantCharacter.create({
userId: null, // Wichtig: null = NPC
regionId: region.id,
firstName: fnObj.id,
lastName: lnObj.id,
gender: gender,
birthdate: birthdate,
titleOfNobility: title.id,
health: 100,
moodId: 1
}, { transaction: t });
createdNPCs.push({
id: npc.id,
firstName: fnObj.name,
lastName: lnObj.name,
gender: gender,
age: randomAge,
region: region.name,
title: title.labelTr
});
}
});
return {
success: true,
count: createdNPCs.length,
npcs: createdNPCs
};
}
}
export default new AdminService();

View File

@@ -635,20 +635,27 @@ class FalukantService extends BaseService {
});
if (already > 0) return null;
// Choose an event (guaranteed once/day, random type)
// Choose an event (reduced frequency - not guaranteed every day)
// Total weight: 50 (50% chance per day, or adjust as needed)
const events = [
{ id: 'windfall', weight: 25 },
{ id: 'theft', weight: 20 },
{ id: 'character_illness', weight: 20 },
{ id: 'character_recovery', weight: 15 },
{ id: 'character_accident', weight: 10 },
{ id: 'regional_festival', weight: 10 },
// Regionale Events sind sehr selten und sollten nicht alle Charaktere töten
{ id: 'windfall', weight: 10 },
{ id: 'theft', weight: 8 },
{ id: 'character_illness', weight: 8 },
{ id: 'character_recovery', weight: 6 },
{ id: 'character_accident', weight: 4 },
{ id: 'regional_festival', weight: 4 },
// Regionale Events sind sehr selten
{ id: 'regional_storm', weight: 1 },
{ id: 'regional_epidemic', weight: 1 },
{ id: 'earthquake', weight: 1 },
];
const total = events.reduce((s, e) => s + e.weight, 0);
// Reduzierte Wahrscheinlichkeit: Nur 30% Chance pro Tag, dass ein Event auftritt
const eventChance = 0.3;
if (Math.random() > eventChance) {
return null; // Kein Event heute
}
let r = Math.random() * total;
let chosen = events[0].id;
for (const e of events) {
@@ -686,9 +693,9 @@ class FalukantService extends BaseService {
const name = [character?.definedFirstName?.name, character?.definedLastName?.name].filter(Boolean).join(' ').trim();
payload.characterName = name || null;
let delta = 0;
if (chosen === 'character_illness') delta = -(Math.floor(Math.random() * 11) + 5); // -5..-15
if (chosen === 'character_illness') delta = -(Math.floor(Math.random() * 10) + 1); // -1..-10
if (chosen === 'character_recovery') delta = (Math.floor(Math.random() * 11) + 5); // +5..+15
if (chosen === 'character_accident') delta = -(Math.floor(Math.random() * 16) + 10); // -10..-25
if (chosen === 'character_accident') delta = -(Math.floor(Math.random() * 24) + 2); // -2..-25
payload.healthChange = delta > 0 ? `+${delta}` : `${delta}`;
if (character) {
const next = Math.min(100, Math.max(0, Number(character.health || 0) + delta));
@@ -704,20 +711,20 @@ class FalukantService extends BaseService {
// Regionale Events sollten nur einen moderaten Health-Verlust verursachen
// NICHT alle Charaktere töten!
if (chosen === 'regional_epidemic' && character) {
// Moderate Health-Reduktion: -10 bis -20 (nicht tödlich!)
const delta = -(Math.floor(Math.random() * 11) + 10); // -10..-20
// Moderate Health-Reduktion: -3 bis -7 (reduziert)
const delta = -(Math.floor(Math.random() * 5) + 3); // -3..-7
payload.healthChange = `${delta}`;
const next = Math.min(100, Math.max(0, Number(character.health || 0) + delta));
await character.update({ health: next }, { transaction: t });
} else if (chosen === 'regional_storm' && character) {
// Sehr geringer Health-Verlust: -5 bis -10
const delta = -(Math.floor(Math.random() * 6) + 5); // -5..-10
// Sehr geringer Health-Verlust: -2 bis -5
const delta = -(Math.floor(Math.random() * 4) + 2); // -2..-5
payload.healthChange = `${delta}`;
const next = Math.min(100, Math.max(0, Number(character.health || 0) + delta));
await character.update({ health: next }, { transaction: t });
} else if (chosen === 'earthquake' && character) {
// Moderate Health-Reduktion: -15 bis -25 (kann gefährlich sein, aber nicht tödlich)
const delta = -(Math.floor(Math.random() * 11) + 15); // -15..-25
// Moderate Health-Reduktion: -5 bis -10 (reduziert)
const delta = -(Math.floor(Math.random() * 6) + 5); // -5..-10
payload.healthChange = `${delta}`;
const next = Math.min(100, Math.max(0, Number(character.health || 0) + delta));
await character.update({ health: next }, { transaction: t });