diff --git a/backend/controllers/navigationController.js b/backend/controllers/navigationController.js index 126abb6..524f6d3 100755 --- a/backend/controllers/navigationController.js +++ b/backend/controllers/navigationController.js @@ -216,6 +216,10 @@ const menuStructure = { visible: ["all"], path: "/settings/language-assistant" }, + falukant: { + visible: ["all"], + path: "/settings/falukant" + }, personal: { visible: ["all"], path: "/settings/personal" diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index be85c25..b78ebfa 100755 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -92,6 +92,13 @@ function calcAge(birthdate) { return differenceInDays(now, b); } +// Ein realer Tag entspricht einem Falukant-Spieljahr. +const FAMILY_AGE = Object.freeze({ + MIN_WOOING: 12, + MIN_MARRIAGE: 14, + MIN_HOUSEHOLD_AND_CHILDREN: 16, +}); + async function getFalukantUserOrFail(hashedId) { const user = await FalukantUser.findOne({ include: [{ model: User, as: 'user', attributes: ['username', 'hashedId'], where: { hashedId } }] @@ -3571,6 +3578,17 @@ class FalukantService extends BaseService { return { scheduled: false }; } + const parents = await FalukantCharacter.unscoped().findAll({ + where: { id: [motherId, fatherId] }, + attributes: ['id', 'birthdate'], + }); + if ( + parents.length !== 2 + || parents.some((parent) => calcAge(parent.birthdate) < FAMILY_AGE.MIN_HOUSEHOLD_AND_CHILDREN) + ) { + return { scheduled: false }; + } + const stateRow = await RelationshipState.findOne({ where: { relationshipId } }); const flags = stateRow?.flagsJson && typeof stateRow.flagsJson === 'object' ? { ...stateRow.flagsJson } @@ -3899,10 +3917,10 @@ class FalukantService extends BaseService { pregnancy, }; const ownAge = calcAge(character.birthdate); - if (ownAge >= 12) { + if (ownAge >= FAMILY_AGE.MIN_WOOING) { family.possibleLovers = await this.getPossibleLovers(character.id); } - if (ownAge >= 12 && family.relationships.length === 0) { + if (ownAge >= FAMILY_AGE.MIN_WOOING && family.relationships.length === 0) { family.possiblePartners = await this.getPossiblePartners(character.id); if (family.possiblePartners.length === 0) { await this.createPossiblePartners( @@ -3937,7 +3955,7 @@ class FalukantService extends BaseService { attributes: ['id', 'regionId', 'birthdate', 'titleOfNobility'] }); if (!requester?.id) return []; - if (calcAge(requester.birthdate) < 12) return []; + if (calcAge(requester.birthdate) < FAMILY_AGE.MIN_WOOING) return []; const existingRelationships = await Relationship.findAll({ where: { @@ -4549,6 +4567,7 @@ class FalukantService extends BaseService { id: { [Op.ne]: requestingCharacterId }, gender: { [Op.ne]: requestingCharacterGender }, regionId: requestingRegionId, + birthdate: { [Op.lte]: new Date(Date.now() - FAMILY_AGE.MIN_WOOING * 24 * 60 * 60 * 1000) }, createdAt: { [Op.lt]: new Date(new Date() - 12 * 24 * 60 * 60 * 1000) }, titleOfNobility: { [Op.between]: [requestingCharacterTitleOfNobility - 1, requestingCharacterTitleOfNobility + 1] } }, @@ -4588,6 +4607,18 @@ class FalukantService extends BaseService { if (!proposal) { throw new Error('Proposal not found'); } + const proposedCharacter = await FalukantCharacter.findByPk(proposal.proposedCharacterId, { + attributes: ['id', 'birthdate'], + }); + if ( + !proposedCharacter + || calcAge(character.birthdate) < FAMILY_AGE.MIN_WOOING + || calcAge(proposedCharacter.birthdate) < FAMILY_AGE.MIN_WOOING + ) { + const error = new Error('Werbung ist erst ab 12 Spieljahren möglich'); + error.status = 422; + throw error; + } if (user.money < proposal.cost) { throw new Error('Not enough money to accept the proposal'); } @@ -5510,7 +5541,12 @@ class FalukantService extends BaseService { async getPartyTypes(hashedUserId) { const falukantUser = await getFalukantUserOrFail(hashedUserId); - const engagedCount = await Relationship.count({ + const character = await FalukantCharacter.findOne({ + where: { userId: falukantUser.id }, + attributes: ['id', 'birthdate'], + }); + const engagedCount = character && calcAge(character.birthdate) >= FAMILY_AGE.MIN_MARRIAGE + ? await Relationship.count({ include: [ { model: RelationshipType, @@ -5537,7 +5573,8 @@ class FalukantService extends BaseService { { '$character2.user_id$': falukantUser.id } ] } - }); + }) + : 0; const orConditions = [{ forMarriage: false }]; if (engagedCount > 0) { orConditions.push({ forMarriage: true }); @@ -5586,7 +5623,35 @@ class FalukantService extends BaseService { throw new Error('Einige ausgewählte Adelstitel existieren nicht'); } - const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, attributes: ['titleOfNobility'] }); + const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, attributes: ['id', 'birthdate', 'titleOfNobility'] }); + if (ptype.forMarriage) { + if (!character || calcAge(character.birthdate) < FAMILY_AGE.MIN_MARRIAGE) { + const error = new Error('Eine Hochzeit ist erst ab 14 Spieljahren möglich'); + error.status = 422; + throw error; + } + const engagement = await Relationship.findOne({ + where: { + [Op.or]: [ + { character1Id: character.id }, + { character2Id: character.id }, + ], + }, + include: [{ model: RelationshipType, as: 'relationshipType', where: { tr: 'engaged' } }], + attributes: ['character1Id', 'character2Id'], + }); + const partnerId = engagement?.character1Id === character.id + ? engagement.character2Id + : engagement?.character1Id; + const partner = partnerId + ? await FalukantCharacter.findByPk(partnerId, { attributes: ['birthdate'] }) + : null; + if (!partner || calcAge(partner.birthdate) < FAMILY_AGE.MIN_MARRIAGE) { + const error = new Error('Beide Verlobten müssen mindestens 14 Spieljahre alt sein'); + error.status = 422; + throw error; + } + } const partyTypeCost = character && (await isPartyTypeFreeForTitle(character.titleOfNobility, ptype.id, ptype.tr)) ? 0 : (ptype.cost || 0); let cost = partyTypeCost + (music.cost || 0) + (banquette.cost || 0); cost += (50 / servantRatio - 1) * 1000; @@ -6747,7 +6812,7 @@ class FalukantService extends BaseService { { model: FalukantCharacter, as: 'holder', - attributes: ['id', 'gender'], + attributes: ['id', 'gender', 'birthdate'], include: [ { model: FalukantPredefineFirstname, @@ -6828,7 +6893,8 @@ class FalukantService extends BaseService { definedFirstName: o.holder.definedFirstName, definedLastName: o.holder.definedLastName, nobleTitle: o.holder.nobleTitle, - gender: o.holder.gender + gender: o.holder.gender, + age: o.holder.birthdate ? calcAge(o.holder.birthdate) : null } : null, termEnds, @@ -8302,7 +8368,7 @@ ORDER BY r.id`, { model: FalukantCharacter, as: 'holder', - attributes: ['id', 'gender'], + attributes: ['id', 'gender', 'birthdate'], required: false, include: [ { @@ -8368,7 +8434,8 @@ ORDER BY r.id`, id: o.holder.id, name: `${o.holder.definedFirstName?.name || ''} ${o.holder.definedLastName?.name || ''}`.trim(), gender: o.holder.gender, - title: o.holder.nobleTitle?.labelTr + title: o.holder.nobleTitle?.labelTr, + age: o.holder.birthdate ? calcAge(o.holder.birthdate) : null } : null, supervisor: o.supervisor diff --git a/backend/services/settingsService.js b/backend/services/settingsService.js index 627f84e..c99ba6b 100755 --- a/backend/services/settingsService.js +++ b/backend/services/settingsService.js @@ -37,6 +37,18 @@ class SettingsService extends BaseService{ adult_verification_status: { datatype: 'string', setting: 'account', orderId: 910, minAge: 18 }, adult_verification_request: { datatype: 'string', setting: 'account', orderId: 911, minAge: 18 }, adult_upload_blocked: { datatype: 'bool', setting: 'account', orderId: 912, minAge: 18 }, + falukantPortraitStyle: { + datatype: 'singleselect', + setting: 'falukant', + orderId: 920, + options: ['classic', 'medieval'] + }, + falukantFigureMode: { + datatype: 'singleselect', + setting: 'falukant', + orderId: 921, + options: ['portraits', '3d', 'both'] + }, }; const definition = specialTypes[description]; if (!definition) { @@ -59,9 +71,38 @@ class SettingsService extends BaseService{ immutable: false } }); + if (Array.isArray(definition.options) && definition.options.length > 0) { + let orderId = 1; + for (const option of definition.options) { + await UserParamValue.findOrCreate({ + where: { + userParamTypeId: paramType.id, + value: option + }, + defaults: { + userParamTypeId: paramType.id, + value: option, + orderId: orderId++ + } + }); + } + } return paramType; } + async ensureSettingsGroup(type) { + const groupDefinitions = { + falukant: ['falukantPortraitStyle', 'falukantFigureMode'] + }; + const descriptions = groupDefinitions[type]; + if (!descriptions) { + return; + } + for (const description of descriptions) { + await this.ensureSpecialUserParamType(description); + } + } + parseAdultVerificationRequest(value) { if (!value) return null; try { @@ -298,6 +339,7 @@ class SettingsService extends BaseService{ } async filterSettings(hashedUserId, type) { + await this.ensureSettingsGroup(type); const user = await this.getUserByHashedId(hashedUserId); const userParams = await this.getUserParams(user.id, ['birthdate', 'gender']); let birthdate = null; diff --git a/backend/utils/initializeSettings.js b/backend/utils/initializeSettings.js index 8d77a60..7a76966 100755 --- a/backend/utils/initializeSettings.js +++ b/backend/utils/initializeSettings.js @@ -25,6 +25,10 @@ const initializeSettings = async () => { where: { name: 'languageAssistant' }, defaults: { name: 'languageAssistant' } }); + await SettingsType.findOrCreate({ + where: { name: 'falukant' }, + defaults: { name: 'falukant' } + }); }; export default initializeSettings; diff --git a/backend/utils/initializeTypes.js b/backend/utils/initializeTypes.js index 9a2dec7..406fdd7 100755 --- a/backend/utils/initializeTypes.js +++ b/backend/utils/initializeTypes.js @@ -51,6 +51,8 @@ const initializeTypes = async () => { adult_upload_blocked: { type: 'bool', setting: 'account', minAge: 18 }, llm_settings: { type: 'string', setting: 'languageAssistant' }, llm_api_key: { type: 'string', setting: 'languageAssistant' }, + falukantPortraitStyle: { type: 'singleselect', setting: 'falukant' }, + falukantFigureMode: { type: 'singleselect', setting: 'falukant' }, }; let orderId = 1; for (const key of Object.keys(userParams)) { @@ -78,7 +80,9 @@ const initializeTypes = async () => { interestedInGender: ['male', 'female'], smokes: ['never', 'socially', 'often', 'daily'], drinks: ['never', 'socially', 'often', 'daily'], - brasize: ['Keine', 'AA', 'A', 'B', 'C', 'D', 'E (DD)', 'F (E)', 'G (F)', 'H (FF)', 'I (G)', 'J (GG)', 'K (H)'] + brasize: ['Keine', 'AA', 'A', 'B', 'C', 'D', 'E (DD)', 'F (E)', 'G (F)', 'H (FF)', 'I (G)', 'J (GG)', 'K (H)'], + falukantPortraitStyle: ['classic', 'medieval'], + falukantFigureMode: ['portraits', '3d', 'both'] }; Object.keys(valuesList).forEach(async (key) => { const values = valuesList[key]; diff --git a/docs/FALUKANT_FAMILY_AGE_RULES.md b/docs/FALUKANT_FAMILY_AGE_RULES.md new file mode 100644 index 0000000..8d788ef --- /dev/null +++ b/docs/FALUKANT_FAMILY_AGE_RULES.md @@ -0,0 +1,78 @@ +# Falukant: Altersregeln für Werbung, Ehe und Kinder + +## Verbindliche Spielregel + +Ein realer Kalendertag entspricht einem Falukant-Spieljahr. Die Altersprüfung +verwendet daher die Differenz zwischen `CURRENT_DATE` und `birthdate` in Tagen. + +| Bereich | Mindestalter | Regel | +| --- | ---: | --- | +| Werbung | 12 | Partner können vorgeschlagen und ein Vorschlag kann angenommen werden. | +| Hochzeit | 14 | Eine Verlobung kann durch eine Hochzeitsfeier vollzogen werden. | +| Gemeinsamer Haushalt und Kinder | 16 | Erst dann darf die Spielmechanik eine Schwangerschaft oder Geburt aus einer Ehe erzeugen. | + +Die Regeln gelten für **beide** Personen einer Beziehung. Sie bilden bewusst +Spielphasen ab; es gibt keine individuelle Prüfung körperlicher Reife und keine +explizite Sexualmechanik. + +## Bereits im Backend und Daemon umgesetzt + +- `backend/services/falukantService.js` + - erzeugt Heiratsvorschläge nur für mindestens 12 Jahre alte Partner; + - prüft beim Annehmen eines Vorschlags beide Alter erneut; + - bietet den Hochzeitstyp erst ab 14 an und prüft beim Bestellen der Feier + beide Verlobte serverseitig; + - plant die Hochzeits-Schwangerschaft erst, wenn beide mindestens 16 sind. +- `src/valuerecalculationworker.h` + - vollzieht die Hochzeit nach der mindestens einen Tag alten Hochzeitsfeier + nur, wenn beide Verlobte mindestens 14 sind. +- `src/usercharacterworker.h` + - berücksichtigt für die bestehende zufällige Ehe-Kinderlogik ausschließlich + Paare, bei denen beide mindestens 16 sind. + +## Auftrag für den externen Daemon + +Falls der produktive Daemon aus diesem Repository herausgelöst ist, müssen die +folgenden Guards in dessen gleichnamige Queries übernommen werden. + +### 1. Übergang `engaged` → `married` + +Beim Verarbeiten einer Hochzeit sind beide Charaktere zu prüfen: + +```sql +c.birthdate <= CURRENT_DATE - INTERVAL '14 days' +AND partner.birthdate <= CURRENT_DATE - INTERVAL '14 days' +``` + +Die bestehende Wartezeit von `party.created_at <= NOW() - INTERVAL '1 day'` +bleibt unverändert. Erfüllt ein Paar die Altersregel noch nicht, bleibt es +verlobt; die bereits angelegte Feier wird nicht verworfen und kann nach Erreichen +des Alters vollzogen werden. + +### 2. Eheliche Schwangerschaften und Geburten + +Jeder automatische Empfängnis- oder Geburtenkandidat muss beide Bedingungen +erfüllen: + +```sql +mother.birthdate <= CURRENT_DATE - INTERVAL '16 days' +AND father.birthdate <= CURRENT_DATE - INTERVAL '16 days' +``` + +Das gilt sowohl für die Zufallslogik als auch für einen geplanten +`pregnancy_due_at`-Pfad. Beim geplanten Pfad soll der Daemon die Felder nicht +leeren, solange mindestens ein Elternteil noch unter 16 ist; die Schwangerschaft +wird erst verarbeitet, sobald beide die Grenze erreicht haben. Admin-Tools +können weiterhin ein bewusstes, separat protokolliertes Override anbieten. + +### 3. Tests für den Daemon + +- 13/13 Jahre, verlobt, Hochzeitsfeier älter als 24 Stunden: bleibt `engaged`. +- 14/14 Jahre, verlobt, Hochzeitsfeier älter als 24 Stunden: wird `married`. +- Verheiratet, ein Elternteil 15: keine automatische Schwangerschaft/Geburt. +- Verheiratet, beide 16: normaler Schwangerschafts-/Geburtspfad ist möglich. + +## Bestehende Daten + +Es ist keine Migration nötig. Bereits bestehende Ehen bleiben bestehen; die +Grenzen steuern ausschließlich neue Übergänge und automatische Kinderereignisse. diff --git a/frontend/public/images/falukant/avatar/female00-medieval.png b/frontend/public/images/falukant/avatar/female00-medieval.png new file mode 100644 index 0000000..5fe617f Binary files /dev/null and b/frontend/public/images/falukant/avatar/female00-medieval.png differ diff --git a/frontend/public/images/falukant/avatar/female01-medieval.png b/frontend/public/images/falukant/avatar/female01-medieval.png new file mode 100644 index 0000000..990838d Binary files /dev/null and b/frontend/public/images/falukant/avatar/female01-medieval.png differ diff --git a/frontend/public/images/falukant/avatar/female02-medieval.png b/frontend/public/images/falukant/avatar/female02-medieval.png new file mode 100644 index 0000000..17f0286 Binary files /dev/null and b/frontend/public/images/falukant/avatar/female02-medieval.png differ diff --git a/frontend/public/images/falukant/avatar/male00-medieval.png b/frontend/public/images/falukant/avatar/male00-medieval.png new file mode 100644 index 0000000..dd334f6 Binary files /dev/null and b/frontend/public/images/falukant/avatar/male00-medieval.png differ diff --git a/frontend/public/images/falukant/avatar/male01-medieval.png b/frontend/public/images/falukant/avatar/male01-medieval.png new file mode 100644 index 0000000..5e2b471 Binary files /dev/null and b/frontend/public/images/falukant/avatar/male01-medieval.png differ diff --git a/frontend/public/images/falukant/avatar/male02-medieval.png b/frontend/public/images/falukant/avatar/male02-medieval.png new file mode 100644 index 0000000..62b6f6b Binary files /dev/null and b/frontend/public/images/falukant/avatar/male02-medieval.png differ diff --git a/frontend/src/components/AppSectionBar.vue b/frontend/src/components/AppSectionBar.vue index 1fc6aa9..67d4d44 100755 --- a/frontend/src/components/AppSectionBar.vue +++ b/frontend/src/components/AppSectionBar.vue @@ -68,6 +68,7 @@ const TITLE_MAP = { 'Flirt settings': 'sectionBar.titles.flirtSettings', 'Account settings': 'sectionBar.titles.accountSettings', 'Language assistant settings': 'sectionBar.titles.languageAssistantSettings', + 'Falukant settings': 'sectionBar.titles.falukantSettings', Interests: 'sectionBar.titles.interests', AdminInterests: 'sectionBar.titles.adminInterests', AdminUsers: 'sectionBar.titles.adminUsers', diff --git a/frontend/src/components/SettingsWidget.vue b/frontend/src/components/SettingsWidget.vue index 0f72fce..07dd4c4 100755 --- a/frontend/src/components/SettingsWidget.vue +++ b/frontend/src/components/SettingsWidget.vue @@ -4,51 +4,51 @@