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:
@@ -116,6 +116,12 @@ class FalukantController {
|
|||||||
}, { successStatus: 201 });
|
}, { successStatus: 201 });
|
||||||
this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId));
|
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.getNotBaptisedChildren = this._wrapWithUser((userId) => this.service.getNotBaptisedChildren(userId));
|
||||||
this.baptise = this._wrapWithUser((userId, req) => {
|
this.baptise = this._wrapWithUser((userId, req) => {
|
||||||
const { characterId: childId, firstName } = req.body;
|
const { characterId: childId, firstName } = req.body;
|
||||||
|
|||||||
47
backend/migrations/20251220001000-add-reputation-actions.cjs
Normal file
47
backend/migrations/20251220001000-add-reputation-actions.cjs
Normal 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;`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
59
backend/models/falukant/log/reputation_action.js
Normal file
59
backend/models/falukant/log/reputation_action.js
Normal 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;
|
||||||
|
|
||||||
|
|
||||||
51
backend/models/falukant/type/reputation_action.js
Normal file
51
backend/models/falukant/type/reputation_action.js
Normal 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;
|
||||||
|
|
||||||
|
|
||||||
@@ -79,6 +79,8 @@ import Party from './falukant/data/party.js';
|
|||||||
import MusicType from './falukant/type/music.js';
|
import MusicType from './falukant/type/music.js';
|
||||||
import BanquetteType from './falukant/type/banquette.js';
|
import BanquetteType from './falukant/type/banquette.js';
|
||||||
import PartyInvitedNobility from './falukant/data/partyInvitedNobility.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 ChildRelation from './falukant/data/child_relation.js';
|
||||||
import LearnRecipient from './falukant/type/learn_recipient.js';
|
import LearnRecipient from './falukant/type/learn_recipient.js';
|
||||||
import Learning from './falukant/data/learning.js';
|
import Learning from './falukant/data/learning.js';
|
||||||
@@ -208,6 +210,8 @@ const models = {
|
|||||||
BanquetteType,
|
BanquetteType,
|
||||||
Party,
|
Party,
|
||||||
PartyInvitedNobility,
|
PartyInvitedNobility,
|
||||||
|
ReputationActionType,
|
||||||
|
ReputationActionLog,
|
||||||
ChildRelation,
|
ChildRelation,
|
||||||
LearnRecipient,
|
LearnRecipient,
|
||||||
Learning,
|
Learning,
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ router.post('/houses', falukantController.buyUserHouse);
|
|||||||
router.get('/party/types', falukantController.getPartyTypes);
|
router.get('/party/types', falukantController.getPartyTypes);
|
||||||
router.post('/party', falukantController.createParty);
|
router.post('/party', falukantController.createParty);
|
||||||
router.get('/party', falukantController.getParties);
|
router.get('/party', falukantController.getParties);
|
||||||
|
router.get('/reputation/actions', falukantController.getReputationActions);
|
||||||
|
router.post('/reputation/actions', falukantController.executeReputationAction);
|
||||||
router.get('/family/notbaptised', falukantController.getNotBaptisedChildren);
|
router.get('/family/notbaptised', falukantController.getNotBaptisedChildren);
|
||||||
router.post('/church/baptise', falukantController.baptise);
|
router.post('/church/baptise', falukantController.baptise);
|
||||||
router.get('/education', falukantController.getEducation);
|
router.get('/education', falukantController.getEducation);
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ import Weather from '../models/falukant/data/weather.js';
|
|||||||
import TownProductWorth from '../models/falukant/data/town_product_worth.js';
|
import TownProductWorth from '../models/falukant/data/town_product_worth.js';
|
||||||
import ProductWeatherEffect from '../models/falukant/type/product_weather_effect.js';
|
import ProductWeatherEffect from '../models/falukant/type/product_weather_effect.js';
|
||||||
import WeatherType from '../models/falukant/type/weather.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) {
|
function calcAge(birthdate) {
|
||||||
const b = new Date(birthdate); b.setHours(0, 0);
|
const b = new Date(birthdate); b.setHours(0, 0);
|
||||||
@@ -327,6 +329,7 @@ class PreconditionError extends Error {
|
|||||||
|
|
||||||
class FalukantService extends BaseService {
|
class FalukantService extends BaseService {
|
||||||
static KNOWLEDGE_MAX = 99;
|
static KNOWLEDGE_MAX = 99;
|
||||||
|
static REPUTATION_ACTION_DAILY_CAP = Number(process.env.FALUKANT_REPUTATION_ACTION_DAILY_CAP ?? 10);
|
||||||
static COST_CONFIG = {
|
static COST_CONFIG = {
|
||||||
one: { min: 50, max: 5000 },
|
one: { min: 50, max: 5000 },
|
||||||
all: { min: 400, max: 40000 }
|
all: { min: 400, max: 40000 }
|
||||||
@@ -3281,6 +3284,140 @@ class FalukantService extends BaseService {
|
|||||||
return { partyTypes, musicTypes, banquetteTypes };
|
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) {
|
async createParty(hashedUserId, partyTypeId, musicId, banquetteId, nobilityIds = [], servantRatio) {
|
||||||
// Reputation-Logik: Party steigert Reputation um 1..5 (bestes Fest 5, kleinstes 1).
|
// 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),
|
// Wir leiten "Ausstattung" aus den Party-Kosten ab (linear zwischen min/max möglicher Konfiguration),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import TitleOfNobility from "../../models/falukant/type/title_of_nobility.js";
|
|||||||
import PartyType from "../../models/falukant/type/party.js";
|
import PartyType from "../../models/falukant/type/party.js";
|
||||||
import MusicType from "../../models/falukant/type/music.js";
|
import MusicType from "../../models/falukant/type/music.js";
|
||||||
import BanquetteType from "../../models/falukant/type/banquette.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 VehicleType from "../../models/falukant/type/vehicle.js";
|
||||||
import LearnRecipient from "../../models/falukant/type/learn_recipient.js";
|
import LearnRecipient from "../../models/falukant/type/learn_recipient.js";
|
||||||
import PoliticalOfficeType from "../../models/falukant/type/political_office_type.js";
|
import PoliticalOfficeType from "../../models/falukant/type/political_office_type.js";
|
||||||
@@ -41,6 +42,7 @@ export const initializeFalukantTypes = async () => {
|
|||||||
await initializeFalukantPartyTypes();
|
await initializeFalukantPartyTypes();
|
||||||
await initializeFalukantMusicTypes();
|
await initializeFalukantMusicTypes();
|
||||||
await initializeFalukantBanquetteTypes();
|
await initializeFalukantBanquetteTypes();
|
||||||
|
await initializeFalukantReputationActions();
|
||||||
await initializeLearnerTypes();
|
await initializeLearnerTypes();
|
||||||
await initializePoliticalOfficeBenefitTypes();
|
await initializePoliticalOfficeBenefitTypes();
|
||||||
await initializePoliticalOfficeTypes();
|
await initializePoliticalOfficeTypes();
|
||||||
@@ -52,6 +54,55 @@ export const initializeFalukantTypes = async () => {
|
|||||||
await initializeFalukantProductWeatherEffects();
|
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 regionTypes = [];
|
||||||
const regionTypeTrs = [
|
const regionTypeTrs = [
|
||||||
"country",
|
"country",
|
||||||
|
|||||||
@@ -782,6 +782,33 @@
|
|||||||
"type": "Festart",
|
"type": "Festart",
|
||||||
"cost": "Kosten",
|
"cost": "Kosten",
|
||||||
"date": "Datum"
|
"date": "Datum"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"title": "Aktionen",
|
||||||
|
"description": "Mit diesen Aktionen kannst du Reputation gewinnen. Je öfter du dieselbe Aktion ausführst, desto weniger Reputation bringt sie (unabhängig von den Kosten).",
|
||||||
|
"action": "Aktion",
|
||||||
|
"cost": "Kosten",
|
||||||
|
"gain": "Reputation",
|
||||||
|
"timesUsed": "Bereits genutzt",
|
||||||
|
"execute": "Ausführen",
|
||||||
|
"running": "Läuft...",
|
||||||
|
"none": "Keine Aktionen verfügbar.",
|
||||||
|
"dailyLimit": "Heute noch verfügbar: {remaining} / {cap} Reputation (durch Aktionen).",
|
||||||
|
"success": "Aktion erfolgreich! Reputation +{gain}, Kosten {cost}.",
|
||||||
|
"successSimple": "Aktion erfolgreich!",
|
||||||
|
"type": {
|
||||||
|
"library_donation": "Spende für eine Bibliothek",
|
||||||
|
"orphanage_build": "Waisenhaus aufbauen",
|
||||||
|
"statue_build": "Statue errichten",
|
||||||
|
"hospital_donation": "Krankenhaus/Heilhaus stiften",
|
||||||
|
"school_funding": "Schule/Lehrstuhl finanzieren",
|
||||||
|
"well_build": "Brunnen/Wasserwerk bauen",
|
||||||
|
"bridge_build": "Straßen-/Brückenbau finanzieren",
|
||||||
|
"soup_kitchen": "Armenspeisung organisieren",
|
||||||
|
"patronage": "Kunst & Mäzenatentum",
|
||||||
|
"church_hospice": "Hospiz-/Kirchenspende",
|
||||||
|
"scholarships": "Stipendienfonds finanzieren"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"party": {
|
"party": {
|
||||||
|
|||||||
@@ -206,6 +206,33 @@
|
|||||||
},
|
},
|
||||||
"party": {
|
"party": {
|
||||||
"title": "Parties"
|
"title": "Parties"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"title": "Actions",
|
||||||
|
"description": "These actions let you gain reputation. The more often you repeat the same action, the less reputation it yields (independent of cost).",
|
||||||
|
"action": "Action",
|
||||||
|
"cost": "Cost",
|
||||||
|
"gain": "Reputation",
|
||||||
|
"timesUsed": "Times used",
|
||||||
|
"execute": "Execute",
|
||||||
|
"running": "Running...",
|
||||||
|
"none": "No actions available.",
|
||||||
|
"dailyLimit": "Available today: {remaining} / {cap} reputation (from actions).",
|
||||||
|
"success": "Action successful! Reputation +{gain}, cost {cost}.",
|
||||||
|
"successSimple": "Action successful!",
|
||||||
|
"type": {
|
||||||
|
"library_donation": "Donate to a library",
|
||||||
|
"orphanage_build": "Build an orphanage",
|
||||||
|
"statue_build": "Erect a statue",
|
||||||
|
"hospital_donation": "Found a hospital/infirmary",
|
||||||
|
"school_funding": "Fund a school/chair",
|
||||||
|
"well_build": "Build a well/waterworks",
|
||||||
|
"bridge_build": "Fund roads/bridges",
|
||||||
|
"soup_kitchen": "Organize a soup kitchen",
|
||||||
|
"patronage": "Arts & patronage",
|
||||||
|
"church_hospice": "Hospice/church donation",
|
||||||
|
"scholarships": "Fund scholarships"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"branchProduction": {
|
"branchProduction": {
|
||||||
|
|||||||
@@ -142,6 +142,44 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="activeTab === 'actions'">
|
||||||
|
<p>
|
||||||
|
{{ $t('falukant.reputation.actions.description') }}
|
||||||
|
</p>
|
||||||
|
<p v-if="reputationActionsDailyCap != null" class="reputation-actions-daily">
|
||||||
|
{{ $t('falukant.reputation.actions.dailyLimit', { remaining: reputationActionsDailyRemaining, cap: reputationActionsDailyCap }) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table v-if="reputationActions.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ $t('falukant.reputation.actions.action') }}</th>
|
||||||
|
<th>{{ $t('falukant.reputation.actions.cost') }}</th>
|
||||||
|
<th>{{ $t('falukant.reputation.actions.gain') }}</th>
|
||||||
|
<th>{{ $t('falukant.reputation.actions.timesUsed') }}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="a in reputationActions" :key="a.id">
|
||||||
|
<td>{{ $t('falukant.reputation.actions.type.' + a.tr) }}</td>
|
||||||
|
<td>{{ Number(a.cost || 0).toLocaleString($i18n.locale) }}</td>
|
||||||
|
<td>+{{ Number(a.currentGain || 0) }}</td>
|
||||||
|
<td>{{ Number(a.timesUsed || 0) }}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" :disabled="runningActionId === a.id"
|
||||||
|
@click.prevent="executeReputationAction(a)">
|
||||||
|
{{ runningActionId === a.id ? $t('falukant.reputation.actions.running') : $t('falukant.reputation.actions.execute') }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-else>
|
||||||
|
{{ $t('falukant.reputation.actions.none') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -159,7 +197,8 @@ export default {
|
|||||||
activeTab: 'overview',
|
activeTab: 'overview',
|
||||||
tabs: [
|
tabs: [
|
||||||
{ value: 'overview', label: 'falukant.reputation.overview.title' },
|
{ value: 'overview', label: 'falukant.reputation.overview.title' },
|
||||||
{ value: 'party', label: 'falukant.reputation.party.title' }
|
{ value: 'party', label: 'falukant.reputation.party.title' },
|
||||||
|
{ value: 'actions', label: 'falukant.reputation.actions.title' }
|
||||||
],
|
],
|
||||||
newPartyView: false,
|
newPartyView: false,
|
||||||
newPartyTypeId: null,
|
newPartyTypeId: null,
|
||||||
@@ -174,6 +213,11 @@ export default {
|
|||||||
inProgressParties: [],
|
inProgressParties: [],
|
||||||
completedParties: [],
|
completedParties: [],
|
||||||
reputation: null,
|
reputation: null,
|
||||||
|
reputationActions: [],
|
||||||
|
reputationActionsDailyCap: null,
|
||||||
|
reputationActionsDailyUsed: null,
|
||||||
|
reputationActionsDailyRemaining: null,
|
||||||
|
runningActionId: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -211,6 +255,41 @@ export default {
|
|||||||
this.reputation = null;
|
this.reputation = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async loadReputationActions() {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get('/api/falukant/reputation/actions');
|
||||||
|
this.reputationActionsDailyCap = data?.dailyCap ?? null;
|
||||||
|
this.reputationActionsDailyUsed = data?.dailyUsed ?? null;
|
||||||
|
this.reputationActionsDailyRemaining = data?.dailyRemaining ?? null;
|
||||||
|
this.reputationActions = Array.isArray(data?.actions) ? data.actions : [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load reputation actions', e);
|
||||||
|
this.reputationActions = [];
|
||||||
|
this.reputationActionsDailyCap = null;
|
||||||
|
this.reputationActionsDailyUsed = null;
|
||||||
|
this.reputationActionsDailyRemaining = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async executeReputationAction(action) {
|
||||||
|
if (!action?.id) return;
|
||||||
|
if (this.runningActionId) return;
|
||||||
|
this.runningActionId = action.id;
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.post('/api/falukant/reputation/actions', { actionTypeId: action.id });
|
||||||
|
const gain = data?.gain ?? null;
|
||||||
|
const cost = data?.cost ?? null;
|
||||||
|
const msg = gain != null
|
||||||
|
? this.$t('falukant.reputation.actions.success', { gain, cost })
|
||||||
|
: this.$t('falukant.reputation.actions.successSimple');
|
||||||
|
this.$root.$refs.messageDialog?.open(this.$t('falukant.reputation.actions.title'), msg);
|
||||||
|
await Promise.all([this.loadReputation(), this.loadReputationActions()]);
|
||||||
|
} catch (e) {
|
||||||
|
const errText = e?.response?.data?.error || e?.message || String(e);
|
||||||
|
this.$root.$refs.messageDialog?.open(this.$t('falukant.reputation.actions.title'), errText);
|
||||||
|
} finally {
|
||||||
|
this.runningActionId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
async loadNobilityTitles() {
|
async loadNobilityTitles() {
|
||||||
this.nobilityTitles = await apiClient.get('/api/falukant/nobility/titels').then(r => r.data)
|
this.nobilityTitles = await apiClient.get('/api/falukant/nobility/titels').then(r => r.data)
|
||||||
},
|
},
|
||||||
@@ -256,13 +335,14 @@ export default {
|
|||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
const tabFromQuery = this.$route?.query?.tab;
|
const tabFromQuery = this.$route?.query?.tab;
|
||||||
if (['overview','party'].includes(tabFromQuery)) {
|
if (['overview','party','actions'].includes(tabFromQuery)) {
|
||||||
this.activeTab = tabFromQuery;
|
this.activeTab = tabFromQuery;
|
||||||
}
|
}
|
||||||
await this.loadPartyTypes();
|
await this.loadPartyTypes();
|
||||||
await this.loadNobilityTitles();
|
await this.loadNobilityTitles();
|
||||||
await this.loadParties();
|
await this.loadParties();
|
||||||
await this.loadReputation();
|
await this.loadReputation();
|
||||||
|
await this.loadReputationActions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -323,4 +403,9 @@ table th {
|
|||||||
border-top: 1px solid #ccc;
|
border-top: 1px solid #ccc;
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reputation-actions-daily {
|
||||||
|
margin: 0.5rem 0 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
Reference in New Issue
Block a user