Verdichteter Blick auf Warenbestand über alle Regionen.
diff --git a/backend/controllers/falukantController.js b/backend/controllers/falukantController.js index a888da3..b0fb50a 100644 --- a/backend/controllers/falukantController.js +++ b/backend/controllers/falukantController.js @@ -29,30 +29,30 @@ class FalukantController { // Dashboard widget: originaler Endpoint (siehe Commit 62d8cd7) this.getDashboardWidget = this._wrapWithUser((userId) => this.service.getDashboardWidget(userId)); this.getBranches = this._wrapWithUser((userId) => this.service.getBranches(userId)); - this.createBranch = this._wrapWithUser((userId, req) => this.service.createBranch(userId, req.body.cityId, req.body.branchTypeId)); + this.createBranch = this._wrapWithUser((userId, req) => this.service.createBranch(userId, req.body.cityId, req.body.branchTypeId), { blockInDebtorsPrison: true }); this.getBranchTypes = this._wrapWithUser((userId) => this.service.getBranchTypes(userId)); this.getBranch = this._wrapWithUser((userId, req) => this.service.getBranch(userId, req.params.branch)); - this.upgradeBranch = this._wrapWithUser((userId, req) => this.service.upgradeBranch(userId, req.body.branchId)); + this.upgradeBranch = this._wrapWithUser((userId, req) => this.service.upgradeBranch(userId, req.body.branchId), { blockInDebtorsPrison: true }); this.createProduction = this._wrapWithUser((userId, req) => { const { branchId, productId, quantity } = req.body; return this.service.createProduction(userId, branchId, productId, quantity); - }, { successStatus: 201 }); + }, { successStatus: 201, blockInDebtorsPrison: true }); this.getProduction = this._wrapWithUser((userId, req) => this.service.getProduction(userId, req.params.branchId)); this.getStock = this._wrapWithUser((userId, req) => this.service.getStock(userId, req.params.branchId || null)); this.createStock = this._wrapWithUser((userId, req) => { const { branchId, stockTypeId, stockSize } = req.body; return this.service.createStock(userId, branchId, stockTypeId, stockSize); - }, { successStatus: 201 }); + }, { successStatus: 201, blockInDebtorsPrison: true }); this.getProducts = this._wrapWithUser((userId) => this.service.getProducts(userId)); this.getInventory = this._wrapWithUser((userId, req) => this.service.getInventory(userId, req.params.branchId)); this.sellProduct = this._wrapWithUser((userId, req) => { const { branchId, productId, quality, quantity } = req.body; return this.service.sellProduct(userId, branchId, productId, quality, quantity); - }, { successStatus: 201 }); + }, { successStatus: 201, blockInDebtorsPrison: true }); this.sellAllProducts = this._wrapWithUser((userId, req) => { const { branchId } = req.body; return this.service.sellAllProducts(userId, branchId); - }, { successStatus: 201 }); + }, { successStatus: 201, blockInDebtorsPrison: true }); this.moneyHistory = this._wrapWithUser((userId, req) => { let { page, filter } = req.body; if (!page) page = 1; @@ -66,11 +66,11 @@ class FalukantController { this.buyStorage = this._wrapWithUser((userId, req) => { const { branchId, amount, stockTypeId } = req.body; return this.service.buyStorage(userId, branchId, amount, stockTypeId); - }, { successStatus: 201 }); + }, { successStatus: 201, blockInDebtorsPrison: true }); this.sellStorage = this._wrapWithUser((userId, req) => { const { branchId, amount, stockTypeId } = req.body; return this.service.sellStorage(userId, branchId, amount, stockTypeId); - }, { successStatus: 202 }); + }, { successStatus: 202, blockInDebtorsPrison: true }); this.getStockTypes = this._wrapSimple(() => this.service.getStockTypes()); this.getStockOverview = this._wrapSimple(() => this.service.getStockOverview()); @@ -80,18 +80,18 @@ class FalukantController { console.log('🔍 getDirectorProposals called with userId:', userId, 'branchId:', req.body.branchId); return this.service.getDirectorProposals(userId, req.body.branchId); }); - this.convertProposalToDirector = this._wrapWithUser((userId, req) => this.service.convertProposalToDirector(userId, req.body.proposalId)); + this.convertProposalToDirector = this._wrapWithUser((userId, req) => this.service.convertProposalToDirector(userId, req.body.proposalId), { blockInDebtorsPrison: true }); this.getDirectorForBranch = this._wrapWithUser((userId, req) => this.service.getDirectorForBranch(userId, req.params.branchId)); this.getAllDirectors = this._wrapWithUser((userId) => this.service.getAllDirectors(userId)); this.updateDirector = this._wrapWithUser((userId, req) => { const { directorId, income } = req.body; return this.service.updateDirector(userId, directorId, income); - }); + }, { blockInDebtorsPrison: true }); this.setSetting = this._wrapWithUser((userId, req) => { const { branchId, directorId, settingKey, value } = req.body; return this.service.setSetting(userId, branchId, directorId, settingKey, value); - }); + }, { blockInDebtorsPrison: true }); this.getFamily = this._wrapWithUser(async (userId) => { const result = await this.service.getFamily(userId); @@ -99,9 +99,9 @@ class FalukantController { return result; }); this.getPotentialHeirs = this._wrapWithUser((userId) => this.service.getPotentialHeirs(userId)); - this.selectHeir = this._wrapWithUser((userId, req) => this.service.selectHeir(userId, req.body.heirId)); - this.setHeir = this._wrapWithUser((userId, req) => this.service.setHeir(userId, req.body.childCharacterId)); - this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId)); + 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.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId), { blockInDebtorsPrison: true }); this.cancelWooing = this._wrapWithUser(async (userId) => { try { return await this.service.cancelWooing(userId); @@ -111,25 +111,25 @@ class FalukantController { } throw e; } - }, { successStatus: 202 }); + }, { successStatus: 202, blockInDebtorsPrison: true }); this.getGifts = this._wrapWithUser((userId) => { console.log('🔍 getGifts called with userId:', userId); return this.service.getGifts(userId); }); this.setLoverMaintenance = this._wrapWithUser((userId, req) => - this.service.setLoverMaintenance(userId, req.params.relationshipId, req.body?.maintenanceLevel)); + this.service.setLoverMaintenance(userId, req.params.relationshipId, req.body?.maintenanceLevel), { blockInDebtorsPrison: true }); this.createLoverRelationship = this._wrapWithUser((userId, req) => - this.service.createLoverRelationship(userId, req.body?.targetCharacterId, req.body?.loverRole), { successStatus: 201 }); + this.service.createLoverRelationship(userId, req.body?.targetCharacterId, req.body?.loverRole), { successStatus: 201, blockInDebtorsPrison: true }); this.spendTimeWithSpouse = this._wrapWithUser((userId) => - this.service.spendTimeWithSpouse(userId)); + this.service.spendTimeWithSpouse(userId), { blockInDebtorsPrison: true }); this.giftToSpouse = this._wrapWithUser((userId, req) => - this.service.giftToSpouse(userId, req.body?.giftLevel)); + this.service.giftToSpouse(userId, req.body?.giftLevel), { blockInDebtorsPrison: true }); this.reconcileMarriage = this._wrapWithUser((userId) => - this.service.reconcileMarriage(userId)); + this.service.reconcileMarriage(userId), { blockInDebtorsPrison: true }); this.acknowledgeLover = this._wrapWithUser((userId, req) => - this.service.acknowledgeLover(userId, req.params.relationshipId)); + this.service.acknowledgeLover(userId, req.params.relationshipId), { blockInDebtorsPrison: true }); this.endLoverRelationship = this._wrapWithUser((userId, req) => - this.service.endLoverRelationship(userId, req.params.relationshipId)); + this.service.endLoverRelationship(userId, req.params.relationshipId), { blockInDebtorsPrison: true }); this.getChildren = this._wrapWithUser((userId) => this.service.getChildren(userId)); this.sendGift = this._wrapWithUser(async (userId, req) => { try { @@ -140,59 +140,59 @@ class FalukantController { } throw e; } - }); + }, { blockInDebtorsPrison: true }); this.getTitlesOfNobility = this._wrapWithUser((userId) => this.service.getTitlesOfNobility(userId)); this.getReputationActions = this._wrapWithUser((userId) => this.service.getReputationActions(userId)); this.executeReputationAction = this._wrapWithUser((userId, req) => - this.service.executeReputationAction(userId, req.body?.actionTypeId), { successStatus: 201 }); + this.service.executeReputationAction(userId, req.body?.actionTypeId), { successStatus: 201, blockInDebtorsPrison: true }); this.getHouseTypes = this._wrapWithUser((userId) => this.service.getHouseTypes(userId)); this.getMoodAffect = this._wrapWithUser((userId) => this.service.getMoodAffect(userId)); this.getCharacterAffect = this._wrapWithUser((userId) => this.service.getCharacterAffect(userId)); this.getUserHouse = this._wrapWithUser((userId) => this.service.getUserHouse(userId)); this.getBuyableHouses = this._wrapWithUser((userId) => this.service.getBuyableHouses(userId)); - this.buyUserHouse = this._wrapWithUser((userId, req) => this.service.buyUserHouse(userId, req.body.houseId), { successStatus: 201 }); - this.hireServants = this._wrapWithUser((userId, req) => this.service.hireServants(userId, req.body?.amount), { successStatus: 201 }); - this.dismissServants = this._wrapWithUser((userId, req) => this.service.dismissServants(userId, req.body?.amount)); - this.setServantPayLevel = this._wrapWithUser((userId, req) => this.service.setServantPayLevel(userId, req.body?.payLevel)); - this.tidyHousehold = this._wrapWithUser((userId) => this.service.tidyHousehold(userId)); + this.buyUserHouse = this._wrapWithUser((userId, req) => this.service.buyUserHouse(userId, req.body.houseId), { successStatus: 201, blockInDebtorsPrison: true }); + this.hireServants = this._wrapWithUser((userId, req) => this.service.hireServants(userId, req.body?.amount), { successStatus: 201, blockInDebtorsPrison: true }); + this.dismissServants = this._wrapWithUser((userId, req) => this.service.dismissServants(userId, req.body?.amount), { blockInDebtorsPrison: true }); + this.setServantPayLevel = this._wrapWithUser((userId, req) => this.service.setServantPayLevel(userId, req.body?.payLevel), { blockInDebtorsPrison: true }); + this.tidyHousehold = this._wrapWithUser((userId) => this.service.tidyHousehold(userId), { blockInDebtorsPrison: true }); 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); - }, { successStatus: 201 }); + }, { successStatus: 201, blockInDebtorsPrison: true }); this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId)); this.getNotBaptisedChildren = this._wrapWithUser((userId) => this.service.getNotBaptisedChildren(userId)); this.baptise = this._wrapWithUser((userId, req) => { const { characterId: childId, firstName } = req.body; return this.service.baptise(userId, childId, firstName); - }); + }, { blockInDebtorsPrison: true }); this.getChurchOverview = this._wrapWithUser((userId) => this.service.getChurchOverview(userId)); this.getAvailableChurchPositions = this._wrapWithUser((userId) => this.service.getAvailableChurchPositions(userId)); this.getSupervisedApplications = this._wrapWithUser((userId) => this.service.getSupervisedApplications(userId)); this.applyForChurchPosition = this._wrapWithUser((userId, req) => { const { officeTypeId, regionId } = req.body; return this.service.applyForChurchPosition(userId, officeTypeId, regionId); - }); + }, { blockInDebtorsPrison: true }); this.decideOnChurchApplication = this._wrapWithUser((userId, req) => { const { applicationId, decision } = req.body; return this.service.decideOnChurchApplication(userId, applicationId, decision); - }); + }, { blockInDebtorsPrison: true }); this.getEducation = this._wrapWithUser((userId) => this.service.getEducation(userId)); this.sendToSchool = this._wrapWithUser((userId, req) => { const { item, student, studentId } = req.body; return this.service.sendToSchool(userId, item, student, studentId); - }); + }, { blockInDebtorsPrison: true }); this.getBankOverview = this._wrapWithUser((userId) => this.service.getBankOverview(userId)); this.getBankCredits = this._wrapWithUser((userId) => this.service.getBankCredits(userId)); - this.takeBankCredits = this._wrapWithUser((userId, req) => this.service.takeBankCredits(userId, req.body.height)); + this.takeBankCredits = this._wrapWithUser((userId, req) => this.service.takeBankCredits(userId, req.body.height), { blockInDebtorsPrison: true }); this.getNobility = this._wrapWithUser((userId) => this.service.getNobility(userId)); - this.advanceNobility = this._wrapWithUser((userId) => this.service.advanceNobility(userId)); + this.advanceNobility = this._wrapWithUser((userId) => this.service.advanceNobility(userId), { blockInDebtorsPrison: true }); this.getHealth = this._wrapWithUser((userId) => this.service.getHealth(userId)); this.healthActivity = this._wrapWithUser(async (userId, req) => { @@ -204,13 +204,13 @@ class FalukantController { } throw e; } - }); + }, { blockInDebtorsPrison: true }); this.getPoliticsOverview = this._wrapWithUser((userId) => this.service.getPoliticsOverview(userId)); this.getOpenPolitics = this._wrapWithUser((userId) => this.service.getOpenPolitics(userId)); this.getElections = this._wrapWithUser((userId) => this.service.getElections(userId)); - this.vote = this._wrapWithUser((userId, req) => this.service.vote(userId, req.body.votes)); - this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds)); + this.vote = this._wrapWithUser((userId, req) => this.service.vote(userId, req.body.votes), { blockInDebtorsPrison: true }); + this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds), { blockInDebtorsPrison: true }); this.getRegions = this._wrapWithUser((userId) => this.service.getRegions(userId)); this.getBranchTaxes = this._wrapWithUser((userId, req) => this.service.getBranchTaxes(userId, req.params.branchId)); @@ -248,8 +248,8 @@ class FalukantController { })).filter(i => !Number.isNaN(i.productId) && !Number.isNaN(i.currentPrice)); return this.service.getProductPricesInCitiesBatch(userId, valid, Number.isNaN(currentRegionId) ? null : currentRegionId); }); - this.renovate = this._wrapWithUser((userId, req) => this.service.renovate(userId, req.body.element)); - this.renovateAll = this._wrapWithUser((userId) => this.service.renovateAll(userId)); + this.renovate = this._wrapWithUser((userId, req) => this.service.renovate(userId, req.body.element), { blockInDebtorsPrison: true }); + this.renovateAll = this._wrapWithUser((userId) => this.service.renovateAll(userId), { blockInDebtorsPrison: true }); this.getUndergroundTypes = this._wrapWithUser((userId) => this.service.getUndergroundTypes(userId)); this.getUndergroundActivities = this._wrapWithUser((userId) => this.service.getUndergroundActivities(userId)); @@ -277,7 +277,7 @@ class FalukantController { throw { status: 400, message: 'goal is required for corrupt_politician' }; } return this.service.createUndergroundActivity(userId, payload); - }, { successStatus: 201 }); + }, { successStatus: 201, blockInDebtorsPrison: true }); this.getUndergroundAttacks = this._wrapWithUser((userId, req) => { const direction = (req.query.direction || '').toLowerCase(); @@ -291,14 +291,14 @@ class FalukantController { this.getVehicleTypes = this._wrapWithUser((userId) => this.service.getVehicleTypes(userId)); this.buyVehicles = this._wrapWithUser( (userId, req) => this.service.buyVehicles(userId, req.body), - { successStatus: 201 } + { successStatus: 201, blockInDebtorsPrison: true } ); this.getVehicles = this._wrapWithUser( (userId, req) => this.service.getVehicles(userId, req.query.regionId) ); this.createTransport = this._wrapWithUser( (userId, req) => this.service.createTransport(userId, req.body), - { successStatus: 201 } + { successStatus: 201, blockInDebtorsPrison: true } ); this.getTransportRoute = this._wrapWithUser( (userId, req) => this.service.getTransportRoute(userId, req.query) @@ -308,23 +308,26 @@ class FalukantController { ); this.repairVehicle = this._wrapWithUser( (userId, req) => this.service.repairVehicle(userId, req.params.vehicleId), - { successStatus: 200 } + { successStatus: 200, blockInDebtorsPrison: true } ); this.repairAllVehicles = this._wrapWithUser( (userId, req) => this.service.repairAllVehicles(userId, req.body.vehicleIds), - { successStatus: 200 } + { successStatus: 200, blockInDebtorsPrison: true } ); } - _wrapWithUser(fn, { successStatus = 200, postProcess } = {}) { + _wrapWithUser(fn, { successStatus = 200, postProcess, blockInDebtorsPrison = false } = {}) { return async (req, res) => { try { const hashedUserId = extractHashedUserId(req); if (!hashedUserId) { return res.status(400).json({ error: 'Missing user identifier' }); } + if (blockInDebtorsPrison) { + await this.service.assertActionAllowedOutsideDebtorsPrison(hashedUserId); + } const result = await fn(hashedUserId, req, res); const toSend = postProcess ? postProcess(result) : result; res.status(successStatus).json(toSend); diff --git a/backend/migrations/20260323010000-expand-debtors-prism.cjs b/backend/migrations/20260323010000-expand-debtors-prism.cjs new file mode 100644 index 0000000..d45c342 --- /dev/null +++ b/backend/migrations/20260323010000-expand-debtors-prism.cjs @@ -0,0 +1,83 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + const table = { schema: 'falukant_data', tableName: 'debtors_prism' }; + + await queryInterface.addColumn(table, 'status', { + type: Sequelize.STRING, + allowNull: false, + defaultValue: 'delinquent' + }); + + await queryInterface.addColumn(table, 'entered_at', { + type: Sequelize.DATE, + allowNull: true + }); + + await queryInterface.addColumn(table, 'released_at', { + type: Sequelize.DATE, + allowNull: true + }); + + await queryInterface.addColumn(table, 'debt_at_entry', { + type: Sequelize.DECIMAL(14, 2), + allowNull: true + }); + + await queryInterface.addColumn(table, 'remaining_debt', { + type: Sequelize.DECIMAL(14, 2), + allowNull: true + }); + + await queryInterface.addColumn(table, 'days_overdue', { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0 + }); + + await queryInterface.addColumn(table, 'reason', { + type: Sequelize.STRING, + allowNull: true + }); + + await queryInterface.addColumn(table, 'creditworthiness_penalty', { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0 + }); + + await queryInterface.addColumn(table, 'next_forced_action', { + type: Sequelize.STRING, + allowNull: true + }); + + await queryInterface.addColumn(table, 'assets_seized_json', { + type: Sequelize.JSONB, + allowNull: true + }); + + await queryInterface.addColumn(table, 'public_known', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }); + }, + + async down(queryInterface) { + const table = { schema: 'falukant_data', tableName: 'debtors_prism' }; + + await queryInterface.removeColumn(table, 'public_known'); + await queryInterface.removeColumn(table, 'assets_seized_json'); + await queryInterface.removeColumn(table, 'next_forced_action'); + await queryInterface.removeColumn(table, 'creditworthiness_penalty'); + await queryInterface.removeColumn(table, 'reason'); + await queryInterface.removeColumn(table, 'days_overdue'); + await queryInterface.removeColumn(table, 'remaining_debt'); + await queryInterface.removeColumn(table, 'debt_at_entry'); + await queryInterface.removeColumn(table, 'released_at'); + await queryInterface.removeColumn(table, 'entered_at'); + await queryInterface.removeColumn(table, 'status'); + } +}; diff --git a/backend/models/falukant/data/debtors_prism.js b/backend/models/falukant/data/debtors_prism.js index 99a01f4..dc3a291 100644 --- a/backend/models/falukant/data/debtors_prism.js +++ b/backend/models/falukant/data/debtors_prism.js @@ -7,7 +7,57 @@ DebtorsPrism.init({ // Verknüpfung auf FalukantCharacter characterId: { type: DataTypes.INTEGER, - allowNull: false}}, { + allowNull: false + }, + status: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'delinquent' + }, + enteredAt: { + type: DataTypes.DATE, + allowNull: true + }, + releasedAt: { + type: DataTypes.DATE, + allowNull: true + }, + debtAtEntry: { + type: DataTypes.DECIMAL(14, 2), + allowNull: true + }, + remainingDebt: { + type: DataTypes.DECIMAL(14, 2), + allowNull: true + }, + daysOverdue: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0 + }, + reason: { + type: DataTypes.STRING, + allowNull: true + }, + creditworthinessPenalty: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0 + }, + nextForcedAction: { + type: DataTypes.STRING, + allowNull: true + }, + assetsSeizedJson: { + type: DataTypes.JSONB, + allowNull: true + }, + publicKnown: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + } +}, { sequelize, modelName: 'DebtorsPrism', tableName: 'debtors_prism', diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index 152427c..9a775d1 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -48,6 +48,7 @@ import ChildRelation from '../models/falukant/data/child_relation.js'; import Learning from '../models/falukant/data/learning.js'; import LearnRecipient from '../models/falukant/type/learn_recipient.js'; import Credit from '../models/falukant/data/credit.js'; +import DebtorsPrism from '../models/falukant/data/debtors_prism.js'; import TitleRequirement from '../models/falukant/type/title_requirement.js'; import HealthActivity from '../models/falukant/log/health_activity.js'; import Election from '../models/falukant/data/election.js'; @@ -808,6 +809,7 @@ class FalukantService extends BaseService { } if (userHouse) user.setDataValue('userHouse', userHouse); } + user.setDataValue('debtorsPrison', await this.getDebtorsPrisonStateForUser(user)); return user; } @@ -865,6 +867,7 @@ class FalukantService extends BaseService { } if (userHouse) u.setDataValue('userHouse', userHouse); if (u.character?.birthdate) u.character.setDataValue('age', calcAge(u.character.birthdate)); + u.setDataValue('debtorsPrison', await this.getDebtorsPrisonStateForUser(u)); return u; } @@ -1026,6 +1029,8 @@ class FalukantService extends BaseService { falukantUser.setDataValue('unreadNotifications', 0); } + falukantUser.setDataValue('debtorsPrison', await this.getDebtorsPrisonStateForUser(falukantUser)); + return falukantUser; } @@ -3278,6 +3283,7 @@ class FalukantService extends BaseService { householdTension: householdTension.label, householdTensionScore: householdTension.score, householdTensionReasons: householdTension.reasons, + debtorsPrison: await this.getDebtorsPrisonStateForUser(user), lovers, deathPartners: relationships.filter(r => r.relationshipType === 'widowed'), children: children.map(({ _createdAt, ...rest }) => rest), @@ -4385,6 +4391,7 @@ class FalukantService extends BaseService { const plainHouse = userHouse.get({ plain: true }); plainHouse.servantSummary = this.buildServantSummary(plainHouse, falukantUser.character); + plainHouse.debtorsPrison = await this.getDebtorsPrisonStateForUser(falukantUser); return plainHouse; } catch (error) { console.log(error); @@ -5121,6 +5128,115 @@ class FalukantService extends BaseService { return true; } + deriveDebtNextForcedAction(status, daysOverdue) { + if (status === 'imprisoned') return 'asset_seizure'; + if (daysOverdue >= 3) return 'debtors_prison'; + if (daysOverdue === 2) return 'final_warning'; + if (daysOverdue === 1) return 'reminder'; + return null; + } + + calculateCreditworthinessFromDebtState(record) { + if (!record) return 100; + + const penalty = Number(record.creditworthinessPenalty || 0); + const daysOverdue = Number(record.daysOverdue || 0); + + if (record.status === 'imprisoned') { + return Math.max(0, 20 - penalty); + } + if (record.status === 'delinquent') { + return Math.max(15, 100 - (daysOverdue * 20) - penalty); + } + return Math.max(0, 80 - penalty); + } + + serializeDebtorsPrisonRecord(record, totalDebt = 0) { + if (!record) { + return { + active: false, + inDebtorsPrison: false, + status: null, + daysOverdue: 0, + debtAtEntry: null, + remainingDebt: Number(totalDebt || 0), + reason: null, + creditworthinessPenalty: 0, + creditworthiness: 100, + nextForcedAction: null, + publicKnown: false, + enteredAt: null, + releasedAt: null, + assetsSeized: null + }; + } + + const remainingDebt = Number(record.remainingDebt ?? totalDebt ?? 0); + const debtState = { + active: record.status !== 'released' && !record.releasedAt, + inDebtorsPrison: record.status === 'imprisoned' && !record.releasedAt, + status: record.status || null, + daysOverdue: Number(record.daysOverdue || 0), + debtAtEntry: record.debtAtEntry != null ? Number(record.debtAtEntry) : null, + remainingDebt, + reason: record.reason || null, + creditworthinessPenalty: Number(record.creditworthinessPenalty || 0), + nextForcedAction: record.nextForcedAction || this.deriveDebtNextForcedAction(record.status, Number(record.daysOverdue || 0)), + publicKnown: !!record.publicKnown, + enteredAt: record.enteredAt || null, + releasedAt: record.releasedAt || null, + assetsSeized: record.assetsSeizedJson || null + }; + debtState.creditworthiness = this.calculateCreditworthinessFromDebtState(debtState); + return debtState; + } + + async getDebtorsPrisonStateForUser(falukantUser) { + if (!falukantUser?.id) { + return this.serializeDebtorsPrisonRecord(null); + } + + const character = falukantUser.character || await FalukantCharacter.findOne({ + where: { userId: falukantUser.id }, + attributes: ['id'] + }); + + if (!character?.id) { + return this.serializeDebtorsPrisonRecord(null); + } + + const records = await DebtorsPrism.findAll({ + where: { characterId: character.id }, + order: [['createdAt', 'DESC']] + }); + + const activeRecord = records.find((record) => record.status !== 'released' && !record.releasedAt) + || records[0] + || null; + + const totalDebt = await Credit.sum('remaining_amount', { + where: { falukant_user_id: falukantUser.id } + }) || 0; + + return this.serializeDebtorsPrisonRecord(activeRecord, totalDebt); + } + + async assertActionAllowedOutsideDebtorsPrison(hashedUserId) { + const falukantUser = await getFalukantUserOrFail(hashedUserId); + const debtorsPrison = await this.getDebtorsPrisonStateForUser(falukantUser); + + if (debtorsPrison.inDebtorsPrison) { + throw { + status: 423, + message: 'Aktion im Schuldturm gesperrt', + code: 'falukant.debtorsPrison.actionBlocked', + debtorsPrison + }; + } + + return { falukantUser, debtorsPrison }; + } + async getBankOverview(hashedUserId) { const falukantUser = await getFalukantUserOrFail(hashedUserId); if (!falukantUser) throw new Error('User not found'); @@ -5149,9 +5265,14 @@ class FalukantService extends BaseService { const branchCount = await Branch.count({ where: { falukantUserId: falukantUser.id } }); const branchValue = branchCount * 1000; + const debtorsPrison = await this.getDebtorsPrisonStateForUser(falukantUser); + // 5) Maximaler Kredit und verfügbare Linie const maxCredit = Math.floor(houseValue + branchValue); - const availableCredit = maxCredit - totalDebt; + const availableCreditRaw = maxCredit - totalDebt; + const availableCredit = debtorsPrison.inDebtorsPrison + ? 0 + : Math.max(0, availableCreditRaw); // 6) aktive Kredite laden const activeCredits = await Credit.findAll({ @@ -5165,7 +5286,12 @@ class FalukantService extends BaseService { maxCredit, availableCredit, activeCredits, - fee: 7 + fee: 7, + inDebtorsPrison: debtorsPrison.inDebtorsPrison, + daysOverdue: debtorsPrison.daysOverdue, + nextForcedAction: debtorsPrison.nextForcedAction, + creditworthiness: debtorsPrison.creditworthiness, + debtorsPrison }; } @@ -5182,6 +5308,9 @@ class FalukantService extends BaseService { const falukantUser = await getFalukantUserOrFail(hashedUserId); if (!falukantUser) throw new Error('User not found'); const financialData = await this.getBankOverview(hashedUserId); + if (financialData.inDebtorsPrison) { + throw new Error('debtorPrisonBlocksCredit'); + } if (financialData.availableCredit < height) { throw new Error('Not enough credit'); } @@ -6454,7 +6583,7 @@ ORDER BY r.id`, const characterName = titleLabelTr ? [titleLabelTr, firstName, lastName].filter(Boolean).join(' ') : nameWithoutTitle; const age = character.birthdate ? calcAge(character.birthdate) : null; - const [unreadNotificationsCount, childrenCount] = await Promise.all([ + const [unreadNotificationsCount, childrenCount, debtorsPrison] = await Promise.all([ Notification.count({ where: { userId: falukantUser.id, shown: false } }), ChildRelation.count({ where: { @@ -6463,7 +6592,8 @@ ORDER BY r.id`, { motherCharacterId: character.id } ] } - }) + }), + this.getDebtorsPrisonStateForUser(falukantUser) ]); return { @@ -6474,7 +6604,8 @@ ORDER BY r.id`, age, money: Number(falukantUser.money ?? 0), unreadNotificationsCount, - childrenCount + childrenCount, + debtorsPrison }; } diff --git a/backend/sql/expand_debtors_prism.sql b/backend/sql/expand_debtors_prism.sql new file mode 100644 index 0000000..30d80c4 --- /dev/null +++ b/backend/sql/expand_debtors_prism.sql @@ -0,0 +1,32 @@ +ALTER TABLE falukant_data.debtors_prism +ADD COLUMN IF NOT EXISTS status varchar(255) NOT NULL DEFAULT 'delinquent'; + +ALTER TABLE falukant_data.debtors_prism +ADD COLUMN IF NOT EXISTS entered_at timestamp with time zone NULL; + +ALTER TABLE falukant_data.debtors_prism +ADD COLUMN IF NOT EXISTS released_at timestamp with time zone NULL; + +ALTER TABLE falukant_data.debtors_prism +ADD COLUMN IF NOT EXISTS debt_at_entry numeric(14,2) NULL; + +ALTER TABLE falukant_data.debtors_prism +ADD COLUMN IF NOT EXISTS remaining_debt numeric(14,2) NULL; + +ALTER TABLE falukant_data.debtors_prism +ADD COLUMN IF NOT EXISTS days_overdue integer NOT NULL DEFAULT 0; + +ALTER TABLE falukant_data.debtors_prism +ADD COLUMN IF NOT EXISTS reason varchar(255) NULL; + +ALTER TABLE falukant_data.debtors_prism +ADD COLUMN IF NOT EXISTS creditworthiness_penalty integer NOT NULL DEFAULT 0; + +ALTER TABLE falukant_data.debtors_prism +ADD COLUMN IF NOT EXISTS next_forced_action varchar(255) NULL; + +ALTER TABLE falukant_data.debtors_prism +ADD COLUMN IF NOT EXISTS assets_seized_json jsonb NULL; + +ALTER TABLE falukant_data.debtors_prism +ADD COLUMN IF NOT EXISTS public_known boolean NOT NULL DEFAULT false; diff --git a/docs/FALUKANT_DEBTORS_PRISON_DAEMON_SPEC.md b/docs/FALUKANT_DEBTORS_PRISON_DAEMON_SPEC.md new file mode 100644 index 0000000..18e4a88 --- /dev/null +++ b/docs/FALUKANT_DEBTORS_PRISON_DAEMON_SPEC.md @@ -0,0 +1,447 @@ +# Falukant: Schuldturm und Pfändung - Daemon-Spezifikation + +Dieses Dokument beschreibt die Umsetzung des **Schuldturm-Systems** im externen Daemon. + +Wichtig: + +- Die projektseitigen DB-Felder, API-Erweiterungen, UI-Warnungen und Aktionssperren sind bereits umgesetzt. +- Der Daemon ist die führende Quelle für: + - Verzugstage + - Eintritt in den Schuldturm + - Pfändung und Verwertung + - soziale Folgen + - Freilassung + +## 1. Bereits vorhandene Datenbasis + +Bereits im Projekt vorhanden: + +- `falukant_data.credit` +- `falukant_data.debtors_prism` +- `falukant_data.user_house` + - inkl. `household_tension_score` + - inkl. `household_tension_reasons_json` +- Familien-/Liebschaftsdaten in: + - `falukant_data.relationship` + - `falukant_data.relationship_state` + - `falukant_data.child_relation` + +Bereits erweitert: + +- `debtors_prism.status` +- `debtors_prism.entered_at` +- `debtors_prism.released_at` +- `debtors_prism.debt_at_entry` +- `debtors_prism.remaining_debt` +- `debtors_prism.days_overdue` +- `debtors_prism.reason` +- `debtors_prism.creditworthiness_penalty` +- `debtors_prism.next_forced_action` +- `debtors_prism.assets_seized_json` +- `debtors_prism.public_known` + +Es sind für den Daemon derzeit keine weiteren DB-Änderungen nötig. + +## 2. Grundregel + +Ein Charakter kommt in den Schuldturm, wenn: + +- mindestens ein aktiver Kredit offen ist +- fällige Kreditbedienung ausbleibt +- und `days_overdue >= 3` + +Der Daemon prüft dies im Daily-Tick. + +## 3. Zustände + +`debtors_prism.status` verwendet mindestens: + +- `delinquent` +- `imprisoned` +- `released` + +Bedeutung: + +- `delinquent`: Kreditverzug, aber noch nicht im Schuldturm +- `imprisoned`: im Schuldturm, Verwertung läuft +- `released`: historischer abgeschlossener Fall + +## 4. Daily-Tick + +Der Daily-Tick prüft pro Falukant-Nutzer: + +1. aktive Kredite +2. verbleibende Schuld +3. geleistete Bedienung seit letztem Tick +4. neue Verzugstage +5. Schuldturm-Eintritt +6. laufende soziale Folgen +7. Verwertungsschritt + +### 4.1 Verzugstage + +Regel: + +- wenn offene Schuld vorhanden und fällige Bedienung ausbleibt: + - `days_overdue += 1` +- wenn Kreditpflicht erfüllt wurde: + - `days_overdue = 0` + - falls nicht im Schuldturm + +Wenn noch kein aktiver `debtors_prism`-Eintrag existiert: + +- bei erstem Verzug `debtors_prism` anlegen mit + - `status = 'delinquent'` + - `days_overdue = 1` + - `remaining_debt = aktuelle offene Schuld` + - `next_forced_action = 'reminder'` + +### 4.2 Warnstufen + +Bei Verzug: + +- Tag 1: + - `next_forced_action = 'reminder'` + - Event `falukantUpdateDebt` mit `reason: 'delinquency'` +- Tag 2: + - `next_forced_action = 'final_warning'` + - Event `falukantUpdateDebt` mit `reason: 'delinquency'` +- Tag 3: + - Schuldturm-Eintritt + +Für Warnstufen senden: + +- `falukantUpdateDebt` +- zusätzlich `falukantUpdateStatus` + +## 5. Eintritt in den Schuldturm + +Bei `days_overdue >= 3`: + +- `status = 'imprisoned'` +- `entered_at = now()` +- `released_at = null` +- `debt_at_entry = aktuelle offene Schuld` +- `remaining_debt = aktuelle offene Schuld` +- `reason = 'credit_default'` +- `creditworthiness_penalty += 45` +- `next_forced_action = 'asset_seizure'` +- `public_known = true` + +### 5.1 Sofortfolgen bei Eintritt + +Einmalig anwenden: + +- Reputation deutlich senken + - Empfehlung: `-12` +- `marriage_satisfaction` senken + - Empfehlung: `-10` +- `household_tension_score` erhöhen + - Empfehlung: `+15` +- `household_tension_reasons_json` um `debtorsPrison` ergänzen + +Zusätzlich: + +- aktive Liebhaber/Mätressen sichtbar destabilisieren + - mindestens `affection -= 4` +- Kreditaufnahme und aktive Falukant-Aktionen bleiben projektseitig bereits gesperrt + +Bei Eintritt senden: + +- `falukantUpdateDebt` + - `reason: 'debtors_prison_entered'` +- `falukantUpdateStatus` +- `falukantUpdateFamily` + - `reason: 'daily'` +- `falukantHouseUpdate` +- `falukantBranchUpdate` + +## 6. Verwertung / Pfändung + +Die Verwertung läuft nicht alles auf einmal, sondern schrittweise pro Tick. + +Reihenfolge: + +1. freies Geld +2. Fahrzeuge +3. Waren / Lagerbestände +4. Haus +5. Niederlassungen + +Ziel: + +- `remaining_debt` schrittweise senken +- Fortschritt im UI sichtbar machen + +### 6.1 Geld + +Wenn `falukant_user.money > 0`: + +- direkt zur Schuld tilgen +- `remaining_debt -= eingezogener_betrag` + +Events: + +- `falukantUpdateDebt` + - `reason: 'asset_seizure'` +- `falukantUpdateStatus` + +### 6.2 Fahrzeuge + +Verkaufe zuerst: + +- freie Fahrzeuge +- dann weniger wertvolle Typen +- keine Fahrzeuge in aktiven Transporten im selben Tick anfassen, falls technisch problematisch + +Erlös: + +- Empfehlung: `vehicle_type.cost * condition_factor * 0.55` + +Zusätzlich in `assets_seized_json` protokollieren: + +- Typ +- Anzahl +- Erlös + +Events: + +- `falukantUpdateDebt` + - `reason: 'vehicle_liquidation'` +- `falukantUpdateStatus` + +### 6.3 Waren / Lager + +Verwertbare Güter: + +- Lagerbestände +- Inventar +- handelbare Waren + +Erlös: + +- Empfehlung: Marktwert mit Abschlag von `35% bis 50%` + +Events: + +- `falukantUpdateDebt` + - `reason: 'asset_seizure'` +- `falukantUpdateStatus` +- `falukantBranchUpdate` + +### 6.4 Haus + +Wenn Restschuld nach Geld/Fahrzeugen/Waren weiter hoch ist: + +- Haus pfänden +- Spieler auf niedrigeres Haus oder Minimalhaus zurücksetzen +- Dienerschaft reduzieren +- `household_order` senken + +Events: + +- `falukantUpdateDebt` + - `reason: 'house_seizure'` +- `falukantHouseUpdate` +- `falukantUpdateStatus` +- `falukantUpdateFamily` + - `reason: 'daily'` + +### 6.5 Niederlassungen + +Wenn weiter nicht gedeckt: + +- Niederlassungen schließen +- zuerst niedrige Stufe / niedriger Wert +- Hauptniederlassung nur als letzter Schritt + +Events: + +- `falukantUpdateDebt` + - `reason: 'branch_closure'` +- `falukantBranchUpdate` +- `falukantUpdateStatus` + +## 7. Laufende soziale Folgen im Schuldturm + +Solange `status = 'imprisoned'`: + +- täglicher Reputationsmalus + - Empfehlung: `-2` +- zusätzliche `creditworthiness_penalty += 1` pro Tag +- `marriage_satisfaction -= 1` +- `household_tension_score += 2` + +Wenn aktive Liebschaften bestehen: + +- `affection -= 2` +- bei niedriger Zuneigung oder hoher Sichtbarkeit kann Beziehung enden + +Empfohlene Absprungregel: + +- wenn `affection <= 30` oder `months_underfunded >= 2` + - Chance auf Beziehungsende prüfen +- bei repräsentativen Beziehungen zusätzlich höhere Absprungchance, wenn `public_known = true` + +Events bei sozialen Folgewirkungen: + +- `falukantUpdateFamily` + - `reason: 'daily'` +- zusätzlich `falukantUpdateStatus` + +## 8. Kreditwürdigkeit + +Die UI rechnet bereits aus `creditworthiness_penalty` und Status einen sichtbaren Wert. + +Der Daemon muss pflegen: + +- `creditworthiness_penalty` +- `status` +- `days_overdue` + +Empfehlung: + +- Eintritt Schuldturm: `+45` +- pro weiterem Hafttag: `+1` +- Hauspfändung: zusätzlich `+10` +- Niederlassungsschließung: zusätzlich `+8` + +## 9. Freilassung + +Freilassung, wenn: + +- keine relevante Restschuld mehr offen ist + oder +- ein definierter Restwert unterschritten wird, falls ihr einen Bagatellgrenzwert wollt + +Dann: + +- `status = 'released'` +- `released_at = now()` +- `next_forced_action = null` +- `days_overdue = 0` +- `remaining_debt = 0` + +Events: + +- `falukantUpdateDebt` + - `reason: 'debtors_prison_released'` +- `falukantUpdateStatus` +- `falukantUpdateFamily` + - `reason: 'daily'` +- `falukantHouseUpdate` +- `falukantBranchUpdate` + +Keine automatische vollständige soziale Heilung: + +- Reputation bleibt reduziert +- Kreditwürdigkeit bleibt reduziert +- Familie/Haus bleiben in Folgezuständen + +## 10. Event-Kommunikation zur UI + +Der Daemon sendet als Primärevent: + +```json +{ + "event": "falukantUpdateDebt", + "user_id": 123, + "reason": "delinquency" +} +``` + +Mögliche `reason`: + +- `delinquency` +- `debtors_prison_entered` +- `asset_seizure` +- `vehicle_liquidation` +- `house_seizure` +- `branch_closure` +- `debtors_prison_released` + +### 10.1 Begleitevents + +Je nach Folge zusätzlich: + +- `falukantUpdateStatus` +- `falukantHouseUpdate` +- `falukantBranchUpdate` +- `falukantUpdateFamily` + +### 10.2 Empfohlene Minimalregeln + +- `delinquency`: + - `falukantUpdateDebt` + - `falukantUpdateStatus` +- `debtors_prison_entered`: + - `falukantUpdateDebt` + - `falukantUpdateStatus` + - `falukantUpdateFamily` + - `falukantHouseUpdate` + - `falukantBranchUpdate` +- `asset_seizure`: + - `falukantUpdateDebt` + - `falukantUpdateStatus` + - optional `falukantBranchUpdate` +- `vehicle_liquidation`: + - `falukantUpdateDebt` + - `falukantUpdateStatus` +- `house_seizure`: + - `falukantUpdateDebt` + - `falukantUpdateStatus` + - `falukantHouseUpdate` + - `falukantUpdateFamily` +- `branch_closure`: + - `falukantUpdateDebt` + - `falukantUpdateStatus` + - `falukantBranchUpdate` +- `debtors_prison_released`: + - `falukantUpdateDebt` + - `falukantUpdateStatus` + - `falukantUpdateFamily` + - `falukantHouseUpdate` + - `falukantBranchUpdate` + +## 11. Idempotenz + +Der Worker muss idempotent arbeiten. + +Wichtig: + +- Eintritt in den Schuldturm nicht mehrfach für denselben aktiven Fall auslösen +- Verwertungsschritte nur einmal je Asset anwenden +- `released` nicht erneut freisetzen + +Empfehlung: + +- pro Tick Transaktion +- pro Nutzer eine klare Reihenfolge +- Änderungen in `assets_seized_json` protokollieren + +## 12. Mindestumsetzung für Version 1 + +Pflicht: + +1. Verzugstage pflegen +2. Eintritt nach 3 Tagen +3. Status und Penalty schreiben +4. Geld zuerst einziehen +5. danach Fahrzeuge +6. Events senden + +Danach: + +7. Hauspfändung +8. Niederlassungsschließung +9. volle Familienfolgen + +## 13. Hinweis an den Daemon + +Die projektseitigen Grundlagen sind bereits umgesetzt: + +- `debtors_prism` ist erweitert +- Bank-/Haus-/Familien-/Übersichts-UI reagiert auf den Status +- aktive Falukant-Aktionen werden im Backend bereits gesperrt, sobald `inDebtorsPrison = true` + +Der Daemon muss daher vor allem die Zustände und Folgen zuverlässig schreiben und die dokumentierten Events senden. diff --git a/docs/FALUKANT_UI_WEBSOCKET.md b/docs/FALUKANT_UI_WEBSOCKET.md index b91bcc9..f3abaf0 100644 --- a/docs/FALUKANT_UI_WEBSOCKET.md +++ b/docs/FALUKANT_UI_WEBSOCKET.md @@ -1,8 +1,9 @@ -# Falukant: UI-Anpassung – WebSocket & Familie / Liebschaften +# Falukant: UI-Anpassung - WebSocket & Familie / Liebschaften -Dieses Dokument beschreibt die Nachrichten, die der externe Falukant-Daemon für Familien-, Ehe- und Liebschaftsänderungen sendet, damit die UI gezielt reagieren kann. +Dieses Dokument beschreibt die Nachrichten, die der externe Falukant-Daemon über den WebSocket-Broadcast sendet, damit die UI gezielt reagieren kann. Transport: + - Alle Clients erhalten denselben Broadcast. - Die UI muss nach `user_id` filtern und nur Events für die eingeloggte Session verarbeiten. @@ -10,10 +11,13 @@ Transport: | `event` | Pflichtfelder | Typische UI-Reaktion | |---------|----------------|----------------------| -| `falukantUpdateFamily` | `user_id`, `reason` | Gezielter Refresh von Familie, Liebschaften und je nach `reason` auch Geld oder Ruf | -| `falukantUpdateStatus` | `user_id` | Allgemeiner Falukant-Status-/Spielstands-Refresh | -| `children_update` | `user_id` | Kinderliste und Familienansicht aktualisieren | -| `falukant_family_scandal_hint` | `relationship_id` | Optionaler Hinweis oder Logeintrag; kein `user_id` | +| `falukantUpdateFamily` | `user_id`, `reason` | Gezielter Refresh Familie/Liebe/Geld je nach `reason` | +| `falukantUpdateStatus` | `user_id` | Allgemeiner Status-/Spielstands-Refresh | +| `falukantUpdateProductionCertificate` | `user_id`, `reason`, `old_certificate`, `new_certificate` | Produkte / Produktions-UI / Zertifikat neu laden | +| `children_update` | `user_id` | Kinderliste / FamilyView aktualisieren | +| `falukant_family_scandal_hint` | `relationship_id` | Optionaler Toast oder Log; kein `user_id` | +| `falukantUpdateChurch` | `user_id`, `reason` | Kirchenämter, Bewerbungen, Ernennungen | +| `falukantUpdateDebt` | `user_id`, `reason` | Schuldturm, Verzug, Pfändung, Freilassung | ## 2. JSON-Payloads @@ -28,14 +32,32 @@ Transport: ``` `reason` ist immer genau einer dieser festen Strings: + - `daily` - `monthly` +- `lover_installment` - `scandal` - `lover_birth` -Es gibt keine weiteren `reason`-Werte. +### 2.2 `falukantUpdateChurch` -### 2.2 `falukantUpdateStatus` +```json +{ + "event": "falukantUpdateChurch", + "user_id": 123, + "reason": "applications" +} +``` + +Mögliche `reason`: + +- `applications` +- `npc_decision` +- `appointment` +- `vacancy_fill` +- `promotion` + +### 2.3 `falukantUpdateStatus` ```json { @@ -44,9 +66,21 @@ Es gibt keine weiteren `reason`-Werte. } ``` -Dieses Event wird typischerweise direkt nach `falukantUpdateFamily` mit derselben `user_id` gesendet. +Dieses Event wird typischerweise direkt nach einem fachlichen Falukant-Event mit derselben `user_id` gesendet. -### 2.3 `children_update` +### 2.4 `falukantUpdateProductionCertificate` + +```json +{ + "event": "falukantUpdateProductionCertificate", + "user_id": 123, + "reason": "daily_recalculation", + "old_certificate": 2, + "new_certificate": 3 +} +``` + +### 2.5 `children_update` ```json { @@ -56,10 +90,11 @@ Dieses Event wird typischerweise direkt nach `falukantUpdateFamily` mit derselbe ``` Dieses Event tritt bei Geburt aus einer Liebschaft auf, meist zusammen mit: + - `falukantUpdateFamily` mit `reason: "lover_birth"` - `falukantUpdateStatus` -### 2.4 `falukant_family_scandal_hint` +### 2.6 `falukant_family_scandal_hint` ```json { @@ -69,56 +104,118 @@ Dieses Event tritt bei Geburt aus einer Liebschaft auf, meist zusammen mit: ``` Hinweis: + - Dieses Event enthält kein `user_id`. - Die UI kann es ignorieren oder optional nur für Log-/Toast-Zwecke verwenden. - Die eigentliche nutzerbezogene Aktualisierung läuft über `falukantUpdateFamily` mit `reason: "scandal"`. +### 2.7 `falukantUpdateDebt` + +```json +{ + "event": "falukantUpdateDebt", + "user_id": 123, + "reason": "debtors_prison_entered" +} +``` + +Mögliche `reason`: + +- `delinquency` +- `debtors_prison_entered` +- `asset_seizure` +- `vehicle_liquidation` +- `house_seizure` +- `branch_closure` +- `debtors_prison_released` + ## 3. Fachliche Bedeutung von `reason` -### `reason: "daily"` +### 3.1 `falukantUpdateFamily` + +#### `reason: "daily"` `daily` ist der Sammelgrund für tägliche Änderungen im Familien- und Liebschaftssystem. Darunter fallen insbesondere: + - tägliche Drift und Änderung der Ehezufriedenheit +- `marriage_public_stability` +- `household_tension_score` - Ehe-Buffs und temporäre Zähler wie Geschenk-, Fest- oder Haus-Effekte - tägliche Liebschaftslogik für aktive Beziehungen - Rufverlust bei zwei oder mehr sichtbaren Liebschaften - Zufalls-Mali wie Gerücht oder Tadel Wichtig: + - Es gibt kein separates Event für „nur Ehe-Buff“. - Es gibt kein separates Event für „nur zwei sichtbare Liebschaften“. - Es gibt kein separates Event für „nur Gerücht/Tadel“. - Alles davon erscheint in der UI ausschließlich als `falukantUpdateFamily` mit `reason: "daily"`. -### `reason: "scandal"` +#### `reason: "monthly"` + +`monthly` steht für monatliche Verarbeitung, insbesondere: + +- Dienerschaftskosten +- laufende Kosten +- Unterversorgung +- Geldänderungen + +#### `reason: "lover_installment"` + +`lover_installment` steht für die 2-Stunden-Unterhaltsbelastung von Liebschaften. + +Die UI sollte dafür mindestens: + +- Geld neu laden +- Family-/Liebschaftsstatus neu laden + +#### `reason: "scandal"` `scandal` wird zusätzlich zu einem gelungenen Skandalwurf gesendet. Typischer Ablauf: + - optional `falukant_family_scandal_hint` - `falukantUpdateFamily` mit `reason: "scandal"` - `falukantUpdateStatus` Danach kann für denselben Nutzer am selben Tag zusätzlich noch `daily` folgen. -### `reason: "monthly"` - -`monthly` steht für Monatsverarbeitung, insbesondere: -- laufende Kosten -- Unterversorgung -- Geldänderungen - -### `reason: "lover_birth"` +#### `reason: "lover_birth"` `lover_birth` signalisiert ein neues Kind aus einer Liebschaft. Meist folgen zusammen: + - `falukantUpdateFamily` mit `reason: "lover_birth"` - `children_update` - `falukantUpdateStatus` +### 3.2 `falukantUpdateChurch` + +- `applications`: Spieler ist kirchlicher Vorgesetzter; offene Bewerbungen warten +- `npc_decision`: NPC-Vorgesetzter hat entschieden +- `appointment`: automatische Annahme älterer Bewerbung +- `vacancy_fill`: Interimsbesetzung +- `promotion`: reserviert / zukünftig + +### 3.3 `falukantUpdateProductionCertificate` + +- `daily_recalculation`: Zertifikat nach täglicher Prüfung geändert + +### 3.4 `falukantUpdateDebt` + +- `delinquency`: Mahnstufe oder Verzug aktualisiert +- `debtors_prison_entered`: Eintritt in den Schuldturm +- `asset_seizure`: Geld, Waren oder sonstige Vermögenswerte eingezogen +- `vehicle_liquidation`: Fahrzeuge zwangsverkauft +- `house_seizure`: Haus gepfändet +- `branch_closure`: Niederlassung geschlossen +- `debtors_prison_released`: Freilassung + ## 4. Empfohlene Handler-Logik ```text @@ -131,10 +228,22 @@ onMessage(json): refreshPlayerStatus() return + case "falukantUpdateProductionCertificate": + refreshProductsAndProductionUi() + return + case "children_update": refreshChildrenAndFamilyView() return + case "falukantUpdateChurch": + refreshChurchContextByReason(json.reason) + return + + case "falukantUpdateDebt": + refreshDebtAndAffectedViews(json.reason) + return + case "falukantUpdateFamily": switch json.reason: case "daily": @@ -145,6 +254,10 @@ onMessage(json): refreshMoney() refreshFamilyAndRelationships() break + case "lover_installment": + refreshMoney() + refreshFamilyAndRelationships() + break case "scandal": showScandalToastOptional() refreshFamilyAndRelationships() @@ -162,15 +275,23 @@ onMessage(json): ## 5. Deduplizierung -Am selben Tag kann ein Nutzer mehrere relevante Events erhalten, zum Beispiel: +Ein Nutzer kann kurz hintereinander mehrere relevante Events erhalten, zum Beispiel: + - `scandal` - danach `daily` - danach `falukantUpdateStatus` +oder: + +- `falukantUpdateDebt` +- direkt danach `falukantUpdateStatus` +- zusätzlich `falukantUpdateFamily` + Die UI sollte deshalb: + - Refreshes bündeln oder entprellen - idempotente Reloads verwenden -- nicht davon ausgehen, dass jeder fachliche Effekt einen eigenen `reason` hat +- nicht davon ausgehen, dass jeder fachliche Effekt einen eigenen Spezial-Eventpfad hat ## 6. Welche Daten sollten neu geladen werden? @@ -178,15 +299,18 @@ Die UI sollte deshalb: |-----------|--------------------| | Jede `falukantUpdateFamily` | Family-/Relationship-Daten neu laden | | `reason: "monthly"` | Family-Daten plus Geld/Status neu laden | +| `reason: "lover_installment"` | Geld plus Family-Daten neu laden | | `reason: "daily"` | Family-Daten neu laden, bei Bedarf auch Ruf-/Statusdaten | | `reason: "scandal"` | Family-Daten plus Ruf-/Statusdaten neu laden | | `children_update` / `lover_birth` | Kinderdaten und FamilyView neu laden | +| `falukantUpdateChurch` | Kirchenämter, Bewerbungen, freie Positionen je nach `reason` | +| `falukantUpdateProductionCertificate` | User-Status, Zertifikat, Produkte, Produktions-UI | +| `falukantUpdateDebt` | Bank, Overview, House, Branch, ggf. Family | ## 7. Sonderfälle | Fall | Verhalten | |------|-----------| -| NPC ohne `user_id` | Keine nutzerbezogenen Family-Socket-Events | +| NPC ohne `user_id` | Keine nutzerbezogenen Socket-Events | | Mehrere Events kurz hintereinander | Normal; UI sollte damit robust umgehen | -| Nur `falukantUpdateStatus` ohne Family-Event | Kann von anderen Falukant-Workern kommen | - +| Nur `falukantUpdateStatus` ohne Fach-Event | Kann von anderen Falukant-Workern kommen | diff --git a/frontend/src/components/DashboardWidget.vue b/frontend/src/components/DashboardWidget.vue index 4f54a40..b3e6433 100644 --- a/frontend/src/components/DashboardWidget.vue +++ b/frontend/src/components/DashboardWidget.vue @@ -108,7 +108,7 @@ export default { }, setupSocketListeners() { this.teardownSocketListeners(); - const daemonEvents = ['falukantUpdateStatus', 'falukantUpdateFamily', 'falukantUpdateChurch', 'children_update', 'falukantUpdateProductionCertificate', 'stock_change', 'familychanged']; + const daemonEvents = ['falukantUpdateStatus', 'falukantUpdateFamily', 'falukantUpdateChurch', 'falukantUpdateDebt', 'children_update', 'falukantUpdateProductionCertificate', 'stock_change', 'familychanged']; if (this.daemonSocket) { this._daemonMessageHandler = (event) => { if (event.data === 'ping') return; @@ -129,6 +129,9 @@ export default { this._churchSocketHandler = (data) => { if (this.matchesCurrentUser(data)) this.queueFetchData(); }; + this._debtSocketHandler = (data) => { + if (this.matchesCurrentUser(data)) this.queueFetchData(); + }; this._childrenSocketHandler = (data) => { if (this.matchesCurrentUser(data)) this.queueFetchData(); }; @@ -140,6 +143,7 @@ export default { this.socket.on('falukantUpdateStatus', this._statusSocketHandler); this.socket.on('falukantUpdateFamily', this._familySocketHandler); this.socket.on('falukantUpdateChurch', this._churchSocketHandler); + this.socket.on('falukantUpdateDebt', this._debtSocketHandler); this.socket.on('children_update', this._childrenSocketHandler); this.socket.on('falukantUpdateProductionCertificate', this._productionCertificateSocketHandler); this.socket.on('falukantBranchUpdate', this._branchSocketHandler); @@ -154,6 +158,7 @@ export default { if (this._statusSocketHandler) this.socket.off('falukantUpdateStatus', this._statusSocketHandler); if (this._familySocketHandler) this.socket.off('falukantUpdateFamily', this._familySocketHandler); if (this._churchSocketHandler) this.socket.off('falukantUpdateChurch', this._churchSocketHandler); + if (this._debtSocketHandler) this.socket.off('falukantUpdateDebt', this._debtSocketHandler); if (this._childrenSocketHandler) this.socket.off('children_update', this._childrenSocketHandler); if (this._productionCertificateSocketHandler) this.socket.off('falukantUpdateProductionCertificate', this._productionCertificateSocketHandler); if (this._branchSocketHandler) this.socket.off('falukantBranchUpdate', this._branchSocketHandler); diff --git a/frontend/src/components/falukant/BranchSelection.vue b/frontend/src/components/falukant/BranchSelection.vue index 81d1cdc..a99d9af 100644 --- a/frontend/src/components/falukant/BranchSelection.vue +++ b/frontend/src/components/falukant/BranchSelection.vue @@ -18,15 +18,19 @@
{{ debtStatusDescription }}
+ +| {{ $t('falukant.bank.account.availableCredit') }} | {{ formatCost(bankOverview.availableCredit) }} |
| {{ $t('falukant.bank.debtorsPrison.creditworthiness') }} | +{{ bankOverview.creditworthiness }} | +
{{ $t('falukant.bank.credits.payoff.total') }}: {{ formatCost(creditCost()) }}
-+ {{ $t('falukant.bank.debtorsPrison.creditBlocked') }} +
@@ -93,6 +112,7 @@ import StatusBar from '@/components/falukant/StatusBar.vue'; import SimpleTabs from '@/components/SimpleTabs.vue'; import apiClient from '@/utils/axios.js'; import { mapState } from 'vuex'; +import { showError } from '@/utils/feedback.js'; export default { name: 'BankView', @@ -118,34 +138,114 @@ export default { }; }, computed: { - ...mapState(['socket']) + ...mapState(['socket', 'daemonSocket', 'user']), + debtorsPrison() { + return this.bankOverview.debtorsPrison || { + active: false, + inDebtorsPrison: false, + daysOverdue: 0, + creditworthiness: 100, + nextForcedAction: null + }; + }, + isCreditBlocked() { + return this.debtorsPrison.inDebtorsPrison; + }, + debtStatusTitle() { + return this.debtorsPrison.inDebtorsPrison + ? this.$t('falukant.bank.debtorsPrison.titlePrison') + : this.$t('falukant.bank.debtorsPrison.titleWarning'); + }, + debtStatusDescription() { + return this.debtorsPrison.inDebtorsPrison + ? this.$t('falukant.bank.debtorsPrison.descriptionPrison') + : this.$t('falukant.bank.debtorsPrison.descriptionWarning'); + } + }, + watch: { + socket(newVal, oldVal) { + if (oldVal) this.teardownSocketEvents(); + if (newVal) this.setupSocketEvents(); + }, + daemonSocket(newVal, oldVal) { + if (oldVal) this.teardownSocketEvents(); + if (newVal) this.setupSocketEvents(); + } }, async mounted() { await this.loadBankOverview(); this.setupSocketEvents(); }, beforeUnmount() { - if (this.socket) { - this.socket.off('falukantUpdateStatus', this.loadBankOverview); + if (this._pendingRefresh) { + clearTimeout(this._pendingRefresh); + this._pendingRefresh = null; } + this.teardownSocketEvents(); }, methods: { + matchesCurrentUser(eventData) { + if (eventData?.user_id == null) { + return true; + } + const currentIds = [this.user?.id, this.user?.hashedId] + .filter(Boolean) + .map((value) => String(value)); + return currentIds.includes(String(eventData.user_id)); + }, setupSocketEvents() { + this.teardownSocketEvents(); if (this.socket) { - this.socket.on('falukantUpdateStatus', (data) => { - this.handleEvent({ event: 'falukantUpdateStatus', ...data }); - }); - } else { - setTimeout(() => this.setupSocketEvents(), 1000); + this._statusSocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateStatus', ...data }); + this._familySocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateFamily', ...data }); + this._debtSocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateDebt', ...data }); + this.socket.on('falukantUpdateStatus', this._statusSocketHandler); + this.socket.on('falukantUpdateFamily', this._familySocketHandler); + this.socket.on('falukantUpdateDebt', this._debtSocketHandler); + } + if (this.daemonSocket) { + this._daemonHandler = (event) => this.handleDaemonMessage(event); + this.daemonSocket.addEventListener('message', this._daemonHandler); + } + }, + teardownSocketEvents() { + if (this.socket) { + if (this._statusSocketHandler) this.socket.off('falukantUpdateStatus', this._statusSocketHandler); + if (this._familySocketHandler) this.socket.off('falukantUpdateFamily', this._familySocketHandler); + if (this._debtSocketHandler) this.socket.off('falukantUpdateDebt', this._debtSocketHandler); + } + if (this.daemonSocket && this._daemonHandler) { + this.daemonSocket.removeEventListener('message', this._daemonHandler); + this._daemonHandler = null; } }, handleEvent(eventData) { + if (!this.matchesCurrentUser(eventData)) { + return; + } switch (eventData.event) { case 'falukantUpdateStatus': - this.loadBankOverview(); + this.queueBankRefresh(); + break; + case 'falukantUpdateDebt': + this.queueBankRefresh(); + break; + case 'falukantUpdateFamily': + if (['monthly', 'lover_installment'].includes(eventData.reason)) { + this.queueBankRefresh(); + } break; } }, + queueBankRefresh() { + if (this._pendingRefresh) { + clearTimeout(this._pendingRefresh); + } + this._pendingRefresh = setTimeout(() => { + this._pendingRefresh = null; + this.loadBankOverview(); + }, 120); + }, async loadBankOverview() { try { const { data } = await apiClient.get('/api/falukant/bank/overview'); @@ -155,6 +255,7 @@ export default { } }, async confirmPayoff() { + if (this.isCreditBlocked) return; try { await apiClient.post('/api/falukant/bank/credits', { height: this.selectedCredit @@ -163,16 +264,17 @@ export default { this.selectedCredit = null; this.activeTab = 'credits'; } catch (err) { - console.error(err); + showError(err.response?.data?.error || this.$t('falukant.bank.debtorsPrison.creditError')); } }, handleDaemonMessage(msg) { try { - if (['falukantUpdateStatus', 'moneyChange', 'creditChange'].includes(msg.event)) { - this.loadBankOverview(); + const data = JSON.parse(msg.data); + if (['falukantUpdateStatus', 'falukantUpdateDebt'].includes(data.event)) { + this.handleEvent(data); } } catch (err) { - console.error(evt, err); + console.error(err); } }, feeRate() { @@ -190,4 +292,38 @@ export default { diff --git a/frontend/src/views/falukant/BranchView.vue b/frontend/src/views/falukant/BranchView.vue index a4c81a4..37f1e3c 100644 --- a/frontend/src/views/falukant/BranchView.vue +++ b/frontend/src/views/falukant/BranchView.vue @@ -15,9 +15,28 @@ ++ {{ debtorsPrison.inDebtorsPrison + ? $t('falukant.branch.debtorsPrison.branchLocked') + : $t('falukant.branch.debtorsPrison.branchRisk') }} +
++ {{ debtorsPrison.inDebtorsPrison + ? $t('falukant.family.debtorsPrison.familyImpact') + : $t('falukant.family.debtorsPrison.familyWarning') }} +
++ {{ debtorsPrison.inDebtorsPrison + ? $t('falukant.house.debtorsPrison.houseRisk') + : $t('falukant.house.debtorsPrison.houseWarning') }} +
++ {{ falukantUser?.debtorsPrison?.inDebtorsPrison + ? $t('falukant.bank.debtorsPrison.descriptionPrison') + : $t('falukant.bank.debtorsPrison.descriptionWarning') }} +
+Verdichteter Blick auf Warenbestand über alle Regionen.
+ {{ falukantUser.debtorsPrison.nextForcedAction + ? $t(`falukant.bank.debtorsPrison.actions.${falukantUser.debtorsPrison.nextForcedAction}`) + : $t('falukant.bank.debtorsPrison.titleWarning') }} +
+