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.
This commit is contained in:
Torsten Schulz (local)
2025-12-21 22:18:29 +01:00
parent 560a9efc69
commit a82ec7de3f
4 changed files with 283 additions and 83 deletions

View File

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

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -25,6 +25,9 @@
<p v-if="reputationActionsDailyCap != null" class="reputation-actions-daily">
{{ $t('falukant.reputation.actions.dailyLimit', { remaining: reputationActionsDailyRemaining, cap: reputationActionsDailyCap }) }}
</p>
<p v-if="reputationActionsCooldownRemainingSec > 0" class="reputation-actions-cooldown">
{{ $t('falukant.reputation.actions.cooldown', { minutes: Math.ceil(reputationActionsCooldownRemainingSec / 60) }) }}
</p>
<table v-if="reputationActions.length">
<thead>
@@ -43,7 +46,7 @@
<td>+{{ Number(a.currentGain || 0) }}</td>
<td>{{ Number(a.timesUsed || 0) }}</td>
<td>
<button type="button" :disabled="runningActionId === a.id"
<button type="button" :disabled="runningActionId === a.id || reputationActionsCooldownRemainingSec > 0"
@click.prevent="executeReputationAction(a)">
{{ runningActionId === a.id ? $t('falukant.reputation.actions.running') : $t('falukant.reputation.actions.execute') }}
</button>
@@ -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;
}
</style>