From a82ec7de3fcd25ca65ff2ce0e217150f4bbd945b Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Sun, 21 Dec 2025 22:18:29 +0100 Subject: [PATCH] Add cooldown feature for reputation actions in FalukantService and update UI components - Introduced a cooldown mechanism for reputation actions, limiting execution to once per configured interval. - Updated FalukantService to handle cooldown logic and return remaining cooldown time. - Enhanced ReputationView component to display cooldown status and prevent action execution during cooldown. - Added translations for cooldown messages in both German and English locales. --- backend/services/falukantService.js | 347 +++++++++++++----- frontend/src/i18n/locales/de/falukant.json | 1 + frontend/src/i18n/locales/en/falukant.json | 1 + .../src/views/falukant/ReputationView.vue | 17 +- 4 files changed, 283 insertions(+), 83 deletions(-) diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index d2c5b1a..a2edcac 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -330,6 +330,8 @@ class PreconditionError extends Error { class FalukantService extends BaseService { static KNOWLEDGE_MAX = 99; static REPUTATION_ACTION_DAILY_CAP = Number(process.env.FALUKANT_REPUTATION_ACTION_DAILY_CAP ?? 10); + static REPUTATION_ACTION_COOLDOWN_MINUTES = Number(process.env.FALUKANT_REPUTATION_ACTION_COOLDOWN_MINUTES ?? 60); + static RANDOM_EVENT_DAILY_ENABLED = String(process.env.FALUKANT_RANDOM_EVENT_DAILY_ENABLED ?? '1') === '1'; static COST_CONFIG = { one: { min: 50, max: 5000 }, all: { min: 400, max: 40000 } @@ -500,7 +502,7 @@ class FalukantService extends BaseService { { model: FalukantCharacter, as: 'character', - attributes: ['birthdate', 'health', 'reputation'], + attributes: ['id', 'regionId', 'birthdate', 'health', 'reputation'], include: [ { model: Relationship, @@ -553,6 +555,16 @@ class FalukantService extends BaseService { const userCharacterIds = userCharacterIdsRows.map(r => r.id); bm('aggregate.userCharacters', { count: userCharacterIds.length, ids: userCharacterIds.slice(0, 5) }); + // Daily random event (once per calendar day per user) -> stored as Notification random_event.* + // Frontend already supports JSON-encoded tr: {"tr":"random_event.windfall","amount":123} + try { + if (FalukantService.RANDOM_EVENT_DAILY_ENABLED) { + await this._maybeCreateDailyRandomEvent(falukantUser, user); + } + } catch (e) { + console.warn('[Falukant] daily random event failed (non-fatal):', e?.message || e); + } + // Count distinct children for any of the user's characters (as father or mother) let childrenCount = 0; let unbaptisedChildrenCount = 0; @@ -605,6 +617,99 @@ class FalukantService extends BaseService { return falukantUser; } + async _maybeCreateDailyRandomEvent(falukantUser, user) { + if (!falukantUser?.id) return null; + + // Already created today? + const since = new Date(); + since.setHours(0, 0, 0, 0); + const already = await Notification.count({ + where: { + userId: falukantUser.id, + createdAt: { [Op.gte]: since }, + [Op.or]: [ + { tr: { [Op.like]: 'random_event.%' } }, + { tr: { [Op.like]: '%\"tr\":\"random_event.%' } }, + ], + }, + }); + if (already > 0) return null; + + // Choose an event (guaranteed once/day, random type) + const events = [ + { id: 'windfall', weight: 25 }, + { id: 'theft', weight: 20 }, + { id: 'character_illness', weight: 20 }, + { id: 'character_recovery', weight: 15 }, + { id: 'character_accident', weight: 10 }, + { id: 'regional_festival', weight: 10 }, + ]; + const total = events.reduce((s, e) => s + e.weight, 0); + let r = Math.random() * total; + let chosen = events[0].id; + for (const e of events) { + r -= e.weight; + if (r <= 0) { chosen = e.id; break; } + } + + const payload = { tr: `random_event.${chosen}` }; + + return await sequelize.transaction(async (t) => { + // Reload current values inside tx + const freshUser = await FalukantUser.findByPk(falukantUser.id, { transaction: t }); + const character = await FalukantCharacter.findOne({ + where: { userId: falukantUser.id }, + include: [ + { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'], required: false }, + { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'], required: false }, + ], + transaction: t, + }); + + // Effects (keine harten Datenlöschungen) + if (chosen === 'windfall') { + const amount = Math.floor(Math.random() * 901) + 100; // 100..1000 + payload.amount = amount; + await updateFalukantUserMoney(falukantUser.id, amount, 'random_event.windfall', falukantUser.id, t); + } else if (chosen === 'theft') { + const maxLoss = Math.max(0, Math.min(500, Math.floor(Number(freshUser?.money || 0)))); + const amount = maxLoss > 0 ? (Math.floor(Math.random() * maxLoss) + 1) : 0; + payload.amount = amount; + if (amount > 0) { + await updateFalukantUserMoney(falukantUser.id, -amount, 'random_event.theft', falukantUser.id, t); + } + } else if (chosen === 'character_illness' || chosen === 'character_recovery' || chosen === 'character_accident') { + const name = [character?.definedFirstName?.name, character?.definedLastName?.name].filter(Boolean).join(' ').trim(); + payload.characterName = name || null; + let delta = 0; + if (chosen === 'character_illness') delta = -(Math.floor(Math.random() * 11) + 5); // -5..-15 + if (chosen === 'character_recovery') delta = (Math.floor(Math.random() * 11) + 5); // +5..+15 + if (chosen === 'character_accident') delta = -(Math.floor(Math.random() * 16) + 10); // -10..-25 + payload.healthChange = delta > 0 ? `+${delta}` : `${delta}`; + if (character) { + const next = Math.min(100, Math.max(0, Number(character.health || 0) + delta)); + await character.update({ health: next }, { transaction: t }); + } + } else if (chosen === 'regional_festival') { + const regionId = character?.regionId || falukantUser?.mainBranchRegionId || null; + if (regionId) { + const region = await RegionData.findByPk(regionId, { attributes: ['name'], transaction: t }); + payload.regionName = region?.name || null; + } + } + + // Store notification as JSON string so frontend can interpolate params + await Notification.create( + { userId: falukantUser.id, tr: JSON.stringify(payload), shown: false }, + { transaction: t } + ); + + // Make statusbar update (unread count, etc.) + try { notifyUser(user.hashedId, 'falukantUpdateStatus', {}); } catch (_) {} + return payload; + }); + } + async getBranches(hashedUserId) { const u = await getFalukantUserOrFail(hashedUserId); const bs = await Branch.findAll({ @@ -1610,76 +1715,103 @@ class FalukantService extends BaseService { } async sellProduct(hashedUserId, branchId, productId, quality, quantity) { - const user = await getFalukantUserOrFail(hashedUserId); - const branch = await getBranchOrFail(user.id, branchId); - const character = await FalukantCharacter.findOne({ where: { userId: user.id } }); - if (!character) throw new Error('No character found for user'); - const stock = await FalukantStock.findOne({ where: { branchId: branch.id } }); - if (!stock) throw new Error('Stock not found'); - const inventory = await Inventory.findAll({ - where: { quality }, - include: [ - { - model: ProductType, - as: 'productType', - required: true, - where: { id: productId }, - include: [ - { - model: Knowledge, - as: 'knowledges', - required: false, - where: { characterId: character.id } - } - ] - } - ] - }); - if (!inventory.length) { - throw new Error('No inventory found'); - } - const available = inventory.reduce((sum, i) => sum + i.quantity, 0); - if (available < quantity) throw new Error('Not enough inventory available'); - const item = inventory[0].productType; - const knowledgeVal = item.knowledges?.[0]?.knowledge || 0; - const pricePerUnit = await calcRegionalSellPrice(item, knowledgeVal, branch.regionId); + // Konsistenz wie sellAll: nur aus Stocks dieses Branches verkaufen und alles atomar ausführen + return await sequelize.transaction(async (t) => { + const user = await getFalukantUserOrFail(hashedUserId, { transaction: t }); + const branch = await getBranchOrFail(user.id, branchId); - // compute cumulative tax (region + ancestors) with political exemptions and inflate price so seller net is unchanged - const cumulativeTax = await getCumulativeTaxPercentWithExemptions(user.id, branch.regionId); - const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100)); - const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100; - const revenue = quantity * adjustedPricePerUnit; + const character = await FalukantCharacter.findOne({ where: { userId: user.id }, transaction: t }); + if (!character) throw new Error('No character found for user'); - // compute tax and net - const taxValue = Math.round((revenue * cumulativeTax / 100) * 100) / 100; - const net = Math.round((revenue - taxValue) * 100) / 100; + const stocks = await FalukantStock.findAll({ + where: { branchId: branch.id }, + attributes: ['id'], + transaction: t + }); + const stockIds = stocks.map(s => s.id); + if (!stockIds.length) throw new Error('Stock not found'); - // Book net to seller - const moneyResult = await updateFalukantUserMoney(user.id, net, `Product sale (net)`, user.id); - if (!moneyResult.success) throw new Error('Failed to update money for seller'); + const inventory = await Inventory.findAll({ + where: { + stockId: { [Op.in]: stockIds }, + productId, + quality + }, + include: [ + { + model: ProductType, + as: 'productType', + required: true, + where: { id: productId }, + include: [ + { + model: Knowledge, + as: 'knowledges', + required: false, + where: { characterId: character.id } + } + ] + } + ], + order: [['producedAt', 'ASC'], ['id', 'ASC']], + transaction: t + }); - // Book tax to treasury (if configured) - const treasuryId = process.env.TREASURY_FALUKANT_USER_ID; - if (treasuryId && taxValue > 0) { - const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), taxValue, `Sales tax (${cumulativeTax}%)`, user.id); - if (!taxResult.success) throw new Error('Failed to update money for treasury'); - } - let remaining = quantity; - for (const inv of inventory) { - if (inv.quantity <= remaining) { - remaining -= inv.quantity; - await inv.destroy(); - } else { - await inv.update({ quantity: inv.quantity - remaining }); - remaining = 0; - break; + if (!inventory.length) { + throw new Error('No inventory found'); } - } - await this.addSellItem(branchId, user.id, productId, quantity); - console.log('[FalukantService.sellProduct] emitting events for user', user.user.hashedId, 'branch', branch?.id); - notifyUser(user.user.hashedId, 'falukantUpdateStatus', {}); - notifyUser(user.user.hashedId, 'falukantBranchUpdate', { branchId: branch.id }); - return { success: true }; + + const available = inventory.reduce((sum, i) => sum + i.quantity, 0); + if (available < quantity) throw new Error('Not enough inventory available'); + + const item = inventory[0].productType; + const knowledgeVal = item.knowledges?.[0]?.knowledge || 0; + const pricePerUnit = await calcRegionalSellPrice(item, knowledgeVal, branch.regionId); + + // compute cumulative tax (region + ancestors) with political exemptions and inflate price so seller net is unchanged + const cumulativeTax = await getCumulativeTaxPercentWithExemptions(user.id, branch.regionId); + const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100)); + const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100; + const revenue = quantity * adjustedPricePerUnit; + + // compute tax and net + const taxValue = Math.round((revenue * cumulativeTax / 100) * 100) / 100; + const net = Math.round((revenue - taxValue) * 100) / 100; + + // Book net to seller (in tx) + const moneyResult = await updateFalukantUserMoney(user.id, net, `Product sale (net)`, user.id, t); + if (!moneyResult.success) throw new Error('Failed to update money for seller'); + + // Book tax to treasury (if configured) + const treasuryId = process.env.TREASURY_FALUKANT_USER_ID; + if (treasuryId && taxValue > 0) { + const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), taxValue, `Sales tax (${cumulativeTax}%)`, user.id, t); + if (!taxResult.success) throw new Error('Failed to update money for treasury'); + } + + let remaining = quantity; + for (const inv of inventory) { + if (remaining <= 0) break; + if (inv.quantity <= remaining) { + remaining -= inv.quantity; + await inv.destroy({ transaction: t }); + } else { + await inv.update({ quantity: inv.quantity - remaining }, { transaction: t }); + remaining = 0; + break; + } + } + if (remaining !== 0) { + throw new Error(`Inventory deduction mismatch (remaining=${remaining})`); + } + + await this.addSellItem(branchId, user.id, productId, quantity, t); + + // notify after successful commit (we can still emit here; worst-case it's slightly early) + notifyUser(user.user.hashedId, 'falukantUpdateStatus', {}); + notifyUser(user.user.hashedId, 'falukantBranchUpdate', { branchId: branch.id }); + return { success: true }; + }); } async sellAllProducts(hashedUserId, branchId) { @@ -1827,28 +1959,26 @@ class FalukantService extends BaseService { }); } - async addSellItem(branchId, userId, productId, quantity) { + async addSellItem(branchId, userId, productId, quantity, transaction = null) { const branch = await Branch.findOne({ where: { id: branchId }, - }) - ; - const daySell = await DaySell.findOne({ + attributes: ['id', 'regionId'], + transaction: transaction || undefined + }); + if (!branch) throw new Error(`Branch not found (branchId: ${branchId})`); + + const [daySell, created] = await DaySell.findOrCreate({ where: { regionId: branch.regionId, productId: productId, sellerId: userId, - } + }, + defaults: { quantity: quantity }, + transaction: transaction || undefined }); - if (daySell) { + if (!created) { daySell.quantity += quantity; - await daySell.save(); - } else { - await DaySell.create({ - regionId: branch.regionId, - productId: productId, - sellerId: userId, - quantity: quantity, - }); + await daySell.save({ transaction: transaction || undefined }); } } @@ -3301,7 +3431,33 @@ class FalukantService extends BaseService { ); const dailyRemaining = Math.max(0, Number(dailyCap) - Number(dailyUsed || 0)); - if (!actionTypes.length) return { dailyCap, dailyUsed: Number(dailyUsed || 0), dailyRemaining, actions: [] }; + // Globaler Cooldown: max. 1 Aktion pro Stunde (oder konfigurierbar) unabhängig vom Typ + const cooldownMinutes = FalukantService.REPUTATION_ACTION_COOLDOWN_MINUTES; + const [{ lastTs }] = await sequelize.query( + ` + SELECT MAX(action_timestamp) AS "lastTs" + FROM falukant_log.reputation_action + WHERE falukant_user_id = :uid + `, + { replacements: { uid: falukantUser.id }, type: sequelize.QueryTypes.SELECT } + ); + let cooldownRemainingSec = 0; + if (lastTs) { + const last = new Date(lastTs).getTime(); + const nextAllowed = last + cooldownMinutes * 60 * 1000; + cooldownRemainingSec = Math.max(0, Math.ceil((nextAllowed - Date.now()) / 1000)); + } + + if (!actionTypes.length) { + return { + dailyCap, + dailyUsed: Number(dailyUsed || 0), + dailyRemaining, + cooldownMinutes, + cooldownRemainingSec, + actions: [] + }; + } // counts in einem Query – aber pro Typ in seinem "Decay-Fenster" (default 7 Tage) const now = Date.now(); @@ -3330,7 +3486,14 @@ class FalukantService extends BaseService { currentGain: gain, }); } - return { dailyCap, dailyUsed: Number(dailyUsed || 0), dailyRemaining, actions }; + return { + dailyCap, + dailyUsed: Number(dailyUsed || 0), + dailyRemaining, + cooldownMinutes, + cooldownRemainingSec, + actions + }; } async executeReputationAction(hashedUserId, actionTypeId) { @@ -3339,6 +3502,26 @@ class FalukantService extends BaseService { const actionType = await ReputationActionType.findByPk(actionTypeId, { transaction: t }); if (!actionType) throw new Error('Unbekannte Aktion'); + // Globaler Cooldown (unabhängig vom Aktionstyp): max. 1 pro Stunde + const cooldownMinutes = FalukantService.REPUTATION_ACTION_COOLDOWN_MINUTES; + const [{ lastTs }] = await sequelize.query( + ` + SELECT MAX(action_timestamp) AS "lastTs" + FROM falukant_log.reputation_action + WHERE falukant_user_id = :uid + `, + { replacements: { uid: falukantUser.id }, type: sequelize.QueryTypes.SELECT, transaction: t } + ); + if (lastTs) { + const last = new Date(lastTs).getTime(); + const nextAllowed = last + cooldownMinutes * 60 * 1000; + const remainingSec = Math.max(0, Math.ceil((nextAllowed - Date.now()) / 1000)); + if (remainingSec > 0) { + const remainingMin = Math.ceil(remainingSec / 60); + throw new Error(`Sozialstatus-Aktionen sind nur ${cooldownMinutes} Minutenweise möglich. Bitte warte noch ca. ${remainingMin} Minuten.`); + } + } + const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, attributes: ['id', 'reputation'], diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index 8e44881..e2a643a 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -794,6 +794,7 @@ "running": "Läuft...", "none": "Keine Aktionen verfügbar.", "dailyLimit": "Heute noch verfügbar: {remaining} / {cap} Reputation (durch Aktionen).", + "cooldown": "Nächste Sozialstatus-Aktion in ca. {minutes} Minuten möglich.", "success": "Aktion erfolgreich! Reputation +{gain}, Kosten {cost}.", "successSimple": "Aktion erfolgreich!", "type": { diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json index 8e613af..455c8e8 100644 --- a/frontend/src/i18n/locales/en/falukant.json +++ b/frontend/src/i18n/locales/en/falukant.json @@ -218,6 +218,7 @@ "running": "Running...", "none": "No actions available.", "dailyLimit": "Available today: {remaining} / {cap} reputation (from actions).", + "cooldown": "Next social status action available in about {minutes} minutes.", "success": "Action successful! Reputation +{gain}, cost {cost}.", "successSimple": "Action successful!", "type": { diff --git a/frontend/src/views/falukant/ReputationView.vue b/frontend/src/views/falukant/ReputationView.vue index 4f3cfe2..d858485 100644 --- a/frontend/src/views/falukant/ReputationView.vue +++ b/frontend/src/views/falukant/ReputationView.vue @@ -25,6 +25,9 @@

{{ $t('falukant.reputation.actions.dailyLimit', { remaining: reputationActionsDailyRemaining, cap: reputationActionsDailyCap }) }}

+

+ {{ $t('falukant.reputation.actions.cooldown', { minutes: Math.ceil(reputationActionsCooldownRemainingSec / 60) }) }} +

@@ -43,7 +46,7 @@
+{{ Number(a.currentGain || 0) }} {{ Number(a.timesUsed || 0) }} - @@ -218,6 +221,8 @@ export default { reputationActionsDailyCap: null, reputationActionsDailyUsed: null, reputationActionsDailyRemaining: null, + reputationActionsCooldownMinutes: null, + reputationActionsCooldownRemainingSec: 0, runningActionId: null, } }, @@ -262,6 +267,8 @@ export default { this.reputationActionsDailyCap = data?.dailyCap ?? null; this.reputationActionsDailyUsed = data?.dailyUsed ?? null; this.reputationActionsDailyRemaining = data?.dailyRemaining ?? null; + this.reputationActionsCooldownMinutes = data?.cooldownMinutes ?? null; + this.reputationActionsCooldownRemainingSec = Number(data?.cooldownRemainingSec ?? 0) || 0; this.reputationActions = Array.isArray(data?.actions) ? data.actions : []; } catch (e) { console.error('Failed to load reputation actions', e); @@ -269,11 +276,14 @@ export default { this.reputationActionsDailyCap = null; this.reputationActionsDailyUsed = null; this.reputationActionsDailyRemaining = null; + this.reputationActionsCooldownMinutes = null; + this.reputationActionsCooldownRemainingSec = 0; } }, async executeReputationAction(action) { if (!action?.id) return; if (this.runningActionId) return; + if (this.reputationActionsCooldownRemainingSec > 0) return; this.runningActionId = action.id; try { const { data } = await apiClient.post('/api/falukant/reputation/actions', { actionTypeId: action.id }); @@ -409,4 +419,9 @@ table th { margin: 0.5rem 0 1rem; font-weight: bold; } + +.reputation-actions-cooldown { + margin: -0.5rem 0 1rem; + font-weight: bold; +} \ No newline at end of file