feat(bisaya): implement child marriage proposal and wooing features, update age rules and relationships
All checks were successful
Deploy to production / deploy (push) Successful in 3m15s

This commit is contained in:
Torsten Schulz (local)
2026-09-08 14:46:26 +02:00
parent 0a9174c486
commit 3d235a621a
12 changed files with 281 additions and 90 deletions

View File

@@ -102,6 +102,10 @@ class FalukantController {
this.getPotentialHeirs = this._wrapWithUser((userId) => this.service.getPotentialHeirs(userId)); 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.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.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.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId), { blockInDebtorsPrison: true });
this.cancelWooing = this._wrapWithUser(async (userId) => { this.cancelWooing = this._wrapWithUser(async (userId) => {
try { try {
@@ -162,8 +166,8 @@ class FalukantController {
this.getPartyTypes = this._wrapWithUser((userId) => this.service.getPartyTypes(userId)); this.getPartyTypes = this._wrapWithUser((userId) => this.service.getPartyTypes(userId));
this.createParty = this._wrapWithUser((userId, req) => { this.createParty = this._wrapWithUser((userId, req) => {
const { partyTypeId, musicId, banquetteId, nobilityIds, servantRatio } = req.body; const { partyTypeId, musicId, banquetteId, nobilityIds, servantRatio, relationshipId } = req.body;
return this.service.createParty(userId, partyTypeId, musicId, banquetteId, nobilityIds, servantRatio); return this.service.createParty(userId, partyTypeId, musicId, banquetteId, nobilityIds, servantRatio, relationshipId);
}, { successStatus: 201, blockInDebtorsPrison: true }); }, { successStatus: 201, blockInDebtorsPrison: true });
this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId)); this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId));

View File

@@ -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;`);
}
};

View File

@@ -633,6 +633,7 @@ export default function setupAssociations() {
FalukantUser.hasMany(Party, { foreignKey: 'falukantUserId', as: 'parties' }); FalukantUser.hasMany(Party, { foreignKey: 'falukantUserId', as: 'parties' });
Party.belongsTo(FalukantUser, { foreignKey: 'falukantUserId', as: 'partyUser' }); Party.belongsTo(FalukantUser, { foreignKey: 'falukantUserId', as: 'partyUser' });
Party.belongsTo(Relationship, { foreignKey: 'relationshipId', as: 'marriageRelationship' });
Party.belongsToMany(TitleOfNobility, { Party.belongsToMany(TitleOfNobility, {
through: PartyInvitedNobility, through: PartyInvitedNobility,

View File

@@ -14,6 +14,11 @@ Party.init({
allowNull: false, allowNull: false,
field: 'falukant_user_id' field: 'falukant_user_id'
}, },
relationshipId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'relationship_id'
},
musicTypeId: { musicTypeId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
@@ -41,4 +46,4 @@ Party.init({
timestamps: true, timestamps: true,
underscored: true}); underscored: true});
export default Party; export default Party;

View File

@@ -49,6 +49,8 @@ router.get('/dashboard-widget', falukantController.getDashboardWidget);
router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal); router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal);
router.post('/family/cancel-wooing', falukantController.cancelWooing); router.post('/family/cancel-wooing', falukantController.cancelWooing);
router.post('/family/set-heir', falukantController.setHeir); 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/lover', falukantController.createLoverRelationship);
router.post('/family/marriage/spend-time', falukantController.spendTimeWithSpouse); router.post('/family/marriage/spend-time', falukantController.spendTimeWithSpouse);
router.post('/family/marriage/gift', falukantController.giftToSpouse); router.post('/family/marriage/gift', falukantController.giftToSpouse);

View File

@@ -94,7 +94,7 @@ function calcAge(birthdate) {
// Ein realer Tag entspricht einem Falukant-Spieljahr. // Ein realer Tag entspricht einem Falukant-Spieljahr.
const FAMILY_AGE = Object.freeze({ const FAMILY_AGE = Object.freeze({
MIN_WOOING: 12, MIN_WOOING: 10,
MIN_MARRIAGE: 14, MIN_MARRIAGE: 14,
MIN_HOUSEHOLD_AND_CHILDREN: 16, MIN_HOUSEHOLD_AND_CHILDREN: 16,
}); });
@@ -3820,7 +3820,7 @@ class FalukantService extends BaseService {
const childChars = childCharIds.length const childChars = childCharIds.length
? await FalukantCharacter.findAll({ ? await FalukantCharacter.findAll({
where: { id: childCharIds }, where: { id: childCharIds },
attributes: ['id', 'birthdate', 'gender'], attributes: ['id', 'birthdate', 'gender', 'regionId', 'titleOfNobility'],
include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }]
}) })
: []; : [];
@@ -3892,6 +3892,42 @@ class FalukantService extends BaseService {
otherParent: otherParentId != null ? (otherParentMap[otherParentId] || null) : null, 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) // Sort children globally by relation createdAt ascending (older first)
children.sort((a, b) => new Date(a._createdAt) - new Date(b._createdAt)); children.sort((a, b) => new Date(a._createdAt) - new Date(b._createdAt));
const inProgress = ['wooing', 'engaged', 'married']; const inProgress = ['wooing', 'engaged', 'married'];
@@ -4637,7 +4673,7 @@ class FalukantService extends BaseService {
gender: { [Op.ne]: requestingCharacterGender }, gender: { [Op.ne]: requestingCharacterGender },
regionId: requestingRegionId, regionId: requestingRegionId,
birthdate: { [Op.lte]: new Date(Date.now() - FAMILY_AGE.MIN_WOOING * 24 * 60 * 60 * 1000) }, 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] } titleOfNobility: { [Op.between]: [requestingCharacterTitleOfNobility - 1, requestingCharacterTitleOfNobility + 1] }
}, },
order: [ order: [
@@ -4684,7 +4720,7 @@ class FalukantService extends BaseService {
|| calcAge(character.birthdate) < FAMILY_AGE.MIN_WOOING || calcAge(character.birthdate) < FAMILY_AGE.MIN_WOOING
|| calcAge(proposedCharacter.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; error.status = 422;
throw error; throw error;
} }
@@ -4723,6 +4759,60 @@ class FalukantService extends BaseService {
return { success: true, message: 'Marriage proposal accepted', relationshipId: newRel.id }; 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) { async cancelWooing(hashedUserId) {
const user = await this.getFalukantUserByHashedId(hashedUserId); const user = await this.getFalukantUserByHashedId(hashedUserId);
if (!user || !user.character) { 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) { async getPartyTypes(hashedUserId) {
const falukantUser = await getFalukantUserOrFail(hashedUserId); const falukantUser = await getFalukantUserOrFail(hashedUserId);
const character = await FalukantCharacter.findOne({ const weddingCandidates = await this.getWeddingCandidates(falukantUser);
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 orConditions = [{ forMarriage: false }]; const orConditions = [{ forMarriage: false }];
if (engagedCount > 0) { if (weddingCandidates.length > 0) {
orConditions.push({ forMarriage: true }); orConditions.push({ forMarriage: true });
} }
const partyTypes = await PartyType.findAll({ const partyTypes = await PartyType.findAll({
@@ -5656,10 +5739,10 @@ class FalukantService extends BaseService {
}); });
const musicTypes = await MusicType.findAll(); const musicTypes = await MusicType.findAll();
const banquetteTypes = await BanquetteType.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 falukantUser = await getFalukantUserOrFail(hashedUserId);
const since = new Date(Date.now() - 24 * 3600 * 1000); const since = new Date(Date.now() - 24 * 3600 * 1000);
const already = await Party.findOne({ 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'] }); const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, attributes: ['id', 'birthdate', 'titleOfNobility'] });
if (ptype.forMarriage) { if (ptype.forMarriage) {
if (!character || calcAge(character.birthdate) < FAMILY_AGE.MIN_MARRIAGE) { const validRelationshipIds = (await this.getWeddingCandidates(falukantUser)).map((candidate) => candidate.relationshipId);
const error = new Error('Eine Hochzeit ist erst ab 14 Spieljahren möglich'); if (!validRelationshipIds.includes(Number(relationshipId))) {
error.status = 422; const error = new Error('Für eine Hochzeitsfeier muss ein verlobtes Paar ab 14 Spieljahren ausgewählt werden');
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');
error.status = 422; error.status = 422;
throw error; throw error;
} }
@@ -5748,6 +5806,7 @@ class FalukantService extends BaseService {
falukantUserId: falukantUser.id, falukantUserId: falukantUser.id,
musicTypeId: musicId, musicTypeId: musicId,
banquetteTypeId: banquetteId, banquetteTypeId: banquetteId,
relationshipId: ptype.forMarriage ? Number(relationshipId) : null,
servantRatio, servantRatio,
cost: cost cost: cost
}); });

View File

@@ -7,7 +7,7 @@ verwendet daher die Differenz zwischen `CURRENT_DATE` und `birthdate` in Tagen.
| Bereich | Mindestalter | Regel | | 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. | | 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. | | 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 Spielphasen ab; es gibt keine individuelle Prüfung körperlicher Reife und keine
explizite Sexualmechanik. 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 ## Bereits im Backend und Daemon umgesetzt
- `backend/services/falukantService.js` - `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; - prüft beim Annehmen eines Vorschlags beide Alter erneut;
- bietet den Hochzeitstyp erst ab 14 an und prüft beim Bestellen der Feier - bietet den Hochzeitstyp erst ab 14 an und prüft beim Bestellen der Feier
beide Verlobte serverseitig; beide Verlobte serverseitig;

View File

@@ -207,10 +207,17 @@ export default {
.app-section-bar__back { .app-section-bar__back {
flex: 0 0 auto; flex: 0 0 auto;
background: rgba(255, 255, 255, 0.82); background: rgba(255, 255, 255, 0.82);
color: var(--color-text-primary);
box-shadow: none; box-shadow: none;
border: 1px solid var(--color-border); 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) { @media (max-width: 760px) {
.app-section-bar { .app-section-bar {
flex-direction: column; flex-direction: column;

View File

@@ -775,6 +775,20 @@
"setAsHeir": "Als Erben festlegen", "setAsHeir": "Als Erben festlegen",
"heirSetSuccess": "Das Kind wurde erfolgreich als Erbe festgelegt.", "heirSetSuccess": "Das Kind wurde erfolgreich als Erbe festgelegt.",
"heirSetError": "Fehler beim Festlegen des Erben.", "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", "actions": "Aktionen",
"none": "Keine Kinder vorhanden.", "none": "Keine Kinder vorhanden.",
"detailButton": "Details anzeigen", "detailButton": "Details anzeigen",

View File

@@ -314,6 +314,7 @@
<th>{{ $t('falukant.family.children.otherParent') }}</th> <th>{{ $t('falukant.family.children.otherParent') }}</th>
<th>{{ $t('falukant.family.children.age') }}</th> <th>{{ $t('falukant.family.children.age') }}</th>
<th>{{ $t('falukant.family.children.heir') }}</th> <th>{{ $t('falukant.family.children.heir') }}</th>
<th>{{ $t('falukant.family.children.matchmaking') }}</th>
<th>{{ $t('falukant.family.children.actions') }}</th> <th>{{ $t('falukant.family.children.actions') }}</th>
</tr> </tr>
</thead> </thead>
@@ -365,6 +366,38 @@
{{ $t('falukant.family.children.setAsHeir') }} {{ $t('falukant.family.children.setAsHeir') }}
</button> </button>
</td> </td>
<td class="child-matchmaking-cell">
<template v-if="child.relationship">
<strong>{{ child.relationship.partnerName }}</strong>
<span>{{ $t('falukant.family.children.relationshipStatus.' + child.relationship.status) }}</span>
<button
v-if="child.relationship.status === 'wooing'"
type="button"
@click="advanceChildWooing(child)"
>
{{ $t('falukant.family.children.completeWooing') }}
</button>
<span v-else-if="child.relationship.status === 'engaged'" class="child-matchmaking-hint">
{{ $t('falukant.family.children.weddingHint') }}
</span>
</template>
<template v-else-if="child.hasName && child.possiblePartners?.length">
<select v-model="selectedChildProposalIds[child.childCharacterId]">
<option :value="null">{{ $t('falukant.family.children.selectPartner') }}</option>
<option v-for="proposal in child.possiblePartners" :key="proposal.id" :value="proposal.proposedCharacterId">
{{ proposal.proposedCharacterName }} ({{ proposal.proposedCharacterAge }})
</option>
</select>
<button
type="button"
:disabled="!selectedChildProposalIds[child.childCharacterId]"
@click="acceptChildProposal(child)"
>
{{ $t('falukant.family.children.arrange') }}
</button>
</template>
<span v-else-if="child.hasName">{{ $t('falukant.family.children.noPartnerProposal') }}</span>
</td>
<td> <td>
<button @click="showChildDetails(child)"> <button @click="showChildDetails(child)">
{{ $t('falukant.family.children.detailButton') }} {{ $t('falukant.family.children.detailButton') }}
@@ -670,6 +703,7 @@ export default {
}, },
pregnancy: null, pregnancy: null,
selectedChild: null, selectedChild: null,
selectedChildProposalIds: {},
pendingFamilyRefresh: null, pendingFamilyRefresh: null,
familyTab: 'partner', familyTab: 'partner',
visualSettings: { ...FALUKANT_VISUAL_DEFAULTS }, visualSettings: { ...FALUKANT_VISUAL_DEFAULTS },
@@ -854,6 +888,9 @@ export default {
const response = await apiClient.get('/api/falukant/family'); const response = await apiClient.get('/api/falukant/family');
this.relationships = response.data.relationships; this.relationships = response.data.relationships;
this.children = response.data.children; this.children = response.data.children;
this.selectedChildProposalIds = Object.fromEntries(
this.children.map((child) => [child.childCharacterId, this.selectedChildProposalIds[child.childCharacterId] || null])
);
this.lovers = response.data.lovers; this.lovers = response.data.lovers;
this.politicalFreeLoverSlots = Number(response.data.politicalFreeLoverSlots) || 0; this.politicalFreeLoverSlots = Number(response.data.politicalFreeLoverSlots) || 0;
this.possibleLovers = response.data.possibleLovers || []; this.possibleLovers = response.data.possibleLovers || [];
@@ -978,6 +1015,30 @@ export default {
} }
}, },
async acceptChildProposal(child) {
const proposedCharacterId = this.selectedChildProposalIds[child.childCharacterId];
if (!proposedCharacterId) return;
try {
await apiClient.post(`/api/falukant/family/children/${child.childCharacterId}/accept-marriage-proposal`, { proposedCharacterId });
await this.loadFamilyData();
showSuccess(this, this.$t('falukant.family.children.arrangeSuccess'));
} catch (error) {
console.error('Error arranging child marriage:', error);
showError(this, error?.response?.data?.message || this.$t('falukant.family.children.arrangeError'));
}
},
async advanceChildWooing(child) {
try {
await apiClient.post(`/api/falukant/family/children/${child.childCharacterId}/advance-wooing`);
await this.loadFamilyData();
showSuccess(this, this.$t('falukant.family.children.engagedSuccess'));
} catch (error) {
console.error('Error advancing child wooing:', error);
showError(this, error?.response?.data?.message || this.$t('falukant.family.children.arrangeError'));
}
},
async setLoverMaintenance(lover, maintenanceLevel) { async setLoverMaintenance(lover, maintenanceLevel) {
try { try {
await apiClient.post(`/api/falukant/family/lover/${lover.relationshipId}/maintenance`, { await apiClient.post(`/api/falukant/family/lover/${lover.relationshipId}/maintenance`, {

View File

@@ -76,6 +76,15 @@
</label> </label>
<div v-if="newPartyTypeId" class="party-options"> <div v-if="newPartyTypeId" class="party-options">
<label v-if="selectedPartyType?.forMarriage">
Hochzeitspaar:
<select v-model.number="relationshipId">
<option :value="null">Bitte Verlobte auswählen</option>
<option v-for="candidate in weddingCandidates" :key="candidate.relationshipId" :value="candidate.relationshipId">
{{ candidate.label }}
</option>
</select>
</label>
<label> <label>
{{ $t('falukant.reputation.party.music.label') }}: {{ $t('falukant.reputation.party.music.label') }}:
<select v-model.number="musicId"> <select v-model.number="musicId">
@@ -214,6 +223,8 @@ export default {
nobilityTitles: [], nobilityTitles: [],
selectedNobilityIds: [], selectedNobilityIds: [],
servantRatio: 50, servantRatio: 50,
relationshipId: null,
weddingCandidates: [],
inProgressParties: [], inProgressParties: [],
completedParties: [], completedParties: [],
reputation: null, reputation: null,
@@ -235,6 +246,7 @@ export default {
this.partyTypes = data.partyTypes; this.partyTypes = data.partyTypes;
this.musicTypes = data.musicTypes; this.musicTypes = data.musicTypes;
this.banquetteTypes = data.banquetteTypes; this.banquetteTypes = data.banquetteTypes;
this.weddingCandidates = data.weddingCandidates || [];
this.musicId = this.musicTypes[0]?.id; this.musicId = this.musicTypes[0]?.id;
this.banquetteId = this.banquetteTypes[0]?.id; this.banquetteId = this.banquetteTypes[0]?.id;
}, },
@@ -310,7 +322,8 @@ export default {
musicId: this.musicId, musicId: this.musicId,
banquetteId: this.banquetteId, banquetteId: this.banquetteId,
nobilityIds: this.selectedNobilityIds.map(n => n.id ?? n), nobilityIds: this.selectedNobilityIds.map(n => n.id ?? n),
servantRatio: this.servantRatio servantRatio: this.servantRatio,
relationshipId: this.relationshipId
}); });
this.toggleNewPartyView(); this.toggleNewPartyView();
}, },
@@ -343,6 +356,10 @@ export default {
maximumFractionDigits: 2 maximumFractionDigits: 2
}); });
} }
,
selectedPartyType() {
return this.partyTypes.find((type) => type.id === this.newPartyTypeId) || null;
}
}, },
async mounted() { async mounted() {
const tabFromQuery = this.$route?.query?.tab; const tabFromQuery = this.$route?.query?.tab;

View File

@@ -112,8 +112,11 @@ private:
JOIN falukant_data."character" AS c JOIN falukant_data."character" AS c
ON c.user_id = fu.id ON c.user_id = fu.id
JOIN falukant_data.relationship AS rel2 JOIN falukant_data.relationship AS rel2
ON rel2.character1_id = c.id ON p.relationship_id = rel2.id
OR rel2.character2_id = c.id OR (
p.relationship_id IS NULL
AND (rel2.character1_id = c.id OR rel2.character2_id = c.id)
)
JOIN falukant_type.relationship AS rt2 JOIN falukant_type.relationship AS rt2
ON rt2.id = rel2.relationship_type_id ON rt2.id = rel2.relationship_type_id
AND rt2.tr = 'engaged' AND rt2.tr = 'engaged'
@@ -121,19 +124,15 @@ private:
-- Die Feier muss für diese bereits bestehende Verlobung bestellt worden sein. -- Die Feier muss für diese bereits bestehende Verlobung bestellt worden sein.
AND p.created_at >= rel2.created_at AND p.created_at >= rel2.created_at
-- Ein realer Tag entspricht einem Spieljahr: Hochzeit ab 14. -- Ein realer Tag entspricht einem Spieljahr: Hochzeit ab 14.
AND c.birthdate <= CURRENT_DATE - INTERVAL '14 days' AND EXISTS (
AND ( SELECT 1 FROM falukant_data."character" spouse1
(rel2.character1_id = c.id AND EXISTS ( WHERE spouse1.id = rel2.character1_id
SELECT 1 FROM falukant_data."character" partner AND spouse1.birthdate <= CURRENT_DATE - INTERVAL '14 days'
WHERE partner.id = rel2.character2_id )
AND partner.birthdate <= CURRENT_DATE - INTERVAL '14 days' AND EXISTS (
)) SELECT 1 FROM falukant_data."character" spouse2
OR WHERE spouse2.id = rel2.character2_id
(rel2.character2_id = c.id AND EXISTS ( AND spouse2.birthdate <= CURRENT_DATE - INTERVAL '14 days'
SELECT 1 FROM falukant_data."character" partner
WHERE partner.id = rel2.character1_id
AND partner.birthdate <= CURRENT_DATE - INTERVAL '14 days'
))
) )
) )
RETURNING character1_id, character2_id RETURNING character1_id, character2_id