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 @@
- + {{ blockedReason }}
@@ -51,6 +55,8 @@ export default { props: { branches: { type: Array, required: true }, selectedBranch: { type: Object, default: null }, + blocked: { type: Boolean, default: false }, + blockedReason: { type: String, default: '' }, }, data() { return { @@ -82,6 +88,7 @@ export default { }, openCreateBranchDialog() { + if (this.blocked) return; this.$refs.createBranchDialog.open(); }, @@ -131,4 +138,13 @@ button { .weather-value { text-transform: capitalize; } + +.blocked-hint { + display: inline-flex; + align-items: center; + margin-left: 8px; + color: #8b2f23; + font-size: 0.88rem; + font-weight: 600; +} diff --git a/frontend/src/components/falukant/StatusBar.vue b/frontend/src/components/falukant/StatusBar.vue index 7397b0c..2f7382e 100644 --- a/frontend/src/components/falukant/StatusBar.vue +++ b/frontend/src/components/falukant/StatusBar.vue @@ -36,6 +36,18 @@ /> +
+ + {{ debtorsPrison.inDebtorsPrison + ? $t('falukant.bank.debtorsPrison.titlePrison') + : $t('falukant.bank.debtorsPrison.titleWarning') }} + + + {{ debtorsPrison.inDebtorsPrison + ? $t('falukant.debtorsPrison.globalLocked') + : $t('falukant.debtorsPrison.globalWarning') }} + +
@@ -60,6 +72,10 @@ export default { { key: "children", icon: "👶", value: null }, ], unreadCount: 0, + debtorsPrison: { + active: false, + inDebtorsPrison: false + }, pendingStatusRefresh: null, }; }, @@ -146,6 +162,10 @@ export default { const childCount = Number(response.data.childrenCount) || 0; const unbaptisedCount = Number(response.data.unbaptisedChildrenCount) || 0; this.unreadCount = Number(response.data.unreadNotifications) || 0; + this.debtorsPrison = response.data.debtorsPrison || { + active: false, + inDebtorsPrison: false + }; const childrenDisplay = `${childCount}${unbaptisedCount > 0 ? `(${unbaptisedCount})` : ''}`; let healthStatus = ''; if (health > 90) { @@ -177,6 +197,7 @@ export default { this._statusSocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateStatus', ...data }); this._familySocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateFamily', ...data }); this._churchSocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateChurch', ...data }); + this._debtSocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateDebt', ...data }); this._childrenSocketHandler = (data) => this.handleEvent({ event: 'children_update', ...data }); this._productionCertificateSocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateProductionCertificate', ...data }); this._stockSocketHandler = (data) => this.handleEvent({ event: 'stock_change', ...data }); @@ -185,6 +206,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('stock_change', this._stockSocketHandler); @@ -195,6 +217,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._stockSocketHandler) this.socket.off('stock_change', this._stockSocketHandler); @@ -207,7 +230,7 @@ export default { this._daemonHandler = (event) => { try { const data = JSON.parse(event.data); - if (['falukantUpdateStatus', 'falukantUpdateFamily', 'falukantUpdateChurch', 'children_update', 'falukantUpdateProductionCertificate', 'stock_change', 'familychanged'].includes(data.event)) { + if (['falukantUpdateStatus', 'falukantUpdateFamily', 'falukantUpdateChurch', 'falukantUpdateDebt', 'children_update', 'falukantUpdateProductionCertificate', 'stock_change', 'familychanged'].includes(data.event)) { this.handleEvent(data); } } catch (_) {} @@ -247,6 +270,7 @@ export default { case 'falukantUpdateStatus': case 'falukantUpdateFamily': case 'falukantUpdateChurch': + case 'falukantUpdateDebt': case 'children_update': case 'falukantUpdateProductionCertificate': case 'stock_change': @@ -294,6 +318,25 @@ export default { box-shadow: var(--shadow-soft); } +.statusbar-warning { + flex: 1 1 100%; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius-md); + border: 1px solid rgba(180, 120, 40, 0.35); + background: rgba(255, 244, 223, 0.92); + color: #8a5411; + font-size: 0.92rem; +} + +.statusbar-warning.is-prison { + border-color: rgba(146, 57, 40, 0.42); + background: rgba(255, 232, 225, 0.94); + color: #8b2f23; +} + .status-item { text-align: center; cursor: pointer; diff --git a/frontend/src/components/widgets/FalukantWidget.vue b/frontend/src/components/widgets/FalukantWidget.vue index 22df3ce..6025aaa 100644 --- a/frontend/src/components/widgets/FalukantWidget.vue +++ b/frontend/src/components/widgets/FalukantWidget.vue @@ -12,6 +12,14 @@
{{ falukantData.unreadNotificationsCount }}
{{ $t('falukant.statusbar.children') }}
{{ falukantData.childrenCount }}
+ @@ -43,6 +51,7 @@ export default { money: pick(raw, 'money', 'money'), unreadNotificationsCount: pick(raw, 'unreadNotificationsCount', 'unread_notifications_count'), childrenCount: pick(raw, 'childrenCount', 'children_count'), + debtorsPrison: pick(raw, 'debtorsPrison', 'debtors_prison'), // keep all original keys as fallback for any other usage ...raw }; @@ -173,4 +182,13 @@ export default { color: #198754; font-weight: 600; } + +.dashboard-widget__falukant dd.falukant-debt { + color: #8b2f23; + font-weight: 700; +} + +.dashboard-widget__falukant dd.falukant-debt--warning { + color: #9a5a08; +} diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index 5fb1251..012e5a8 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -24,6 +24,11 @@ "children": "Kinder", "children_unbaptised": "ungetaufte Kinder" }, + "debtorsPrison": { + "actionBlocked": "Im Schuldturm kannst du diese Aktion derzeit nicht ausführen.", + "globalWarning": "Dein Kreditverzug schränkt dein Handeln ein. Zwangsmaßnahmen können bald folgen.", + "globalLocked": "Du bist im Schuldturm. Fast alle aktiven Falukant-Handlungen sind derzeit gesperrt." + }, "messages": { "title": "Nachrichten", "tooltip": "Nachrichten", @@ -220,6 +225,11 @@ }, "branch": { "title": "Filiale", + "debtorsPrison": { + "branchLocked": "Im Schuldturm sind neue wirtschaftliche Schritte blockiert. Geschlossene oder gepfändete Standorte werden hier ebenfalls sichtbar.", + "branchRisk": "Dein Kreditverzug gefährdet Niederlassungen, Fahrzeuge und Lagerbestände.", + "selectionBlocked": "Neue Ausbauten sind im Schuldturm gesperrt." + }, "currentCertificate": "Derzeitiges Zertifikat", "tabs": { "director": "Direktor", @@ -483,6 +493,10 @@ }, "family": { "title": "Familie", + "debtorsPrison": { + "familyWarning": "Anhaltender Kreditverzug belastet Ehe, Haushalt und Liebschaften.", + "familyImpact": "Der Schuldturm schadet Ehe, Hausfrieden und der Stabilität von Liebschaften." + }, "spouse": { "title": "Beziehung", "name": "Name", @@ -845,6 +859,10 @@ }, "house": { "title": "Haus", + "debtorsPrison": { + "houseWarning": "Mit wachsendem Kreditverzug steigt das Risiko für Pfändung und erzwungenen Hausverlust.", + "houseRisk": "Dein Haus ist jetzt Teil der möglichen Zwangsverwertung." + }, "statusreport": "Zustand des Hauses", "element": "Bereich", "state": "Zustand", @@ -1137,6 +1155,23 @@ "maxCredit": "Maximaler Kredit", "availableCredit": "Verfügbarer Kredit" }, + "debtorsPrison": { + "titleWarning": "Kreditverzug", + "titlePrison": "Schuldturm", + "descriptionWarning": "Deine Kredite sind im Verzug. Wenn du weiter nicht bedienst, drohen Zwangsmaßnahmen.", + "descriptionPrison": "Du sitzt im Schuldturm. Neue Kredite sind gesperrt und dein Vermögen wird schrittweise verwertet.", + "daysOverdue": "Verzugstage", + "creditworthiness": "Kreditwürdigkeit", + "nextForcedAction": "Nächste Zwangsmaßnahme", + "creditBlocked": "Im Schuldturm kannst du keine neuen Kredite aufnehmen.", + "creditError": "Der Kredit konnte nicht aufgenommen werden.", + "actions": { + "reminder": "Erste Mahnung", + "final_warning": "Letzte Mahnung", + "debtors_prison": "Einweisung in den Schuldturm", + "asset_seizure": "Pfändung von Vermögen" + } + }, "credits": { "title": "Kredite", "none": "Derzeit hast Du keinen Kredit aufgenommen.", diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json index 8b39401..4fef96e 100644 --- a/frontend/src/i18n/locales/en/falukant.json +++ b/frontend/src/i18n/locales/en/falukant.json @@ -10,6 +10,11 @@ "windy": "Windy", "clear": "Clear" }, + "debtorsPrison": { + "actionBlocked": "This action is blocked while you are in debtors' prison.", + "globalWarning": "Your credit delinquency is already restricting your actions. Forced measures may follow soon.", + "globalLocked": "You are in debtors' prison. Almost all active Falukant actions are currently blocked." + }, "messages": { "title": "Messages", "tooltip": "Messages", @@ -184,6 +189,10 @@ }, "house": { "title": "House", + "debtorsPrison": { + "houseWarning": "As delinquency grows, the risk of seizure and forced loss of the house increases.", + "houseRisk": "Your house is now part of the possible forced liquidation." + }, "statusreport": "House condition", "element": "Element", "state": "Condition", @@ -265,6 +274,11 @@ "noProposals": "No director candidates available." }, "branch": { + "debtorsPrison": { + "branchLocked": "While in debtors' prison, new economic steps are blocked. Closed or seized branches will also become visible here.", + "branchRisk": "Your delinquency puts branches, vehicles and stored goods at risk.", + "selectionBlocked": "New expansions are blocked while imprisoned for debt." + }, "currentCertificate": "Current certificate", "selection": { "title": "Branch Selection", @@ -505,6 +519,10 @@ } }, "family": { + "debtorsPrison": { + "familyWarning": "Ongoing debt delinquency puts strain on marriage, household and affairs.", + "familyImpact": "Debtors' prison damages marriage, household peace and the stability of affairs." + }, "children": { "title": "Children", "name": "Name", @@ -736,6 +754,55 @@ "error": "The child could not be baptized." } }, + "bank": { + "title": "Bank", + "account": { + "title": "Account", + "balance": "Balance", + "totalDebt": "Total debt", + "maxCredit": "Maximum credit", + "availableCredit": "Available credit" + }, + "debtorsPrison": { + "titleWarning": "Credit delinquency", + "titlePrison": "Debtors' prison", + "descriptionWarning": "Your credits are overdue. If you continue to default, forced measures will follow.", + "descriptionPrison": "You are in debtors' prison. New credits are blocked and your assets will be liquidated step by step.", + "daysOverdue": "Days overdue", + "creditworthiness": "Creditworthiness", + "nextForcedAction": "Next forced action", + "creditBlocked": "You cannot take new credits while imprisoned for debt.", + "creditError": "The credit could not be taken.", + "actions": { + "reminder": "First reminder", + "final_warning": "Final warning", + "debtors_prison": "Commitment to debtors' prison", + "asset_seizure": "Asset seizure" + } + }, + "credits": { + "title": "Credits", + "none": "You currently do not have any credits.", + "amount": "Amount", + "remaining": "Remaining", + "interestRate": "Interest rate", + "table": { + "name": "Name", + "amount": "Amount", + "reason": "Reason", + "date": "Date" + }, + "payoff": { + "title": "Take a new credit", + "height": "Credit amount", + "remaining": "Remaining possible credit amount", + "fee": "Credit interest", + "feeHeight": "Installment (10 payments)", + "total": "Total", + "confirm": "Take credit" + } + } + }, "reputation": { "title": "Reputation", "overview": { diff --git a/frontend/src/i18n/locales/es/falukant.json b/frontend/src/i18n/locales/es/falukant.json index 045e3e5..19e56ca 100644 --- a/frontend/src/i18n/locales/es/falukant.json +++ b/frontend/src/i18n/locales/es/falukant.json @@ -24,6 +24,11 @@ "children": "Hijos", "children_unbaptised": "hijos no bautizados" }, + "debtorsPrison": { + "actionBlocked": "Esta acción está bloqueada mientras estés en la prisión por deudas.", + "globalWarning": "Tu mora crediticia ya restringe tus acciones. Pronto pueden llegar medidas forzosas.", + "globalLocked": "Estás en la prisión por deudas. Casi todas las acciones activas de Falukant están actualmente bloqueadas." + }, "messages": { "title": "Mensajes", "tooltip": "Mensajes", @@ -208,6 +213,11 @@ }, "branch": { "title": "Sucursal", + "debtorsPrison": { + "branchLocked": "En la prisión por deudas se bloquean los nuevos pasos económicos. Las sucursales cerradas o embargadas también se reflejarán aquí.", + "branchRisk": "Tu mora pone en peligro sucursales, vehículos y mercancías almacenadas.", + "selectionBlocked": "Las nuevas ampliaciones están bloqueadas en la prisión por deudas." + }, "currentCertificate": "Certificado actual", "tabs": { "director": "Director", @@ -467,6 +477,10 @@ }, "family": { "title": "Familia", + "debtorsPrison": { + "familyWarning": "La mora continuada perjudica el matrimonio, el hogar y las relaciones.", + "familyImpact": "La prisión por deudas daña el matrimonio, la paz del hogar y la estabilidad de las relaciones." + }, "spouse": { "title": "Relación", "name": "Nombre", @@ -811,6 +825,10 @@ }, "house": { "title": "Casa", + "debtorsPrison": { + "houseWarning": "A medida que aumenta la mora, crece el riesgo de embargo y pérdida forzosa de la casa.", + "houseRisk": "Tu casa forma ahora parte de la posible liquidación forzosa." + }, "statusreport": "Estado de la casa", "element": "Elemento", "state": "Estado", @@ -1070,6 +1088,23 @@ "maxCredit": "Crédito máximo", "availableCredit": "Crédito disponible" }, + "debtorsPrison": { + "titleWarning": "Mora crediticia", + "titlePrison": "Prisión por deudas", + "descriptionWarning": "Tus créditos están en mora. Si sigues sin pagar, te amenazan medidas forzosas.", + "descriptionPrison": "Estás en la prisión por deudas. Los nuevos créditos están bloqueados y tu patrimonio se liquidará gradualmente.", + "daysOverdue": "Días de retraso", + "creditworthiness": "Solvencia crediticia", + "nextForcedAction": "Siguiente medida forzosa", + "creditBlocked": "No puedes solicitar nuevos créditos mientras estés en la prisión por deudas.", + "creditError": "No se pudo solicitar el crédito.", + "actions": { + "reminder": "Primer aviso", + "final_warning": "Último aviso", + "debtors_prison": "Ingreso en prisión por deudas", + "asset_seizure": "Embargo de bienes" + } + }, "credits": { "title": "Créditos", "none": "Actualmente no tienes ningún crédito.", diff --git a/frontend/src/views/falukant/BankView.vue b/frontend/src/views/falukant/BankView.vue index ffa22c0..7bb0d45 100644 --- a/frontend/src/views/falukant/BankView.vue +++ b/frontend/src/views/falukant/BankView.vue @@ -8,6 +8,18 @@
+
+

{{ debtStatusTitle }}

+

{{ debtStatusDescription }}

+
+ {{ $t('falukant.bank.debtorsPrison.daysOverdue') }}: {{ debtorsPrison.daysOverdue }} + {{ $t('falukant.bank.debtorsPrison.creditworthiness') }}: {{ debtorsPrison.creditworthiness }} + + {{ $t('falukant.bank.debtorsPrison.nextForcedAction') }}: + {{ $t(`falukant.bank.debtorsPrison.actions.${debtorsPrison.nextForcedAction}`) }} + +
+
@@ -77,9 +93,12 @@

{{ $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.bank.debtorsPrison.titlePrison') + : $t('falukant.bank.debtorsPrison.titleWarning') }} + +

+ {{ debtorsPrison.inDebtorsPrison + ? $t('falukant.branch.debtorsPrison.branchLocked') + : $t('falukant.branch.debtorsPrison.branchRisk') }} +

+
+ this.handleEvent({ event: 'falukantUpdateStatus', ...data })); + this.socket.on('falukantUpdateDebt', (data) => this.handleEvent({ event: 'falukantUpdateDebt', ...data })); this.socket.on('falukantUpdateProductionCertificate', (data) => this.handleEvent({ event: 'falukantUpdateProductionCertificate', ...data })); this.socket.on('falukantBranchUpdate', (data) => this.handleEvent({ event: 'falukantBranchUpdate', ...data })); this.socket.on('transport_arrived', (data) => this.handleEvent({ event: 'transport_arrived', ...data })); @@ -482,6 +506,7 @@ export default { } if (this.socket) { this.socket.off('falukantUpdateStatus'); + this.socket.off('falukantUpdateDebt'); this.socket.off('falukantUpdateProductionCertificate'); this.socket.off('falukantBranchUpdate'); this.socket.off('transport_arrived'); @@ -558,6 +583,10 @@ export default { try { const result = await apiClient.get('/api/falukant/user'); this.currentCertificate = result.data?.certificate ?? null; + this.debtorsPrison = result.data?.debtorsPrison || { + active: false, + inDebtorsPrison: false + }; } catch (error) { console.error('Error loading certificate:', error); } @@ -678,6 +707,10 @@ export default { }, async createBranch() { + if (this.debtorsPrison.inDebtorsPrison) { + showError(this, this.$t('falukant.branch.debtorsPrison.selectionBlocked')); + return; + } await this.loadBranches(); // Nach dem Anlegen eines neuen Branches automatisch den // zuletzt/neu erstellten Branch auswählen. @@ -694,6 +727,10 @@ export default { async upgradeBranch() { if (!this.selectedBranch) return; + if (this.debtorsPrison.inDebtorsPrison) { + showError(this, this.$t('falukant.branch.debtorsPrison.selectionBlocked')); + return; + } try { await apiClient.post('/api/falukant/branches/upgrade', { branchId: this.selectedBranch.id, @@ -851,6 +888,7 @@ export default { this.$refs.productionSection?.loadStorage(); break; case 'falukantUpdateStatus': + case 'falukantUpdateDebt': case 'falukantUpdateProductionCertificate': case 'falukantBranchUpdate': this.queueBranchRefresh(); @@ -1184,6 +1222,23 @@ export default { color: var(--color-text-secondary); } +.branch-debt-warning { + margin-bottom: 16px; + padding: 16px 18px; + border: 1px solid rgba(180, 120, 40, 0.32); + background: linear-gradient(180deg, rgba(255, 244, 223, 0.95), rgba(255, 250, 239, 0.98)); +} + +.branch-debt-warning.is-prison { + border-color: rgba(146, 57, 40, 0.4); + background: linear-gradient(180deg, rgba(255, 232, 225, 0.96), rgba(255, 245, 241, 0.98)); +} + +.branch-debt-warning p { + margin: 6px 0 0; + color: var(--color-text-secondary); +} + .branch-hero__meta { margin-top: 12px; } diff --git a/frontend/src/views/falukant/FamilyView.vue b/frontend/src/views/falukant/FamilyView.vue index 605c7b8..fc4b181 100644 --- a/frontend/src/views/falukant/FamilyView.vue +++ b/frontend/src/views/falukant/FamilyView.vue @@ -11,6 +11,23 @@ +
+ + {{ debtorsPrison.inDebtorsPrison + ? $t('falukant.bank.debtorsPrison.titlePrison') + : $t('falukant.bank.debtorsPrison.titleWarning') }} + +

+ {{ debtorsPrison.inDebtorsPrison + ? $t('falukant.family.debtorsPrison.familyImpact') + : $t('falukant.family.debtorsPrison.familyWarning') }} +

+
+

{{ $t('falukant.family.spouse.title') }}

@@ -386,6 +403,10 @@ export default { householdTension: null, householdTensionScore: null, householdTensionReasons: [], + debtorsPrison: { + active: false, + inDebtorsPrison: false + }, selectedChild: null, pendingFamilyRefresh: null } @@ -426,11 +447,13 @@ export default { if (this.socket) { this._falukantUpdateStatusHandler = (data) => this.handleEvent({ event: 'falukantUpdateStatus', ...data }); this._falukantUpdateFamilyHandler = (data) => this.handleEvent({ event: 'falukantUpdateFamily', ...data }); + this._falukantUpdateDebtHandler = (data) => this.handleEvent({ event: 'falukantUpdateDebt', ...data }); this._childrenUpdateHandler = (data) => this.handleEvent({ event: 'children_update', ...data }); this._familyChangedHandler = (data) => this.handleEvent({ event: 'familychanged', ...data }); this.socket.on('falukantUpdateStatus', this._falukantUpdateStatusHandler); this.socket.on('falukantUpdateFamily', this._falukantUpdateFamilyHandler); + this.socket.on('falukantUpdateDebt', this._falukantUpdateDebtHandler); this.socket.on('children_update', this._childrenUpdateHandler); this.socket.on('familychanged', this._familyChangedHandler); } else { @@ -441,6 +464,7 @@ export default { if (!this.socket) return; if (this._falukantUpdateStatusHandler) this.socket.off('falukantUpdateStatus', this._falukantUpdateStatusHandler); if (this._falukantUpdateFamilyHandler) this.socket.off('falukantUpdateFamily', this._falukantUpdateFamilyHandler); + if (this._falukantUpdateDebtHandler) this.socket.off('falukantUpdateDebt', this._falukantUpdateDebtHandler); if (this._childrenUpdateHandler) this.socket.off('children_update', this._childrenUpdateHandler); if (this._familyChangedHandler) this.socket.off('familychanged', this._familyChangedHandler); }, @@ -454,6 +478,7 @@ export default { if ([ 'falukantUpdateStatus', 'falukantUpdateFamily', + 'falukantUpdateDebt', 'children_update', 'falukantUpdateChurch', 'familychanged', @@ -502,6 +527,7 @@ export default { switch (eventData.event) { case 'falukantUpdateStatus': + case 'falukantUpdateDebt': case 'familychanged': this.queueFamilyRefresh({ reloadCharacter: true }); break; @@ -533,6 +559,10 @@ export default { this.householdTension = response.data.householdTension; this.householdTensionScore = response.data.householdTensionScore; this.householdTensionReasons = response.data.householdTensionReasons || []; + this.debtorsPrison = response.data.debtorsPrison || { + active: false, + inDebtorsPrison: false + }; } catch (error) { console.error('Error loading family data:', error); } @@ -874,6 +904,23 @@ export default { color: var(--color-text-secondary); } +.family-debt-warning { + margin-bottom: 16px; + padding: 16px 18px; + border: 1px solid rgba(180, 120, 40, 0.32); + background: linear-gradient(180deg, rgba(255, 244, 223, 0.95), rgba(255, 250, 239, 0.98)); +} + +.family-debt-warning.is-prison { + border-color: rgba(146, 57, 40, 0.4); + background: linear-gradient(180deg, rgba(255, 232, 225, 0.96), rgba(255, 245, 241, 0.98)); +} + +.family-debt-warning p { + margin: 6px 0 0; + color: var(--color-text-secondary); +} + .marriage-overview { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); diff --git a/frontend/src/views/falukant/HouseView.vue b/frontend/src/views/falukant/HouseView.vue index e5e47b2..9aa4b06 100644 --- a/frontend/src/views/falukant/HouseView.vue +++ b/frontend/src/views/falukant/HouseView.vue @@ -2,6 +2,22 @@

{{ $t('falukant.house.title') }}

+
+ + {{ debtorsPrison.inDebtorsPrison + ? $t('falukant.bank.debtorsPrison.titlePrison') + : $t('falukant.bank.debtorsPrison.titleWarning') }} + +

+ {{ debtorsPrison.inDebtorsPrison + ? $t('falukant.house.debtorsPrison.houseRisk') + : $t('falukant.house.debtorsPrison.houseWarning') }} +

+
@@ -127,7 +143,7 @@
{{ $t('falukant.house.price') }}: {{ buyCost(house) }}
-
@@ -161,11 +177,15 @@ export default { servantPayLevel: 'normal', servantPayOptions: ['low', 'normal', 'high'], buyableHouses: [], - currency: '€' + currency: '€', + debtorsPrison: { + active: false, + inDebtorsPrison: false + } }; }, computed: { - ...mapState(['socket']), + ...mapState(['socket', 'daemonSocket', 'user']), allRenovated() { return Object.values(this.status).every(v => v >= 100); } @@ -176,6 +196,10 @@ export default { const userRes = await apiClient.get('/api/falukant/houses'); this.userHouse = userRes.data; this.houseType = this.userHouse.houseType; + this.debtorsPrison = this.userHouse.debtorsPrison || { + active: false, + inDebtorsPrison: false + }; const { roofCondition, wallCondition, floorCondition, windowCondition } = this.userHouse; this.status = { roofCondition, wallCondition, floorCondition, windowCondition }; this.servantSummary = this.userHouse.servantSummary || this.servantSummary; @@ -327,14 +351,27 @@ export default { handleDaemonMessage(evt) { try { const msg = JSON.parse(evt.data); - if (msg.event === 'houseupdated') this.loadData(); + if (!this.matchesCurrentUser(msg)) return; + if (['houseupdated', 'falukantUpdateStatus', 'falukantUpdateDebt', 'falukantHouseUpdate'].includes(msg.event)) this.loadData(); } catch { } }, + 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() { if (this.socket) { this.socket.on('falukantHouseUpdate', (data) => { this.handleEvent({ event: 'falukantHouseUpdate', ...data }); }); + this.socket.on('falukantUpdateDebt', (data) => { + this.handleEvent({ event: 'falukantUpdateDebt', ...data }); + }); this.socket.on('falukantUpdateStatus', (data) => { this.handleEvent({ event: 'falukantUpdateStatus', ...data }); }); @@ -345,6 +382,7 @@ export default { handleEvent(eventData) { switch (eventData.event) { case 'falukantUpdateStatus': + case 'falukantUpdateDebt': case 'falukantHouseUpdate': this.loadData(); break; @@ -354,10 +392,17 @@ export default { async mounted() { await this.loadData(); this.setupSocketEvents(); + if (this.daemonSocket) { + this.daemonSocket.addEventListener('message', this.handleDaemonMessage); + } }, beforeUnmount() { + if (this.daemonSocket) { + this.daemonSocket.removeEventListener('message', this.handleDaemonMessage); + } if (this.socket) { this.socket.off('falukantHouseUpdate', this.loadData); + this.socket.off('falukantUpdateDebt', this.loadData); this.socket.off('falukantUpdateStatus', this.loadData); } } @@ -387,6 +432,22 @@ h2 { margin: 0 0 10px; } +.house-debt-warning { + padding: 16px 18px; + border: 1px solid rgba(180, 120, 40, 0.32); + background: linear-gradient(180deg, rgba(255, 244, 223, 0.95), rgba(255, 250, 239, 0.98)); +} + +.house-debt-warning.is-prison { + border-color: rgba(146, 57, 40, 0.4); + background: linear-gradient(180deg, rgba(255, 232, 225, 0.96), rgba(255, 245, 241, 0.98)); +} + +.house-debt-warning p { + margin: 6px 0 0; + color: var(--color-text-secondary); +} + .existing-house { display: flex; gap: 20px; diff --git a/frontend/src/views/falukant/OverviewView.vue b/frontend/src/views/falukant/OverviewView.vue index ddf957a..a7251df 100644 --- a/frontend/src/views/falukant/OverviewView.vue +++ b/frontend/src/views/falukant/OverviewView.vue @@ -9,7 +9,24 @@
-
+
+ + {{ falukantUser?.debtorsPrison?.inDebtorsPrison + ? $t('falukant.bank.debtorsPrison.titlePrison') + : $t('falukant.bank.debtorsPrison.titleWarning') }} + +

+ {{ falukantUser?.debtorsPrison?.inDebtorsPrison + ? $t('falukant.bank.debtorsPrison.descriptionPrison') + : $t('falukant.bank.debtorsPrison.descriptionWarning') }} +

+
+ +
@@ -23,6 +40,16 @@
+
+ +
+
{{ $t('falukant.overview.metadata.certificate') }} @@ -44,6 +71,15 @@ {{ stockEntryCount }}

Verdichteter Blick auf Warenbestand über alle Regionen.

+
+ {{ $t('falukant.bank.debtorsPrison.creditworthiness') }} + {{ falukantUser.debtorsPrison.creditworthiness }} +

+ {{ falukantUser.debtorsPrison.nextForcedAction + ? $t(`falukant.bank.debtorsPrison.actions.${falukantUser.debtorsPrison.nextForcedAction}`) + : $t('falukant.bank.debtorsPrison.titleWarning') }} +

+
@@ -357,6 +393,7 @@ export default { this.socket.off("falukantUpdateStatus"); this.socket.off("falukantUpdateFamily"); this.socket.off("falukantUpdateChurch"); + this.socket.off("falukantUpdateDebt"); this.socket.off("falukantUpdateProductionCertificate"); this.socket.off("children_update"); this.socket.off("falukantBranchUpdate"); @@ -376,6 +413,9 @@ export default { this.socket.on("falukantUpdateChurch", (data) => { this.handleEvent({ event: 'falukantUpdateChurch', ...data }); }); + this.socket.on("falukantUpdateDebt", (data) => { + this.handleEvent({ event: 'falukantUpdateDebt', ...data }); + }); this.socket.on("falukantUpdateProductionCertificate", (data) => { this.handleEvent({ event: 'falukantUpdateProductionCertificate', ...data }); }); @@ -446,6 +486,7 @@ export default { case 'falukantUpdateStatus': case 'falukantUpdateFamily': case 'falukantUpdateChurch': + case 'falukantUpdateDebt': case 'falukantUpdateProductionCertificate': case 'children_update': case 'falukantBranchUpdate': @@ -579,6 +620,23 @@ export default { color: var(--color-text-secondary); } +.falukant-debt-warning { + margin-bottom: 16px; + padding: 16px 18px; + border: 1px solid rgba(180, 120, 40, 0.32); + background: linear-gradient(180deg, rgba(255, 244, 223, 0.95), rgba(255, 250, 239, 0.98)); +} + +.falukant-debt-warning.is-prison { + border-color: rgba(146, 57, 40, 0.4); + background: linear-gradient(180deg, rgba(255, 232, 225, 0.96), rgba(255, 245, 241, 0.98)); +} + +.falukant-debt-warning p { + margin: 6px 0 0; + color: var(--color-text-secondary); +} + .falukant-summary-grid, .falukant-routine-grid { display: grid; @@ -694,6 +752,101 @@ export default { z-index: 0; } +.imagecontainer--prison { + min-height: 320px; +} + +.debtors-prison-visual { + position: relative; + width: min(100%, 540px); + height: 320px; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + overflow: hidden; + background: + radial-gradient(circle at 18% 22%, rgba(255, 233, 167, 0.95) 0, rgba(255, 233, 167, 0.95) 10%, rgba(255, 233, 167, 0) 11%), + linear-gradient(180deg, #273149 0%, #31476b 42%, #6d5953 42%, #6d5953 100%); + box-shadow: var(--shadow-soft); +} + +.debtors-prison-visual__moon { + position: absolute; + top: 42px; + left: 72px; + width: 38px; + height: 38px; + border-radius: 50%; + background: rgba(255, 241, 194, 0.9); + box-shadow: 0 0 24px rgba(255, 241, 194, 0.5); +} + +.debtors-prison-visual__tower { + position: absolute; + left: 50%; + bottom: 54px; + width: 160px; + height: 190px; + transform: translateX(-50%); + border-radius: 18px 18px 10px 10px; + background: + linear-gradient(180deg, #8c8a86 0%, #6f6a64 100%); + box-shadow: inset 0 0 0 2px rgba(53, 49, 45, 0.18); +} + +.debtors-prison-visual__tower::before { + content: ''; + position: absolute; + top: -26px; + left: 18px; + width: 124px; + height: 34px; + border-radius: 10px 10px 0 0; + background: + repeating-linear-gradient(90deg, #7d786f 0, #7d786f 18px, #646057 18px, #646057 26px); +} + +.debtors-prison-visual__tower::after { + content: ''; + position: absolute; + left: 50%; + bottom: 0; + width: 42px; + height: 88px; + transform: translateX(-50%); + border-radius: 18px 18px 0 0; + background: #40362f; + box-shadow: inset 0 0 0 2px rgba(17, 13, 11, 0.22); +} + +.debtors-prison-visual__bars { + position: absolute; + top: 116px; + width: 34px; + height: 54px; + border-radius: 8px; + background: + repeating-linear-gradient(90deg, rgba(33, 31, 29, 0.85) 0, rgba(33, 31, 29, 0.85) 5px, transparent 5px, transparent 11px); +} + +.debtors-prison-visual__bars--left { + left: calc(50% - 54px); +} + +.debtors-prison-visual__bars--right { + right: calc(50% - 54px); +} + +.debtors-prison-visual__ground { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 64px; + background: + linear-gradient(180deg, rgba(72, 57, 51, 0.2), rgba(72, 57, 51, 0.42)), + repeating-linear-gradient(90deg, #756357 0, #756357 18px, #6d5a4f 18px, #6d5a4f 30px); +} + .avatar { border: 1px solid var(--color-border); border-radius: var(--radius-lg);