Add TitleBenefit model and integrate benefits into FalukantService: Introduced TitleBenefit for managing title-related advantages, including tax exemptions and office eligibility. Updated service methods to utilize these benefits for character reputation, tax distribution, and allowed office types, enhancing gameplay mechanics and user experience.

This commit is contained in:
Torsten Schulz (local)
2026-02-06 09:50:31 +01:00
parent a60c6d173c
commit c7a05c3213
8 changed files with 235 additions and 26 deletions

View File

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

View File

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

View File

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

View File

@@ -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 515 % (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 => {

View File

@@ -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,6 +40,7 @@ export const initializeFalukantTypes = async () => {
// Adelstitel VOR Haustypen initialisieren
await initializeFalukantTitlesOfNobility();
await initializeTitleBenefits();
await initializeFalukantHouseTypes();
await initializeFalukantPartyTypes();
@@ -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 515 % 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" },

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 B

After

Width:  |  Height:  |  Size: 440 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 599 B

After

Width:  |  Height:  |  Size: 582 B