diff --git a/backend/models/associations.js b/backend/models/associations.js index cbcb5c2..5f66d66 100644 --- a/backend/models/associations.js +++ b/backend/models/associations.js @@ -45,6 +45,7 @@ import FalukantStockType from './falukant/type/stock.js'; import Knowledge from './falukant/data/product_knowledge.js'; import ProductType from './falukant/type/product.js'; import TitleOfNobility from './falukant/type/title_of_nobility.js'; +import TitleBenefit from './falukant/type/title_benefit.js'; import TitleRequirement from './falukant/type/title_requirement.js'; import Branch from './falukant/data/branch.js'; import BranchType from './falukant/type/branch.js'; @@ -352,6 +353,8 @@ export default function setupAssociations() { TitleRequirement.belongsTo(TitleOfNobility, { foreignKey: 'titleId', as: 'title' }); TitleOfNobility.hasMany(TitleRequirement, { foreignKey: 'titleId', as: 'requirements' }); + TitleOfNobility.hasMany(TitleBenefit, { foreignKey: 'titleId', as: 'benefits' }); + TitleBenefit.belongsTo(TitleOfNobility, { foreignKey: 'titleId', as: 'title' }); Branch.belongsTo(RegionData, { foreignKey: 'regionId', as: 'region' }); RegionData.hasMany(Branch, { foreignKey: 'regionId', as: 'branches' }); diff --git a/backend/models/falukant/type/title_benefit.js b/backend/models/falukant/type/title_benefit.js new file mode 100644 index 0000000..3ecc790 --- /dev/null +++ b/backend/models/falukant/type/title_benefit.js @@ -0,0 +1,41 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../../utils/sequelize.js'; + +/** + * Vorteile pro Stand (Adelstitel). + * benefit_type: 'tax_share' | 'tax_exempt' | 'office_eligibility' | 'free_party_type' | 'reputation_bonus' + * parameters: JSONB, z.B. { officeTypeNames: [] }, { partyTypeIds: [] }, { minPercent: 5, maxPercent: 15 } + */ +class TitleBenefit extends Model {} + +TitleBenefit.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + titleId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'title_id' + }, + benefitType: { + type: DataTypes.STRING, + allowNull: false, + field: 'benefit_type' + }, + parameters: { + type: DataTypes.JSONB, + allowNull: true, + defaultValue: {} + } +}, { + sequelize, + modelName: 'TitleBenefit', + tableName: 'title_benefit', + schema: 'falukant_type', + timestamps: false, + underscored: true +}); + +export default TitleBenefit; diff --git a/backend/models/index.js b/backend/models/index.js index 78d4056..b39d143 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -51,6 +51,7 @@ import ProductType from './falukant/type/product.js'; import Knowledge from './falukant/data/product_knowledge.js'; import TitleRequirement from './falukant/type/title_requirement.js'; import TitleOfNobility from './falukant/type/title_of_nobility.js'; +import TitleBenefit from './falukant/type/title_benefit.js'; import BranchType from './falukant/type/branch.js'; import Branch from './falukant/data/branch.js'; import Production from './falukant/data/production.js'; @@ -201,6 +202,7 @@ const models = { ProductType, Knowledge, TitleOfNobility, + TitleBenefit, TitleRequirement, BranchType, Branch, diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index 52a5fc9..1387127 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -10,6 +10,7 @@ import RegionType from '../models/falukant/type/region.js'; import FalukantStock from '../models/falukant/data/stock.js'; import FalukantStockType from '../models/falukant/type/stock.js'; import TitleOfNobility from '../models/falukant/type/title_of_nobility.js'; +import TitleBenefit from '../models/falukant/type/title_benefit.js'; import Branch from '../models/falukant/data/branch.js'; import BranchType from '../models/falukant/type/branch.js'; import Production from '../models/falukant/data/production.js'; @@ -167,6 +168,7 @@ async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPe async function getCumulativeTaxPercentWithExemptions(userId, regionId) { if (!regionId) return 0; + if (await hasTitleTaxExempt(userId)) return 0; // fetch user's political offices (active) and their region types const offices = await PoliticalOffice.findAll({ where: { userId }, @@ -212,6 +214,81 @@ async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPe return parseFloat(val) || 0; } + /** Standesvorteil: Steuerbefreiung für bestimmte Titel */ + async function hasTitleTaxExempt(falukantUserId) { + const char = await FalukantCharacter.findOne({ where: { userId: falukantUserId }, attributes: ['titleOfNobility'] }); + if (!char?.titleOfNobility) return false; + const benefit = await TitleBenefit.findOne({ where: { titleId: char.titleOfNobility, benefitType: 'tax_exempt' } }); + return !!benefit; + } + + /** + * Oberster Stand einer Region bekommt die Steuereinnahmen; Aufteilung auf alle Mitglieder dieses Standes. + * Returns { recipientUserIds: number[], useTreasury: boolean }. useTreasury true = an TREASURY_FALUKANT_USER_ID zahlen. + */ + async function getTaxRecipientsForRegion(regionId) { + const treasuryId = process.env.TREASURY_FALUKANT_USER_ID ? parseInt(process.env.TREASURY_FALUKANT_USER_ID, 10) : null; + const chars = await FalukantCharacter.findAll({ + where: { regionId }, + attributes: ['userId', 'titleOfNobility'], + include: [{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['id', 'level'], required: true }] + }); + if (!chars.length) return { recipientUserIds: [], useTreasury: true }; + const maxLevel = Math.max(...chars.map(c => c.nobleTitle?.level ?? 0)); + const topTitleId = chars.find(c => (c.nobleTitle?.level ?? 0) === maxLevel)?.nobleTitle?.id; + if (!topTitleId) return { recipientUserIds: [], useTreasury: true }; + const hasTaxShare = await TitleBenefit.findOne({ where: { titleId: topTitleId, benefitType: 'tax_share' } }); + if (!hasTaxShare) return { recipientUserIds: [], useTreasury: true }; + const topCharUserIds = [...new Set(chars.filter(c => c.titleOfNobility === topTitleId).map(c => c.userId).filter(Boolean))]; + if (!topCharUserIds.length) return { recipientUserIds: [], useTreasury: true }; + return { recipientUserIds: topCharUserIds, useTreasury: false }; + } + + /** Standesvorteil: Welche politischen Ämter (officeType.name) darf dieser Titel besetzen? */ + async function getAllowedOfficeTypeNamesByTitle(titleId) { + const benefits = await TitleBenefit.findAll({ + where: { titleId, benefitType: 'office_eligibility' }, + attributes: ['parameters'] + }); + const names = new Set(); + for (const b of benefits) { + const arr = b.parameters?.officeTypeNames; + if (Array.isArray(arr)) arr.forEach(n => names.add(n)); + } + return names; + } + + /** Standesvorteil: Ist dieser Festtyp (partyTypeId oder partyType.tr) für diesen Titel kostenfrei? */ + async function isPartyTypeFreeForTitle(titleId, partyTypeId, partyTypeTr) { + const benefits = await TitleBenefit.findAll({ + where: { titleId, benefitType: 'free_party_type' }, + attributes: ['parameters'] + }); + for (const b of benefits) { + const p = b.parameters || {}; + const ids = p.partyTypeIds; + const trs = p.partyTypeLabelTrs || p.partyTypeTrs; + if (Array.isArray(ids) && ids.includes(partyTypeId)) return true; + if (Array.isArray(trs) && trs.includes(partyTypeTr)) return true; + } + return false; + } + + /** Standesvorteil: Beliebtheits-Bonus 5–15 % (nur Anzeige, nicht gespeichert). Deterministisch pro Charakter. */ + async function getDisplayReputation(character) { + const base = character?.reputation ?? 0; + const benefit = await TitleBenefit.findOne({ + where: { titleId: character?.titleOfNobility, benefitType: 'reputation_bonus' }, + attributes: ['parameters'] + }); + if (!benefit?.parameters) return base; + const minP = benefit.parameters.minPercent ?? 5; + const maxP = benefit.parameters.maxPercent ?? 15; + const range = Math.max(1, maxP - minP + 1); + const bonusPercent = minP + (Math.abs(character.id) % range); + return Math.min(100, Math.round(base * (1 + bonusPercent / 100))); + } + function calculateMarriageCost(titleOfNobility, age) { const minTitle = 1; const adjustedTitle = titleOfNobility - minTitle + 1; @@ -494,7 +571,7 @@ class FalukantService extends BaseService { { model: FalukantCharacter, as: 'character', - attributes: ['id', 'birthdate', 'health', 'reputation'], + attributes: ['id', 'birthdate', 'health', 'reputation', 'titleOfNobility'], }, ], attributes: ['id', 'money'] @@ -529,6 +606,10 @@ class FalukantService extends BaseService { falukantUser.character.setDataValue('relationshipsAsCharacter2', rawRelsAs2.filter(r => typeMap[r.relationshipTypeId]).map(attachType)); } if (falukantUser.character?.birthdate) falukantUser.character.setDataValue('age', calcAge(falukantUser.character.birthdate)); + if (falukantUser.character?.id) { + const displayRep = await getDisplayReputation(falukantUser.character); + falukantUser.character.setDataValue('reputationDisplay', displayRep); + } // Aggregate status additions: children counts and unread notifications try { @@ -1677,11 +1758,22 @@ class FalukantService extends BaseService { const moneyResult = await updateFalukantUserMoney(user.id, net, `Product sale (net)`, user.id); if (!moneyResult.success) throw new Error('Failed to update money for seller'); - // Book tax to treasury (if configured) - const treasuryId = process.env.TREASURY_FALUKANT_USER_ID; - if (treasuryId && taxValue > 0) { - const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), taxValue, `Sales tax (${cumulativeTax}%)`, user.id); - if (!taxResult.success) throw new Error('Failed to update money for treasury'); + // Book tax: oberster Stand der Region oder Treasury + if (taxValue > 0) { + const { recipientUserIds, useTreasury } = await getTaxRecipientsForRegion(branch.regionId); + if (!useTreasury && recipientUserIds.length > 0) { + const share = Math.round((taxValue / recipientUserIds.length) * 100) / 100; + for (const recipientId of recipientUserIds) { + const taxResult = await updateFalukantUserMoney(recipientId, share, `Steueranteil Region (Verkauf)`, user.id); + if (!taxResult.success) throw new Error('Failed to update money for tax recipient'); + } + } else { + const treasuryId = process.env.TREASURY_FALUKANT_USER_ID; + if (treasuryId) { + const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), taxValue, `Sales tax (${cumulativeTax}%)`, user.id); + if (!taxResult.success) throw new Error('Failed to update money for treasury'); + } + } } let remaining = quantity; for (const inv of inventory) { @@ -1746,30 +1838,22 @@ class FalukantService extends BaseService { }); if (!inventory.length) return { success: true, revenue: 0 }; let total = 0; + /** regionId -> total tax amount to distribute in that region */ + const taxPerRegion = new Map(); for (const item of inventory) { const knowledgeVal = item.productType.knowledges[0]?.knowledge || 0; const regionId = item.stock.branch.regionId; const pricePerUnit = await calcRegionalSellPrice(item.productType, knowledgeVal, regionId); - const cumulativeTax = await getCumulativeTaxPercentWithExemptions(user.id, regionId); - const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100)); - const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100; - total += item.quantity * adjustedPricePerUnit; - await this.addSellItem(item.stock.branch.id, falukantUser.id, item.productType.id, item.quantity); - } - // compute tax per region (using cumulative tax per region) and aggregate - let totalTax = 0; - for (const item of inventory) { - const regionId = item.stock.branch.regionId; - const region = await RegionData.findOne({ where: { id: regionId } }); - const cumulativeTax = await getCumulativeTaxPercent(regionId); - const pricePerUnit = await calcRegionalSellPrice(item.productType, item.productType.knowledges?.[0]?.knowledge || 0, regionId); + const cumulativeTax = await getCumulativeTaxPercentWithExemptions(falukantUser.id, regionId); const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100)); const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100; const itemRevenue = item.quantity * adjustedPricePerUnit; + total += itemRevenue; const itemTax = Math.round((itemRevenue * cumulativeTax / 100) * 100) / 100; - totalTax += itemTax; + if (itemTax > 0) taxPerRegion.set(regionId, (taxPerRegion.get(regionId) || 0) + itemTax); + await this.addSellItem(item.stock.branch.id, falukantUser.id, item.productType.id, item.quantity); } - + const totalTax = [...taxPerRegion.values()].reduce((s, t) => s + t, 0); const totalNet = Math.round((total - totalTax) * 100) / 100; const moneyResult = await updateFalukantUserMoney( @@ -1781,9 +1865,19 @@ class FalukantService extends BaseService { if (!moneyResult.success) throw new Error('Failed to update money for seller'); const treasuryId = process.env.TREASURY_FALUKANT_USER_ID; - if (treasuryId && totalTax > 0) { - const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), Math.round(totalTax * 100) / 100, `Sales tax (aggregate)`, falukantUser.id); - if (!taxResult.success) throw new Error('Failed to update money for treasury'); + for (const [regionId, regionTax] of taxPerRegion) { + if (regionTax <= 0) continue; + const { recipientUserIds, useTreasury } = await getTaxRecipientsForRegion(regionId); + if (!useTreasury && recipientUserIds.length > 0) { + const share = Math.round((regionTax / recipientUserIds.length) * 100) / 100; + for (const recipientId of recipientUserIds) { + const taxResult = await updateFalukantUserMoney(recipientId, share, `Steueranteil Region (Verkauf)`, falukantUser.id); + if (!taxResult.success) throw new Error('Failed to update money for tax recipient'); + } + } else if (treasuryId) { + const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), Math.round(regionTax * 100) / 100, `Sales tax (aggregate)`, falukantUser.id); + if (!taxResult.success) throw new Error('Failed to update money for treasury'); + } } for (const item of inventory) { await Inventory.destroy({ where: { id: item.id } }); @@ -3567,7 +3661,9 @@ class FalukantService extends BaseService { throw new Error('Einige ausgewählte Adelstitel existieren nicht'); } - let cost = (ptype.cost || 0) + (music.cost || 0) + (banquette.cost || 0); + const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, attributes: ['titleOfNobility'] }); + 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; const nobilityCost = nobilities.reduce((sum, n) => sum + ((n.id ^ 5) * 1000), 0); cost += nobilityCost; @@ -4483,7 +4579,13 @@ class FalukantService extends BaseService { } ] }); + const titleId = character.titleOfNobility ?? character.nobleTitle?.id; + const allowedOfficeNames = await getAllowedOfficeTypeNamesByTitle(titleId); const result = openPositions + .filter(election => { + if (allowedOfficeNames.size > 0 && !allowedOfficeNames.has(election.officeType?.name)) return false; + return true; + }) .filter(election => { const prereqs = election.officeType.prerequisites || []; return prereqs.some(pr => { diff --git a/backend/utils/falukant/initializeFalukantTypes.js b/backend/utils/falukant/initializeFalukantTypes.js index ad31e05..8143ba6 100644 --- a/backend/utils/falukant/initializeFalukantTypes.js +++ b/backend/utils/falukant/initializeFalukantTypes.js @@ -9,6 +9,7 @@ import PromotionalGiftMood from "../../models/falukant/predefine/promotional_gif import { sequelize } from '../sequelize.js'; import HouseType from '../../models/falukant/type/house.js'; import TitleOfNobility from "../../models/falukant/type/title_of_nobility.js"; +import TitleBenefit from "../../models/falukant/type/title_benefit.js"; import PartyType from "../../models/falukant/type/party.js"; import MusicType from "../../models/falukant/type/music.js"; import BanquetteType from "../../models/falukant/type/banquette.js"; @@ -39,7 +40,8 @@ export const initializeFalukantTypes = async () => { // Adelstitel VOR Haustypen initialisieren await initializeFalukantTitlesOfNobility(); - + await initializeTitleBenefits(); + await initializeFalukantHouseTypes(); await initializeFalukantPartyTypes(); await initializeFalukantMusicTypes(); @@ -1204,6 +1206,65 @@ export const initializeFalukantTitlesOfNobility = async () => { } }; +/** Standesvorteile: tax_share, tax_exempt, office_eligibility, free_party_type, reputation_bonus */ +async function initializeTitleBenefits() { + const titles = await TitleOfNobility.findAll({ attributes: ['id', 'labelTr', 'level'] }); + const byLabel = new Map(titles.map(t => [t.labelTr, t])); + const benefits = []; + // tax_share: oberster Stand einer Region bekommt Steuer – Titel mit hohem level (z.B. ab count) + const taxShareTitles = ['count', 'palsgrave', 'margrave', 'landgrave', 'ruler', 'elector', 'imperial-prince', 'duke', 'grand-duke', 'prince-regent', 'king']; + for (const label of taxShareTitles) { + const t = byLabel.get(label); + if (t) benefits.push({ titleId: t.id, benefitType: 'tax_share', parameters: {} }); + } + // tax_exempt: z.B. noncivil, oder hohe Titel + const taxExemptTitles = ['noncivil', 'king', 'prince-regent']; + for (const label of taxExemptTitles) { + const t = byLabel.get(label); + if (t) benefits.push({ titleId: t.id, benefitType: 'tax_exempt', parameters: {} }); + } + // office_eligibility: pro Titel eine Zeile mit allen erlaubten Ämtern (officeTypeNames) + const officeEligibility = [ + { label: 'assessor', titles: ['civil', 'sir', 'townlord', 'by', 'landlord', 'knight', 'baron', 'count', 'palsgrave', 'margrave', 'landgrave', 'ruler', 'elector', 'imperial-prince', 'duke', 'grand-duke', 'prince-regent', 'king'] }, + { label: 'council', titles: ['sir', 'townlord', 'by', 'landlord', 'knight', 'baron', 'count', 'palsgrave', 'margrave', 'landgrave', 'ruler', 'elector', 'imperial-prince', 'duke', 'grand-duke', 'prince-regent', 'king'] }, + { label: 'taxman', titles: ['townlord', 'by', 'landlord', 'knight', 'baron', 'count', 'palsgrave', 'margrave', 'landgrave', 'ruler', 'elector', 'imperial-prince', 'duke', 'grand-duke', 'prince-regent', 'king'] }, + { label: 'chancellor', titles: ['king'] } + ]; + const titleToOffices = new Map(); + for (const { label: officeName, titles: allowedTitles } of officeEligibility) { + for (const label of allowedTitles) { + const t = byLabel.get(label); + if (t) { + const list = titleToOffices.get(t.id) || []; + if (!list.includes(officeName)) list.push(officeName); + titleToOffices.set(t.id, list); + } + } + } + for (const [titleId, officeTypeNames] of titleToOffices) { + benefits.push({ titleId, benefitType: 'office_eligibility', parameters: { officeTypeNames } }); + } + // free_party_type: z.B. "wedding" für civil+ (partyTypeIds oder labelTr) + const freePartyTitles = ['sir', 'townlord', 'by', 'landlord', 'knight', 'baron', 'count', 'palsgrave', 'margrave', 'landgrave', 'ruler', 'elector', 'imperial-prince', 'duke', 'grand-duke', 'prince-regent', 'king']; + for (const label of freePartyTitles) { + const t = byLabel.get(label); + if (t) benefits.push({ titleId: t.id, benefitType: 'free_party_type', parameters: { partyTypeLabelTrs: ['wedding'] } }); + } + // reputation_bonus: zufällig 5–15 % für ausgewählte Stände (hier: knight, count, duke) + const reputationBonusTitles = ['knight', 'count', 'duke']; + for (const label of reputationBonusTitles) { + const t = byLabel.get(label); + if (t) benefits.push({ titleId: t.id, benefitType: 'reputation_bonus', parameters: { minPercent: 5, maxPercent: 15 } }); + } + for (const b of benefits) { + await TitleBenefit.findOrCreate({ + where: { titleId: b.titleId, benefitType: b.benefitType }, + defaults: { parameters: b.parameters } + }); + } + console.log(`[Falukant] Standesvorteile (title_benefit) initialisiert: ${benefits.length} Einträge`); +} + const weatherTypes = [ { tr: "sunny" }, { tr: "cloudy" }, diff --git a/frontend/public/images/icons/profile.png b/frontend/public/images/icons/profile.png new file mode 100644 index 0000000..89add5f Binary files /dev/null and b/frontend/public/images/icons/profile.png differ diff --git a/frontend/public/images/icons/profile16.png b/frontend/public/images/icons/profile16.png index 16da58e..152248f 100644 Binary files a/frontend/public/images/icons/profile16.png and b/frontend/public/images/icons/profile16.png differ diff --git a/frontend/public/images/icons/profile24.png b/frontend/public/images/icons/profile24.png index 3d0ecd2..9f3ad85 100644 Binary files a/frontend/public/images/icons/profile24.png and b/frontend/public/images/icons/profile24.png differ