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

@@ -116,6 +116,12 @@ class FalukantController {
}, { successStatus: 201 });
this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId));
this.getReputationActions = this._wrapWithUser((userId) => this.service.getReputationActions(userId));
this.executeReputationAction = this._wrapWithUser((userId, req) => {
const { actionTypeId } = req.body;
return this.service.executeReputationAction(userId, actionTypeId);
}, { successStatus: 201 });
this.getNotBaptisedChildren = this._wrapWithUser((userId) => this.service.getNotBaptisedChildren(userId));
this.baptise = this._wrapWithUser((userId, req) => {
const { characterId: childId, firstName } = req.body;

View File

@@ -0,0 +1,47 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
// Typ-Tabelle (konfigurierbar ohne Code): falukant_type.reputation_action
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS falukant_type.reputation_action (
id serial PRIMARY KEY,
tr text NOT NULL UNIQUE,
cost integer NOT NULL CHECK (cost >= 0),
base_gain integer NOT NULL CHECK (base_gain >= 0),
decay_factor double precision NOT NULL CHECK (decay_factor > 0 AND decay_factor <= 1),
min_gain integer NOT NULL DEFAULT 0 CHECK (min_gain >= 0),
decay_window_days integer NOT NULL DEFAULT 7 CHECK (decay_window_days >= 1 AND decay_window_days <= 365)
);
`);
// Log-Tabelle: falukant_log.reputation_action
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS falukant_log.reputation_action (
id serial PRIMARY KEY,
falukant_user_id integer NOT NULL,
action_type_id integer NOT NULL,
cost integer NOT NULL CHECK (cost >= 0),
base_gain integer NOT NULL CHECK (base_gain >= 0),
gain integer NOT NULL CHECK (gain >= 0),
times_used_before integer NOT NULL CHECK (times_used_before >= 0),
action_timestamp timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS reputation_action_log_user_type_idx
ON falukant_log.reputation_action (falukant_user_id, action_type_id);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS reputation_action_log_ts_idx
ON falukant_log.reputation_action (action_timestamp);
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS falukant_log.reputation_action;`);
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS falukant_type.reputation_action;`);
},
};

View File

@@ -0,0 +1,46 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
// Für bereits existierende Installationen: Spalte sicherstellen + Backfill
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
ADD COLUMN IF NOT EXISTS decay_window_days integer;
`);
await queryInterface.sequelize.query(`
UPDATE falukant_type.reputation_action
SET decay_window_days = 7
WHERE decay_window_days IS NULL;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
ALTER COLUMN decay_window_days SET DEFAULT 7;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
ALTER COLUMN decay_window_days SET NOT NULL;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
DROP CONSTRAINT IF EXISTS reputation_action_decay_window_days_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
ADD CONSTRAINT reputation_action_decay_window_days_chk
CHECK (decay_window_days >= 1 AND decay_window_days <= 365);
`);
},
async down(queryInterface, Sequelize) {
// optional: wieder entfernen
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
DROP CONSTRAINT IF EXISTS reputation_action_decay_window_days_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
DROP COLUMN IF EXISTS decay_window_days;
`);
},
};

View File

@@ -0,0 +1,50 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
// Idempotentes Seed: legt Ruf-Aktionen an bzw. aktualisiert sie anhand "tr"
await queryInterface.sequelize.query(`
INSERT INTO falukant_type.reputation_action
(tr, cost, base_gain, decay_factor, min_gain, decay_window_days)
VALUES
('soup_kitchen', 500, 2, 0.85, 0, 7),
('library_donation', 5000, 4, 0.88, 0, 7),
('well_build', 8000, 4, 0.87, 0, 7),
('scholarships', 10000, 5, 0.87, 0, 7),
('church_hospice', 12000, 5, 0.87, 0, 7),
('school_funding', 15000, 6, 0.88, 0, 7),
('orphanage_build', 20000, 7, 0.90, 0, 7),
('bridge_build', 25000, 7, 0.90, 0, 7),
('hospital_donation', 30000, 8, 0.90, 0, 7),
('patronage', 40000, 9, 0.91, 0, 7),
('statue_build', 50000, 10, 0.92, 0, 7)
ON CONFLICT (tr) DO UPDATE SET
cost = EXCLUDED.cost,
base_gain = EXCLUDED.base_gain,
decay_factor = EXCLUDED.decay_factor,
min_gain = EXCLUDED.min_gain,
decay_window_days = EXCLUDED.decay_window_days;
`);
},
async down(queryInterface, Sequelize) {
// Entfernt nur die gesetzten Seeds (tr-basiert)
await queryInterface.sequelize.query(`
DELETE FROM falukant_type.reputation_action
WHERE tr IN (
'soup_kitchen',
'library_donation',
'well_build',
'scholarships',
'church_hospice',
'school_funding',
'orphanage_build',
'bridge_build',
'hospital_donation',
'patronage',
'statue_build'
);
`);
},
};

View File

@@ -0,0 +1,59 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
class ReputationActionLog extends Model {}
ReputationActionLog.init(
{
falukantUserId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'falukant_user_id',
},
actionTypeId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'action_type_id',
},
cost: {
type: DataTypes.INTEGER,
allowNull: false,
},
baseGain: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'base_gain',
},
gain: {
type: DataTypes.INTEGER,
allowNull: false,
},
timesUsedBefore: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'times_used_before',
},
actionTimestamp: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: sequelize.literal('CURRENT_TIMESTAMP'),
field: 'action_timestamp',
},
},
{
sequelize,
modelName: 'ReputationActionLog',
tableName: 'reputation_action',
schema: 'falukant_log',
timestamps: false,
underscored: true,
indexes: [
{ fields: ['falukant_user_id', 'action_type_id'] },
{ fields: ['action_timestamp'] },
],
}
);
export default ReputationActionLog;

View File

@@ -0,0 +1,51 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
class ReputationActionType extends Model {}
ReputationActionType.init(
{
tr: {
type: DataTypes.STRING,
allowNull: false,
},
cost: {
type: DataTypes.INTEGER,
allowNull: false,
},
baseGain: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'base_gain',
},
decayFactor: {
type: DataTypes.FLOAT,
allowNull: false,
field: 'decay_factor',
},
minGain: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'min_gain',
},
decayWindowDays: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 7,
field: 'decay_window_days',
},
},
{
sequelize,
modelName: 'ReputationActionType',
tableName: 'reputation_action',
schema: 'falukant_type',
timestamps: false,
underscored: true,
}
);
export default ReputationActionType;

View File

@@ -79,6 +79,8 @@ import Party from './falukant/data/party.js';
import MusicType from './falukant/type/music.js';
import BanquetteType from './falukant/type/banquette.js';
import PartyInvitedNobility from './falukant/data/partyInvitedNobility.js';
import ReputationActionType from './falukant/type/reputation_action.js';
import ReputationActionLog from './falukant/log/reputation_action.js';
import ChildRelation from './falukant/data/child_relation.js';
import LearnRecipient from './falukant/type/learn_recipient.js';
import Learning from './falukant/data/learning.js';
@@ -208,6 +210,8 @@ const models = {
BanquetteType,
Party,
PartyInvitedNobility,
ReputationActionType,
ReputationActionLog,
ChildRelation,
LearnRecipient,
Learning,

View File

@@ -53,6 +53,8 @@ router.post('/houses', falukantController.buyUserHouse);
router.get('/party/types', falukantController.getPartyTypes);
router.post('/party', falukantController.createParty);
router.get('/party', falukantController.getParties);
router.get('/reputation/actions', falukantController.getReputationActions);
router.post('/reputation/actions', falukantController.executeReputationAction);
router.get('/family/notbaptised', falukantController.getNotBaptisedChildren);
router.post('/church/baptise', falukantController.baptise);
router.get('/education', falukantController.getEducation);

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),

View File

@@ -12,6 +12,7 @@ import TitleOfNobility from "../../models/falukant/type/title_of_nobility.js";
import PartyType from "../../models/falukant/type/party.js";
import MusicType from "../../models/falukant/type/music.js";
import BanquetteType from "../../models/falukant/type/banquette.js";
import ReputationActionType from "../../models/falukant/type/reputation_action.js";
import VehicleType from "../../models/falukant/type/vehicle.js";
import LearnRecipient from "../../models/falukant/type/learn_recipient.js";
import PoliticalOfficeType from "../../models/falukant/type/political_office_type.js";
@@ -41,6 +42,7 @@ export const initializeFalukantTypes = async () => {
await initializeFalukantPartyTypes();
await initializeFalukantMusicTypes();
await initializeFalukantBanquetteTypes();
await initializeFalukantReputationActions();
await initializeLearnerTypes();
await initializePoliticalOfficeBenefitTypes();
await initializePoliticalOfficeTypes();
@@ -52,6 +54,55 @@ export const initializeFalukantTypes = async () => {
await initializeFalukantProductWeatherEffects();
};
const reputationActions = [
// Günstig / häufig: schnelle Abnutzung
{ tr: "soup_kitchen", cost: 500, baseGain: 2, decayFactor: 0.85, minGain: 0, decayWindowDays: 7 },
// Mittel: gesellschaftlich anerkannt
{ tr: "library_donation", cost: 5000, baseGain: 4, decayFactor: 0.88, minGain: 0, decayWindowDays: 7 },
{ tr: "scholarships", cost: 10000, baseGain: 5, decayFactor: 0.87, minGain: 0, decayWindowDays: 7 },
{ tr: "church_hospice", cost: 12000, baseGain: 5, decayFactor: 0.87, minGain: 0, decayWindowDays: 7 },
{ tr: "school_funding", cost: 15000, baseGain: 6, decayFactor: 0.88, minGain: 0, decayWindowDays: 7 },
// Großprojekte: teurer, langsamerer Rufverfall
{ tr: "orphanage_build", cost: 20000, baseGain: 7, decayFactor: 0.90, minGain: 0, decayWindowDays: 7 },
{ tr: "bridge_build", cost: 25000, baseGain: 7, decayFactor: 0.90, minGain: 0, decayWindowDays: 7 },
{ tr: "hospital_donation", cost: 30000, baseGain: 8, decayFactor: 0.90, minGain: 0, decayWindowDays: 7 },
{ tr: "patronage", cost: 40000, baseGain: 9, decayFactor: 0.91, minGain: 0, decayWindowDays: 7 },
{ tr: "statue_build", cost: 50000, baseGain: 10, decayFactor: 0.92, minGain: 0, decayWindowDays: 7 },
{ tr: "well_build", cost: 8000, baseGain: 4, decayFactor: 0.87, minGain: 0, decayWindowDays: 7 },
];
async function initializeFalukantReputationActions() {
// robustes Upsert ohne FK/Constraints-Ärger:
// - finde existierende nach tr
// - update bei Änderungen
// - create wenn fehlt
const existing = await ReputationActionType.findAll({ attributes: ['id', 'tr', 'cost', 'baseGain', 'decayFactor', 'minGain'] });
const existingByTr = new Map(existing.map(e => [e.tr, e]));
for (const a of reputationActions) {
const found = existingByTr.get(a.tr);
if (!found) {
await ReputationActionType.create(a);
continue;
}
const needsUpdate =
Number(found.cost) !== Number(a.cost) ||
Number(found.baseGain) !== Number(a.baseGain) ||
Number(found.decayFactor) !== Number(a.decayFactor) ||
Number(found.minGain) !== Number(a.minGain) ||
Number(found.decayWindowDays || 7) !== Number(a.decayWindowDays || 7);
if (needsUpdate) {
await found.update({
cost: a.cost,
baseGain: a.baseGain,
decayFactor: a.decayFactor,
minGain: a.minGain,
decayWindowDays: a.decayWindowDays ?? 7,
});
}
}
}
const regionTypes = [];
const regionTypeTrs = [
"country",