diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index 5369c37..f6919cf 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -13,6 +13,9 @@ class AdminController { this.searchUser = this.searchUser.bind(this); this.getFalukantUserById = this.getFalukantUserById.bind(this); this.changeFalukantUser = this.changeFalukantUser.bind(this); + this.adminForceFalukantPregnancy = this.adminForceFalukantPregnancy.bind(this); + this.adminClearFalukantPregnancy = this.adminClearFalukantPregnancy.bind(this); + this.adminForceFalukantBirth = this.adminForceFalukantBirth.bind(this); this.getFalukantUserBranches = this.getFalukantUserBranches.bind(this); this.updateFalukantStock = this.updateFalukantStock.bind(this); this.addFalukantStock = this.addFalukantStock.bind(this); @@ -372,6 +375,50 @@ class AdminController { } } + async adminForceFalukantPregnancy(req, res) { + try { + const { userid: userId } = req.headers; + const { characterId, fatherCharacterId, dueInDays } = req.body; + const response = await AdminService.adminForceFalukantPregnancy(userId, characterId, { + fatherCharacterId, + dueInDays, + }); + res.status(200).json(response); + } catch (error) { + console.log(error); + res.status(400).json({ error: error.message }); + } + } + + async adminClearFalukantPregnancy(req, res) { + try { + const { userid: userId } = req.headers; + const { characterId } = req.body; + const response = await AdminService.adminClearFalukantPregnancy(userId, characterId); + res.status(200).json(response); + } catch (error) { + console.log(error); + res.status(400).json({ error: error.message }); + } + } + + async adminForceFalukantBirth(req, res) { + try { + const { userid: userId } = req.headers; + const { motherCharacterId, fatherCharacterId, birthContext, legitimacy, gender } = req.body; + const response = await AdminService.adminForceFalukantBirth(userId, motherCharacterId, { + fatherCharacterId, + birthContext, + legitimacy, + gender, + }); + res.status(200).json(response); + } catch (error) { + console.log(error); + res.status(400).json({ error: error.message }); + } + } + async getFalukantUserBranches(req, res) { try { const { userid: userId } = req.headers; diff --git a/backend/migrations/20260330000000-add-character-pregnancy.cjs b/backend/migrations/20260330000000-add-character-pregnancy.cjs new file mode 100644 index 0000000..bee7780 --- /dev/null +++ b/backend/migrations/20260330000000-add-character-pregnancy.cjs @@ -0,0 +1,36 @@ +"use strict"; + +/** Schwangerschaft (Admin / Spiel): erwarteter Geburtstermin + optionaler Vater-Charakter */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'falukant_data' AND table_name = 'character' AND column_name = 'pregnancy_due_at' + ) THEN + ALTER TABLE falukant_data."character" + ADD COLUMN pregnancy_due_at TIMESTAMPTZ NULL; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'falukant_data' AND table_name = 'character' AND column_name = 'pregnancy_father_character_id' + ) THEN + ALTER TABLE falukant_data."character" + ADD COLUMN pregnancy_father_character_id INTEGER NULL + REFERENCES falukant_data."character"(id) ON DELETE SET NULL; + END IF; + END$$; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE falukant_data."character" DROP COLUMN IF EXISTS pregnancy_father_character_id; + `); + await queryInterface.sequelize.query(` + ALTER TABLE falukant_data."character" DROP COLUMN IF EXISTS pregnancy_due_at; + `); + }, +}; diff --git a/backend/models/associations.js b/backend/models/associations.js index 978a693..b4b5556 100644 --- a/backend/models/associations.js +++ b/backend/models/associations.js @@ -379,6 +379,8 @@ export default function setupAssociations() { FalukantCharacter.belongsTo(RegionData, { foreignKey: 'regionId', as: 'region' }); RegionData.hasMany(FalukantCharacter, { foreignKey: 'regionId', as: 'charactersInRegion' }); + FalukantCharacter.belongsTo(FalukantCharacter, { foreignKey: 'pregnancyFatherCharacterId', as: 'pregnancyFather' }); + FalukantStock.belongsTo(FalukantStockType, { foreignKey: 'stockTypeId', as: 'stockType' }); FalukantStockType.hasMany(FalukantStock, { foreignKey: 'stockTypeId', as: 'stocks' }); diff --git a/backend/models/falukant/data/character.js b/backend/models/falukant/data/character.js index e76b99b..c4327f6 100644 --- a/backend/models/falukant/data/character.js +++ b/backend/models/falukant/data/character.js @@ -45,6 +45,14 @@ FalukantCharacter.init( min: 0, max: 100 } + }, + pregnancyDueAt: { + type: DataTypes.DATE, + allowNull: true, + }, + pregnancyFatherCharacterId: { + type: DataTypes.INTEGER, + allowNull: true, } }, { diff --git a/backend/routers/adminRouter.js b/backend/routers/adminRouter.js index ebf8984..a8bdec9 100644 --- a/backend/routers/adminRouter.js +++ b/backend/routers/adminRouter.js @@ -43,6 +43,9 @@ router.post('/contacts/answer', authenticate, adminController.answerContact); router.post('/falukant/searchuser', authenticate, adminController.searchUser); router.get('/falukant/getuser/:id', authenticate, adminController.getFalukantUserById); router.post('/falukant/edituser', authenticate, adminController.changeFalukantUser); +router.post('/falukant/character/force-pregnancy', authenticate, adminController.adminForceFalukantPregnancy); +router.post('/falukant/character/clear-pregnancy', authenticate, adminController.adminClearFalukantPregnancy); +router.post('/falukant/character/force-birth', authenticate, adminController.adminForceFalukantBirth); router.get('/falukant/branches/:falukantUserId', authenticate, adminController.getFalukantUserBranches); router.put('/falukant/stock/:stockId', authenticate, adminController.updateFalukantStock); router.post('/falukant/stock', authenticate, adminController.addFalukantStock); diff --git a/backend/services/adminService.js b/backend/services/adminService.js index 83ed357..2071b93 100644 --- a/backend/services/adminService.js +++ b/backend/services/adminService.js @@ -28,6 +28,7 @@ import Image from '../models/community/image.js'; import EroticVideo from '../models/community/erotic_video.js'; import EroticContentReport from '../models/community/erotic_content_report.js'; import TitleOfNobility from "../models/falukant/type/title_of_nobility.js"; +import ChildRelation from "../models/falukant/data/child_relation.js"; import { sequelize } from '../utils/sequelize.js'; import npcCreationJobService from './npcCreationJobService.js'; import { v4 as uuidv4 } from 'uuid'; @@ -669,7 +670,6 @@ class AdminService { include: [{ model: FalukantCharacter, as: 'character', - attributes: ['birthdate', 'health', 'title_of_nobility'], include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', @@ -945,6 +945,145 @@ class AdminService { await character.save(); } + /** + * Admin: Charakter als schwanger markieren (erwarteter Geburtstermin). + * @param {number} fatherCharacterId - optional; Vater-Charakter-ID + * @param {number} dueInDays - Tage bis zur „Geburt“ (Default 21) + */ + async adminForceFalukantPregnancy(userId, characterId, { fatherCharacterId = null, dueInDays = 21 } = {}) { + if (!(await this.hasUserAccess(userId, 'falukantusers'))) { + throw new Error('noaccess'); + } + const mother = await FalukantCharacter.findByPk(characterId); + if (!mother) throw new Error('notfound'); + if (fatherCharacterId != null) { + const father = await FalukantCharacter.findByPk(Number(fatherCharacterId)); + if (!father) throw new Error('fatherNotFound'); + } + const days = Math.max(1, Math.min(365, Number(dueInDays) || 21)); + const due = new Date(); + due.setDate(due.getDate() + days); + await mother.update({ + pregnancyDueAt: due, + pregnancyFatherCharacterId: fatherCharacterId != null ? Number(fatherCharacterId) : null, + }); + const fu = mother.userId ? await FalukantUser.findByPk(mother.userId) : null; + if (fu) { + const u = await User.findByPk(fu.userId); + if (u?.hashedId) await notifyUser(u.hashedId, 'familychanged', {}); + } + return { + success: true, + pregnancyDueAt: due.toISOString(), + pregnancyFatherCharacterId: fatherCharacterId != null ? Number(fatherCharacterId) : null, + }; + } + + async adminClearFalukantPregnancy(userId, characterId) { + if (!(await this.hasUserAccess(userId, 'falukantusers'))) { + throw new Error('noaccess'); + } + const mother = await FalukantCharacter.findByPk(characterId); + if (!mother) throw new Error('notfound'); + await mother.update({ + pregnancyDueAt: null, + pregnancyFatherCharacterId: null, + }); + const fu = mother.userId ? await FalukantUser.findByPk(mother.userId) : null; + if (fu) { + const u = await User.findByPk(fu.userId); + if (u?.hashedId) await notifyUser(u.hashedId, 'familychanged', {}); + } + return { success: true }; + } + + /** + * Admin: Geburt auslösen – Kind-Charakter + child_relation; setzt Schwangerschaft zurück. + */ + async adminForceFalukantBirth(userId, motherCharacterId, { + fatherCharacterId, + birthContext = 'marriage', + legitimacy = 'legitimate', + gender = null, + } = {}) { + if (!(await this.hasUserAccess(userId, 'falukantusers'))) { + throw new Error('noaccess'); + } + if (fatherCharacterId == null || fatherCharacterId === '') { + throw new Error('fatherRequired'); + } + const mother = await FalukantCharacter.findByPk(motherCharacterId); + if (!mother) throw new Error('notfound'); + const father = await FalukantCharacter.findByPk(Number(fatherCharacterId)); + if (!father) throw new Error('fatherNotFound'); + if (Number(fatherCharacterId) === Number(motherCharacterId)) { + throw new Error('invalidParents'); + } + const ctx = ['marriage', 'lover'].includes(birthContext) ? birthContext : 'marriage'; + const leg = ['legitimate', 'acknowledged_bastard', 'hidden_bastard'].includes(legitimacy) + ? legitimacy + : 'legitimate'; + const childGender = gender === 'male' || gender === 'female' + ? gender + : (Math.random() < 0.5 ? 'male' : 'female'); + + const nobility = await TitleOfNobility.findOne({ where: { labelTr: 'noncivil' } }); + if (!nobility) throw new Error('titleNotFound'); + + const fnObj = await FalukantPredefineFirstname.findOne({ + where: { gender: childGender }, + order: Sequelize.fn('RANDOM'), + }); + if (!fnObj) throw new Error('firstNameNotFound'); + + let childCharacterId; + await sequelize.transaction(async (t) => { + const baby = await FalukantCharacter.create({ + userId: null, + regionId: mother.regionId, + firstName: fnObj.id, + lastName: mother.lastName, + gender: childGender, + birthdate: new Date(), + titleOfNobility: nobility.id, + health: 100, + moodId: 1, + }, { transaction: t }); + childCharacterId = baby.id; + + await ChildRelation.create({ + fatherCharacterId: father.id, + motherCharacterId: mother.id, + childCharacterId: baby.id, + nameSet: false, + isHeir: false, + legitimacy: leg, + birthContext: ctx, + publicKnown: ctx === 'marriage', + }, { transaction: t }); + + await mother.update({ + pregnancyDueAt: null, + pregnancyFatherCharacterId: null, + }, { transaction: t }); + }); + + const notifyParent = async (char) => { + if (!char?.userId) return; + const fu = await FalukantUser.findByPk(char.userId); + if (!fu) return; + const u = await User.findByPk(fu.userId); + if (u?.hashedId) { + await notifyUser(u.hashedId, 'familychanged', {}); + await notifyUser(u.hashedId, 'falukantUpdateStatus', {}); + } + }; + await notifyParent(mother); + await notifyParent(father); + + return { success: true, childCharacterId, gender: childGender }; + } + // --- User Administration --- async searchUsers(requestingHashedUserId, query) { if (!(await this.hasUserAccess(requestingHashedUserId, 'useradministration'))) { diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index 6160ede..62347ac 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -3518,7 +3518,13 @@ class FalukantService extends BaseService { deathPartners: relationships.filter(r => r.relationshipType === 'widowed'), children: children.map(({ _createdAt, ...rest }) => rest), possiblePartners: [], - possibleLovers: [] + possibleLovers: [], + pregnancy: character.pregnancyDueAt + ? { + dueAt: character.pregnancyDueAt, + fatherCharacterId: character.pregnancyFatherCharacterId, + } + : null, }; const ownAge = calcAge(character.birthdate); if (ownAge >= 12) { diff --git a/backend/sql/add_character_pregnancy.sql b/backend/sql/add_character_pregnancy.sql new file mode 100644 index 0000000..0f1edf4 --- /dev/null +++ b/backend/sql/add_character_pregnancy.sql @@ -0,0 +1,6 @@ +-- Optional: manuell ausführen, falls Migration nicht genutzt wird +ALTER TABLE falukant_data."character" + ADD COLUMN IF NOT EXISTS pregnancy_due_at TIMESTAMPTZ NULL; +ALTER TABLE falukant_data."character" + ADD COLUMN IF NOT EXISTS pregnancy_father_character_id INTEGER NULL + REFERENCES falukant_data."character"(id) ON DELETE SET NULL; diff --git a/frontend/src/i18n/locales/de/admin.json b/frontend/src/i18n/locales/de/admin.json index c46dd31..e76d231 100644 --- a/frontend/src/i18n/locales/de/admin.json +++ b/frontend/src/i18n/locales/de/admin.json @@ -168,7 +168,40 @@ "errorLoadingStockTypes": "Fehler beim Laden der Lagertypen.", "errorAddingStock": "Fehler beim Hinzufügen des Lagers.", "stockAdded": "Lager erfolgreich hinzugefügt.", - "invalidStockData": "Bitte gültige Lagertyp- und Mengenangaben eingeben." + "invalidStockData": "Bitte gültige Lagertyp- und Mengenangaben eingeben.", + "pregnancy": { + "title": "Schwangerschaft (Admin)", + "characterId": "Charakter-ID", + "status": "Status", + "statusActive": "Schwanger bis", + "statusNone": "Nicht schwanger", + "fatherId": "Vater-Charakter-ID (optional)", + "dueDays": "Tage bis zum Termin", + "force": "Schwangerschaft setzen", + "clear": "Schwangerschaft entfernen", + "successForce": "Schwangerschaft wurde gesetzt.", + "successClear": "Schwangerschaft wurde entfernt.", + "error": "Aktion fehlgeschlagen." + }, + "birth": { + "title": "Geburt erzwingen (Admin)", + "motherHint": "Es wird der oben genannte Charakter (Mutter) verwendet.", + "fatherId": "Vater-Charakter-ID", + "context": "Kontext", + "contextMarriage": "Ehe", + "contextLover": "Liebschaft", + "legitimacy": "Legitimität", + "legitimate": "Legitim", + "ackBastard": "Anerkannt unehelich", + "hiddenBastard": "Verborgen unehelich", + "gender": "Kind-Geschlecht", + "genderRandom": "Zufällig", + "male": "Männlich", + "female": "Weiblich", + "force": "Geburt auslösen", + "success": "Kind wurde angelegt (Taufe ausstehend).", + "error": "Geburt konnte nicht ausgelöst werden." + } }, "map": { "title": "Falukant Karten-Editor (Regionen)", diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index 7307b93..7c34260 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -540,6 +540,10 @@ "familyWarning": "Anhaltender Kreditverzug belastet Ehe, Haushalt und Liebschaften.", "familyImpact": "Der Schuldturm schadet Ehe, Hausfrieden und der Stabilität von Liebschaften." }, + "pregnancy": { + "banner": "Du erwartest ein Kind.", + "dueHint": "Voraussichtlicher Geburtstermin" + }, "spouse": { "title": "Beziehung", "name": "Name", diff --git a/frontend/src/i18n/locales/en/admin.json b/frontend/src/i18n/locales/en/admin.json index 423f528..3072668 100644 --- a/frontend/src/i18n/locales/en/admin.json +++ b/frontend/src/i18n/locales/en/admin.json @@ -223,7 +223,40 @@ "errorLoadingStockTypes": "Error loading warehouse types.", "errorAddingStock": "Error adding warehouse.", "stockAdded": "Warehouse successfully added.", - "invalidStockData": "Please enter valid warehouse type and quantity." + "invalidStockData": "Please enter valid warehouse type and quantity.", + "pregnancy": { + "title": "Pregnancy (admin)", + "characterId": "Character ID", + "status": "Status", + "statusActive": "Expecting until", + "statusNone": "Not pregnant", + "fatherId": "Father character ID (optional)", + "dueDays": "Days until due date", + "force": "Set pregnancy", + "clear": "Clear pregnancy", + "successForce": "Pregnancy has been set.", + "successClear": "Pregnancy has been cleared.", + "error": "Action failed." + }, + "birth": { + "title": "Force birth (admin)", + "motherHint": "The character listed above is used as the mother.", + "fatherId": "Father character ID", + "context": "Context", + "contextMarriage": "Marriage", + "contextLover": "Affair", + "legitimacy": "Legitimacy", + "legitimate": "Legitimate", + "ackBastard": "Acknowledged bastard", + "hiddenBastard": "Hidden bastard", + "gender": "Child gender", + "genderRandom": "Random", + "male": "Male", + "female": "Female", + "force": "Trigger birth", + "success": "Child created (baptism pending).", + "error": "Could not trigger birth." + } }, "createNPC": { "title": "Create NPCs", diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json index bab028c..86a88b2 100644 --- a/frontend/src/i18n/locales/en/falukant.json +++ b/frontend/src/i18n/locales/en/falukant.json @@ -611,6 +611,10 @@ "familyWarning": "Ongoing debt delinquency puts strain on marriage, household and affairs.", "familyImpact": "Debtors' prison damages marriage, household peace and the stability of affairs." }, + "pregnancy": { + "banner": "You are expecting a child.", + "dueHint": "Expected due date" + }, "children": { "title": "Children", "name": "Name", diff --git a/frontend/src/i18n/locales/es/admin.json b/frontend/src/i18n/locales/es/admin.json index 79313d6..4780dca 100644 --- a/frontend/src/i18n/locales/es/admin.json +++ b/frontend/src/i18n/locales/es/admin.json @@ -168,7 +168,40 @@ "errorLoadingStockTypes": "Error al cargar los tipos de almacén.", "errorAddingStock": "Error al añadir el almacén.", "stockAdded": "Almacén añadido correctamente.", - "invalidStockData": "Por favor, introduce un tipo de almacén y una cantidad válidos." + "invalidStockData": "Por favor, introduce un tipo de almacén y una cantidad válidos.", + "pregnancy": { + "title": "Embarazo (admin)", + "characterId": "ID de personaje", + "status": "Estado", + "statusActive": "Embarazo hasta", + "statusNone": "No embarazada", + "fatherId": "ID del padre (opcional)", + "dueDays": "Días hasta el parto previsto", + "force": "Establecer embarazo", + "clear": "Quitar embarazo", + "successForce": "Embarazo establecido.", + "successClear": "Embarazo eliminado.", + "error": "La acción ha fallado." + }, + "birth": { + "title": "Forzar nacimiento (admin)", + "motherHint": "Se usa el personaje indicado arriba como madre.", + "fatherId": "ID del padre", + "context": "Contexto", + "contextMarriage": "Matrimonio", + "contextLover": "Amante", + "legitimacy": "Legitimidad", + "legitimate": "Legítimo", + "ackBastard": "Bastardo reconocido", + "hiddenBastard": "Bastardo oculto", + "gender": "Sexo del niño", + "genderRandom": "Aleatorio", + "male": "Masculino", + "female": "Femenino", + "force": "Provocar nacimiento", + "success": "Niño creado (bautizo pendiente).", + "error": "No se pudo provocar el nacimiento." + } }, "map": { "title": "Editor de mapas de Falukant (regiones)", diff --git a/frontend/src/i18n/locales/es/falukant.json b/frontend/src/i18n/locales/es/falukant.json index afc5938..0618596 100644 --- a/frontend/src/i18n/locales/es/falukant.json +++ b/frontend/src/i18n/locales/es/falukant.json @@ -521,6 +521,10 @@ "familyWarning": "La mora continuada perjudica el matrimonio, el hogar y las relaciones.", "familyImpact": "La prisión por deudas daña el matrimonio, la paz del hogar y la estabilidad de las relaciones." }, + "pregnancy": { + "banner": "Esperas un hijo.", + "dueHint": "Fecha prevista de nacimiento" + }, "spouse": { "title": "Relación", "name": "Nombre", diff --git a/frontend/src/views/admin/falukant/EditUserView.vue b/frontend/src/views/admin/falukant/EditUserView.vue index 7e84bb7..eadc376 100644 --- a/frontend/src/views/admin/falukant/EditUserView.vue +++ b/frontend/src/views/admin/falukant/EditUserView.vue @@ -39,6 +39,67 @@ + +
{{ $t('admin.falukant.edituser.birth.motherHint') }}
+ + + + + +{{ $t('falukant.family.pregnancy.dueHint') }}: {{ formatPregnancyDue(pregnancy.dueAt) }}
+