diff --git a/backend/controllers/falukantController.js b/backend/controllers/falukantController.js index fe2899d..228fd4f 100755 --- a/backend/controllers/falukantController.js +++ b/backend/controllers/falukantController.js @@ -102,6 +102,10 @@ class FalukantController { this.getPotentialHeirs = this._wrapWithUser((userId) => this.service.getPotentialHeirs(userId)); this.selectHeir = this._wrapWithUser((userId, req) => this.service.selectHeir(userId, req.body.heirId), { blockInDebtorsPrison: true }); this.setHeir = this._wrapWithUser((userId, req) => this.service.setHeir(userId, req.body.childCharacterId), { blockInDebtorsPrison: true }); + this.acceptChildMarriageProposal = this._wrapWithUser((userId, req) => + this.service.acceptChildMarriageProposal(userId, req.params.childCharacterId, req.body.proposedCharacterId), { blockInDebtorsPrison: true }); + this.advanceChildWooing = this._wrapWithUser((userId, req) => + this.service.advanceChildWooing(userId, req.params.childCharacterId), { blockInDebtorsPrison: true }); this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId), { blockInDebtorsPrison: true }); this.cancelWooing = this._wrapWithUser(async (userId) => { try { @@ -162,8 +166,8 @@ class FalukantController { this.getPartyTypes = this._wrapWithUser((userId) => this.service.getPartyTypes(userId)); this.createParty = this._wrapWithUser((userId, req) => { - const { partyTypeId, musicId, banquetteId, nobilityIds, servantRatio } = req.body; - return this.service.createParty(userId, partyTypeId, musicId, banquetteId, nobilityIds, servantRatio); + const { partyTypeId, musicId, banquetteId, nobilityIds, servantRatio, relationshipId } = req.body; + return this.service.createParty(userId, partyTypeId, musicId, banquetteId, nobilityIds, servantRatio, relationshipId); }, { successStatus: 201, blockInDebtorsPrison: true }); this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId)); diff --git a/backend/migrations-active/20260908000000-add-party-relationship.cjs b/backend/migrations-active/20260908000000-add-party-relationship.cjs new file mode 100644 index 0000000..727c0a5 --- /dev/null +++ b/backend/migrations-active/20260908000000-add-party-relationship.cjs @@ -0,0 +1,16 @@ +'use strict'; + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE falukant_data.party + ADD COLUMN IF NOT EXISTS relationship_id INTEGER NULL + REFERENCES falukant_data.relationship(id) ON DELETE SET NULL; + CREATE INDEX IF NOT EXISTS party_relationship_id_idx + ON falukant_data.party (relationship_id); + `); + }, + async down(queryInterface) { + await queryInterface.sequelize.query(`ALTER TABLE falukant_data.party DROP COLUMN IF EXISTS relationship_id;`); + } +}; diff --git a/backend/models/associations.js b/backend/models/associations.js index f9c8cfa..00a7f8d 100755 --- a/backend/models/associations.js +++ b/backend/models/associations.js @@ -633,6 +633,7 @@ export default function setupAssociations() { FalukantUser.hasMany(Party, { foreignKey: 'falukantUserId', as: 'parties' }); Party.belongsTo(FalukantUser, { foreignKey: 'falukantUserId', as: 'partyUser' }); + Party.belongsTo(Relationship, { foreignKey: 'relationshipId', as: 'marriageRelationship' }); Party.belongsToMany(TitleOfNobility, { through: PartyInvitedNobility, diff --git a/backend/models/falukant/data/party.js b/backend/models/falukant/data/party.js index 3f4718f..87a09c0 100755 --- a/backend/models/falukant/data/party.js +++ b/backend/models/falukant/data/party.js @@ -14,6 +14,11 @@ Party.init({ allowNull: false, field: 'falukant_user_id' }, + relationshipId: { + type: DataTypes.INTEGER, + allowNull: true, + field: 'relationship_id' + }, musicTypeId: { type: DataTypes.INTEGER, allowNull: false, @@ -41,4 +46,4 @@ Party.init({ timestamps: true, underscored: true}); -export default Party; \ No newline at end of file +export default Party; diff --git a/backend/routers/falukantRouter.js b/backend/routers/falukantRouter.js index aa29c3e..ae9535b 100755 --- a/backend/routers/falukantRouter.js +++ b/backend/routers/falukantRouter.js @@ -49,6 +49,8 @@ router.get('/dashboard-widget', falukantController.getDashboardWidget); router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal); router.post('/family/cancel-wooing', falukantController.cancelWooing); router.post('/family/set-heir', falukantController.setHeir); +router.post('/family/children/:childCharacterId/accept-marriage-proposal', falukantController.acceptChildMarriageProposal); +router.post('/family/children/:childCharacterId/advance-wooing', falukantController.advanceChildWooing); router.post('/family/lover', falukantController.createLoverRelationship); router.post('/family/marriage/spend-time', falukantController.spendTimeWithSpouse); router.post('/family/marriage/gift', falukantController.giftToSpouse); diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index f59f312..fb626a3 100755 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -94,7 +94,7 @@ function calcAge(birthdate) { // Ein realer Tag entspricht einem Falukant-Spieljahr. const FAMILY_AGE = Object.freeze({ - MIN_WOOING: 12, + MIN_WOOING: 10, MIN_MARRIAGE: 14, MIN_HOUSEHOLD_AND_CHILDREN: 16, }); @@ -3820,7 +3820,7 @@ class FalukantService extends BaseService { const childChars = childCharIds.length ? await FalukantCharacter.findAll({ where: { id: childCharIds }, - attributes: ['id', 'birthdate', 'gender'], + attributes: ['id', 'birthdate', 'gender', 'regionId', 'titleOfNobility'], include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] }) : []; @@ -3892,6 +3892,42 @@ class FalukantService extends BaseService { otherParent: otherParentId != null ? (otherParentMap[otherParentId] || null) : null, }; }); + const childIds = children.map((child) => child.childCharacterId); + const childRelationships = childIds.length + ? await Relationship.findAll({ + where: { [Op.or]: [{ character1Id: { [Op.in]: childIds } }, { character2Id: { [Op.in]: childIds } }] }, + include: [ + { model: RelationshipType, as: 'relationshipType', attributes: ['tr'] }, + { model: FalukantCharacter, as: 'character1', attributes: ['id'], include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] }, + { model: FalukantCharacter, as: 'character2', attributes: ['id'], include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] }, + ], + attributes: ['id', 'character1Id', 'character2Id', 'nextStepProgress'] + }) + : []; + const childRelationshipMap = new Map(); + for (const relation of childRelationships) { + const type = relation.relationshipType?.tr; + if (!['wooing', 'engaged', 'married'].includes(type)) continue; + const childId = childIds.includes(relation.character1Id) ? relation.character1Id : relation.character2Id; + const partner = relation.character1Id === childId ? relation.character2 : relation.character1; + childRelationshipMap.set(childId, { + id: relation.id, + status: type, + progress: relation.nextStepProgress || 0, + partnerName: partner?.definedFirstName?.name || 'Unbekannt', + }); + } + for (const child of children) { + child.relationship = childRelationshipMap.get(child.childCharacterId) || null; + child.possiblePartners = []; + const kid = childCharMap[child.childCharacterId]; + if (!child.hasName || child.relationship || !kid || calcAge(kid.birthdate) < FAMILY_AGE.MIN_WOOING) continue; + child.possiblePartners = await this.getPossiblePartners(kid.id); + if (child.possiblePartners.length === 0) { + await this.createPossiblePartners(kid.id, kid.gender, kid.regionId, kid.titleOfNobility, calcAge(kid.birthdate)); + child.possiblePartners = await this.getPossiblePartners(kid.id); + } + } // Sort children globally by relation createdAt ascending (older first) children.sort((a, b) => new Date(a._createdAt) - new Date(b._createdAt)); const inProgress = ['wooing', 'engaged', 'married']; @@ -4637,7 +4673,7 @@ class FalukantService extends BaseService { 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) }, + createdAt: { [Op.lt]: new Date(Date.now() - FAMILY_AGE.MIN_WOOING * 24 * 60 * 60 * 1000) }, titleOfNobility: { [Op.between]: [requestingCharacterTitleOfNobility - 1, requestingCharacterTitleOfNobility + 1] } }, order: [ @@ -4684,7 +4720,7 @@ class FalukantService extends BaseService { || 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'); + const error = new Error('Werbung ist erst ab 10 Spieljahren möglich'); error.status = 422; throw error; } @@ -4723,6 +4759,60 @@ class FalukantService extends BaseService { return { success: true, message: 'Marriage proposal accepted', relationshipId: newRel.id }; } + async acceptChildMarriageProposal(hashedUserId, childCharacterId, proposedCharacterId) { + const user = await this.getFalukantUserByHashedId(hashedUserId); + const childId = Number(childCharacterId); + const partnerId = Number(proposedCharacterId); + if (!Number.isInteger(childId) || !Number.isInteger(partnerId)) throw { status: 400, message: 'Ungültige Partnerauswahl' }; + + const childRelation = await ChildRelation.findOne({ + where: { childCharacterId: childId, [Op.or]: [{ fatherCharacterId: user.character.id }, { motherCharacterId: user.character.id }] } + }); + if (!childRelation) throw { status: 404, message: 'Kind gehört nicht zu deinem Charakter' }; + + const [child, partner, proposal, activeRelation] = await Promise.all([ + FalukantCharacter.findByPk(childId, { attributes: ['id', 'birthdate'] }), + FalukantCharacter.findByPk(partnerId, { attributes: ['id', 'birthdate'] }), + MarriageProposal.findOne({ where: { requesterCharacterId: childId, proposedCharacterId: partnerId } }), + Relationship.findOne({ where: { [Op.or]: [{ character1Id: childId }, { character2Id: childId }] }, include: [{ model: RelationshipType, as: 'relationshipType', where: { tr: { [Op.in]: ['wooing', 'engaged', 'married'] } } }] }) + ]); + if (!proposal || !child || !partner) throw { status: 404, message: 'Partnervorschlag nicht gefunden' }; + if (activeRelation) throw { status: 409, message: 'Das Kind ist bereits in einer Beziehung' }; + if (calcAge(child.birthdate) < FAMILY_AGE.MIN_WOOING || calcAge(partner.birthdate) < FAMILY_AGE.MIN_WOOING) { + throw { status: 422, message: 'Verkuppelung ist erst ab 10 Spieljahren möglich' }; + } + if (Number(user.money) < Number(proposal.cost)) throw { status: 422, message: 'Nicht genügend Guthaben' }; + const wooingType = await RelationshipType.findOne({ where: { tr: 'wooing' } }); + const moneyResult = await updateFalukantUserMoney(user.id, -proposal.cost, 'Child marriage arrangement', user.id); + if (!moneyResult.success) throw new Error('Geld konnte nicht abgezogen werden'); + const relationship = await Relationship.create({ + character1Id: childId, + character2Id: partnerId, + relationshipTypeId: wooingType.id, + nextStepProgress: Math.ceil(FalukantService.WOOING_PROGRESS_TARGET / 2), + }); + await MarriageProposal.destroy({ where: { requesterCharacterId: childId } }); + return { success: true, relationshipId: relationship.id }; + } + + async advanceChildWooing(hashedUserId, childCharacterId) { + const user = await this.getFalukantUserByHashedId(hashedUserId); + const childId = Number(childCharacterId); + const isOwnChild = await ChildRelation.findOne({ + where: { childCharacterId: childId, [Op.or]: [{ fatherCharacterId: user.character.id }, { motherCharacterId: user.character.id }] }, + attributes: ['childCharacterId'] + }); + if (!isOwnChild) throw { status: 404, message: 'Kind gehört nicht zu deinem Charakter' }; + const relationship = await Relationship.findOne({ + where: { [Op.or]: [{ character1Id: childId }, { character2Id: childId }] }, + include: [{ model: RelationshipType, as: 'relationshipType', where: { tr: 'wooing' } }] + }); + if (!relationship) throw { status: 409, message: 'Keine laufende Werbung für dieses Kind' }; + const engagedType = await RelationshipType.findOne({ where: { tr: 'engaged' } }); + await relationship.update({ nextStepProgress: 0, relationshipTypeId: engagedType.id }); + return { success: true, status: 'engaged' }; + } + async cancelWooing(hashedUserId) { const user = await this.getFalukantUserByHashedId(hashedUserId); if (!user || !user.character) { @@ -5608,44 +5698,37 @@ class FalukantService extends BaseService { }; } + async getWeddingCandidates(falukantUser) { + const parent = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, attributes: ['id'] }); + if (!parent) return []; + const childRelations = await ChildRelation.findAll({ + where: { [Op.or]: [{ fatherCharacterId: parent.id }, { motherCharacterId: parent.id }] }, + attributes: ['childCharacterId'] + }); + const familyCharacterIds = [parent.id, ...childRelations.map((relation) => relation.childCharacterId)]; + const relationships = await Relationship.findAll({ + where: { [Op.or]: [{ character1Id: { [Op.in]: familyCharacterIds } }, { character2Id: { [Op.in]: familyCharacterIds } }] }, + include: [ + { model: RelationshipType, as: 'relationshipType', where: { tr: 'engaged' } }, + { model: FalukantCharacter, as: 'character1', attributes: ['id', 'birthdate'], include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] }, + { model: FalukantCharacter, as: 'character2', attributes: ['id', 'birthdate'], include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] }, + ], + attributes: ['id', 'character1Id', 'character2Id'] + }); + return relationships + .filter((relationship) => calcAge(relationship.character1.birthdate) >= FAMILY_AGE.MIN_MARRIAGE + && calcAge(relationship.character2.birthdate) >= FAMILY_AGE.MIN_MARRIAGE) + .map((relationship) => ({ + relationshipId: relationship.id, + label: `${relationship.character1.definedFirstName?.name || 'Unbekannt'} & ${relationship.character2.definedFirstName?.name || 'Unbekannt'}`, + })); + } + async getPartyTypes(hashedUserId) { const falukantUser = await getFalukantUserOrFail(hashedUserId); - 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, - as: 'relationshipType', - where: { tr: 'engaged' }, - required: true - }, - { - model: FalukantCharacter, - as: 'character1', - where: { userId: falukantUser.id }, - required: false - }, - { - model: FalukantCharacter, - as: 'character2', - where: { userId: falukantUser.id }, - required: false - } - ], - where: { - [Op.or]: [ - { '$character1.user_id$': falukantUser.id }, - { '$character2.user_id$': falukantUser.id } - ] - } - }) - : 0; + const weddingCandidates = await this.getWeddingCandidates(falukantUser); const orConditions = [{ forMarriage: false }]; - if (engagedCount > 0) { + if (weddingCandidates.length > 0) { orConditions.push({ forMarriage: true }); } const partyTypes = await PartyType.findAll({ @@ -5656,10 +5739,10 @@ class FalukantService extends BaseService { }); const musicTypes = await MusicType.findAll(); const banquetteTypes = await BanquetteType.findAll(); - return { partyTypes, musicTypes, banquetteTypes }; + return { partyTypes, musicTypes, banquetteTypes, weddingCandidates }; } - async createParty(hashedUserId, partyTypeId, musicId, banquetteId, nobilityIds = [], servantRatio) { + async createParty(hashedUserId, partyTypeId, musicId, banquetteId, nobilityIds = [], servantRatio, relationshipId = null) { const falukantUser = await getFalukantUserOrFail(hashedUserId); const since = new Date(Date.now() - 24 * 3600 * 1000); const already = await Party.findOne({ @@ -5694,34 +5777,9 @@ class FalukantService extends BaseService { 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 (!engagement) { - const error = new Error('Für eine Hochzeitsfeier musst du mit deinem Partner verlobt sein'); - error.status = 422; - throw error; - } - if (!partner || calcAge(partner.birthdate) < FAMILY_AGE.MIN_MARRIAGE) { - const error = new Error('Beide Verlobten müssen mindestens 14 Spieljahre alt sein'); + const validRelationshipIds = (await this.getWeddingCandidates(falukantUser)).map((candidate) => candidate.relationshipId); + if (!validRelationshipIds.includes(Number(relationshipId))) { + const error = new Error('Für eine Hochzeitsfeier muss ein verlobtes Paar ab 14 Spieljahren ausgewählt werden'); error.status = 422; throw error; } @@ -5748,6 +5806,7 @@ class FalukantService extends BaseService { falukantUserId: falukantUser.id, musicTypeId: musicId, banquetteTypeId: banquetteId, + relationshipId: ptype.forMarriage ? Number(relationshipId) : null, servantRatio, cost: cost }); diff --git a/docs/FALUKANT_FAMILY_AGE_RULES.md b/docs/FALUKANT_FAMILY_AGE_RULES.md index 8d788ef..9ca1980 100644 --- a/docs/FALUKANT_FAMILY_AGE_RULES.md +++ b/docs/FALUKANT_FAMILY_AGE_RULES.md @@ -7,7 +7,7 @@ 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. | +| Werbung | 10 | 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. | @@ -15,10 +15,16 @@ 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. +Eltern können ihre benannten Kinder ab 10 Spieljahren verkuppeln. Diese +Werbung ist verkürzt: Nach der Auswahl eines Kandidaten genügt ein weiterer +Verkuppelungsschritt zur Verlobung. Die Hochzeitsfeier bleibt trotzdem ab 14 +Spieljahren erforderlich und wird dabei ausdrücklich dem verlobten Paar +zugeordnet. Erst ab 16 kann die normale automatische Kinderlogik greifen. + ## Bereits im Backend und Daemon umgesetzt - `backend/services/falukantService.js` - - erzeugt Heiratsvorschläge nur für mindestens 12 Jahre alte Partner; + - erzeugt Heiratsvorschläge nur für mindestens 10 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; diff --git a/frontend/src/components/AppSectionBar.vue b/frontend/src/components/AppSectionBar.vue index f5ecdc7..231018d 100755 --- a/frontend/src/components/AppSectionBar.vue +++ b/frontend/src/components/AppSectionBar.vue @@ -207,10 +207,17 @@ export default { .app-section-bar__back { flex: 0 0 auto; background: rgba(255, 255, 255, 0.82); + color: var(--color-text-primary); box-shadow: none; border: 1px solid var(--color-border); } +.app-section-bar__back:hover:not(:disabled) { + background: var(--color-secondary-soft); + color: var(--color-text-primary); + box-shadow: none; +} + @media (max-width: 760px) { .app-section-bar { flex-direction: column; diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index b1d0a68..958eca1 100755 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -775,6 +775,20 @@ "setAsHeir": "Als Erben festlegen", "heirSetSuccess": "Das Kind wurde erfolgreich als Erbe festgelegt.", "heirSetError": "Fehler beim Festlegen des Erben.", + "matchmaking": "Verkuppelung", + "selectPartner": "Partner auswählen", + "arrange": "Werbung arrangieren", + "arrangeSuccess": "Die Werbung des Kindes wurde arrangiert.", + "arrangeError": "Die Verkuppelung konnte nicht durchgeführt werden.", + "completeWooing": "Werbung abschließen", + "engagedSuccess": "Das Kind ist nun verlobt.", + "weddingHint": "Hochzeitsfeier ab 14 Jahren möglich.", + "noPartnerProposal": "Noch keine passenden Vorschläge.", + "relationshipStatus": { + "wooing": "Werbung läuft", + "engaged": "Verlobt", + "married": "Verheiratet" + }, "actions": "Aktionen", "none": "Keine Kinder vorhanden.", "detailButton": "Details anzeigen", diff --git a/frontend/src/views/falukant/FamilyView.vue b/frontend/src/views/falukant/FamilyView.vue index ddda105..e33f72b 100755 --- a/frontend/src/views/falukant/FamilyView.vue +++ b/frontend/src/views/falukant/FamilyView.vue @@ -314,6 +314,7 @@ {{ $t('falukant.family.children.otherParent') }} {{ $t('falukant.family.children.age') }} {{ $t('falukant.family.children.heir') }} + {{ $t('falukant.family.children.matchmaking') }} {{ $t('falukant.family.children.actions') }} @@ -365,6 +366,38 @@ {{ $t('falukant.family.children.setAsHeir') }} + + + + {{ $t('falukant.family.children.noPartnerProposal') }} +