feat(falukant): add character visual representation and settings
All checks were successful
Deploy to production / deploy (push) Successful in 2m53s

- Introduced FalukantCharacterVisual component to display character portraits and 3D figures based on user settings.
- Updated DirectorInfo, ChurchView, PoliticsView, and other components to utilize the new character visual component.
- Added visual settings management for users, allowing selection between portrait styles and display modes (portraits, 3D, or both).
- Implemented age rules for engagement and marriage in the backend, ensuring compliance with specified age limits.
- Created a new settings view for Falukant to manage visual preferences.
- Added new images for medieval character portraits.
- Updated translations for new settings and features in multiple languages.
This commit is contained in:
Torsten Schulz (local)
2026-07-24 10:55:19 +02:00
parent c13339f771
commit 16703f467f
36 changed files with 811 additions and 49 deletions

View File

@@ -216,6 +216,10 @@ const menuStructure = {
visible: ["all"],
path: "/settings/language-assistant"
},
falukant: {
visible: ["all"],
path: "/settings/falukant"
},
personal: {
visible: ["all"],
path: "/settings/personal"

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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];