Add reputation actions feature to Falukant module

- Introduced new endpoints for retrieving and executing reputation actions in FalukantController and falukantRouter.
- Implemented service methods in FalukantService to handle reputation actions, including daily limits and action execution logic.
- Updated the frontend ReputationView component to display available actions and their details, including cost and potential reputation gain.
- Added translations for reputation actions in both German and English locales.
- Enhanced initialization logic to set up reputation action types in the database.
This commit is contained in:
Torsten Schulz (local)
2025-12-21 21:09:31 +01:00
parent 38f23cc6ae
commit 38dd51f757
13 changed files with 594 additions and 2 deletions

View File

@@ -65,6 +65,8 @@ import Weather from '../models/falukant/data/weather.js';
import TownProductWorth from '../models/falukant/data/town_product_worth.js';
import ProductWeatherEffect from '../models/falukant/type/product_weather_effect.js';
import WeatherType from '../models/falukant/type/weather.js';
import ReputationActionType from '../models/falukant/type/reputation_action.js';
import ReputationActionLog from '../models/falukant/log/reputation_action.js';
function calcAge(birthdate) {
const b = new Date(birthdate); b.setHours(0, 0);
@@ -327,6 +329,7 @@ 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 COST_CONFIG = {
one: { min: 50, max: 5000 },
all: { min: 400, max: 40000 }
@@ -3281,6 +3284,140 @@ class FalukantService extends BaseService {
return { partyTypes, musicTypes, banquetteTypes };
}
async getReputationActions(hashedUserId) {
const falukantUser = await getFalukantUserOrFail(hashedUserId);
const actionTypes = await ReputationActionType.findAll({ order: [['cost', 'ASC']] });
// Tageslimit (global, aus Aktionen) Anzeige im UI
const dailyCap = FalukantService.REPUTATION_ACTION_DAILY_CAP;
const [{ dailyUsed }] = await sequelize.query(
`
SELECT COALESCE(SUM(gain), 0)::int AS "dailyUsed"
FROM falukant_log.reputation_action
WHERE falukant_user_id = :uid
AND action_timestamp >= date_trunc('day', now())
`,
{ replacements: { uid: falukantUser.id }, type: sequelize.QueryTypes.SELECT }
);
const dailyRemaining = Math.max(0, Number(dailyCap) - Number(dailyUsed || 0));
if (!actionTypes.length) return { dailyCap, dailyUsed: Number(dailyUsed || 0), dailyRemaining, actions: [] };
// counts in einem Query aber pro Typ in seinem "Decay-Fenster" (default 7 Tage)
const now = Date.now();
const actions = [];
for (const t of actionTypes) {
const windowDays = Number(t.decayWindowDays || 7);
const since = new Date(now - windowDays * 24 * 3600 * 1000);
const timesUsed = await ReputationActionLog.count({
where: {
falukantUserId: falukantUser.id,
actionTypeId: t.id,
actionTimestamp: { [Op.gte]: since },
}
});
const raw = Number(t.baseGain) * Math.pow(Number(t.decayFactor), Number(timesUsed));
const gain = Math.max(Number(t.minGain || 0), Math.ceil(raw));
actions.push({
id: t.id,
tr: t.tr,
cost: t.cost,
baseGain: t.baseGain,
decayFactor: t.decayFactor,
minGain: t.minGain,
decayWindowDays: t.decayWindowDays,
timesUsed,
currentGain: gain,
});
}
return { dailyCap, dailyUsed: Number(dailyUsed || 0), dailyRemaining, actions };
}
async executeReputationAction(hashedUserId, actionTypeId) {
return await sequelize.transaction(async (t) => {
const falukantUser = await getFalukantUserOrFail(hashedUserId, { transaction: t });
const actionType = await ReputationActionType.findByPk(actionTypeId, { transaction: t });
if (!actionType) throw new Error('Unbekannte Aktion');
const character = await FalukantCharacter.findOne({
where: { userId: falukantUser.id },
attributes: ['id', 'reputation'],
transaction: t
});
if (!character) throw new Error('No character for user');
// Abnutzung nur innerhalb des Fensters (default 7 Tage)
const windowDays = Number(actionType.decayWindowDays || 7);
const since = new Date(Date.now() - windowDays * 24 * 3600 * 1000);
const timesUsedBefore = await ReputationActionLog.count({
where: {
falukantUserId: falukantUser.id,
actionTypeId: actionType.id,
actionTimestamp: { [Op.gte]: since },
},
transaction: t
});
const raw = Number(actionType.baseGain) * Math.pow(Number(actionType.decayFactor), Number(timesUsedBefore));
const plannedGain = Math.max(Number(actionType.minGain || 0), Math.ceil(raw));
// Tageslimit aus Aktionen (global)
const dailyCap = FalukantService.REPUTATION_ACTION_DAILY_CAP;
const [{ dailyUsed }] = await sequelize.query(
`
SELECT COALESCE(SUM(gain), 0)::int AS "dailyUsed"
FROM falukant_log.reputation_action
WHERE falukant_user_id = :uid
AND action_timestamp >= date_trunc('day', now())
`,
{ replacements: { uid: falukantUser.id }, type: sequelize.QueryTypes.SELECT, transaction: t }
);
const dailyRemaining = Math.max(0, Number(dailyCap) - Number(dailyUsed || 0));
if (dailyRemaining <= 0) {
throw new Error(`Tageslimit erreicht (max. ${dailyCap} Reputation pro Tag durch Aktionen)`);
}
const gain = Math.min(plannedGain, dailyRemaining);
if (gain <= 0) {
throw new Error('Diese Aktion bringt aktuell keine Reputation mehr');
}
const cost = Number(actionType.cost || 0);
if (Number(falukantUser.money) < cost) {
throw new Error('Nicht genügend Guthaben');
}
const moneyResult = await updateFalukantUserMoney(
falukantUser.id,
-cost,
`reputationAction.${actionType.tr}`,
falukantUser.id,
t
);
if (!moneyResult.success) throw new Error('Geld konnte nicht abgezogen werden');
await ReputationActionLog.create({
falukantUserId: falukantUser.id,
actionTypeId: actionType.id,
cost,
baseGain: actionType.baseGain,
gain,
timesUsedBefore: Number(timesUsedBefore),
}, { transaction: t });
await character.update(
{ reputation: Sequelize.literal(`LEAST(100, COALESCE(reputation,0) + ${gain})`) },
{ transaction: t }
);
const user = await User.findByPk(falukantUser.userId, { transaction: t });
notifyUser(user.hashedId, 'falukantUpdateStatus', {});
notifyUser(user.hashedId, 'falukantReputationUpdate', { gain, actionTr: actionType.tr });
return { success: true, gain, plannedGain, dailyCap, dailyRemainingBefore: dailyRemaining, cost, actionTr: actionType.tr };
});
}
async createParty(hashedUserId, partyTypeId, musicId, banquetteId, nobilityIds = [], servantRatio) {
// Reputation-Logik: Party steigert Reputation um 1..5 (bestes Fest 5, kleinstes 1).
// Wir leiten "Ausstattung" aus den Party-Kosten ab (linear zwischen min/max möglicher Konfiguration),