diff --git a/backend/controllers/falukantController.js b/backend/controllers/falukantController.js index 848b726..acae733 100644 --- a/backend/controllers/falukantController.js +++ b/backend/controllers/falukantController.js @@ -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; diff --git a/backend/migrations/20251220001000-add-reputation-actions.cjs b/backend/migrations/20251220001000-add-reputation-actions.cjs new file mode 100644 index 0000000..a811f0d --- /dev/null +++ b/backend/migrations/20251220001000-add-reputation-actions.cjs @@ -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;`); + }, +}; + + diff --git a/backend/migrations/20251220002000-backfill-reputation-action-window.cjs b/backend/migrations/20251220002000-backfill-reputation-action-window.cjs new file mode 100644 index 0000000..e34e128 --- /dev/null +++ b/backend/migrations/20251220002000-backfill-reputation-action-window.cjs @@ -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; + `); + }, +}; + + diff --git a/backend/migrations/20251220003000-seed-reputation-actions.cjs b/backend/migrations/20251220003000-seed-reputation-actions.cjs new file mode 100644 index 0000000..d642925 --- /dev/null +++ b/backend/migrations/20251220003000-seed-reputation-actions.cjs @@ -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' + ); + `); + }, +}; + + diff --git a/backend/models/falukant/log/reputation_action.js b/backend/models/falukant/log/reputation_action.js new file mode 100644 index 0000000..23f34d3 --- /dev/null +++ b/backend/models/falukant/log/reputation_action.js @@ -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; + + diff --git a/backend/models/falukant/type/reputation_action.js b/backend/models/falukant/type/reputation_action.js new file mode 100644 index 0000000..4ee07ef --- /dev/null +++ b/backend/models/falukant/type/reputation_action.js @@ -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; + + diff --git a/backend/models/index.js b/backend/models/index.js index 74a46e9..1085842 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -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, diff --git a/backend/routers/falukantRouter.js b/backend/routers/falukantRouter.js index 1d36092..c1e1536 100644 --- a/backend/routers/falukantRouter.js +++ b/backend/routers/falukantRouter.js @@ -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); diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index a5bf0d1..d2c5b1a 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -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), diff --git a/backend/utils/falukant/initializeFalukantTypes.js b/backend/utils/falukant/initializeFalukantTypes.js index 2f2d282..c86278b 100644 --- a/backend/utils/falukant/initializeFalukantTypes.js +++ b/backend/utils/falukant/initializeFalukantTypes.js @@ -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", diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index 9c9b13c..8e44881 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -782,6 +782,33 @@ "type": "Festart", "cost": "Kosten", "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": { diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json index 897bf48..8e613af 100644 --- a/frontend/src/i18n/locales/en/falukant.json +++ b/frontend/src/i18n/locales/en/falukant.json @@ -206,6 +206,33 @@ }, "party": { "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": { diff --git a/frontend/src/views/falukant/ReputationView.vue b/frontend/src/views/falukant/ReputationView.vue index cd810ce..82769c1 100644 --- a/frontend/src/views/falukant/ReputationView.vue +++ b/frontend/src/views/falukant/ReputationView.vue @@ -142,6 +142,44 @@ + +
+

+ {{ $t('falukant.reputation.actions.description') }} +

+

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

+ + + + + + + + + + + + + + + + + + + + +
{{ $t('falukant.reputation.actions.action') }}{{ $t('falukant.reputation.actions.cost') }}{{ $t('falukant.reputation.actions.gain') }}{{ $t('falukant.reputation.actions.timesUsed') }}
{{ $t('falukant.reputation.actions.type.' + a.tr) }}{{ Number(a.cost || 0).toLocaleString($i18n.locale) }}+{{ Number(a.currentGain || 0) }}{{ Number(a.timesUsed || 0) }} + +
+

+ {{ $t('falukant.reputation.actions.none') }} +

+
@@ -159,7 +197,8 @@ export default { activeTab: 'overview', tabs: [ { 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, newPartyTypeId: null, @@ -174,6 +213,11 @@ export default { inProgressParties: [], completedParties: [], reputation: null, + reputationActions: [], + reputationActionsDailyCap: null, + reputationActionsDailyUsed: null, + reputationActionsDailyRemaining: null, + runningActionId: null, } }, methods: { @@ -211,6 +255,41 @@ export default { 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() { this.nobilityTitles = await apiClient.get('/api/falukant/nobility/titels').then(r => r.data) }, @@ -256,13 +335,14 @@ export default { }, async mounted() { const tabFromQuery = this.$route?.query?.tab; - if (['overview','party'].includes(tabFromQuery)) { + if (['overview','party','actions'].includes(tabFromQuery)) { this.activeTab = tabFromQuery; } await this.loadPartyTypes(); await this.loadNobilityTitles(); await this.loadParties(); await this.loadReputation(); + await this.loadReputationActions(); } } @@ -323,4 +403,9 @@ table th { border-top: 1px solid #ccc; margin-top: 1em; } + +.reputation-actions-daily { + margin: 0.5rem 0 1rem; + font-weight: bold; +} \ No newline at end of file