26 Commits

Author SHA1 Message Date
Torsten Schulz (local)
de52b6f26d Add raid transport feature and related updates: Introduce new raid transport functionality in FalukantService and FalukantController, including methods for retrieving raid transport regions and handling guard counts. Update frontend components to support guard count input and display related costs. Enhance localization files to include new terms for raid transport and associated metrics in English, German, and Spanish. 2026-03-23 18:47:01 +01:00
Torsten Schulz (local)
43dd1a3b7f Update package dependencies across frontend and backend: Upgrade various libraries including nodemon, sequelize-cli, and axios to their latest versions for improved performance and security. Adjust dependency versions in package.json files for both frontend and backend to ensure compatibility and stability. 2026-03-23 12:16:14 +01:00
Torsten Schulz (local)
22f1803e7d Add localization for marriage gift, lover maintenance, and household order: Update German, English, and Spanish translation files to include new terms related to marriage and household management. Enhance MoneyHistoryView to improve activity translation handling with additional candidate formats. 2026-03-23 12:09:31 +01:00
Torsten Schulz (local)
42e894d4e4 Enhance FalukantService error handling for debtors prison records: Implement try-catch logic to manage potential database errors when retrieving debtor records. Update nobility title requirements to include new house position values for various titles, ensuring consistency across the application. Adjust initialization script for title requirements to reflect these changes. 2026-03-23 12:05:26 +01:00
Torsten Schulz (local)
9b88a98a20 Implement debtors prison features across the application: Enhance FalukantController to include debtors prison logic in various service methods. Update FalukantService to manage debtors prison state and integrate it into user data retrieval. Modify frontend components, including DashboardWidget, StatusBar, and BankView, to display debtors prison status and warnings. Add localization for debtors prison messages in English, German, and Spanish, ensuring clarity in user notifications and actions. 2026-03-23 11:59:59 +01:00
Torsten Schulz (local)
f2343098d2 Refactor political office type references in FalukantService: Update the alias for PoliticalOfficeType from 'officeType' to 'type' in multiple locations to improve clarity and consistency in candidate ranking logic. 2026-03-23 11:11:40 +01:00
Torsten Schulz (local)
57ab85fe10 Implement church career information retrieval and update related components: Add a new method in FalukantService to fetch church career details for characters, including current and approved office levels. Enhance DashboardWidget, StatusBar, and ChurchView components to handle new church-related socket events and display relevant information. Update localization files for church-related terms and error messages in English, German, and Spanish. 2026-03-23 11:05:48 +01:00
Torsten Schulz (local)
ce36315b58 Enhance NobilityView with new house position and condition formatting: Introduce methods to format house position labels and house condition descriptions based on numeric values. Update requirement translations to utilize these new methods for improved clarity and localization. 2026-03-23 10:47:54 +01:00
Torsten Schulz (local)
80d8caee88 Add new requirements for nobility titles and enhance service logic: Introduce checks for reputation, house position, house condition, office rank, and lover count in the FalukantService. Update title requirements in the initialization script to include these new criteria. Enhance localization for requirements in English, German, and Spanish, ensuring accurate translations for new conditions. 2026-03-23 10:31:32 +01:00
Torsten Schulz (local)
b3607849d2 Update servant cost calculation and documentation: Adjust base servant cost formula to reflect a compressed time scale in Falukant, changing the calculation from a realistic monthly wage to an abstract maintenance value. Update implementation spec to clarify the new cost structure and time measurement for the external daemon. 2026-03-23 10:10:11 +01:00
Torsten Schulz (local)
d901257be1 Fix typos in BranchView and OverviewView: Correct spelling of 'Steuerfläche', 'prüfen', and 'alltäglicher' for improved localization accuracy. 2026-03-23 10:04:32 +01:00
Torsten Schulz (local)
d7c59df225 Enhance FamilyView styles for improved layout and responsiveness: Adjust flex properties, grid configurations, and dimensions for better visual consistency. Implement new styles for lover candidate cards and family view to prevent overlap and ensure proper spacing across components. 2026-03-23 09:53:31 +01:00
Torsten Schulz (local)
f7e0d97174 Add marriage management features: Implement endpoints for spending time with, gifting to, and reconciling with spouses in the FalukantController. Update UserHouse model to include household tension attributes. Enhance frontend components to manage marriage actions and display household tension details, including localization updates in multiple languages. 2026-03-23 09:34:56 +01:00
Torsten Schulz (local)
2055c11fd9 Add random chat button to NoLoginView: Introduce a new button for starting random chats, enhancing user engagement options. Update layout for improved accessibility and visual consistency. 2026-03-22 13:19:22 +01:00
Torsten Schulz (local)
f98352088e Refactor NoLoginView styles for improved layout and spacing: Adjust padding, margins, and grid properties to enhance visual consistency and responsiveness across components. 2026-03-22 11:44:44 +01:00
Torsten Schulz (local)
63d9aab66a Update NoLoginView styles: Change action panel alignment from space-between to flex-start for improved layout consistency and responsiveness. 2026-03-22 11:00:18 +01:00
Torsten Schulz (local)
5f9e0a5a49 Refactor AppContent and NoLoginView styles for improved layout: Adjust flex properties and dimensions to enhance responsiveness and ensure consistent spacing across components. 2026-03-22 10:28:24 +01:00
Torsten Schulz (local)
9af974d2f2 Update NoLoginView styling: Adjust action panel dimensions and layout for improved responsiveness and spacing. Enhance flex properties to ensure better alignment and visual consistency across components. 2026-03-22 10:10:36 +01:00
Torsten Schulz (local)
c0f9fc8970 Add lightweight mode to Character3D component: Introduce a new lightweight prop for optimized model loading based on age group. Update NoLoginView to utilize lightweight characters. Adjust styling for better layout and overflow handling in home view components. 2026-03-22 10:05:28 +01:00
Torsten Schulz (local)
876ee2ab49 Add servant management features: Implement endpoints for hiring, dismissing, and setting pay levels for servants in the FalukantController. Update UserHouse model to include servant-related attributes. Enhance frontend components to manage servant details, including staffing state and household order, with corresponding localization updates in multiple languages. 2026-03-22 09:57:44 +01:00
Torsten Schulz (local)
2977b152a2 Implement lover relationship management features: Add endpoints for creating, acknowledging, and managing lover relationships in the FalukantController. Enhance backend models with RelationshipState for tracking relationship statuses. Update frontend components to display and manage lover details, including marriage satisfaction and household tension. Improve localization for new features in multiple languages. 2026-03-20 11:37:46 +01:00
Torsten Schulz (local)
c7d33525ff Enhance usability and localization across components: Update USABILITY_CONCEPT.md with new focus areas, improve user feedback in AppFooter and FamilyView components, and refine text in various UI elements for better clarity and consistency. Replace console logs with user-friendly messages, correct German translations, and streamline interaction logic in multiple components. 2026-03-20 09:41:03 +01:00
Torsten Schulz (local)
1774d7df88 Refactor feedback handling across components: Replace alert and confirm calls with centralized feedback functions for improved user experience. Update various components to utilize showError, showSuccess, and confirmAction for consistent messaging and confirmation dialogs. Enhance UI responsiveness and maintainability by streamlining feedback logic. 2026-03-19 16:18:51 +01:00
Torsten Schulz (local)
2c58ef37c4 Enhance OverviewView component to conditionally display character avatar and house: Introduce a new image container for character representation, ensuring it only renders when a character is present. Refactor existing code to remove duplicate avatar rendering logic and maintain a clean overview layout. 2026-03-19 15:07:53 +01:00
Torsten Schulz (local)
9d44a265ca Refactor backend CORS settings to include default origins and improve error handling in chat services: Introduce dynamic CORS origin handling, enhance RabbitMQ message sending with fallback mechanisms, and update WebSocket service to manage pending messages. Update UI components for better accessibility and responsiveness, including adjustments to dialog and navigation elements. Enhance styling for improved user experience across various components. 2026-03-19 14:44:04 +01:00
Torsten Schulz (local)
4442937ebd Enhance backend configuration and error handling: Update CORS settings to allow dynamic origins, improve RabbitMQ connection handling in chat services, and adjust API server host configuration. Refactor environment variables for better flexibility and add fallback mechanisms for WebSocket and chat services. Update frontend environment files for consistent API and WebSocket URLs. 2026-03-18 22:45:22 +01:00
167 changed files with 22126 additions and 3186 deletions

View File

@@ -36,6 +36,19 @@ const app = express();
// - LOG_ALL_REQ=1: Logge alle Requests
const LOG_ALL_REQ = process.env.LOG_ALL_REQ === '1';
const LOG_SLOW_REQ_MS = Number.parseInt(process.env.LOG_SLOW_REQ_MS || '500', 10);
const defaultCorsOrigins = [
'http://localhost:3000',
'http://localhost:5173',
'http://127.0.0.1:3000',
'http://127.0.0.1:5173'
];
const corsOrigins = (process.env.CORS_ORIGINS || process.env.FRONTEND_URL || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
const effectiveCorsOrigins = corsOrigins.length > 0 ? corsOrigins : defaultCorsOrigins;
const corsAllowAll = process.env.CORS_ALLOW_ALL === '1';
app.use((req, res, next) => {
const reqId = req.headers['x-request-id'] || (crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(8).toString('hex'));
req.reqId = reqId;
@@ -51,15 +64,26 @@ app.use((req, res, next) => {
});
const corsOptions = {
origin: ['http://localhost:3000', 'http://localhost:5173', 'http://127.0.0.1:3000', 'http://127.0.0.1:5173'],
origin(origin, callback) {
if (!origin) {
return callback(null, true);
}
if (corsAllowAll || effectiveCorsOrigins.includes(origin)) {
return callback(null, true);
}
return callback(null, false);
},
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'userId', 'authCode'],
allowedHeaders: ['Content-Type', 'Authorization', 'userid', 'authcode', 'userId', 'authCode'],
credentials: true,
preflightContinue: false,
optionsSuccessStatus: 204
};
app.use(cors(corsOptions));
app.options('*', cors(corsOptions));
app.use(express.json()); // To handle JSON request bodies
app.use('/api/chat', chatRouter);

View File

@@ -29,30 +29,30 @@ class FalukantController {
// Dashboard widget: originaler Endpoint (siehe Commit 62d8cd7)
this.getDashboardWidget = this._wrapWithUser((userId) => this.service.getDashboardWidget(userId));
this.getBranches = this._wrapWithUser((userId) => this.service.getBranches(userId));
this.createBranch = this._wrapWithUser((userId, req) => this.service.createBranch(userId, req.body.cityId, req.body.branchTypeId));
this.createBranch = this._wrapWithUser((userId, req) => this.service.createBranch(userId, req.body.cityId, req.body.branchTypeId), { blockInDebtorsPrison: true });
this.getBranchTypes = this._wrapWithUser((userId) => this.service.getBranchTypes(userId));
this.getBranch = this._wrapWithUser((userId, req) => this.service.getBranch(userId, req.params.branch));
this.upgradeBranch = this._wrapWithUser((userId, req) => this.service.upgradeBranch(userId, req.body.branchId));
this.upgradeBranch = this._wrapWithUser((userId, req) => this.service.upgradeBranch(userId, req.body.branchId), { blockInDebtorsPrison: true });
this.createProduction = this._wrapWithUser((userId, req) => {
const { branchId, productId, quantity } = req.body;
return this.service.createProduction(userId, branchId, productId, quantity);
}, { successStatus: 201 });
}, { successStatus: 201, blockInDebtorsPrison: true });
this.getProduction = this._wrapWithUser((userId, req) => this.service.getProduction(userId, req.params.branchId));
this.getStock = this._wrapWithUser((userId, req) => this.service.getStock(userId, req.params.branchId || null));
this.createStock = this._wrapWithUser((userId, req) => {
const { branchId, stockTypeId, stockSize } = req.body;
return this.service.createStock(userId, branchId, stockTypeId, stockSize);
}, { successStatus: 201 });
}, { successStatus: 201, blockInDebtorsPrison: true });
this.getProducts = this._wrapWithUser((userId) => this.service.getProducts(userId));
this.getInventory = this._wrapWithUser((userId, req) => this.service.getInventory(userId, req.params.branchId));
this.sellProduct = this._wrapWithUser((userId, req) => {
const { branchId, productId, quality, quantity } = req.body;
return this.service.sellProduct(userId, branchId, productId, quality, quantity);
}, { successStatus: 201 });
}, { successStatus: 201, blockInDebtorsPrison: true });
this.sellAllProducts = this._wrapWithUser((userId, req) => {
const { branchId } = req.body;
return this.service.sellAllProducts(userId, branchId);
}, { successStatus: 201 });
}, { successStatus: 201, blockInDebtorsPrison: true });
this.moneyHistory = this._wrapWithUser((userId, req) => {
let { page, filter } = req.body;
if (!page) page = 1;
@@ -66,11 +66,11 @@ class FalukantController {
this.buyStorage = this._wrapWithUser((userId, req) => {
const { branchId, amount, stockTypeId } = req.body;
return this.service.buyStorage(userId, branchId, amount, stockTypeId);
}, { successStatus: 201 });
}, { successStatus: 201, blockInDebtorsPrison: true });
this.sellStorage = this._wrapWithUser((userId, req) => {
const { branchId, amount, stockTypeId } = req.body;
return this.service.sellStorage(userId, branchId, amount, stockTypeId);
}, { successStatus: 202 });
}, { successStatus: 202, blockInDebtorsPrison: true });
this.getStockTypes = this._wrapSimple(() => this.service.getStockTypes());
this.getStockOverview = this._wrapSimple(() => this.service.getStockOverview());
@@ -80,18 +80,18 @@ class FalukantController {
console.log('🔍 getDirectorProposals called with userId:', userId, 'branchId:', req.body.branchId);
return this.service.getDirectorProposals(userId, req.body.branchId);
});
this.convertProposalToDirector = this._wrapWithUser((userId, req) => this.service.convertProposalToDirector(userId, req.body.proposalId));
this.convertProposalToDirector = this._wrapWithUser((userId, req) => this.service.convertProposalToDirector(userId, req.body.proposalId), { blockInDebtorsPrison: true });
this.getDirectorForBranch = this._wrapWithUser((userId, req) => this.service.getDirectorForBranch(userId, req.params.branchId));
this.getAllDirectors = this._wrapWithUser((userId) => this.service.getAllDirectors(userId));
this.updateDirector = this._wrapWithUser((userId, req) => {
const { directorId, income } = req.body;
return this.service.updateDirector(userId, directorId, income);
});
}, { blockInDebtorsPrison: true });
this.setSetting = this._wrapWithUser((userId, req) => {
const { branchId, directorId, settingKey, value } = req.body;
return this.service.setSetting(userId, branchId, directorId, settingKey, value);
});
}, { blockInDebtorsPrison: true });
this.getFamily = this._wrapWithUser(async (userId) => {
const result = await this.service.getFamily(userId);
@@ -99,9 +99,9 @@ class FalukantController {
return result;
});
this.getPotentialHeirs = this._wrapWithUser((userId) => this.service.getPotentialHeirs(userId));
this.selectHeir = this._wrapWithUser((userId, req) => this.service.selectHeir(userId, req.body.heirId));
this.setHeir = this._wrapWithUser((userId, req) => this.service.setHeir(userId, req.body.childCharacterId));
this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId));
this.selectHeir = this._wrapWithUser((userId, req) => this.service.selectHeir(userId, req.body.heirId), { blockInDebtorsPrison: true });
this.setHeir = this._wrapWithUser((userId, req) => this.service.setHeir(userId, req.body.childCharacterId), { blockInDebtorsPrison: true });
this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId), { blockInDebtorsPrison: true });
this.cancelWooing = this._wrapWithUser(async (userId) => {
try {
return await this.service.cancelWooing(userId);
@@ -111,11 +111,25 @@ class FalukantController {
}
throw e;
}
}, { successStatus: 202 });
}, { successStatus: 202, blockInDebtorsPrison: true });
this.getGifts = this._wrapWithUser((userId) => {
console.log('🔍 getGifts called with userId:', userId);
return this.service.getGifts(userId);
});
this.setLoverMaintenance = this._wrapWithUser((userId, req) =>
this.service.setLoverMaintenance(userId, req.params.relationshipId, req.body?.maintenanceLevel), { blockInDebtorsPrison: true });
this.createLoverRelationship = this._wrapWithUser((userId, req) =>
this.service.createLoverRelationship(userId, req.body?.targetCharacterId, req.body?.loverRole), { successStatus: 201, blockInDebtorsPrison: true });
this.spendTimeWithSpouse = this._wrapWithUser((userId) =>
this.service.spendTimeWithSpouse(userId), { blockInDebtorsPrison: true });
this.giftToSpouse = this._wrapWithUser((userId, req) =>
this.service.giftToSpouse(userId, req.body?.giftLevel), { blockInDebtorsPrison: true });
this.reconcileMarriage = this._wrapWithUser((userId) =>
this.service.reconcileMarriage(userId), { blockInDebtorsPrison: true });
this.acknowledgeLover = this._wrapWithUser((userId, req) =>
this.service.acknowledgeLover(userId, req.params.relationshipId), { blockInDebtorsPrison: true });
this.endLoverRelationship = this._wrapWithUser((userId, req) =>
this.service.endLoverRelationship(userId, req.params.relationshipId), { blockInDebtorsPrison: true });
this.getChildren = this._wrapWithUser((userId) => this.service.getChildren(userId));
this.sendGift = this._wrapWithUser(async (userId, req) => {
try {
@@ -126,55 +140,59 @@ class FalukantController {
}
throw e;
}
});
}, { blockInDebtorsPrison: true });
this.getTitlesOfNobility = this._wrapWithUser((userId) => this.service.getTitlesOfNobility(userId));
this.getReputationActions = this._wrapWithUser((userId) => this.service.getReputationActions(userId));
this.executeReputationAction = this._wrapWithUser((userId, req) =>
this.service.executeReputationAction(userId, req.body?.actionTypeId), { successStatus: 201 });
this.service.executeReputationAction(userId, req.body?.actionTypeId), { successStatus: 201, blockInDebtorsPrison: true });
this.getHouseTypes = this._wrapWithUser((userId) => this.service.getHouseTypes(userId));
this.getMoodAffect = this._wrapWithUser((userId) => this.service.getMoodAffect(userId));
this.getCharacterAffect = this._wrapWithUser((userId) => this.service.getCharacterAffect(userId));
this.getUserHouse = this._wrapWithUser((userId) => this.service.getUserHouse(userId));
this.getBuyableHouses = this._wrapWithUser((userId) => this.service.getBuyableHouses(userId));
this.buyUserHouse = this._wrapWithUser((userId, req) => this.service.buyUserHouse(userId, req.body.houseId), { successStatus: 201 });
this.buyUserHouse = this._wrapWithUser((userId, req) => this.service.buyUserHouse(userId, req.body.houseId), { successStatus: 201, blockInDebtorsPrison: true });
this.hireServants = this._wrapWithUser((userId, req) => this.service.hireServants(userId, req.body?.amount), { successStatus: 201, blockInDebtorsPrison: true });
this.dismissServants = this._wrapWithUser((userId, req) => this.service.dismissServants(userId, req.body?.amount), { blockInDebtorsPrison: true });
this.setServantPayLevel = this._wrapWithUser((userId, req) => this.service.setServantPayLevel(userId, req.body?.payLevel), { blockInDebtorsPrison: true });
this.tidyHousehold = this._wrapWithUser((userId) => this.service.tidyHousehold(userId), { blockInDebtorsPrison: true });
this.getPartyTypes = this._wrapWithUser((userId) => this.service.getPartyTypes(userId));
this.createParty = this._wrapWithUser((userId, req) => {
const { partyTypeId, musicId, banquetteId, nobilityIds, servantRatio } = req.body;
return this.service.createParty(userId, partyTypeId, musicId, banquetteId, nobilityIds, servantRatio);
}, { successStatus: 201 });
}, { successStatus: 201, blockInDebtorsPrison: true });
this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId));
this.getNotBaptisedChildren = this._wrapWithUser((userId) => this.service.getNotBaptisedChildren(userId));
this.baptise = this._wrapWithUser((userId, req) => {
const { characterId: childId, firstName } = req.body;
return this.service.baptise(userId, childId, firstName);
});
}, { blockInDebtorsPrison: true });
this.getChurchOverview = this._wrapWithUser((userId) => this.service.getChurchOverview(userId));
this.getAvailableChurchPositions = this._wrapWithUser((userId) => this.service.getAvailableChurchPositions(userId));
this.getSupervisedApplications = this._wrapWithUser((userId) => this.service.getSupervisedApplications(userId));
this.applyForChurchPosition = this._wrapWithUser((userId, req) => {
const { officeTypeId, regionId } = req.body;
return this.service.applyForChurchPosition(userId, officeTypeId, regionId);
});
}, { blockInDebtorsPrison: true });
this.decideOnChurchApplication = this._wrapWithUser((userId, req) => {
const { applicationId, decision } = req.body;
return this.service.decideOnChurchApplication(userId, applicationId, decision);
});
}, { blockInDebtorsPrison: true });
this.getEducation = this._wrapWithUser((userId) => this.service.getEducation(userId));
this.sendToSchool = this._wrapWithUser((userId, req) => {
const { item, student, studentId } = req.body;
return this.service.sendToSchool(userId, item, student, studentId);
});
}, { blockInDebtorsPrison: true });
this.getBankOverview = this._wrapWithUser((userId) => this.service.getBankOverview(userId));
this.getBankCredits = this._wrapWithUser((userId) => this.service.getBankCredits(userId));
this.takeBankCredits = this._wrapWithUser((userId, req) => this.service.takeBankCredits(userId, req.body.height));
this.takeBankCredits = this._wrapWithUser((userId, req) => this.service.takeBankCredits(userId, req.body.height), { blockInDebtorsPrison: true });
this.getNobility = this._wrapWithUser((userId) => this.service.getNobility(userId));
this.advanceNobility = this._wrapWithUser((userId) => this.service.advanceNobility(userId));
this.advanceNobility = this._wrapWithUser((userId) => this.service.advanceNobility(userId), { blockInDebtorsPrison: true });
this.getHealth = this._wrapWithUser((userId) => this.service.getHealth(userId));
this.healthActivity = this._wrapWithUser(async (userId, req) => {
@@ -186,13 +204,13 @@ class FalukantController {
}
throw e;
}
});
}, { blockInDebtorsPrison: true });
this.getPoliticsOverview = this._wrapWithUser((userId) => this.service.getPoliticsOverview(userId));
this.getOpenPolitics = this._wrapWithUser((userId) => this.service.getOpenPolitics(userId));
this.getElections = this._wrapWithUser((userId) => this.service.getElections(userId));
this.vote = this._wrapWithUser((userId, req) => this.service.vote(userId, req.body.votes));
this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds));
this.vote = this._wrapWithUser((userId, req) => this.service.vote(userId, req.body.votes), { blockInDebtorsPrison: true });
this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds), { blockInDebtorsPrison: true });
this.getRegions = this._wrapWithUser((userId) => this.service.getRegions(userId));
this.getBranchTaxes = this._wrapWithUser((userId, req) => this.service.getBranchTaxes(userId, req.params.branchId));
@@ -230,10 +248,12 @@ class FalukantController {
})).filter(i => !Number.isNaN(i.productId) && !Number.isNaN(i.currentPrice));
return this.service.getProductPricesInCitiesBatch(userId, valid, Number.isNaN(currentRegionId) ? null : currentRegionId);
});
this.renovate = this._wrapWithUser((userId, req) => this.service.renovate(userId, req.body.element));
this.renovateAll = this._wrapWithUser((userId) => this.service.renovateAll(userId));
this.renovate = this._wrapWithUser((userId, req) => this.service.renovate(userId, req.body.element), { blockInDebtorsPrison: true });
this.renovateAll = this._wrapWithUser((userId) => this.service.renovateAll(userId), { blockInDebtorsPrison: true });
this.getUndergroundTypes = this._wrapWithUser((userId) => this.service.getUndergroundTypes(userId));
this.getRaidTransportRegions = this._wrapWithUser((userId) => this.service.getRaidTransportRegions(userId));
this.getUndergroundActivities = this._wrapWithUser((userId) => this.service.getUndergroundActivities(userId));
this.getNotifications = this._wrapWithUser((userId) => this.service.getNotifications(userId));
this.getAllNotifications = this._wrapWithUser((userId, req) => this.service.getAllNotifications(userId, req.query.page, req.query.size));
this.markNotificationsShown = this._wrapWithUser((userId) => this.service.markNotificationsShown(userId), { successStatus: 202 });
@@ -258,7 +278,7 @@ class FalukantController {
throw { status: 400, message: 'goal is required for corrupt_politician' };
}
return this.service.createUndergroundActivity(userId, payload);
}, { successStatus: 201 });
}, { successStatus: 201, blockInDebtorsPrison: true });
this.getUndergroundAttacks = this._wrapWithUser((userId, req) => {
const direction = (req.query.direction || '').toLowerCase();
@@ -272,14 +292,14 @@ class FalukantController {
this.getVehicleTypes = this._wrapWithUser((userId) => this.service.getVehicleTypes(userId));
this.buyVehicles = this._wrapWithUser(
(userId, req) => this.service.buyVehicles(userId, req.body),
{ successStatus: 201 }
{ successStatus: 201, blockInDebtorsPrison: true }
);
this.getVehicles = this._wrapWithUser(
(userId, req) => this.service.getVehicles(userId, req.query.regionId)
);
this.createTransport = this._wrapWithUser(
(userId, req) => this.service.createTransport(userId, req.body),
{ successStatus: 201 }
{ successStatus: 201, blockInDebtorsPrison: true }
);
this.getTransportRoute = this._wrapWithUser(
(userId, req) => this.service.getTransportRoute(userId, req.query)
@@ -289,23 +309,26 @@ class FalukantController {
);
this.repairVehicle = this._wrapWithUser(
(userId, req) => this.service.repairVehicle(userId, req.params.vehicleId),
{ successStatus: 200 }
{ successStatus: 200, blockInDebtorsPrison: true }
);
this.repairAllVehicles = this._wrapWithUser(
(userId, req) => this.service.repairAllVehicles(userId, req.body.vehicleIds),
{ successStatus: 200 }
{ successStatus: 200, blockInDebtorsPrison: true }
);
}
_wrapWithUser(fn, { successStatus = 200, postProcess } = {}) {
_wrapWithUser(fn, { successStatus = 200, postProcess, blockInDebtorsPrison = false } = {}) {
return async (req, res) => {
try {
const hashedUserId = extractHashedUserId(req);
if (!hashedUserId) {
return res.status(400).json({ error: 'Missing user identifier' });
}
if (blockInDebtorsPrison) {
await this.service.assertActionAllowedOutsideDebtorsPrison(hashedUserId);
}
const result = await fn(hashedUserId, req, res);
const toSend = postProcess ? postProcess(result) : result;
res.status(successStatus).json(toSend);

View File

@@ -0,0 +1,122 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS falukant_data.relationship_state (
id serial PRIMARY KEY,
relationship_id integer NOT NULL UNIQUE,
marriage_satisfaction integer NOT NULL DEFAULT 55 CHECK (marriage_satisfaction >= 0 AND marriage_satisfaction <= 100),
marriage_public_stability integer NOT NULL DEFAULT 55 CHECK (marriage_public_stability >= 0 AND marriage_public_stability <= 100),
lover_role text NULL CHECK (lover_role IN ('secret_affair', 'lover', 'mistress_or_favorite')),
affection integer NOT NULL DEFAULT 50 CHECK (affection >= 0 AND affection <= 100),
visibility integer NOT NULL DEFAULT 15 CHECK (visibility >= 0 AND visibility <= 100),
discretion integer NOT NULL DEFAULT 50 CHECK (discretion >= 0 AND discretion <= 100),
maintenance_level integer NOT NULL DEFAULT 50 CHECK (maintenance_level >= 0 AND maintenance_level <= 100),
status_fit integer NOT NULL DEFAULT 0 CHECK (status_fit >= -2 AND status_fit <= 2),
monthly_base_cost integer NOT NULL DEFAULT 0 CHECK (monthly_base_cost >= 0),
months_underfunded integer NOT NULL DEFAULT 0 CHECK (months_underfunded >= 0),
active boolean NOT NULL DEFAULT true,
acknowledged boolean NOT NULL DEFAULT false,
exclusive_flag boolean NOT NULL DEFAULT false,
last_monthly_processed_at timestamp with time zone NULL,
last_daily_processed_at timestamp with time zone NULL,
notes_json jsonb NULL,
flags_json jsonb NULL,
created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT relationship_state_relationship_fk
FOREIGN KEY (relationship_id)
REFERENCES falukant_data.relationship(id)
ON DELETE CASCADE
);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS relationship_state_active_idx
ON falukant_data.relationship_state (active);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS relationship_state_lover_role_idx
ON falukant_data.relationship_state (lover_role);
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
ADD COLUMN IF NOT EXISTS legitimacy text NOT NULL DEFAULT 'legitimate';
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
ADD COLUMN IF NOT EXISTS birth_context text NOT NULL DEFAULT 'marriage';
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
ADD COLUMN IF NOT EXISTS public_known boolean NOT NULL DEFAULT false;
`);
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'child_relation_legitimacy_chk'
) THEN
ALTER TABLE falukant_data.child_relation
ADD CONSTRAINT child_relation_legitimacy_chk
CHECK (legitimacy IN ('legitimate', 'acknowledged_bastard', 'hidden_bastard'));
END IF;
END
$$;
`);
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'child_relation_birth_context_chk'
) THEN
ALTER TABLE falukant_data.child_relation
ADD CONSTRAINT child_relation_birth_context_chk
CHECK (birth_context IN ('marriage', 'lover'));
END IF;
END
$$;
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
DROP CONSTRAINT IF EXISTS child_relation_birth_context_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
DROP CONSTRAINT IF EXISTS child_relation_legitimacy_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
DROP COLUMN IF EXISTS public_known;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
DROP COLUMN IF EXISTS birth_context;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
DROP COLUMN IF EXISTS legitimacy;
`);
await queryInterface.sequelize.query(`
DROP TABLE IF EXISTS falukant_data.relationship_state;
`);
},
};

View File

@@ -0,0 +1,60 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
INSERT INTO falukant_data.relationship_state (
relationship_id,
marriage_satisfaction,
marriage_public_stability,
lover_role,
affection,
visibility,
discretion,
maintenance_level,
status_fit,
monthly_base_cost,
months_underfunded,
active,
acknowledged,
exclusive_flag,
created_at,
updated_at
)
SELECT
r.id,
55,
55,
CASE WHEN rt.tr = 'lover' THEN 'lover' ELSE NULL END,
50,
CASE WHEN rt.tr = 'lover' THEN 20 ELSE 15 END,
CASE WHEN rt.tr = 'lover' THEN 45 ELSE 50 END,
50,
0,
CASE WHEN rt.tr = 'lover' THEN 30 ELSE 0 END,
0,
true,
false,
false,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM falukant_data.relationship r
INNER JOIN falukant_type.relationship rt
ON rt.id = r.relationship_type_id
LEFT JOIN falukant_data.relationship_state rs
ON rs.relationship_id = r.id
WHERE rs.id IS NULL
AND rt.tr IN ('lover', 'wooing', 'engaged', 'married');
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
DELETE FROM falukant_data.relationship_state rs
USING falukant_data.relationship r
INNER JOIN falukant_type.relationship rt
ON rt.id = r.relationship_type_id
WHERE rs.relationship_id = r.id
AND rt.tr IN ('lover', 'wooing', 'engaged', 'married');
`);
},
};

View File

@@ -0,0 +1,53 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn(
{ schema: 'falukant_data', tableName: 'user_house' },
'servant_count',
{
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
}
);
await queryInterface.addColumn(
{ schema: 'falukant_data', tableName: 'user_house' },
'servant_quality',
{
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 50
}
);
await queryInterface.addColumn(
{ schema: 'falukant_data', tableName: 'user_house' },
'servant_pay_level',
{
type: Sequelize.STRING(20),
allowNull: false,
defaultValue: 'normal'
}
);
await queryInterface.addColumn(
{ schema: 'falukant_data', tableName: 'user_house' },
'household_order',
{
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 55
}
);
},
async down(queryInterface) {
await queryInterface.removeColumn({ schema: 'falukant_data', tableName: 'user_house' }, 'household_order');
await queryInterface.removeColumn({ schema: 'falukant_data', tableName: 'user_house' }, 'servant_pay_level');
await queryInterface.removeColumn({ schema: 'falukant_data', tableName: 'user_house' }, 'servant_quality');
await queryInterface.removeColumn({ schema: 'falukant_data', tableName: 'user_house' }, 'servant_count');
}
};

View File

@@ -0,0 +1,36 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn(
{ schema: 'falukant_data', tableName: 'user_house' },
'household_tension_score',
{
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 10
}
);
await queryInterface.addColumn(
{ schema: 'falukant_data', tableName: 'user_house' },
'household_tension_reasons_json',
{
type: Sequelize.JSONB,
allowNull: true
}
);
},
async down(queryInterface) {
await queryInterface.removeColumn(
{ schema: 'falukant_data', tableName: 'user_house' },
'household_tension_reasons_json'
);
await queryInterface.removeColumn(
{ schema: 'falukant_data', tableName: 'user_house' },
'household_tension_score'
);
}
};

View File

@@ -0,0 +1,83 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
const table = { schema: 'falukant_data', tableName: 'debtors_prism' };
await queryInterface.addColumn(table, 'status', {
type: Sequelize.STRING,
allowNull: false,
defaultValue: 'delinquent'
});
await queryInterface.addColumn(table, 'entered_at', {
type: Sequelize.DATE,
allowNull: true
});
await queryInterface.addColumn(table, 'released_at', {
type: Sequelize.DATE,
allowNull: true
});
await queryInterface.addColumn(table, 'debt_at_entry', {
type: Sequelize.DECIMAL(14, 2),
allowNull: true
});
await queryInterface.addColumn(table, 'remaining_debt', {
type: Sequelize.DECIMAL(14, 2),
allowNull: true
});
await queryInterface.addColumn(table, 'days_overdue', {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
});
await queryInterface.addColumn(table, 'reason', {
type: Sequelize.STRING,
allowNull: true
});
await queryInterface.addColumn(table, 'creditworthiness_penalty', {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
});
await queryInterface.addColumn(table, 'next_forced_action', {
type: Sequelize.STRING,
allowNull: true
});
await queryInterface.addColumn(table, 'assets_seized_json', {
type: Sequelize.JSONB,
allowNull: true
});
await queryInterface.addColumn(table, 'public_known', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
});
},
async down(queryInterface) {
const table = { schema: 'falukant_data', tableName: 'debtors_prism' };
await queryInterface.removeColumn(table, 'public_known');
await queryInterface.removeColumn(table, 'assets_seized_json');
await queryInterface.removeColumn(table, 'next_forced_action');
await queryInterface.removeColumn(table, 'creditworthiness_penalty');
await queryInterface.removeColumn(table, 'reason');
await queryInterface.removeColumn(table, 'days_overdue');
await queryInterface.removeColumn(table, 'remaining_debt');
await queryInterface.removeColumn(table, 'debt_at_entry');
await queryInterface.removeColumn(table, 'released_at');
await queryInterface.removeColumn(table, 'entered_at');
await queryInterface.removeColumn(table, 'status');
}
};

View File

@@ -0,0 +1,22 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.transport
ADD COLUMN IF NOT EXISTS guard_count integer NOT NULL DEFAULT 0;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.underground
ALTER COLUMN victim_id DROP NOT NULL;
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.transport
DROP COLUMN IF EXISTS guard_count;
`);
}
};

View File

@@ -68,6 +68,7 @@ import PromotionalGiftCharacterTrait from './falukant/predefine/promotional_gift
import PromotionalGiftMood from './falukant/predefine/promotional_gift_mood.js';
import RelationshipType from './falukant/type/relationship.js';
import Relationship from './falukant/data/relationship.js';
import RelationshipState from './falukant/data/relationship_state.js';
import PromotionalGiftLog from './falukant/log/promotional_gift.js';
import HouseType from './falukant/type/house.js';
import BuyableHouse from './falukant/data/buyable_house.js';
@@ -460,6 +461,8 @@ export default function setupAssociations() {
Relationship.belongsTo(FalukantCharacter, { foreignKey: 'character2Id', as: 'character2', });
FalukantCharacter.hasMany(Relationship, { foreignKey: 'character1Id', as: 'relationshipsAsCharacter1', });
FalukantCharacter.hasMany(Relationship, { foreignKey: 'character2Id', as: 'relationshipsAsCharacter2', });
Relationship.hasOne(RelationshipState, { foreignKey: 'relationshipId', as: 'state' });
RelationshipState.belongsTo(Relationship, { foreignKey: 'relationshipId', as: 'relationship' });
PromotionalGiftLog.belongsTo(PromotionalGift, { foreignKey: 'giftId', as: 'gift' });
PromotionalGift.hasMany(PromotionalGiftLog, { foreignKey: 'giftId', as: 'logs' });
@@ -1095,4 +1098,3 @@ export default function setupAssociations() {
CalendarEvent.belongsTo(User, { foreignKey: 'userId', as: 'user' });
User.hasMany(CalendarEvent, { foreignKey: 'userId', as: 'calendarEvents' });
}

View File

@@ -27,7 +27,25 @@ ChildRelation.init(
isHeir: {
type: DataTypes.BOOLEAN,
allowNull: true,
default: false}
default: false},
legitimacy: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'legitimate',
validate: {
isIn: [['legitimate', 'acknowledged_bastard', 'hidden_bastard']]
}},
birthContext: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'marriage',
validate: {
isIn: [['marriage', 'lover']]
}},
publicKnown: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false}
},
{
sequelize,

View File

@@ -7,7 +7,57 @@ DebtorsPrism.init({
// Verknüpfung auf FalukantCharacter
characterId: {
type: DataTypes.INTEGER,
allowNull: false}}, {
allowNull: false
},
status: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'delinquent'
},
enteredAt: {
type: DataTypes.DATE,
allowNull: true
},
releasedAt: {
type: DataTypes.DATE,
allowNull: true
},
debtAtEntry: {
type: DataTypes.DECIMAL(14, 2),
allowNull: true
},
remainingDebt: {
type: DataTypes.DECIMAL(14, 2),
allowNull: true
},
daysOverdue: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
reason: {
type: DataTypes.STRING,
allowNull: true
},
creditworthinessPenalty: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
nextForcedAction: {
type: DataTypes.STRING,
allowNull: true
},
assetsSeizedJson: {
type: DataTypes.JSONB,
allowNull: true
},
publicKnown: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
}
}, {
sequelize,
modelName: 'DebtorsPrism',
tableName: 'debtors_prism',

View File

@@ -0,0 +1,141 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
class RelationshipState extends Model {}
RelationshipState.init(
{
relationshipId: {
type: DataTypes.INTEGER,
allowNull: false,
unique: true,
},
marriageSatisfaction: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 55,
validate: {
min: 0,
max: 100,
},
},
marriagePublicStability: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 55,
validate: {
min: 0,
max: 100,
},
},
loverRole: {
type: DataTypes.STRING,
allowNull: true,
validate: {
isIn: [[null, 'secret_affair', 'lover', 'mistress_or_favorite'].filter(Boolean)],
},
},
affection: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 50,
validate: {
min: 0,
max: 100,
},
},
visibility: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 15,
validate: {
min: 0,
max: 100,
},
},
discretion: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 50,
validate: {
min: 0,
max: 100,
},
},
maintenanceLevel: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 50,
validate: {
min: 0,
max: 100,
},
},
statusFit: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
validate: {
min: -2,
max: 2,
},
},
monthlyBaseCost: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
validate: {
min: 0,
},
},
monthsUnderfunded: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
validate: {
min: 0,
},
},
active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
acknowledged: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
exclusiveFlag: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
lastMonthlyProcessedAt: {
type: DataTypes.DATE,
allowNull: true,
},
lastDailyProcessedAt: {
type: DataTypes.DATE,
allowNull: true,
},
notesJson: {
type: DataTypes.JSONB,
allowNull: true,
},
flagsJson: {
type: DataTypes.JSONB,
allowNull: true,
},
},
{
sequelize,
modelName: 'RelationshipState',
tableName: 'relationship_state',
schema: 'falukant_data',
timestamps: true,
underscored: true,
}
);
export default RelationshipState;

View File

@@ -25,6 +25,11 @@ Transport.init(
type: DataTypes.INTEGER,
allowNull: false,
},
guardCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
},
{
sequelize,
@@ -38,4 +43,3 @@ Transport.init(
export default Transport;

View File

@@ -12,7 +12,7 @@ Underground.init({
allowNull: false},
victimId: {
type: DataTypes.INTEGER,
allowNull: false},
allowNull: true},
parameters: {
type: DataTypes.JSON,
allowNull: true},

View File

@@ -24,6 +24,35 @@ UserHouse.init({
allowNull: false,
defaultValue: 100
},
servantCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
servantQuality: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 50
},
servantPayLevel: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'normal'
},
householdOrder: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 55
},
householdTensionScore: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 10
},
householdTensionReasonsJson: {
type: DataTypes.JSONB,
allowNull: true
},
houseTypeId: {
type: DataTypes.INTEGER,
allowNull: false

View File

@@ -67,6 +67,7 @@ import Notification from './falukant/log/notification.js';
import MarriageProposal from './falukant/data/marriage_proposal.js';
import RelationshipType from './falukant/type/relationship.js';
import Relationship from './falukant/data/relationship.js';
import RelationshipState from './falukant/data/relationship_state.js';
import CharacterTrait from './falukant/type/character_trait.js';
import FalukantCharacterTrait from './falukant/data/falukant_character_trait.js';
import Mood from './falukant/type/mood.js';
@@ -219,6 +220,7 @@ const models = {
MarriageProposal,
RelationshipType,
Relationship,
RelationshipState,
CharacterTrait,
FalukantCharacterTrait,
Mood,

View File

@@ -18,32 +18,32 @@
"author": "",
"license": "ISC",
"dependencies": {
"amqplib": "^0.10.4",
"bcryptjs": "^2.4.3",
"connect-redis": "^7.1.1",
"cors": "^2.8.5",
"amqplib": "^0.10.9",
"bcryptjs": "^3.0.3",
"connect-redis": "^9.0.0",
"cors": "^2.8.6",
"date-fns": "^4.1.0",
"dompurify": "^3.1.7",
"dotenv": "^17.2.1",
"express": "^4.19.2",
"express-session": "^1.18.1",
"i18n": "^0.15.1",
"joi": "^17.13.3",
"jsdom": "^26.1.0",
"multer": "^2.0.0",
"mysql2": "^3.10.3",
"nodemailer": "^7.0.11",
"pg": "^8.12.0",
"dompurify": "^3.3.3",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"express-session": "^1.19.0",
"i18n": "^0.15.3",
"joi": "^18.0.2",
"jsdom": "^29.0.1",
"multer": "^2.1.1",
"mysql2": "^3.20.0",
"nodemailer": "^8.0.3",
"pg": "^8.20.0",
"pg-hstore": "^2.3.4",
"redis": "^4.7.0",
"sequelize": "^6.37.3",
"sharp": "^0.34.3",
"socket.io": "^4.7.5",
"uuid": "^11.1.0",
"ws": "^8.18.0",
"redis": "^5.11.0",
"sequelize": "^6.37.8",
"sharp": "^0.34.5",
"socket.io": "^4.8.3",
"uuid": "^13.0.0",
"ws": "^8.20.0",
"@gltf-transform/cli": "^4.3.0"
},
"devDependencies": {
"sequelize-cli": "^6.6.2"
"sequelize-cli": "^6.6.5"
}
}

View File

@@ -47,6 +47,13 @@ router.get('/dashboard-widget', falukantController.getDashboardWidget);
router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal);
router.post('/family/cancel-wooing', falukantController.cancelWooing);
router.post('/family/set-heir', falukantController.setHeir);
router.post('/family/lover', falukantController.createLoverRelationship);
router.post('/family/marriage/spend-time', falukantController.spendTimeWithSpouse);
router.post('/family/marriage/gift', falukantController.giftToSpouse);
router.post('/family/marriage/reconcile', falukantController.reconcileMarriage);
router.post('/family/lover/:relationshipId/maintenance', falukantController.setLoverMaintenance);
router.post('/family/lover/:relationshipId/acknowledge', falukantController.acknowledgeLover);
router.post('/family/lover/:relationshipId/end', falukantController.endLoverRelationship);
router.get('/heirs/potential', falukantController.getPotentialHeirs);
router.post('/heirs/select', falukantController.selectHeir);
router.get('/family/gifts', falukantController.getGifts);
@@ -61,6 +68,10 @@ router.get('/houses/buyable', falukantController.getBuyableHouses);
router.get('/houses', falukantController.getUserHouse);
router.post('/houses/renovate-all', falukantController.renovateAll);
router.post('/houses/renovate', falukantController.renovate);
router.post('/houses/servants/hire', falukantController.hireServants);
router.post('/houses/servants/dismiss', falukantController.dismissServants);
router.post('/houses/servants/pay-level', falukantController.setServantPayLevel);
router.post('/houses/order', falukantController.tidyHousehold);
router.post('/houses', falukantController.buyUserHouse);
router.get('/party/types', falukantController.getPartyTypes);
router.post('/party', falukantController.createParty);
@@ -101,6 +112,8 @@ router.post('/transports', falukantController.createTransport);
router.get('/transports/route', falukantController.getTransportRoute);
router.get('/transports/branch/:branchId', falukantController.getBranchTransports);
router.get('/underground/types', falukantController.getUndergroundTypes);
router.get('/underground/raid-regions', falukantController.getRaidTransportRegions);
router.get('/underground/activities', falukantController.getUndergroundActivities);
router.get('/notifications', falukantController.getNotifications);
router.get('/notifications/all', falukantController.getAllNotifications);
router.post('/notifications/mark-shown', falukantController.markNotificationsShown);

View File

@@ -13,7 +13,8 @@ import { setupWebSocket } from './utils/socket.js';
import { syncDatabase } from './utils/syncDatabase.js';
// HTTP-Server für API (Port 2020, intern, über Apache-Proxy)
const API_PORT = Number.parseInt(process.env.PORT || '2020', 10);
const API_PORT = Number.parseInt(process.env.API_PORT || process.env.PORT || '2020', 10);
const API_HOST = process.env.API_HOST || '127.0.0.1';
const httpServer = http.createServer(app);
// Socket.io wird nur auf HTTPS-Server bereitgestellt, nicht auf HTTP-Server
// setupWebSocket(httpServer); // Entfernt: Socket.io nur über HTTPS
@@ -25,6 +26,7 @@ const USE_TLS = process.env.SOCKET_IO_TLS === '1';
const TLS_KEY_PATH = process.env.SOCKET_IO_TLS_KEY_PATH;
const TLS_CERT_PATH = process.env.SOCKET_IO_TLS_CERT_PATH;
const TLS_CA_PATH = process.env.SOCKET_IO_TLS_CA_PATH;
const SOCKET_IO_HOST = process.env.SOCKET_IO_HOST || '0.0.0.0';
if (USE_TLS && TLS_KEY_PATH && TLS_CERT_PATH) {
try {
@@ -45,14 +47,14 @@ if (USE_TLS && TLS_KEY_PATH && TLS_CERT_PATH) {
syncDatabase().then(() => {
// API-Server auf Port 2020 (intern, nur localhost)
httpServer.listen(API_PORT, '127.0.0.1', () => {
console.log(`[API] HTTP-Server läuft auf localhost:${API_PORT} (intern, über Apache-Proxy)`);
httpServer.listen(API_PORT, API_HOST, () => {
console.log(`[API] HTTP-Server läuft auf ${API_HOST}:${API_PORT}`);
});
// Socket.io-Server auf Port 4443 (extern, direkt erreichbar)
if (httpsServer) {
httpsServer.listen(SOCKET_IO_PORT, '0.0.0.0', () => {
console.log(`[Socket.io] HTTPS-Server läuft auf Port ${SOCKET_IO_PORT} (direkt erreichbar)`);
httpsServer.listen(SOCKET_IO_PORT, SOCKET_IO_HOST, () => {
console.log(`[Socket.io] HTTPS-Server läuft auf ${SOCKET_IO_HOST}:${SOCKET_IO_PORT}`);
});
}
}).catch(err => {

View File

@@ -3,7 +3,7 @@ import amqp from 'amqplib/callback_api.js';
import User from '../models/community/user.js';
import Room from '../models/chat/room.js';
const RABBITMQ_URL = 'amqp://localhost';
const RABBITMQ_URL = process.env.AMQP_URL || 'amqp://localhost';
const QUEUE = 'oneToOne_messages';
class ChatService {
@@ -13,11 +13,37 @@ class ChatService {
this.users = [];
this.randomChats = [];
this.oneToOneChats = [];
this.channel = null;
this.amqpAvailable = false;
this.initRabbitMq();
}
initRabbitMq() {
amqp.connect(RABBITMQ_URL, (err, connection) => {
if (err) throw err;
connection.createChannel((err, channel) => {
if (err) throw err;
if (err) {
console.warn(`[chatService] RabbitMQ nicht erreichbar (${RABBITMQ_URL}) - fallback ohne Queue wird verwendet.`);
return;
}
connection.on('error', (connectionError) => {
console.warn('[chatService] RabbitMQ-Verbindung fehlerhaft:', connectionError.message);
this.channel = null;
this.amqpAvailable = false;
});
connection.on('close', () => {
console.warn('[chatService] RabbitMQ-Verbindung geschlossen.');
this.channel = null;
this.amqpAvailable = false;
});
connection.createChannel((channelError, channel) => {
if (channelError) {
console.warn('[chatService] RabbitMQ-Channel konnte nicht erstellt werden:', channelError.message);
return;
}
this.channel = channel;
this.amqpAvailable = true;
channel.assertQueue(QUEUE, { durable: false });
});
});
@@ -118,8 +144,14 @@ class ChatService {
history: [messageBundle],
});
}
if (this.channel) {
if (this.channel && this.amqpAvailable) {
try {
this.channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(messageBundle)));
} catch (error) {
console.warn('[chatService] sendToQueue fehlgeschlagen, Queue-Bridge vorübergehend deaktiviert:', error.message);
this.channel = null;
this.amqpAvailable = false;
}
}
}

View File

@@ -2,7 +2,10 @@ import net from 'net';
import fs from 'fs';
import path from 'path';
const DEFAULT_CONFIG = { host: 'localhost', port: 1235 };
const DEFAULT_CONFIG = {
host: process.env.CHAT_TCP_HOST || 'localhost',
port: Number.parseInt(process.env.CHAT_TCP_PORT || '1235', 10),
};
function loadBridgeConfig() {
try {

File diff suppressed because it is too large Load Diff

View File

@@ -2,38 +2,110 @@
import { Server } from 'socket.io';
import amqp from 'amqplib/callback_api.js';
const RABBITMQ_URL = 'amqp://localhost';
const RABBITMQ_URL = process.env.AMQP_URL || 'amqp://localhost';
const QUEUE = 'chat_messages';
const MAX_PENDING_MESSAGES = 500;
function routeMessage(io, message) {
if (!message || typeof message !== 'object') return;
if (message.socketId) {
io.to(message.socketId).emit('newMessage', message);
return;
}
if (message.recipientSocketId) {
io.to(message.recipientSocketId).emit('newMessage', message);
return;
}
if (message.roomId) {
io.to(String(message.roomId)).emit('newMessage', message);
return;
}
if (message.room) {
io.to(String(message.room)).emit('newMessage', message);
return;
}
io.emit('newMessage', message);
}
export function setupWebSocket(server) {
const io = new Server(server);
let channel = null;
let pendingMessages = [];
const flushPendingMessages = () => {
if (!channel || pendingMessages.length === 0) return;
const queued = pendingMessages;
pendingMessages = [];
for (const message of queued) {
try {
channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(message)));
} catch (err) {
console.warn('[webSocketService] Flush fehlgeschlagen, Nachricht bleibt im Fallback:', err.message);
pendingMessages.unshift(message);
break;
}
}
};
amqp.connect(RABBITMQ_URL, (err, connection) => {
if (err) throw err;
if (err) {
console.warn(`[webSocketService] RabbitMQ nicht erreichbar (${RABBITMQ_URL}) - WebSocket läuft ohne Queue-Bridge.`);
return;
}
connection.createChannel((err, channel) => {
if (err) throw err;
connection.on('error', (connectionError) => {
console.warn('[webSocketService] RabbitMQ-Verbindung fehlerhaft:', connectionError.message);
channel = null;
});
connection.on('close', () => {
console.warn('[webSocketService] RabbitMQ-Verbindung geschlossen.');
channel = null;
});
connection.createChannel((channelError, createdChannel) => {
if (channelError) {
console.warn('[webSocketService] RabbitMQ-Channel konnte nicht erstellt werden:', channelError.message);
return;
}
channel = createdChannel;
channel.assertQueue(QUEUE, { durable: false });
channel.consume(QUEUE, (msg) => {
if (!msg) return;
const message = JSON.parse(msg.content.toString());
routeMessage(io, message);
}, { noAck: true });
flushPendingMessages();
});
});
io.on('connection', (socket) => {
console.log('Client connected via WebSocket');
// Konsumiert Nachrichten aus RabbitMQ und sendet sie an den WebSocket-Client
channel.consume(QUEUE, (msg) => {
const message = JSON.parse(msg.content.toString());
io.emit('newMessage', message); // Broadcast an alle Clients
}, { noAck: true });
// Empfangt eine Nachricht vom WebSocket-Client und sendet sie an die RabbitMQ-Warteschlange
socket.on('newMessage', (message) => {
if (channel) {
try {
channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(message)));
} catch (err) {
console.warn('[webSocketService] sendToQueue fehlgeschlagen, nutze In-Memory-Fallback:', err.message);
channel = null;
}
}
if (!channel) {
pendingMessages.push(message);
if (pendingMessages.length > MAX_PENDING_MESSAGES) {
pendingMessages = pendingMessages.slice(-MAX_PENDING_MESSAGES);
}
return;
}
});
socket.on('disconnect', () => {
console.log('Client disconnected');
});
});
});
});
}

View File

@@ -0,0 +1,5 @@
ALTER TABLE falukant_data.user_house
ADD COLUMN IF NOT EXISTS household_tension_score integer NOT NULL DEFAULT 10;
ALTER TABLE falukant_data.user_house
ADD COLUMN IF NOT EXISTS household_tension_reasons_json jsonb NULL;

View File

@@ -0,0 +1,88 @@
-- PostgreSQL-only migration script.
-- Dieses Projekt-Backend nutzt Schemas, JSONB und PostgreSQL-Datentypen.
-- Nicht auf MariaDB/MySQL ausführen.
BEGIN;
CREATE TABLE IF NOT EXISTS falukant_data.relationship_state (
id serial PRIMARY KEY,
relationship_id integer NOT NULL UNIQUE,
marriage_satisfaction integer NOT NULL DEFAULT 55 CHECK (marriage_satisfaction >= 0 AND marriage_satisfaction <= 100),
marriage_public_stability integer NOT NULL DEFAULT 55 CHECK (marriage_public_stability >= 0 AND marriage_public_stability <= 100),
lover_role text NULL CHECK (lover_role IN ('secret_affair', 'lover', 'mistress_or_favorite')),
affection integer NOT NULL DEFAULT 50 CHECK (affection >= 0 AND affection <= 100),
visibility integer NOT NULL DEFAULT 15 CHECK (visibility >= 0 AND visibility <= 100),
discretion integer NOT NULL DEFAULT 50 CHECK (discretion >= 0 AND discretion <= 100),
maintenance_level integer NOT NULL DEFAULT 50 CHECK (maintenance_level >= 0 AND maintenance_level <= 100),
status_fit integer NOT NULL DEFAULT 0 CHECK (status_fit >= -2 AND status_fit <= 2),
monthly_base_cost integer NOT NULL DEFAULT 0 CHECK (monthly_base_cost >= 0),
months_underfunded integer NOT NULL DEFAULT 0 CHECK (months_underfunded >= 0),
active boolean NOT NULL DEFAULT true,
acknowledged boolean NOT NULL DEFAULT false,
exclusive_flag boolean NOT NULL DEFAULT false,
last_monthly_processed_at timestamp with time zone NULL,
last_daily_processed_at timestamp with time zone NULL,
notes_json jsonb NULL,
flags_json jsonb NULL,
created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT relationship_state_relationship_fk
FOREIGN KEY (relationship_id)
REFERENCES falukant_data.relationship(id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS relationship_state_active_idx
ON falukant_data.relationship_state (active);
CREATE INDEX IF NOT EXISTS relationship_state_lover_role_idx
ON falukant_data.relationship_state (lover_role);
ALTER TABLE IF EXISTS falukant_data.child_relation
ADD COLUMN IF NOT EXISTS legitimacy text NOT NULL DEFAULT 'legitimate';
ALTER TABLE IF EXISTS falukant_data.child_relation
ADD COLUMN IF NOT EXISTS birth_context text NOT NULL DEFAULT 'marriage';
ALTER TABLE IF EXISTS falukant_data.child_relation
ADD COLUMN IF NOT EXISTS public_known boolean NOT NULL DEFAULT false;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'child_relation_legitimacy_chk'
) THEN
ALTER TABLE falukant_data.child_relation
ADD CONSTRAINT child_relation_legitimacy_chk
CHECK (legitimacy IN ('legitimate', 'acknowledged_bastard', 'hidden_bastard'));
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'child_relation_birth_context_chk'
) THEN
ALTER TABLE falukant_data.child_relation
ADD CONSTRAINT child_relation_birth_context_chk
CHECK (birth_context IN ('marriage', 'lover'));
END IF;
END
$$;
COMMIT;
-- Rollback separat bei Bedarf:
-- BEGIN;
-- ALTER TABLE falukant_data.child_relation DROP CONSTRAINT IF EXISTS child_relation_birth_context_chk;
-- ALTER TABLE falukant_data.child_relation DROP CONSTRAINT IF EXISTS child_relation_legitimacy_chk;
-- ALTER TABLE falukant_data.child_relation DROP COLUMN IF EXISTS public_known;
-- ALTER TABLE falukant_data.child_relation DROP COLUMN IF EXISTS birth_context;
-- ALTER TABLE falukant_data.child_relation DROP COLUMN IF EXISTS legitimacy;
-- DROP TABLE IF EXISTS falukant_data.relationship_state;
-- COMMIT;

View File

@@ -0,0 +1,7 @@
-- PostgreSQL only
ALTER TABLE falukant_data.user_house
ADD COLUMN IF NOT EXISTS servant_count integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS servant_quality integer NOT NULL DEFAULT 50,
ADD COLUMN IF NOT EXISTS servant_pay_level varchar(20) NOT NULL DEFAULT 'normal',
ADD COLUMN IF NOT EXISTS household_order integer NOT NULL DEFAULT 55;

View File

@@ -0,0 +1,7 @@
-- PostgreSQL only
ALTER TABLE falukant_data.transport
ADD COLUMN IF NOT EXISTS guard_count integer NOT NULL DEFAULT 0;
ALTER TABLE falukant_data.underground
ALTER COLUMN victim_id DROP NOT NULL;

View File

@@ -0,0 +1,5 @@
-- PostgreSQL-only
INSERT INTO falukant_type.underground (tr, cost)
VALUES ('investigate_affair', 7000)
ON CONFLICT (tr) DO UPDATE
SET cost = EXCLUDED.cost;

View File

@@ -0,0 +1,5 @@
-- PostgreSQL only
INSERT INTO falukant_type.underground (tr, cost)
VALUES ('raid_transport', 9000)
ON CONFLICT (tr) DO UPDATE
SET cost = EXCLUDED.cost;

View File

@@ -0,0 +1,50 @@
-- PostgreSQL-only backfill script.
-- Dieses Projekt-Backend nutzt Schemas und PostgreSQL-spezifische SQL-Strukturen.
-- Nicht auf MariaDB/MySQL ausführen.
BEGIN;
INSERT INTO falukant_data.relationship_state (
relationship_id,
marriage_satisfaction,
marriage_public_stability,
lover_role,
affection,
visibility,
discretion,
maintenance_level,
status_fit,
monthly_base_cost,
months_underfunded,
active,
acknowledged,
exclusive_flag,
created_at,
updated_at
)
SELECT
r.id,
55,
55,
CASE WHEN rt.tr = 'lover' THEN 'lover' ELSE NULL END,
50,
CASE WHEN rt.tr = 'lover' THEN 20 ELSE 15 END,
CASE WHEN rt.tr = 'lover' THEN 45 ELSE 50 END,
50,
0,
CASE WHEN rt.tr = 'lover' THEN 30 ELSE 0 END,
0,
true,
false,
false,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM falukant_data.relationship r
INNER JOIN falukant_type.relationship rt
ON rt.id = r.relationship_type_id
LEFT JOIN falukant_data.relationship_state rs
ON rs.relationship_id = r.id
WHERE rs.id IS NULL
AND rt.tr IN ('lover', 'wooing', 'engaged', 'married');
COMMIT;

View File

@@ -0,0 +1,32 @@
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS status varchar(255) NOT NULL DEFAULT 'delinquent';
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS entered_at timestamp with time zone NULL;
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS released_at timestamp with time zone NULL;
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS debt_at_entry numeric(14,2) NULL;
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS remaining_debt numeric(14,2) NULL;
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS days_overdue integer NOT NULL DEFAULT 0;
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS reason varchar(255) NULL;
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS creditworthiness_penalty integer NOT NULL DEFAULT 0;
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS next_forced_action varchar(255) NULL;
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS assets_seized_json jsonb NULL;
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS public_known boolean NOT NULL DEFAULT false;

View File

@@ -0,0 +1,132 @@
-- PostgreSQL only
-- Ersetzt die Standesanforderungen fuer Falukant durch das erweiterte Profilmodell.
DELETE FROM falukant_type.title_requirement
WHERE title_id IN (
SELECT id
FROM falukant_type.title
WHERE label_tr IN (
'civil', 'sir', 'townlord', 'by', 'landlord', 'knight',
'baron', 'count', 'palsgrave', 'margrave', 'landgrave',
'ruler', 'elector', 'imperial-prince', 'duke', 'grand-duke',
'prince-regent', 'king'
)
);
INSERT INTO falukant_type.title_requirement (title_id, requirement_type, requirement_value)
SELECT tm.id, req.requirement_type, req.requirement_value
FROM (
SELECT id, label_tr
FROM falukant_type.title
WHERE label_tr IN (
'civil', 'sir', 'townlord', 'by', 'landlord', 'knight',
'baron', 'count', 'palsgrave', 'margrave', 'landgrave',
'ruler', 'elector', 'imperial-prince', 'duke', 'grand-duke',
'prince-regent', 'king'
)
) tm
JOIN (
VALUES
('civil', 'money', 5000::numeric),
('civil', 'cost', 500::numeric),
('civil', 'house_position', 2::numeric),
('sir', 'branches', 2::numeric),
('sir', 'cost', 1000::numeric),
('sir', 'house_position', 3::numeric),
('townlord', 'cost', 3000::numeric),
('townlord', 'money', 12000::numeric),
('townlord', 'reputation', 18::numeric),
('townlord', 'house_position', 4::numeric),
('by', 'cost', 5000::numeric),
('by', 'money', 18000::numeric),
('by', 'house_position', 4::numeric),
('landlord', 'cost', 7500::numeric),
('landlord', 'money', 26000::numeric),
('landlord', 'reputation', 24::numeric),
('landlord', 'house_position', 4::numeric),
('landlord', 'house_condition', 60::numeric),
('knight', 'cost', 11000::numeric),
('knight', 'money', 38000::numeric),
('knight', 'office_rank_any', 1::numeric),
('knight', 'house_position', 5::numeric),
('baron', 'branches', 4::numeric),
('baron', 'cost', 16000::numeric),
('baron', 'money', 55000::numeric),
('baron', 'house_position', 5::numeric),
('count', 'cost', 23000::numeric),
('count', 'money', 80000::numeric),
('count', 'reputation', 32::numeric),
('count', 'house_position', 5::numeric),
('count', 'house_condition', 68::numeric),
('palsgrave', 'cost', 32000::numeric),
('palsgrave', 'money', 115000::numeric),
('palsgrave', 'office_rank_any', 2::numeric),
('palsgrave', 'house_position', 6::numeric),
('margrave', 'cost', 45000::numeric),
('margrave', 'money', 165000::numeric),
('margrave', 'reputation', 40::numeric),
('margrave', 'house_position', 6::numeric),
('margrave', 'house_condition', 72::numeric),
('margrave', 'lover_count_min', 1::numeric),
('landgrave', 'cost', 62000::numeric),
('landgrave', 'money', 230000::numeric),
('landgrave', 'office_rank_any', 3::numeric),
('landgrave', 'house_position', 6::numeric),
('ruler', 'cost', 85000::numeric),
('ruler', 'money', 320000::numeric),
('ruler', 'reputation', 48::numeric),
('ruler', 'house_position', 7::numeric),
('ruler', 'house_condition', 76::numeric),
('elector', 'cost', 115000::numeric),
('elector', 'money', 440000::numeric),
('elector', 'office_rank_political', 4::numeric),
('elector', 'house_position', 7::numeric),
('elector', 'lover_count_max', 3::numeric),
('imperial-prince', 'cost', 155000::numeric),
('imperial-prince', 'money', 600000::numeric),
('imperial-prince', 'reputation', 56::numeric),
('imperial-prince', 'house_position', 7::numeric),
('imperial-prince', 'house_condition', 80::numeric),
('duke', 'cost', 205000::numeric),
('duke', 'money', 820000::numeric),
('duke', 'office_rank_any', 5::numeric),
('duke', 'house_position', 8::numeric),
('grand-duke', 'cost', 270000::numeric),
('grand-duke', 'money', 1120000::numeric),
('grand-duke', 'reputation', 64::numeric),
('grand-duke', 'house_position', 8::numeric),
('grand-duke', 'house_condition', 84::numeric),
('grand-duke', 'lover_count_min', 1::numeric),
('grand-duke', 'lover_count_max', 3::numeric),
('prince-regent', 'cost', 360000::numeric),
('prince-regent', 'money', 1520000::numeric),
('prince-regent', 'office_rank_any', 6::numeric),
('prince-regent', 'house_position', 9::numeric),
('king', 'cost', 500000::numeric),
('king', 'money', 2100000::numeric),
('king', 'reputation', 72::numeric),
('king', 'house_position', 9::numeric),
('king', 'house_condition', 88::numeric),
('king', 'lover_count_min', 1::numeric),
('king', 'lover_count_max', 4::numeric)
) AS req(label_tr, requirement_type, requirement_value)
ON req.label_tr = tm.label_tr
ON CONFLICT (title_id, requirement_type)
DO UPDATE SET requirement_value = EXCLUDED.requirement_value;

View File

@@ -297,24 +297,24 @@ async function initializeFalukantProducts() {
async function initializeFalukantTitleRequirements() {
const titleRequirements = [
{ labelTr: "civil", requirements: [{ type: "money", value: 5000 }, { type: "cost", value: 500 }] },
{ labelTr: "sir", requirements: [{ type: "branches", value: 2 }, { type: "cost", value: 1000 }] },
{ labelTr: "townlord", requirements: [{ type: "cost", value: 3000 }] },
{ labelTr: "by", requirements: [{ type: "cost", value: 6000 }] },
{ labelTr: "landlord", requirements: [{ type: "cost", value: 9000 }] },
{ labelTr: "knight", requirements: [{ type: "cost", value: 11000 }] },
{ labelTr: "baron", requirements: [{ type: "branches", value: 4 }, { type: "cost", value: 15000 }] },
{ labelTr: "count", requirements: [{ type: "cost", value: 19000 }] },
{ labelTr: "palsgrave", requirements: [{ type: "cost", value: 25000 }] },
{ labelTr: "margrave", requirements: [{ type: "cost", value: 33000 }] },
{ labelTr: "landgrave", requirements: [{ type: "cost", value: 47000 }] },
{ labelTr: "ruler", requirements: [{ type: "cost", value: 66000 }] },
{ labelTr: "elector", requirements: [{ type: "cost", value: 79000 }] },
{ labelTr: "imperial-prince", requirements: [{ type: "cost", value: 99999 }] },
{ labelTr: "duke", requirements: [{ type: "cost", value: 130000 }] },
{ labelTr: "grand-duke",requirements: [{ type: "cost", value: 170000 }] },
{ labelTr: "prince-regent", requirements: [{ type: "cost", value: 270000 }] },
{ labelTr: "king", requirements: [{ type: "cost", value: 500000 }] },
{ labelTr: "civil", requirements: [{ type: "money", value: 5000 }, { type: "cost", value: 500 }, { type: "house_position", value: 2 }] },
{ labelTr: "sir", requirements: [{ type: "branches", value: 2 }, { type: "cost", value: 1000 }, { type: "house_position", value: 3 }] },
{ labelTr: "townlord", requirements: [{ type: "cost", value: 3000 }, { type: "money", value: 12000 }, { type: "reputation", value: 18 }, { type: "house_position", value: 4 }] },
{ labelTr: "by", requirements: [{ type: "cost", value: 5000 }, { type: "money", value: 18000 }, { type: "house_position", value: 4 }] },
{ labelTr: "landlord", requirements: [{ type: "cost", value: 7500 }, { type: "money", value: 26000 }, { type: "reputation", value: 24 }, { type: "house_position", value: 4 }, { type: "house_condition", value: 60 }] },
{ labelTr: "knight", requirements: [{ type: "cost", value: 11000 }, { type: "money", value: 38000 }, { type: "office_rank_any", value: 1 }, { type: "house_position", value: 5 }] },
{ labelTr: "baron", requirements: [{ type: "branches", value: 4 }, { type: "cost", value: 16000 }, { type: "money", value: 55000 }, { type: "house_position", value: 5 }] },
{ labelTr: "count", requirements: [{ type: "cost", value: 23000 }, { type: "money", value: 80000 }, { type: "reputation", value: 32 }, { type: "house_position", value: 5 }, { type: "house_condition", value: 68 }] },
{ labelTr: "palsgrave", requirements: [{ type: "cost", value: 32000 }, { type: "money", value: 115000 }, { type: "office_rank_any", value: 2 }, { type: "house_position", value: 6 }] },
{ labelTr: "margrave", requirements: [{ type: "cost", value: 45000 }, { type: "money", value: 165000 }, { type: "reputation", value: 40 }, { type: "house_position", value: 6 }, { type: "house_condition", value: 72 }, { type: "lover_count_min", value: 1 }] },
{ labelTr: "landgrave", requirements: [{ type: "cost", value: 62000 }, { type: "money", value: 230000 }, { type: "office_rank_any", value: 3 }, { type: "house_position", value: 6 }] },
{ labelTr: "ruler", requirements: [{ type: "cost", value: 85000 }, { type: "money", value: 320000 }, { type: "reputation", value: 48 }, { type: "house_position", value: 7 }, { type: "house_condition", value: 76 }] },
{ labelTr: "elector", requirements: [{ type: "cost", value: 115000 }, { type: "money", value: 440000 }, { type: "office_rank_political", value: 4 }, { type: "house_position", value: 7 }, { type: "lover_count_max", value: 3 }] },
{ labelTr: "imperial-prince", requirements: [{ type: "cost", value: 155000 }, { type: "money", value: 600000 }, { type: "reputation", value: 56 }, { type: "house_position", value: 7 }, { type: "house_condition", value: 80 }] },
{ labelTr: "duke", requirements: [{ type: "cost", value: 205000 }, { type: "money", value: 820000 }, { type: "office_rank_any", value: 5 }, { type: "house_position", value: 8 }] },
{ labelTr: "grand-duke",requirements: [{ type: "cost", value: 270000 }, { type: "money", value: 1120000 }, { type: "reputation", value: 64 }, { type: "house_position", value: 8 }, { type: "house_condition", value: 84 }, { type: "lover_count_min", value: 1 }, { type: "lover_count_max", value: 3 }] },
{ labelTr: "prince-regent", requirements: [{ type: "cost", value: 360000 }, { type: "money", value: 1520000 }, { type: "office_rank_any", value: 6 }, { type: "house_position", value: 9 }] },
{ labelTr: "king", requirements: [{ type: "cost", value: 500000 }, { type: "money", value: 2100000 }, { type: "reputation", value: 72 }, { type: "house_position", value: 9 }, { type: "house_condition", value: 88 }, { type: "lover_count_min", value: 1 }, { type: "lover_count_max", value: 4 }] },
];
const titles = await TitleOfNobility.findAll();
@@ -325,13 +325,6 @@ async function initializeFalukantTitleRequirements() {
const title = titles.find(t => t.labelTr === titleReq.labelTr);
if (!title) continue;
if (i > 1) {
titleReq.requirements.push({
type: "money",
value: 5000 * Math.pow(3, i - 1),
});
}
for (const req of titleReq.requirements) {
requirementsToInsert.push({
titleId: title.id,
@@ -341,6 +334,7 @@ async function initializeFalukantTitleRequirements() {
}
}
await TitleRequirement.destroy({ where: {} });
await TitleRequirement.bulkCreate(requirementsToInsert, { ignoreDuplicates: true });
}

View File

@@ -659,6 +659,14 @@ const undergroundTypes = [
"tr": "rob",
"cost": 500
},
{
"tr": "investigate_affair",
"cost": 7000
},
{
"tr": "raid_transport",
"cost": 9000
},
];
{

View File

@@ -35,7 +35,7 @@ export function setupWebSocket(server) {
export function getIo() {
if (!io) {
throw new Error('Socket.io ist nicht initialisiert!');
return null;
}
return io;
}
@@ -46,6 +46,10 @@ export function getUserSockets() {
export async function notifyUser(recipientHashedUserId, event, data) {
const io = getIo();
if (!io) {
if (DEBUG_SOCKETS) console.warn('[socket.io] notifyUser übersprungen: Socket.io nicht initialisiert');
return;
}
const userSockets = getUserSockets();
try {
const recipientUser = await baseService.getUserByHashedId(recipientHashedUserId);
@@ -70,6 +74,10 @@ export async function notifyUser(recipientHashedUserId, event, data) {
export async function notifyAllUsers(event, data) {
const io = getIo();
if (!io) {
if (DEBUG_SOCKETS) console.warn('[socket.io] notifyAllUsers übersprungen: Socket.io nicht initialisiert');
return;
}
const userSockets = getUserSockets();
try {

View File

@@ -0,0 +1,328 @@
# Falukant: Kirchenämter, Aufstieg und NPC-Besetzung
Dieses Dokument beschreibt das Zielmodell für das Kirchensystem in Falukant. Es fokussiert auf drei Probleme:
- Spieler können sich derzeit nicht sinnvoll auf höhere kirchliche Ämter bewerben.
- Nicht alle Ämter werden besetzt.
- NPCs sollen sich ebenfalls bewerben und Ämter aktiv besetzen.
Der Daemon soll die laufende Besetzung und Beförderung übernehmen. Der eigentliche Antrag des Spielers bleibt ein aktiver Spielzug in der UI.
## 1. Zielbild
Kirchliche Ämter sollen ein lebendes Hierarchiesystem sein:
- Leere Ämter werden nach und nach besetzt.
- Spieler und NPCs konkurrieren um offene Positionen.
- Höhere Amtsträger entscheiden über untere Ebenen.
- Wo kein Spieler-Entscheider vorhanden ist, übernimmt ein NPC-Amtsträger die Entscheidung.
- Wo ganze Hierarchieebenen leer sind, darf das System kontrolliert von unten nach oben oder über Interimslogik nachbesetzen.
## 2. Grundregeln
### 2.1 Bewerbung
- Ein Spieler beantragt ein Amt weiterhin aktiv über die UI.
- NPCs bewerben sich nicht per UI, sondern durch den Daemon.
- Es darf gleichzeitig mehrere Bewerber für dieselbe Position geben.
- Eine Bewerbung ist immer regionsbezogen.
### 2.2 Hierarchie
Kirchenämter bleiben über `church_office_type.hierarchy_level` geordnet.
Der normale Aufstiegspfad ist:
1. `lay-preacher`
2. `village-priest`
3. `parish-priest`
4. `dean`
5. `archdeacon`
6. `bishop`
7. `archbishop`
8. `cardinal`
9. `pope`
### 2.3 Bewerbung auf höhere Ämter
Der aktuelle Fehler "man kann sich nicht auf höhere Positionen bewerben als man gerade hat" soll ersetzt werden durch:
- Ein Charakter darf sich auf das nächsthöhere sinnvolle Amt bewerben.
- Zusätzlich darf ein Charakter sich auf ein höheres Amt bewerben, wenn sein bisher höchstes Kirchenamt die Mindestvoraussetzung erfüllt.
- Das System soll nicht nur aktuelle Ämter, sondern auch die bisher höchste kirchliche Laufbahn berücksichtigen.
Daraus folgt:
- Es reicht nicht, nur aktuelle `church_office` zu prüfen.
- Es muss ein Konzept von `highestChurchOfficeRankEver` geben.
## 3. Entscheidungsmodell für Bewerbungen
### 3.1 Grundsatz
Über eine Bewerbung entscheidet immer das direkt übergeordnete Amt.
Beispiele:
- Über `village-priest` entscheidet `parish-priest`.
- Über `parish-priest` entscheidet `dean`.
- Über `dean` entscheidet `archdeacon`.
### 3.2 Wenn der direkte Vorgesetzte fehlt
Falls das direkt übergeordnete Amt in der relevanten Aufsichtskette nicht besetzt ist:
- Das System sucht das nächsthöhere besetzte Amt.
- Falls überhaupt kein höheres Amt vorhanden ist, greift ein Interimsmodus.
Interimsmodus:
- Für die untersten Ebenen darf der Daemon nach Reputation und Eignung direkt besetzen.
- Für hohe Ämter oberhalb von `bishop` soll das nur sehr zurückhaltend geschehen.
## 4. NPC-Bewerbungen
### 4.1 Ziel
NPCs sollen das Kirchensystem lebendig halten und offene Ämter nach und nach füllen.
### 4.2 Wann NPCs sich bewerben
Der Daemon prüft täglich:
- offene Sitze pro Region und Amt
- vorhandene Spielerbewerbungen
- vorhandene NPC-Kandidaten
NPC-Bewerbungen entstehen bevorzugt wenn:
- ein Amt offen ist
- keine ausreichende Zahl an Bewerbungen existiert
- in der Region oder der Elternregion geeignete NPCs vorhanden sind
### 4.3 Geeignete NPCs
Ein NPC ist grundsätzlich geeignet, wenn:
- er lebt
- er nicht bereits ein gleiches oder höheres unvereinbares Kirchenamt innehat
- sein bisher höchstes Kirchenamt oder seine bisherige Laufbahn die Stufe plausibel macht
- seine Reputation ausreichend ist
Zusätzliche Faktoren für NPC-Eignung:
- Alter
- Gesundheit
- Adelstitel
- Reputation
- bestehendes Kirchenamt
- bisher höchstes Kirchenamt
## 5. Auswahl- und Beförderungslogik
### 5.1 Bewertungswert
Für jede Bewerbung wird ein Score berechnet:
`churchCandidateScore`
Bestandteile:
- bisher höchstes Kirchenamt
- aktuelles Kirchenamt
- Reputation
- Adelstitel
- Alter in idealem Bereich
- regionale Nähe
- ggf. geringe Bonuspunkte für lange Wartezeit
### 5.2 Entscheidung durch Spieler
Wenn der zuständige Vorgesetzte ein Spielercharakter ist:
- Die Bewerbung erscheint wie bisher in der UI.
- Der Spieler kann annehmen oder ablehnen.
- Solange eine Spielerentscheidung aussteht, entscheidet der Daemon nicht automatisch.
Optionaler Timeout:
- Nach längerer Untätigkeit darf später ein automatischer Verfall oder eine automatische Daemon-Entscheidung ergänzt werden.
- Das ist nicht Teil der ersten Ausbaustufe.
### 5.3 Entscheidung durch NPC
Wenn der zuständige Vorgesetzte ein NPC ist:
- Der Daemon entscheidet automatisch.
- Maßgeblich ist primär der Bewerber-Score.
- Zusätzlich wirkt die Reputation des NPC-Vorgesetzten als "Strengefaktor".
## 6. Reputation des NPC-Vorgesetzten
Wenn ein NPC ein Amt innehat, entscheidet er über die unter ihm liegende Position anhand von Reputation.
Das bedeutet:
- Ein angesehener NPC-Vorgesetzter bevorzugt reputationsstarke, standesgemäße und stabile Bewerber.
- Ein schwacher oder verrufener NPC-Vorgesetzter entscheidet unberechenbarer.
Empfohlenes Modell:
- `supervisorInfluence = supervisor.reputation / 100`
- je höher dieser Wert, desto stärker zählt der objektive Bewerber-Score
- bei niedriger Reputation steigt der Zufallsanteil
Praktische Wirkung:
- Hohe NPC-Reputation:
- bessere, berechenbarere Besetzung
- Niedrige NPC-Reputation:
- mehr Fehlbesetzungen
- mehr schwankende Entscheidungen
## 7. Fehlende historische Kirchenlaufbahn
Damit ein Charakter sich später auf höhere Ämter bewerben kann, braucht das System mehr als nur aktuelle `church_office`.
Es wird deshalb ein persistierter Höchstwert benötigt:
- `highestChurchOfficeRankEver`
Empfehlung:
- eigener Wert am Charakter oder in einer Laufbahntabelle
- beim erstmaligen Erreichen eines höheren Kirchenamts aktualisieren
- bei Verlust des Amts nicht zurücksetzen
Ohne diesen Wert bleibt höherer Aufstieg nach Amtsverlust oder Umstrukturierung unzuverlässig.
## 8. Verfügbarkeit in der UI
Die UI soll später drei Dinge klar darstellen:
- aktuelle Ämter
- verfügbare Bewerbungen
- eigene höchste Kirchenlaufbahn
Zusätzlich sinnvoll:
- ob die Entscheidung durch einen Spieler oder NPC getroffen wird
- wer der zuständige Vorgesetzte ist
- ob eine Position automatisch nachbesetzt wird
## 9. Daemon-Aufgaben
Der Daemon soll täglich folgende Schritte ausführen:
### 9.1 Kirchenlage erfassen
- offene Sitze je `church_office_type` und Region zählen
- aktuelle Amtsträger laden
- Spielerbewerbungen laden
- NPC-Kandidaten bestimmen
### 9.2 NPC-Bewerbungen erzeugen
- für vakante Positionen fehlende NPC-Bewerbungen anlegen
- keine Doppelbewerbungen für dieselbe Position erzeugen
### 9.3 Bewerbungen bewerten
- Bewerber-Score berechnen
- zuständigen Vorgesetzten ermitteln
- falls NPC-Vorgesetzter: Entscheidung automatisch treffen
- falls Spieler-Vorgesetzter: Bewerbung offen lassen
### 9.4 Beförderungen und Besetzungen durchführen
- `church_office` anlegen oder aktualisieren
- alte widersprechende Bewerbungen schließen
- `highestChurchOfficeRankEver` aktualisieren
### 9.5 Sonderfall komplett leere Hierarchie
Wenn eine Hierarchiestufe samt Vorgesetzten fehlt:
- untere Ebene darf durch den Daemon interimistisch mit dem besten Kandidaten besetzt werden
- dies soll selten und regelgeleitet geschehen
- für hohe Spitzenämter deutlich restriktiver als für niedrige Ämter
## 10. Event-Kommunikation zwischen Daemon und UI
Neue oder präzisierte Events:
### 10.1 `falukantUpdateChurch`
```json
{
"event": "falukantUpdateChurch",
"user_id": 123,
"reason": "applications"
}
```
Zulässige `reason`-Werte:
- `applications`
- `appointment`
- `promotion`
- `vacancy_fill`
- `npc_decision`
### 10.2 UI-Reaktion
- `applications`:
- Bewerbungslisten neu laden
- `appointment`:
- aktuelle Ämter und verfügbare Ämter neu laden
- `promotion`:
- aktuelle Ämter, verfügbare Ämter, ggf. Sozialstatus/Ansehen neu laden
- `vacancy_fill`:
- aktuelle Ämter und verfügbare Positionen neu laden
- `npc_decision`:
- supervised applications und current positions neu laden
Zusätzlich kann weiterhin `falukantUpdateStatus` gesendet werden.
## 11. Backend-Anpassungen außerhalb des Daemons
Die Daemon-Logik allein reicht nicht. Das Backend muss angepasst werden:
- `getAvailableChurchPositions()` darf nicht nur aktuelle Ämter als Voraussetzung ansehen
- es muss die bisher höchste Kirchenlaufbahn berücksichtigen
- freie Positionen dürfen nicht nur an schon exakt lineare Amtshalter gebunden sein
- Spielerbewerbungen und NPC-Bewerbungen müssen dieselbe Bewertungslogik unterstützen
## 12. Empfohlene Umsetzung in Phasen
### Phase C1
- Konzept `highestChurchOfficeRankEver` einführen
- `getAvailableChurchPositions()` auf höchste Kirchenlaufbahn erweitern
- UI lesbar machen
### Phase C2
- NPC-Bewerbungen im Daemon
- automatische NPC-Entscheidungen
### Phase C3
- Interimsbesetzung für leere Hierarchien
- Feintuning von Reputation und Zufall
## 13. Wichtige Designentscheidungen
- Spieleraufstieg bleibt antragsbasiert
- NPCs füllen das System aktiv auf
- hohe Reputation eines NPC-Vorgesetzten verbessert die Besetzungsqualität
- höhere Ämter sollen auch dann erreichbar bleiben, wenn der Charakter das Voramt nicht mehr aktuell innehat
- komplett leere Kirchenstrukturen dürfen sich wieder aufbauen
## 14. Offene Punkte
- Wo genau `highestChurchOfficeRankEver` gespeichert wird
- ob es zusätzlich `highestChurchOfficeTypeEver` geben soll
- ob automatische NPC-Entscheidungen ein Timeout für offene Spielerbewerbungen bekommen
- wie stark Reputation gegenüber Adelstitel und Alter gewichtet wird

View File

@@ -0,0 +1,403 @@
# Falukant: Schuldturm, Pfändung und wirtschaftlicher Zusammenbruch
Dieses Dokument beschreibt das Zielmodell für den **Schuldturm** in Falukant. Ausgangspunkt ist das bestehende Kreditsystem mit `credit` und dem bereits vorhandenen, aber noch ungenutzten Datenmodell `debtors_prism`.
## 1. Bestandsaufnahme
Bereits vorhanden:
- Kredite in `falukant_data.credit`
- `amount`
- `remaining_amount`
- `interest_rate`
- `falukant_user_id`
- Bankübersicht in [BankView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/BankView.vue)
- Modell `falukant_data.debtors_prism` über [debtors_prism.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/debtors_prism.js)
- Kreditaufnahme und Bankübersicht im Backend in [falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js)
Noch nicht vorhanden:
- fällige Kreditraten mit Verzug
- automatische Mahnlogik
- echte Schuldturm-Logik
- Pfändung / Verwertung von Vermögen
- Reputations- und Sozialfolgen
- Beziehungsfolgen für Liebhaber/Mätressen
- UI für Haftstatus / wirtschaftlichen Zusammenbruch
Wichtig:
- `debtors_prism` existiert bereits, ist aber funktional bisher nicht eingebunden.
- Ein Teil der eigentlichen Tick-Logik gehört in den externen Daemon.
- Das Backend muss dennoch Datenmodell, APIs und UI-Basis bereitstellen.
## 2. Kernidee
Wer seine Kreditverpflichtungen **über 3 Tage** nicht bedient, kommt in den **Schuldturm**.
Schuldturm bedeutet:
- Verlust wirtschaftlicher Handlungsfähigkeit
- staatliche / herrschaftliche Pfändung
- Zwangsverwertung veräußerbarer Güter
- sozialer und familiärer Absturz
Das System soll nicht nur eine Geldstrafe sein, sondern ein spürbarer Statuswechsel im Spiel.
## 3. Auslöser
### 3.1 Kreditverzug
Der Daemon prüft täglich:
- welche Kreditrate fällig war
- ob sie bedient wurde
- wie viele Verzugstage bestehen
Regel:
- `missed_days >= 3` bei mindestens einem aktiven Kredit
- danach Eintritt in den Schuldturm
### 3.2 Verhältnis zu Bankrott
Schuldturm ist der **harte Bankrottpfad für private Kreditverschuldung**.
Das bedeutet:
- nicht jeder Geldmangel führt sofort in den Schuldturm
- aber anhaltender Kreditverzug schon
Bankrott und Schuldturm können später getrennt modelliert werden:
- `wirtschaftlicher Bankrott`
- `privater Kreditverzug / Schuldturm`
Für die erste Stufe dürfen sie aber gekoppelt sein.
## 4. Zustand "im Schuldturm"
Ein Charakter im Schuldturm hat:
- kein normales wirtschaftliches Standing
- stark eingeschränkten Zugriff auf Vermögen
- massive Reputations- und Standesfolgen
Empfohlene Effekte:
- keine neuen Kredite
- keine neuen großen Investitionen
- keine Standeserhöhung
- keine neuen prestigeträchtigen Ämter
- evtl. eingeschränkte politische / kirchliche Karriere
## 5. Pfändungsreihenfolge
Beim Eintritt in den Schuldturm oder im Anschluss über mehrere Ticks wird Vermögen verwertet.
Empfohlene Reihenfolge:
1. frei verfügbares Geld
2. Transportmittel / Fahrzeuge
3. Lagerbestände / verwertbare Waren
4. Häuser / Hausbesitz
5. Schließung von Standorten / Niederlassungen
Wichtig:
- Nicht alles muss in einem Tick geschehen.
- Sinnvoll ist ein mehrstufiger Abbau, damit die UI den Prozess sichtbar machen kann.
## 6. Verwertbare Güter
### 6.1 Fahrzeuge
Transportmittel sollen verkauft werden, sofern sie nicht unpfändbar markiert sind.
Folgen:
- geringere Handlungsfähigkeit
- weniger Handelsoptionen
### 6.2 Lager und Waren
Lagerbestände und handelbare Waren sollen mit Abschlag verwertet werden.
Ziel:
- offene Kreditschuld reduzieren
- laufende Produktion destabilisieren
### 6.3 Haus
Das Haus soll gepfändet werden, wenn die Schuld nicht anders gedeckt werden kann.
Folgen:
- Rückfall auf ein niedrigeres Haus
- Einbruch bei Hauszustand, Hausstand und Dienerschaft
- negative Effekte auf Ehe, Haushalt und Stand
### 6.4 Niederlassungen
Standorte sollen geschlossen werden können, wenn Fahrzeuge/Waren/Haus nicht ausreichen.
Empfehlung:
- zuerst unrentable oder niedrigwertige Niederlassungen
- danach teurere / prestigeträchtigere
## 7. Soziale Folgen
### 7.1 Reputation
Beim Eintritt in den Schuldturm:
- spürbarer einmaliger Reputationsverlust
Während der Haft:
- täglicher oder periodischer weiterer Malus
### 7.2 Kreditwürdigkeit
Es braucht einen eigenen Zustand oder Wert:
- `creditworthiness`
oder
- `credit_penalty_level`
Folgen:
- geringere `availableCredit`
- höhere Gebühren
- evtl. komplette Kreditsperre für längere Zeit
### 7.3 Liebhaber / Mätressen
Liebhaber/Mätressen können abspringen.
Wirkung:
- hohe Chance bei geringer Zuneigung oder niedriger Finanzierung
- höhere Chance bei öffentlich gewordenem Schuldturm
- repräsentative Beziehungen brechen eher bei massivem Statusverlust
Mögliche Folgen:
- Beziehungsende
- starke Senkung von `affection`
- Sichtbarkeit eines Skandals
### 7.4 Ehe und Familie
Der Schuldturm soll auch auf Ehe und Hausfrieden wirken:
- `marriage_satisfaction` sinkt
- `household_tension_score` steigt
- Kinder-/Erbpfad kann instabiler werden
## 8. Bezug zu bereits existierenden Systemen
Der Schuldturm soll sich an bestehende Falukant-Systeme ankoppeln:
- Kredite
- Haus / Dienerschaft
- Familie / Liebschaften
- Reputation
- Produktionszertifikat
- Sozialstatus
### 8.1 Produktionszertifikat
Bankrott / Schuldturm kann ein Sonderfall für Zertifikatsverlust sein.
Das passt zur bereits dokumentierten Regel:
- Herabstufung bei `Bankrott`
### 8.2 Sozialstatus
Während oder nach schwerem Schuldturm:
- kein Aufstieg im Stand
- evtl. spätere Herabstufung im Extremfall
Für die erste Stufe reicht:
- Aufstieg blockieren
## 9. Daemon-Aufgaben
Der externe Daemon soll:
### 9.1 täglich prüfen
- fällige Kreditraten
- bezahlte / unbezahlte Beträge
- Verzugstage je Kredit oder Nutzer
### 9.2 Schuldturm auslösen
Wenn Verzug >= 3 Tage:
- Schuldturmstatus setzen
- Reputations- und Kreditwürdigkeits-Malus anwenden
- Socket-Events senden
### 9.3 Verwertung durchführen
In geordneter Reihenfolge:
- Geld abbuchen
- Fahrzeuge verkaufen
- Waren verwerten
- Häuser pfänden
- Niederlassungen schließen
### 9.4 Familienfolgen anwenden
- Ehe verschlechtern
- Haushaltsspannung erhöhen
- Liebschaften destabilisieren
## 10. Event-Kommunikation zwischen Daemon und UI
Neue Events:
### 10.1 `falukantUpdateDebt`
```json
{
"event": "falukantUpdateDebt",
"user_id": 123,
"reason": "delinquency"
}
```
Mögliche `reason`:
- `delinquency`
- `debtors_prison_entered`
- `asset_seizure`
- `branch_closure`
- `vehicle_liquidation`
- `house_seizure`
- `debtors_prison_released`
### 10.2 UI-Reaktion
- Bankansicht neu laden
- Haus neu laden
- Niederlassungen neu laden
- Statusbar / Dashboard neu laden
- Familienansicht ggf. neu laden
Zusätzlich sinnvoll:
- Toast für Eintritt in den Schuldturm
- Toast für Pfändung / Zwangsverkauf
## 11. Backend-Aufgaben außerhalb des Daemons
Das Backend muss:
- Schuldturmstatus lesbar machen
- Bankansicht um Verzug / Haftstatus erweitern
- veräußerbare Güter für den Daemon eindeutig bereitstellen
- Endpunkte und UI-Infos für den Schuldturm liefern
### 11.1 Datenmodell
Da `debtors_prism` bereits existiert, bietet sich dieses Modell an für:
- `character_id`
- `entered_at`
- `released_at`
- `status`
- `debt_at_entry`
- `remaining_debt`
- `reason`
Falls die Tabelle noch nur `character_id` enthält, muss sie erweitert werden.
### 11.2 Bank-API
Die Bankübersicht soll später zusätzlich liefern:
- `inDebtorsPrison`
- `daysOverdue`
- `nextForcedAction`
- `creditworthiness`
## 12. UI-Anforderungen
### 12.1 Bank
In [BankView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/BankView.vue):
- Hinweis auf Zahlungsverzug
- Warnstufe bei 1 / 2 / 3 Tagen
- eigener Block für Schuldturmstatus
### 12.2 Übersicht / Status
In Falukant-Overview / StatusBar:
- sichtbarer Status "Schuldturm"
- evtl. reduzierter Handlungsstatus
### 12.3 Haus / Niederlassungen
- Hinweise bei Pfändung / Zwangsverkauf
- Schließungsereignisse sichtbar machen
### 12.4 Familie
- Hinweise auf abgesprungene Liebhaber / Mätressen
- Auswirkungen auf Ehe / Haushalt sichtbar
## 13. Empfohlene Umsetzung in Phasen
### Phase D1: Basis
- `debtors_prism` fachlich ausbauen
- Bank-API um Verzug und Haftstatus erweitern
- UI-Warnungen in Bank und Status
### Phase D2: Verwertung
- Fahrzeuge, Waren und Häuser als verwertbare Assets modellieren
- Daemon führt Pfändung schrittweise aus
### Phase D3: Soziale Folgen
- Reputation
- Kreditwürdigkeit
- Liebhaber / Mätressen
- Ehe / Hausfrieden
### Phase D4: Langfristige Folgen
- Produktionszertifikat
- Stand / Karriereblockaden
- eventuelle spätere Herabstufung
## 14. Offene Punkte
- genaue Kreditratenlogik im Daemon
- wie stark Häuser und Niederlassungen mit Abschlag verkauft werden
- ob Schuldturm zeitlich begrenzt oder rein schuldgetrieben endet
- ob Kreditwürdigkeit als eigener numerischer Wert gespeichert wird
## 15. Empfehlung
Für die erste echte Umsetzung:
1. `debtors_prism` ausbauen
2. Verzugstage im Daemon sauber pflegen
3. Eintritt in den Schuldturm sichtbar machen
4. zuerst Fahrzeuge/Waren/Haus, erst danach Niederlassungen
So bleibt der Spielzustand hart, aber nachvollziehbar und technisch gut integrierbar.

View File

@@ -0,0 +1,447 @@
# Falukant: Schuldturm und Pfändung - Daemon-Spezifikation
Dieses Dokument beschreibt die Umsetzung des **Schuldturm-Systems** im externen Daemon.
Wichtig:
- Die projektseitigen DB-Felder, API-Erweiterungen, UI-Warnungen und Aktionssperren sind bereits umgesetzt.
- Der Daemon ist die führende Quelle für:
- Verzugstage
- Eintritt in den Schuldturm
- Pfändung und Verwertung
- soziale Folgen
- Freilassung
## 1. Bereits vorhandene Datenbasis
Bereits im Projekt vorhanden:
- `falukant_data.credit`
- `falukant_data.debtors_prism`
- `falukant_data.user_house`
- inkl. `household_tension_score`
- inkl. `household_tension_reasons_json`
- Familien-/Liebschaftsdaten in:
- `falukant_data.relationship`
- `falukant_data.relationship_state`
- `falukant_data.child_relation`
Bereits erweitert:
- `debtors_prism.status`
- `debtors_prism.entered_at`
- `debtors_prism.released_at`
- `debtors_prism.debt_at_entry`
- `debtors_prism.remaining_debt`
- `debtors_prism.days_overdue`
- `debtors_prism.reason`
- `debtors_prism.creditworthiness_penalty`
- `debtors_prism.next_forced_action`
- `debtors_prism.assets_seized_json`
- `debtors_prism.public_known`
Es sind für den Daemon derzeit keine weiteren DB-Änderungen nötig.
## 2. Grundregel
Ein Charakter kommt in den Schuldturm, wenn:
- mindestens ein aktiver Kredit offen ist
- fällige Kreditbedienung ausbleibt
- und `days_overdue >= 3`
Der Daemon prüft dies im Daily-Tick.
## 3. Zustände
`debtors_prism.status` verwendet mindestens:
- `delinquent`
- `imprisoned`
- `released`
Bedeutung:
- `delinquent`: Kreditverzug, aber noch nicht im Schuldturm
- `imprisoned`: im Schuldturm, Verwertung läuft
- `released`: historischer abgeschlossener Fall
## 4. Daily-Tick
Der Daily-Tick prüft pro Falukant-Nutzer:
1. aktive Kredite
2. verbleibende Schuld
3. geleistete Bedienung seit letztem Tick
4. neue Verzugstage
5. Schuldturm-Eintritt
6. laufende soziale Folgen
7. Verwertungsschritt
### 4.1 Verzugstage
Regel:
- wenn offene Schuld vorhanden und fällige Bedienung ausbleibt:
- `days_overdue += 1`
- wenn Kreditpflicht erfüllt wurde:
- `days_overdue = 0`
- falls nicht im Schuldturm
Wenn noch kein aktiver `debtors_prism`-Eintrag existiert:
- bei erstem Verzug `debtors_prism` anlegen mit
- `status = 'delinquent'`
- `days_overdue = 1`
- `remaining_debt = aktuelle offene Schuld`
- `next_forced_action = 'reminder'`
### 4.2 Warnstufen
Bei Verzug:
- Tag 1:
- `next_forced_action = 'reminder'`
- Event `falukantUpdateDebt` mit `reason: 'delinquency'`
- Tag 2:
- `next_forced_action = 'final_warning'`
- Event `falukantUpdateDebt` mit `reason: 'delinquency'`
- Tag 3:
- Schuldturm-Eintritt
Für Warnstufen senden:
- `falukantUpdateDebt`
- zusätzlich `falukantUpdateStatus`
## 5. Eintritt in den Schuldturm
Bei `days_overdue >= 3`:
- `status = 'imprisoned'`
- `entered_at = now()`
- `released_at = null`
- `debt_at_entry = aktuelle offene Schuld`
- `remaining_debt = aktuelle offene Schuld`
- `reason = 'credit_default'`
- `creditworthiness_penalty += 45`
- `next_forced_action = 'asset_seizure'`
- `public_known = true`
### 5.1 Sofortfolgen bei Eintritt
Einmalig anwenden:
- Reputation deutlich senken
- Empfehlung: `-12`
- `marriage_satisfaction` senken
- Empfehlung: `-10`
- `household_tension_score` erhöhen
- Empfehlung: `+15`
- `household_tension_reasons_json` um `debtorsPrison` ergänzen
Zusätzlich:
- aktive Liebhaber/Mätressen sichtbar destabilisieren
- mindestens `affection -= 4`
- Kreditaufnahme und aktive Falukant-Aktionen bleiben projektseitig bereits gesperrt
Bei Eintritt senden:
- `falukantUpdateDebt`
- `reason: 'debtors_prison_entered'`
- `falukantUpdateStatus`
- `falukantUpdateFamily`
- `reason: 'daily'`
- `falukantHouseUpdate`
- `falukantBranchUpdate`
## 6. Verwertung / Pfändung
Die Verwertung läuft nicht alles auf einmal, sondern schrittweise pro Tick.
Reihenfolge:
1. freies Geld
2. Fahrzeuge
3. Waren / Lagerbestände
4. Haus
5. Niederlassungen
Ziel:
- `remaining_debt` schrittweise senken
- Fortschritt im UI sichtbar machen
### 6.1 Geld
Wenn `falukant_user.money > 0`:
- direkt zur Schuld tilgen
- `remaining_debt -= eingezogener_betrag`
Events:
- `falukantUpdateDebt`
- `reason: 'asset_seizure'`
- `falukantUpdateStatus`
### 6.2 Fahrzeuge
Verkaufe zuerst:
- freie Fahrzeuge
- dann weniger wertvolle Typen
- keine Fahrzeuge in aktiven Transporten im selben Tick anfassen, falls technisch problematisch
Erlös:
- Empfehlung: `vehicle_type.cost * condition_factor * 0.55`
Zusätzlich in `assets_seized_json` protokollieren:
- Typ
- Anzahl
- Erlös
Events:
- `falukantUpdateDebt`
- `reason: 'vehicle_liquidation'`
- `falukantUpdateStatus`
### 6.3 Waren / Lager
Verwertbare Güter:
- Lagerbestände
- Inventar
- handelbare Waren
Erlös:
- Empfehlung: Marktwert mit Abschlag von `35% bis 50%`
Events:
- `falukantUpdateDebt`
- `reason: 'asset_seizure'`
- `falukantUpdateStatus`
- `falukantBranchUpdate`
### 6.4 Haus
Wenn Restschuld nach Geld/Fahrzeugen/Waren weiter hoch ist:
- Haus pfänden
- Spieler auf niedrigeres Haus oder Minimalhaus zurücksetzen
- Dienerschaft reduzieren
- `household_order` senken
Events:
- `falukantUpdateDebt`
- `reason: 'house_seizure'`
- `falukantHouseUpdate`
- `falukantUpdateStatus`
- `falukantUpdateFamily`
- `reason: 'daily'`
### 6.5 Niederlassungen
Wenn weiter nicht gedeckt:
- Niederlassungen schließen
- zuerst niedrige Stufe / niedriger Wert
- Hauptniederlassung nur als letzter Schritt
Events:
- `falukantUpdateDebt`
- `reason: 'branch_closure'`
- `falukantBranchUpdate`
- `falukantUpdateStatus`
## 7. Laufende soziale Folgen im Schuldturm
Solange `status = 'imprisoned'`:
- täglicher Reputationsmalus
- Empfehlung: `-2`
- zusätzliche `creditworthiness_penalty += 1` pro Tag
- `marriage_satisfaction -= 1`
- `household_tension_score += 2`
Wenn aktive Liebschaften bestehen:
- `affection -= 2`
- bei niedriger Zuneigung oder hoher Sichtbarkeit kann Beziehung enden
Empfohlene Absprungregel:
- wenn `affection <= 30` oder `months_underfunded >= 2`
- Chance auf Beziehungsende prüfen
- bei repräsentativen Beziehungen zusätzlich höhere Absprungchance, wenn `public_known = true`
Events bei sozialen Folgewirkungen:
- `falukantUpdateFamily`
- `reason: 'daily'`
- zusätzlich `falukantUpdateStatus`
## 8. Kreditwürdigkeit
Die UI rechnet bereits aus `creditworthiness_penalty` und Status einen sichtbaren Wert.
Der Daemon muss pflegen:
- `creditworthiness_penalty`
- `status`
- `days_overdue`
Empfehlung:
- Eintritt Schuldturm: `+45`
- pro weiterem Hafttag: `+1`
- Hauspfändung: zusätzlich `+10`
- Niederlassungsschließung: zusätzlich `+8`
## 9. Freilassung
Freilassung, wenn:
- keine relevante Restschuld mehr offen ist
oder
- ein definierter Restwert unterschritten wird, falls ihr einen Bagatellgrenzwert wollt
Dann:
- `status = 'released'`
- `released_at = now()`
- `next_forced_action = null`
- `days_overdue = 0`
- `remaining_debt = 0`
Events:
- `falukantUpdateDebt`
- `reason: 'debtors_prison_released'`
- `falukantUpdateStatus`
- `falukantUpdateFamily`
- `reason: 'daily'`
- `falukantHouseUpdate`
- `falukantBranchUpdate`
Keine automatische vollständige soziale Heilung:
- Reputation bleibt reduziert
- Kreditwürdigkeit bleibt reduziert
- Familie/Haus bleiben in Folgezuständen
## 10. Event-Kommunikation zur UI
Der Daemon sendet als Primärevent:
```json
{
"event": "falukantUpdateDebt",
"user_id": 123,
"reason": "delinquency"
}
```
Mögliche `reason`:
- `delinquency`
- `debtors_prison_entered`
- `asset_seizure`
- `vehicle_liquidation`
- `house_seizure`
- `branch_closure`
- `debtors_prison_released`
### 10.1 Begleitevents
Je nach Folge zusätzlich:
- `falukantUpdateStatus`
- `falukantHouseUpdate`
- `falukantBranchUpdate`
- `falukantUpdateFamily`
### 10.2 Empfohlene Minimalregeln
- `delinquency`:
- `falukantUpdateDebt`
- `falukantUpdateStatus`
- `debtors_prison_entered`:
- `falukantUpdateDebt`
- `falukantUpdateStatus`
- `falukantUpdateFamily`
- `falukantHouseUpdate`
- `falukantBranchUpdate`
- `asset_seizure`:
- `falukantUpdateDebt`
- `falukantUpdateStatus`
- optional `falukantBranchUpdate`
- `vehicle_liquidation`:
- `falukantUpdateDebt`
- `falukantUpdateStatus`
- `house_seizure`:
- `falukantUpdateDebt`
- `falukantUpdateStatus`
- `falukantHouseUpdate`
- `falukantUpdateFamily`
- `branch_closure`:
- `falukantUpdateDebt`
- `falukantUpdateStatus`
- `falukantBranchUpdate`
- `debtors_prison_released`:
- `falukantUpdateDebt`
- `falukantUpdateStatus`
- `falukantUpdateFamily`
- `falukantHouseUpdate`
- `falukantBranchUpdate`
## 11. Idempotenz
Der Worker muss idempotent arbeiten.
Wichtig:
- Eintritt in den Schuldturm nicht mehrfach für denselben aktiven Fall auslösen
- Verwertungsschritte nur einmal je Asset anwenden
- `released` nicht erneut freisetzen
Empfehlung:
- pro Tick Transaktion
- pro Nutzer eine klare Reihenfolge
- Änderungen in `assets_seized_json` protokollieren
## 12. Mindestumsetzung für Version 1
Pflicht:
1. Verzugstage pflegen
2. Eintritt nach 3 Tagen
3. Status und Penalty schreiben
4. Geld zuerst einziehen
5. danach Fahrzeuge
6. Events senden
Danach:
7. Hauspfändung
8. Niederlassungsschließung
9. volle Familienfolgen
## 13. Hinweis an den Daemon
Die projektseitigen Grundlagen sind bereits umgesetzt:
- `debtors_prism` ist erweitert
- Bank-/Haus-/Familien-/Übersichts-UI reagiert auf den Status
- aktive Falukant-Aktionen werden im Backend bereits gesperrt, sobald `inDebtorsPrison = true`
Der Daemon muss daher vor allem die Zustände und Folgen zuverlässig schreiben und die dokumentierten Events senden.

View File

@@ -0,0 +1,342 @@
# Falukant: Konzept für Liebhaber, Geliebte und Mätressen
## Ziel
Das Familiensystem von Falukant soll neben Ehe, Verlobung und Nachkommen auch außereheliche Bindungen abbilden. Im frühen Mittelalter sind Liebhaberinnen, Geliebte und Mätressen kein moderner Privatbereich, sondern ein sozialer, wirtschaftlicher und standesabhängiger Faktor. Das System soll deshalb:
- zur Spielwelt passen
- je nach Stand unterschiedlich bewertet werden
- Ansehen, Frömmigkeit und Familienfrieden beeinflussen
- laufende Kosten verursachen
- Stoff für Ereignisse, Skandale und Machtspiele liefern
## Grundprinzip
Eine außereheliche Beziehung ist in Falukant weder pauschal erlaubt noch pauschal verboten. Entscheidend sind:
- öffentlicher Bekanntheitsgrad
- sozialer Stand der Spielfigur
- Familienstand der Spielfigur
- gesellschaftliche Erwartung der Umgebung
- Fähigkeit, die Beziehung finanziell und politisch zu tragen
Die gleiche Beziehung kann für einen niedrigen Stand ruinös, für einen reichen Stadtadeligen unerquicklich, aber handhabbar und für einen hohen Adeligen unter Bedingungen tolerierbar sein.
## Begriffe
Für die Mechanik sollten drei Hauptformen unterschieden werden:
### Heimliche Liebschaft
- diskrete Beziehung ohne offizielle Duldung
- geringe laufende Grundkosten
- erhöhtes Skandal- und Erpressungsrisiko
- besonders gefährlich bei verheirateten Figuren
### Geliebte oder Liebhaber
- wiederkehrende, bekannte außereheliche Beziehung
- im engeren Umfeld teilweise bekannt
- mittlere Unterhaltskosten
- spürbarer Einfluss auf Ehe, Hausstand und Ansehen
### Mätresse oder Favorit
- gesellschaftlich wahrnehmbare, dauerhaft unterhaltene Beziehung
- vor allem für gehobene Stände denkbar
- hohe regelmäßige Kosten
- kann Status, Gerüchte, Neid und politische Verbindungen erzeugen
Hinweis für die Spielwelt: Für männliche und weibliche Spielfiguren soll das System symmetrisch funktionieren. Die gesellschaftliche Reaktion kann jedoch je nach Geschlecht und Stand unterschiedlich stark ausfallen.
## Standeslogik
Die Behandlung soll nicht nur von „gut oder schlecht“ abhängen, sondern vom Stand.
### Unfreie, Freie, einfache Bürger
- außereheliche Beziehungen werden schnell als Verschwendung oder Unsittlichkeit gewertet
- schon geringe Zusatzkosten können den Haushalt destabilisieren
- offenkundige Affären senken Ansehen deutlich
- Heimlichkeit ist wichtiger als Repräsentation
Typische Wirkung:
- stärkerer Ansehensverlust
- erhöhtes Risiko von Streit im Haus
- kaum gesellschaftlicher Nutzen
### Wohlhabende Bürger, Patrizier, städtische Oberschicht
- diskrete Beziehungen können geduldet werden, wenn Haushalt und Ehe nach außen stabil bleiben
- auffällige Affären schaden dem Ruf in Zünften, Rat und Nachbarschaft
- die finanzielle Belastung ist tragbar, wird aber sichtbar
Typische Wirkung:
- bei Diskretion nur mäßiger Ansehensverlust
- bei öffentlichem Bekanntwerden deutlicher Malus
- gelegentlich soziale Vorteile über Kontakte der Geliebten möglich
### Niederer Adel
- Geliebte oder Mätressen sind nicht unvorstellbar, aber müssen „standesgemäß“ geführt werden
- eine vernachlässigte Ehe oder ein niedriger sozialer Rang der Geliebten kann das Haus kompromittieren
- uneheliche Kinder oder öffentliche Kränkungen des Ehepartners schaden besonders
Typische Wirkung:
- moderate bis starke Ansehensschwankungen je nach Öffentlichkeit
- Frömmigkeit und Hausfrieden werden wichtiger
- politische Nebeneffekte möglich
### Hoher Adel
- eine diskret und kostspielig unterhaltene Mätresse kann als Ausdruck von Macht und Überfluss toleriert werden
- dieselbe Situation wird zum Skandal, wenn Haus, Kirche oder Erbfolge bedroht sind
- das Problem ist weniger die bloße Existenz als die öffentliche Unordnung
Typische Wirkung:
- geringe oder neutrale Wirkung bei geordneter Diskretion
- starker Malus bei Skandal, Erpressung, Streit mit Ehepartner oder unehelichen Erbansprüchen
- hohe Unterhaltskosten sind Pflicht, nicht Kür
## Kernwerte pro Beziehung
Jede Liebhaber-Beziehung sollte mindestens diese Werte tragen:
- `type`: heimlich, geliebt, Mätresse/Favorit
- `affection`: Zuneigung und Bindung
- `visibility`: wie bekannt die Beziehung ist
- `discretion`: wie gut sie verborgen oder kontrolliert wird
- `maintenanceLevel`: wie aufwendig die Beziehung unterhalten wird
- `monthlyCost`: laufende Kosten
- `statusFit`: passt die Beziehung zum Stand der Spielfigur
- `householdTension`: Spannungen im eigenen Haus
- `scandalRisk`: Risiko für Gerüchte, Erpressung oder Entdeckung
- `fertilityRisk`
- `politicalValue`
- `churchOffense`
- `favoredByCourt`
## Laufende Kosten
Eine außereheliche Beziehung muss regelmäßig Geld kosten. Sonst wird sie spielerisch zu billig.
### Basiskosten
- Geschenke
- Unterkunft oder Versorgung
- Kleidung und Schmuck
- Reisen, Botengänge, Treffen
### Zusätzliche Kosten bei gehobenen Formen
- eigenes Haus oder eigene Zimmer
- Dienerschaft
- Bewachung oder Diskretionsgeld
- Kleidung auf Standesniveau
- gesellschaftliche Geschenke
### Kostenlogik
Die Kosten sollen aus zwei Faktoren entstehen:
- Beziehungsform
- Stand der Spielfigur
Beispielhaft:
- Heimliche Liebschaft: niedrige Grundkosten, aber höheres Risiko
- Geliebte: mittlere planbare Kosten
- Mätresse/Favorit: hohe planbare Kosten plus mögliche Sonderausgaben
Wichtig:
- Ein hoher Adeliger darf eine Mätresse nicht billig führen.
- Wer zu wenig investiert, verliert Diskretion, Zuneigung und Ansehen.
## Wirkung auf Ansehen
Ansehen soll nicht nur einmalig sinken, sondern über Zustände beeinflusst werden.
### Positive oder neutrale Fälle
- hohe Stände
- gute Diskretion
- Ehe und Haushalt wirken stabil
- keine Erbfolgen oder offenen Kränkungen
- Geliebte steht sozial nicht völlig außerhalb des Hauses
Mögliche Wirkung:
- kein Malus
- geringer passiver Malus
- in Ausnahmefällen leichter Statusbonus als Zeichen von Überfluss und Einfluss
### Negative Fälle
- Beziehung ist öffentlich
- Spielfigur ist verheiratet
- die Ehefrau oder der Ehemann wird sichtbar gedemütigt
- die Geliebte passt nicht zum Stand
- die Kosten ruinieren den Haushalt
- die Kirche oder lokale Autoritäten greifen das Thema auf
Mögliche Wirkung:
- täglicher oder wöchentlicher Ansehensverlust
- einmalige Skandalereignisse
- höhere Kosten für Reputationspflege
- Nachteile bei Standesaufstieg
## Wirkung auf Familienleben
Das System muss spürbar mit Ehe und Haushalt verbunden sein.
### Auf die Ehe
- Ehezufriedenheit sinkt
- Streitwahrscheinlichkeit steigt
- Geschenke oder Feste für den Ehepartner können Konflikte mildern
- bei sehr hoher Spannung drohen Trennung, Rückzug oder öffentliche Kränkung
### Auf Kinder und Erbfolge
- uneheliche Kinder können später Ereignisse auslösen
- anerkannte uneheliche Kinder können Hausfrieden und Stand belasten
- je höher der Stand, desto wichtiger wird die Frage nach legitimer Erbfolge
### Auf den Familienbereich
In `FamilyView` sollte eine Liebhaber-Person nicht nur mit Name und Zuneigung erscheinen, sondern auch mit:
- Form der Beziehung
- monatlichen Kosten
- Bekanntheitsgrad
- aktuellem Einfluss auf Hausfrieden
- aktuellem Einfluss auf Ansehen
## Wirkung auf Kirche und Frömmigkeit
Für die Epoche ist die religiöse Dimension wichtig.
- Hohe Frömmigkeit plus öffentliche Affäre erzeugt stärkere Heuchelei-Strafe.
- Niedrige Frömmigkeit macht Affären sozial nicht folgenlos, kann aber kirchliche Reaktionen weniger überraschend wirken lassen.
- Kirchenspenden oder Bußhandlungen könnten später Skandale abmildern, aber nicht kostenlos neutralisieren.
## Ereignisse
Das System braucht nicht nur passive Werte, sondern Ereignisse.
### Alltägliche Ereignisse
- Wunsch nach Geschenk
- Wunsch nach besserer Unterkunft
- Streit mit Ehepartner
- Bitte um öffentliche Anerkennung
### Risikoereignisse
- Gerücht am Hof oder in der Stadt
- Erpressung durch Diener, Rivalen oder Geistliche
- Schwangerschaft oder uneheliches Kind
- Duell- oder Ehrenkonflikt
- Forderung nach Versorgung eines Kindes
### Standesereignisse
- niedrige Stände: Nachbarschaftsgerede, wirtschaftliche Belastung, häuslicher Streit
- Bürgerliche: Ratshausgerüchte, Zunftschaden, moralischer Druck
- Adel: Hofklatsch, Machtfraktionen, Belastung der Erbfolge, kirchliche Einmischung
## Spielregeln zur Balance
Damit das System interessant bleibt und nicht zur reinen Strafe oder zum Gratisbonus wird:
- maximal eine aktiv unterhaltene Mätresse/Favorit gleichzeitig
- mehrere heimliche Liebschaften sind möglich, aber das Skandalrisiko steigt stark
- hohe Kosten müssen echte Opportunitätskosten erzeugen
- Ansehen darf nicht einfach mit Geld zurückgekauft werden
- zu geringe Versorgung verschlechtert Diskretion und Beziehung
- eine Beziehung darf keinen simplen Gratisbonus auf Werte geben
## UI- und UX-Konzept
Der bestehende Bereich in [FamilyView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/FamilyView.vue) kann direkt ausgebaut werden.
### Anzeige pro Person
- Name und Titel
- Rolle: heimliche Liebschaft, Geliebte, Mätresse/Favorit
- Zuneigung
- Bekanntheitsgrad
- monatliche Kosten
- Standespassung
- aktueller Effekt auf Ansehen
- aktueller Effekt auf Hausfrieden
### Aktionen
- beschenken
- besser unterbringen
- diskret halten
- öffentlich anerkennen
- Beziehung beenden
- Versorgung reduzieren
### Hinweise
- Warnung bei drohendem Skandal
- Warnung bei unpassender Standeswahl
- Warnung bei zu geringer Versorgung
- Hinweis, wenn die Beziehung die Ehe oder den Aufstieg belastet
## Umsetzungsphasen
### Phase 1: Grundsystem
- Beziehungen vom Typ `lover` im Familienbereich sauber anzeigen
- Beziehungstypen unterscheiden
- monatliche Kosten berechnen
- passiven Einfluss auf Ansehen und Hausfrieden einführen
### Phase 2: Reibung und Entscheidungen
- Sichtbarkeit und Diskretion einführen
- Ereignisse zu Streit, Geschenkforderungen und Gerüchten
- Wechselwirkungen mit Ehe und Ansehen
### Phase 3: Tiefe Systeme
- uneheliche Kinder
- Erpressung und kirchliche Reaktionen
- politische oder hofbezogene Nebeneffekte
- Standes- und Erbfolgekonflikte
## Konkrete Empfehlungsregel für Falukant
Als Startregel für die erste spielbare Version:
- jede Liebhaber-Beziehung hat laufende Monatskosten
- jede Beziehung erzeugt je nach Stand einen passiven Ansehensmodifikator
- verheiratete Figuren erhalten zusätzlich Hausfriedensverlust
- hohe Stände können eine diskrete, gut unterhaltene Mätresse mit geringem oder neutralem Ansehensmalus führen
- niedrige und mittlere Stände tragen bei öffentlicher Affäre deutlich stärkere Nachteile
- unzureichende Versorgung erhöht pro Tick Sichtbarkeit, Streit und Skandalrisiko
Damit entsteht genau das gewünschte Spannungsfeld:
- romantisch oder politisch nützlich
- aber nie kostenlos
- gesellschaftlich nie neutral
- je nach Stand anders lesbar und anders gefährlich
## Offene Designentscheidungen
Vor der technischen Umsetzung sollten noch drei Punkte festgelegt werden:
1. Soll es einen festen Wert `householdTension` geben oder soll das über bestehende Ehe-/Familienwerte laufen?
2. Soll Frömmigkeit direkt mit dem Liebhaber-System gekoppelt werden oder erst in einer späteren Kirchenphase?
3. Sollen uneheliche Kinder bereits in Phase 1 möglich sein oder erst ab Phase 3?

View File

@@ -0,0 +1,263 @@
# Falukant: Übergabedokument für den externen Daemon
## Zweck
Dieses Dokument ist die technische Übergabe an den externen Daemon, der nicht Teil dieses Projekts ist.
Es beschreibt:
- welche Daten der Daemon lesen muss
- welche Regeln er anwenden soll
- welche Felder er zurückschreiben muss
- welche Ereignisse und Nebenwirkungen erwartet werden
Die fachlichen Regeln selbst stehen in:
- [FALUKANT_LOVERS_DAEMON_SPEC.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_SPEC.md)
Die lokale technische Datenbasis dieses Projekts steht in:
- [FALUKANT_LOVERS_TECHNICAL_CONCEPT.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_TECHNICAL_CONCEPT.md)
## Architekturgrenze
Wichtig:
- dieses Backend hält die Datenstruktur und liefert Family-/UI-Daten
- der eigentliche Tick-Lauf für Kosten, Ansehen, Ehezufriedenheit und Kinder passiert im externen Daemon
- der externe Daemon ist damit zuständig für die periodische Spiellogik
Dieses Projekt ist nicht zuständig für:
- die Scheduler-Ausführung
- Tick-Zeitpunkte
- operative Daemon-Laufzeit
## Datenquelle
Der externe Daemon arbeitet auf folgenden Tabellen:
- `falukant_data.relationship`
- `falukant_data.relationship_state`
- `falukant_data.character`
- `falukant_data.child_relation`
- `falukant_data.falukant_user`
- `falukant_type.relationship`
- `falukant_type.title`
Optional später:
- Notification-Tabellen
- Frömmigkeits- oder Kirchen-bezogene Tabellen
## Mindestdatensatz pro Tick
Für jede aktive Liebschaft muss der Daemon laden:
- `relationship.id`
- `relationship.character1_id`
- `relationship.character2_id`
- `relationship_type.tr`
- `relationship_state.lover_role`
- `relationship_state.affection`
- `relationship_state.visibility`
- `relationship_state.discretion`
- `relationship_state.maintenance_level`
- `relationship_state.status_fit`
- `relationship_state.monthly_base_cost`
- `relationship_state.months_underfunded`
- `relationship_state.active`
- `relationship_state.acknowledged`
- `relationship_state.last_daily_processed_at`
- `relationship_state.last_monthly_processed_at`
Zusätzlich pro beteiligter Figur:
- `character.id`
- `character.user_id`
- `character.gender`
- `character.birthdate`
- `character.reputation`
- `character.title_of_nobility`
Zusätzlich für Geld:
- `falukant_user.id`
- `falukant_user.money`
Zusätzlich für Ehekontext:
- aktive Beziehung vom Typ `married`, `engaged` oder `wooing`
- `relationship_state.marriage_satisfaction`
Zusätzlich für Kinderprüfung:
- bestehende `child_relation` für dieselben Eltern
## Pflichtlogik Daily Tick
Der externe Daemon muss täglich:
1. Sichtbarkeit anpassen
2. Diskretion anpassen
3. Ehezufriedenheit anpassen
4. Ansehen anpassen
5. Skandalchance prüfen
6. Zustände speichern
7. optionale Benachrichtigung oder Log-Einträge erzeugen
### Daily Input
- alle aktiven `lover`-Beziehungen
- zugehörige Ehebeziehung, falls vorhanden
- Standesgruppe
- das jüngere Alter der beiden Beteiligten `minAge`
### Daily Output
Rückzuschreiben:
- `relationship_state.visibility`
- `relationship_state.discretion`
- `relationship_state.marriage_satisfaction` der Ehebeziehung
- `character.reputation`
- `relationship_state.last_daily_processed_at`
Optional:
- Notification
- Ereignislog
## Pflichtlogik Monthly Tick
Der externe Daemon muss monatlich:
1. Monatskosten berechnen
2. Geld abbuchen
3. Unterversorgung behandeln
4. Kinderchance prüfen
5. ggf. Kind anlegen
6. Folgen auf Ansehen und Ehe anwenden
7. Zustände speichern
### Monthly Output
Rückzuschreiben:
- `falukant_user.money`
- Geldfluss-Log
- `relationship_state.months_underfunded`
- `relationship_state.affection`
- `relationship_state.discretion`
- `relationship_state.visibility`
- `relationship_state.last_monthly_processed_at`
- ggf. `child_relation`
- ggf. neuer Kind-Charakter
## Formeln
Die verbindlichen Regeln und Formeln kommen aus:
- [FALUKANT_LOVERS_DAEMON_SPEC.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_SPEC.md)
Der externe Daemon soll insbesondere exakt übernehmen:
- Standesgruppen
- Monatskostenformel
- Unterversorgungsfolgen
- Ehezufriedenheitslogik
- Reputationslogik
- Altersmalus bei zu jungen Liebschaften
- Sichtbarkeits- und Diskretionslogik
- Skandalchance
- Kinderwahrscheinlichkeit
## Idempotenz
Der externe Daemon muss idempotent arbeiten.
Pflicht:
- Daily Tick nie zweimal für denselben Ingame-Tag auf dieselbe Beziehung anwenden
- Monthly Tick nie zweimal für denselben Ingame-Monat auf dieselbe Beziehung anwenden
Pflichtfelder dafür:
- `last_daily_processed_at`
- `last_monthly_processed_at`
## Transaktionsanforderungen
Folgende Monthly-Vorgänge müssen atomar laufen:
- Geldabbuchung
- Statusänderung der Liebschaft
- Kind-Erzeugung
- Folgeänderung an Ansehen oder Ehe
Empfehlung:
- pro verarbeiteter Beziehung eine DB-Transaktion
## Kind-Erzeugung
Bei erfolgreicher Monatsprüfung auf Kind:
1. neues Kind in `falukant_data.character` anlegen
2. neue `child_relation` anlegen
3. Felder setzen:
- `birth_context = lover`
- `legitimacy = hidden_bastard`
- `public_known = false`
Wenn der Daemon Kinder nicht selbst anlegen soll, muss er stattdessen ein klar definiertes Create-Event an dieses Backend oder an ein anderes Backend-Modul senden. Standardempfehlung ist aber direkte DB-Erzeugung im Daemon.
## Gleichbehandlung der Geschlechter
Der externe Daemon muss dieselben Regeln für männliche und weibliche Spielfiguren anwenden.
Das betrifft:
- Kosten
- Reputationswirkung
- Ehezufriedenheit
- Skandalrisiko
- Status- und Sichtbarkeitslogik
Unterschiedlich ist nur die biologische Kinderentstehung im aktuellen Modell.
## Was dieses Backend dafür bereitstellt
Dieses Projekt stellt aktuell bereit:
- Datenstruktur für `relationship_state`
- Datenstruktur für `child_relation`-Erweiterungen
- Family-API mit lesbaren Zuständen
Später kann dieses Backend zusätzlich bereitstellen:
- Komfort-Endpunkte für Lover-Aktionen
- Admin-/Debug-Ansichten
- eventuelle Helper-Endpoints für den Daemon
## Erwartete externe Deliverables
Damit die externe Daemon-Umsetzung vollständig ist, werden dort mindestens benötigt:
1. Daily-Tick-Job
2. Monthly-Tick-Job
3. SQL- oder ORM-Zugriff auf die Falukant-Tabellen
4. saubere Transaktionslogik
5. Schutz gegen doppelte Verarbeitung
6. Logging oder Monitoring für Tick-Fehler
## Definition of Done für die Übergabe
Die Übergabe an den externen Daemon gilt als vollständig, wenn:
1. Datenfelder und Tabellen eindeutig definiert sind
2. Daily- und Monthly-Inputs beschrieben sind
3. Daily- und Monthly-Outputs beschrieben sind
4. die verbindliche Fachlogik referenziert ist
5. Idempotenz- und Transaktionsanforderungen klar sind
6. Kinder aus Liebschaften technisch beschrieben sind

View File

@@ -0,0 +1,775 @@
# Falukant: Daemon-Spezifikation für Liebhaber, Mätressen, Ehezufriedenheit und uneheliche Kinder
## Zweck
Dieses Dokument beschreibt die konkrete Server- und Daemon-Logik für außereheliche Beziehungen im Familiensystem von Falukant. Es ergänzt das Grundkonzept in [FALUKANT_LOVERS_CONCEPT.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_CONCEPT.md) um exakte Regeln für:
- laufende Kosten
- laufende Änderungen von Ansehen
- neues System `Ehe-Zufriedenheit`
- standesabhängige Wirkung von Ehe und Liebschaften
- mögliche Kinder aus Liebschaften
- gendergleiche Behandlung im Regelwerk
Das Dokument ist bewusst daemon-orientiert, also als Grundlage für periodische Verarbeitung im Backend.
## Bewusst vertagte Themen
Zwei Themen werden in dieser Spezifikation ausdrücklich nur vorgemerkt und nicht in die erste Umsetzung gezogen:
### Dienerschaft
`Dienerschaft` ist ein interessanter späterer Ausbau, weil sie gut zu Diskretion, Repräsentation, Hausstand und Kosten passt. Für die erste Version wird sie jedoch nicht als eigenes System modelliert.
Für Phase 1 gilt deshalb:
- Dienerschaft ist nur indirekt in den Unterhaltskosten enthalten
- keine eigenen Diener-Slots, Rollen oder Haushaltsobjekte
- keine gesonderte Interaktion mit Heimlichkeit oder Hofstatus
Später kann daraus ein eigenes Hausstands- oder Hofsystem entstehen.
### Balancing
Die in diesem Dokument genannten Zahlen sind Regelrahmen für die technische Umsetzung, nicht finale Produktionswerte.
Für Phase 1 gilt deshalb:
- Formeln und relative Verhältnisse sind wichtiger als absolute Zahlen
- Kosten-, Ansehens- und Zufriedenheitswerte werden später nach realen Spieltests feinjustiert
- Balancing ist eine eigene Nachphase und kein Blocker für die erste technische Integration
## Leitprinzipien
### 1. Gleichbehandlung der Geschlechter
Die Regeln für Ansehen, Ehezufriedenheit, Unterhalt, Skandal und soziale Bewertung gelten für männliche und weibliche Spielfiguren gleich.
Das heißt:
- dieselbe Beziehungsform erzeugt dieselben Grundkosten
- dieselbe Sichtbarkeit erzeugt dieselben Reputationsfolgen
- dieselbe Standeslage erzeugt dieselben Modifikatoren
- dieselbe Untreue erzeugt dieselbe Wirkung auf die Ehe
Biologische Unterschiede betreffen nur die Frage, ob aus einer konkreten Paarung natürlich ein Kind entstehen kann. Die soziale und spielmechanische Behandlung bleibt gleich.
### 2. Stand vor Moral
Das System bewertet nicht abstrakt „Treue“ oder „Untreue“, sondern:
- wie geordnet die Situation ist
- wie standesgemäß sie geführt wird
- wie sichtbar sie ist
- ob Ehe, Haus und Erbfolge destabilisiert werden
### 2a. Zu jung ist reputationsschädlich
Sehr junge Liebschaften sollen im Daemon nie neutral behandelt werden.
Das heißt:
- eine Beziehung kann technisch erlaubt sein
- sie kann aber trotzdem das Ansehen zusätzlich belasten
- dieser Malus kommt zusätzlich zu Sichtbarkeit, Skandal und Stand
- der Altersmalus gilt geschlechtsunabhängig
### 3. Daemon statt Einmal-Effekt
Kosten, Ehezufriedenheit, Sichtbarkeit und Ansehen werden nicht nur beim Anlegen oder Beenden einer Beziehung verändert, sondern laufend im Daemon fortgeschrieben.
## Bestehende Anknüpfungspunkte
Das System passt auf die vorhandenen Strukturen:
- Beziehungen werden bereits über `Relationship` geführt in [relationship.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/relationship.js)
- Kinder werden bereits über `ChildRelation` geführt in [child_relation.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/child_relation.js)
- Ansehen ist bereits auf dem Charakter vorhanden in [character.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/character.js)
- Stand ist bereits über `titleOfNobility` abbildbar in [title_of_nobility.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/type/title_of_nobility.js)
- `lover` ist bereits ein vorhandener Beziehungstyp
## Neue Spielwerte
## A. Pro Spielfigur: Ehezufriedenheit
Neuer Charakter- oder Ehewert:
- `marriageSatisfaction`
- Wertebereich `0..100`
- Standardwert bei frischer Ehe: `55`
Interpretation:
- `0..19`: offene Ehekrise
- `20..39`: stark belastet
- `40..59`: angespannt bis normal
- `60..79`: stabil
- `80..100`: sehr stabil
Wichtig:
- Ehezufriedenheit gehört zur Ehebeziehung, nicht zur Person allein.
- Technisch ist dafür eine eigene Beziehungstabelle oder eine Erweiterung der Ehe-`Relationship` sinnvoll.
- Für eine erste Version kann der Wert aber auf der Ehebeziehung gespeichert werden.
## B. Pro Liebhaber-Beziehung
Für jede Beziehung vom Typ `lover` werden zusätzliche Felder benötigt.
Pflichtfelder:
- `loverRole`
- `secret_affair`
- `lover`
- `mistress_or_favorite`
- `affection`
- `0..100`
- `visibility`
- `0..100`
- `discretion`
- `0..100`
- `maintenanceLevel`
- `0..100`
- `statusFit`
- `-2..2`
- `monthlyBaseCost`
- integer
- `active`
- boolean
- `acknowledged`
- boolean
- `exclusive`
- boolean optional
Abgeleitete, nicht zwingend gespeicherte Werte:
- `monthlyTotalCost`
- `reputationDeltaDaily`
- `marriageDeltaDaily`
- `scandalRiskDaily`
- `pregnancyChanceMonthly`
## C. Optionaler Kinderwert
Für Kinder aus Liebschaften wird kein neues Kindmodell benötigt. `ChildRelation` reicht, benötigt aber zusätzlich:
- `legitimacy`
- `legitimate`
- `acknowledged_bastard`
- `hidden_bastard`
- `birthContext`
- `marriage`
- `lover`
- `publicKnown`
- boolean
## Standesgruppen
Für alle Daemon-Berechnungen werden Adelstitel auf vier Gruppen verdichtet:
### Gruppe 0: niedrige Stände
- `noncivil`
- `civil`
- `sir`
### Gruppe 1: wohlhabende Bürger und lokale Oberschicht
- `townlord`
- `by`
- `landlord`
### Gruppe 2: niederer und mittlerer Adel
- `knight`
- `baron`
- `count`
- `palsgrave`
- `margrave`
- `landgrave`
### Gruppe 3: hoher Adel und Herrschaft
- `ruler`
- `elector`
- `imperial-prince`
- `duke`
- `grand-duke`
- `prince-regent`
- `king`
Diese Gruppen steuern:
- Toleranz gegenüber sichtbaren Liebschaften
- erforderliches Unterhaltsniveau
- Wirkung auf Ehezufriedenheit
- Strafe bei Skandal
## Taktung im Daemon
Empfohlene Verarbeitung:
- `daily tick`: alle 24 Ingame-Stunden
- `monthly tick`: alle 30 Ingame-Tage
Aufteilung:
### Daily Tick
- Sichtbarkeit und Diskretion anpassen
- Ehezufriedenheit anpassen
- Ansehen anpassen
- Skandalrisiko prüfen
- Ereignisse auslösen
### Monthly Tick
- Unterhaltskosten abbuchen
- Beziehungskosten neu berechnen
- Kinderchance prüfen
- Statuswechsel prüfen
## Kostenmodell
## 1. Grundkosten pro Monat
### secret_affair
- Basis: `10`
### lover
- Basis: `30`
### mistress_or_favorite
- Basis: `80`
Diese Werte sind Ingame-Basiswerte und sollen relativ zu Falukant-Geldwerten noch feinjustiert werden.
## 2. Standesmultiplikator
### Gruppe 0
- `x 1.0`
### Gruppe 1
- `x 1.6`
### Gruppe 2
- `x 2.6`
### Gruppe 3
- `x 4.0`
Begründung:
- Höhere Stände können sich die Beziehung leisten.
- Gleichzeitig muss sie teurer sein, weil „standesgemäß“ mehr Aufwand verlangt.
## 3. Unterhaltsfaktor
`maintenanceFactor = 0.6 + (maintenanceLevel / 100) * 1.2`
Beispiele:
- `0` => `0.6`
- `50` => `1.2`
- `100` => `1.8`
Niedrige Versorgung spart kurzfristig Geld, erhöht aber später Sichtbarkeit, Unzufriedenheit und Skandalrisiko.
## 4. Status-Fit-Kosten
Wenn `statusFit < 0`, steigen Kosten für Diskretion und Konfliktpflege:
- `statusFit = -1` => `+15 %`
- `statusFit = -2` => `+35 %`
## 5. Monatsformel
`monthlyTotalCost = round(baseCost * rankMultiplier * maintenanceFactor * statusFitModifier)`
## 6. Folgen bei Nichtzahlung
Wenn der Monatsbetrag nicht vollständig gezahlt werden kann:
- `affection -8`
- `discretion -6`
- `visibility +8`
- `marriageSatisfaction -4` falls verheiratet
- `reputation -1` sofort, falls `visibility >= 40`
Bei zwei aufeinanderfolgenden Monaten Unterversorgung:
- tägliches Skandalrisiko zusätzlich `+2 %`
## Ehezufriedenheit: Grundmodell
## 1. Basistendenz pro Tag
Jede bestehende Ehe bewegt sich täglich leicht Richtung `55`, wenn keine besonderen Faktoren wirken.
Formel:
- wenn `marriageSatisfaction > 55`: `-1` alle 3 Tage
- wenn `marriageSatisfaction < 55`: `+1` alle 5 Tage
So bleiben Ehen nicht dauerhaft extrem, wenn nichts passiert.
## 2. Modifikatoren durch Liebschaften
Nur wenn eine aktive Ehe und mindestens eine aktive Liebschaft existiert.
### Gruppe 0
- heimliche Liebschaft: `-1` pro Tag
- lover: `-2` pro Tag
- Mätresse/Favorit: `-3` pro Tag
### Gruppe 1
- heimliche Liebschaft: `-1` pro Tag
- lover: `-1` pro Tag
- Mätresse/Favorit: `-2` pro Tag
### Gruppe 2
- heimliche Liebschaft: `0 bis -1` pro Tag
- lover: `-1` pro Tag
- Mätresse/Favorit: `-1` pro Tag
### Gruppe 3
- heimliche Liebschaft: `0`
- lover: `0`
- Mätresse/Favorit: `+1 / 0 / -1` je nach Ordnung
Für Gruppe 3 gilt:
- `+1`, wenn:
- `visibility <= 35`
- `maintenanceLevel >= 65`
- `marriageSatisfaction >= 45`
- nur eine aktive Mätresse/Favorit vorhanden
- `0`, wenn die Lage geordnet, aber nicht positiv ist
- `-1`, wenn die Beziehung sichtbar Unruhe erzeugt
Das bildet den gewünschten Spezialfall ab:
- Bei einem König kann eine diskrete, geordnete Nebenbeziehung die Ehe sogar entlasten oder stabilisieren.
- Dieselbe Lage kippt ins Negative, wenn sie chaotisch oder öffentlich demütigend wird.
## 3. Zusätzliche Ehemodifikatoren
### Positive Faktoren
- Ehepartner regelmäßig beschenkt: `+1` pro Tag für 5 Tage
- großes Fest oder Hochzeitspflege: `+2..+5` einmalig
- keine aktive Liebschaft und hohe Versorgung des Hauses: `+1` alle 4 Tage
### Negative Faktoren
- sichtbare Liebschaft `visibility >= 60`: `-2` pro Tag
- Liebschaft mit `minAge <= 15`: zusätzlich `-1` pro Tag
- Kind aus Liebschaft wird bekannt: `-8` einmalig
- zwei oder mehr aktive Liebschaften: `-2` pro Tag zusätzlich
- Unterhaltsausfall bei Mätresse/Favorit: `-1` pro Tag
## Ansehen: Grundmodell
Ansehen wird im Daily Tick pro aktiver Liebschaft angepasst.
## 1. Basiswert pro Beziehungsform
### secret_affair
- `-0.2` pro Tag
### lover
- `-0.4` pro Tag
### mistress_or_favorite
- `-0.6` pro Tag
Diese Werte sind Rohwerte vor Modifikatoren.
## 2. Sichtbarkeitsfaktor
`visibilityFactor = 0.4 + (visibility / 100) * 1.6`
Beispiele:
- `0` => `0.4`
- `50` => `1.2`
- `100` => `2.0`
## 3. Standesmodifikator auf Reputationsverlust
### Gruppe 0
- `x 1.8`
### Gruppe 1
- `x 1.3`
### Gruppe 2
- `x 1.0`
### Gruppe 3
- `x 0.7`
Aber:
- bei Gruppe 3 gilt nur dann `0.7`, wenn die Beziehung geordnet ist
- bei Skandal, Erbfolgedruck oder offener Demütigung springt Gruppe 3 auf `1.5`
## 4. Ordnungsbonus
Wenn alle Bedingungen erfüllt sind:
- `maintenanceLevel >= 65`
- `discretion >= 60`
- `visibility <= 35`
- maximal eine aktive Mätresse/Favorit
dann:
- Gruppe 2: `+0.1` Ansehen pro Tag statt Malus bei `mistress_or_favorite`
- Gruppe 3: `+0.2` Ansehen pro Tag statt Malus bei `mistress_or_favorite`
Das repräsentiert:
- geordneten Überfluss
- höfische Attraktivität
- kontrollierte Nebenbeziehung ohne Hauschaos
Für `secret_affair` und normalen `lover` gibt es keinen positiven Reputationswert.
## 5. Tagesformel
`dailyReputationDelta = baseValue * visibilityFactor * rankModifier`
Dann:
- Ordnungsbonus anwenden, falls aktiv
- auf `[-3, +1]` pro Tag je Beziehung begrenzen
## 5a. Altersmalus bei zu jungen Liebschaften
Zusätzlich zur normalen Reputationsformel wird ein eigener Altersmalus berechnet.
Grundlage ist immer das jüngere Alter der beiden Beteiligten:
- `minAge <= 13`: `ageReputationDelta = -1.5` pro Tag
- `minAge <= 15`: `ageReputationDelta = -0.8` pro Tag
- `minAge <= 17`: `ageReputationDelta = -0.3` pro Tag
- `minAge >= 18`: `ageReputationDelta = 0`
Dann gilt:
`finalDailyReputationDelta = dailyReputationDelta + ageReputationDelta`
Zusatzregel:
- wenn `minAge <= 15` und `visibility >= 50`, zusätzlicher Malus `-0.5` pro Tag
Damit gilt dann:
`finalDailyReputationDelta = dailyReputationDelta + ageReputationDelta + visibilityYoungPenalty`
Interpretation:
- junge Beziehungen sind schon im privaten Bereich reputationsschädlich
- sichtbare junge Beziehungen schaden noch stärker
- der Malus kommt zusätzlich zu allen übrigen Skandal- und Sichtbarkeitsfolgen
## 6. Harte Malus-Ereignisse
Zusätzliche Einmal-Effekte:
- öffentliches Gerücht: `-3`
- kirchlicher Tadel: `-5`
- bekanntes Kind aus Liebschaft: `-6`
- Erbfolgestreit durch uneheliches Kind: `-10`
- zwei sichtbare Liebschaften gleichzeitig: `-4`
## Sichtbarkeit und Diskretion
Diese Werte verändern sich täglich.
## Sichtbarkeit + pro Tag
- `+1`, wenn `maintenanceLevel < 35`
- `+1`, wenn `affection < 30`
- `+2`, wenn `statusFit = -2`
- `+1`, wenn bereits ein Ehekonflikt aktiv ist
## Sichtbarkeit - pro Tag
- `-1`, wenn `discretion >= 60`
- `-1`, wenn `maintenanceLevel >= 70`
## Diskretion + pro Tag
- `+1`, wenn `maintenanceLevel >= 70`
## Diskretion - pro Tag
- `-1`, wenn `maintenanceLevel < 35`
- `-1`, wenn `visibility > 60`
Beide Werte bleiben in `0..100`.
## Skandalrisiko
Tägliche Grundchance:
`baseScandalChance = 1 %`
Modifikatoren:
- `+ visibility / 25`
- `+2 %`, wenn verheiratet
- `+2 %`, wenn `statusFit = -2`
- `+3 %`, wenn `maintenanceLevel < 25`
- `+3 %`, wenn zwei oder mehr aktive Liebschaften bestehen
- `+6 %`, wenn `minAge <= 13`
- `+3 %`, wenn `minAge <= 15`
- `+1 %`, wenn `minAge <= 17`
- `-2 %`, wenn `discretion >= 75`
- `-2 %`, wenn Gruppe 3 und Beziehung geordnet als Mätresse/Favorit geführt wird
Deckel:
- Minimum `0 %`
- Maximum `25 %` pro Tag
Mögliche Ereignisse:
- Gerücht
- Ehekrach
- Forderung nach höherem Unterhalt
- kirchlicher Tadel
- Erpressung
- Kind wird bekannt
## Kinder aus Liebschaften
## 1. Grundsatz
Kinder aus Liebschaften sind möglich.
Sie sollen:
- nicht die Ehe ersetzen
- das Familiensystem erweitern
- vor allem bei höheren Ständen politische und soziale Reibung erzeugen
## 2. Technische Bedingung
Im aktuellen biologischen Modell nur bei gegengeschlechtlicher Paarung.
Die soziale Behandlung bleibt gleich:
- weibliche und männliche Spielfiguren erzeugen dieselben Ansehens- und Ehefolgen
- das System bewertet nicht unterschiedlich nach Geschlecht
## 3. Monatschance auf Kind
Nur wenn:
- Beziehung aktiv ist
- beide Figuren im fruchtbaren Altersbereich sind
- `affection >= 45`
- `maintenanceLevel >= 30`
- kein Sperrstatus aktiv
Empfohlene Monatswahrscheinlichkeit:
### secret_affair
- `2 %`
### lover
- `4 %`
### mistress_or_favorite
- `6 %`
Modifikatoren:
- `+2 %`, wenn `affection >= 75`
- `-2 %`, wenn `visibility >= 70` und Beziehung instabil ist
- `-3 %`, wenn eine Figur deutlich über den Fruchtbarkeitsgrenzen liegt
Deckel:
- Minimum `0 %`
- Maximum `12 %`
## 4. Status des Kindes
Bei Geburt aus Liebschaft:
- `birthContext = lover`
- `legitimacy = hidden_bastard` standardmäßig
Wenn die Spielfigur das Kind anerkennt:
- `legitimacy = acknowledged_bastard`
- `publicKnown = true`
Das Kind darf nicht automatisch Erbe werden.
Nur explizite spätere Sonderregeln dürfen Erbfolgedruck erzeugen.
## 5. Folgen eines Kindes aus Liebschaft
### Sofortfolgen
- `marriageSatisfaction -8`
- `reputation -4`
### Wenn öffentlich bekannt
- Gruppe 0: zusätzlich `-4`
- Gruppe 1: zusätzlich `-5`
- Gruppe 2: zusätzlich `-6`
- Gruppe 3: zusätzlich `-8`
### Wenn Kind anerkannt wird
- laufende Monatskosten `+20..+80` je Stand
- zusätzliche Ereignisse zu Versorgung, Namen und Status
## Standesabhängigkeit der Ehe
Die Ehe darf nicht in allen Ständen gleich funktionieren.
## Gruppe 0
- Ehe ist vor allem ökonomische Stabilität
- Liebschaften belasten den Haushalt direkt
- Ehezufriedenheit reagiert stark negativ
## Gruppe 1
- Ehe ist Haus- und Rufgemeinschaft
- diskrete Affären sind denkbar, aber riskant
- sichtbare Liebschaften schaden deutlich
## Gruppe 2
- Ehe ist Hauspolitik und Nachfolgeordnung
- Mätressen können vorkommen, aber nur kontrolliert
- Ehezufriedenheit reagiert weniger moralisch, stärker auf öffentliche Ordnung
## Gruppe 3
- Ehe ist Dynastie, Bündnis und Hofordnung
- eine diskrete Mätresse/Favorit kann den ehelichen Druck senken, solange:
- die offizielle Ehe respektiert bleibt
- keine Erbfolge bedroht wird
- kein öffentlicher Gesichtsverlust entsteht
Deshalb ist bei hohen Ständen ein positiver Effekt auf die Ehe ausdrücklich zulässig, aber nur in geordneten Fällen.
## Empfohlene Umsetzung im Daemon
Reihenfolge pro Daily Tick:
1. aktive Ehen laden
2. aktive Liebschaften laden
3. Sichtbarkeit und Diskretion fortschreiben
4. Ehezufriedenheit pro Ehe fortschreiben
5. Ansehen pro Charakter fortschreiben
6. Skandalereignisse prüfen
7. Benachrichtigungen schreiben
Reihenfolge pro Monthly Tick:
1. Monatskosten je Liebschaft berechnen
2. Geld abbuchen
3. Unterversorgungsfolgen anwenden
4. Kinderchance prüfen
5. neue Kinder aus Liebschaften anlegen
6. Folgeereignisse erzeugen
## Minimale technische Erweiterungen
Für eine erste umsetzbare Version werden mindestens benötigt:
### Beziehungserweiterung
- Zusatzfelder an `relationship` oder neue Nebentabelle `relationship_state`
### Ehewert
- `marriage_satisfaction` an Ehebeziehung
### Kind-Zusatzfelder
- `legitimacy`
- `birth_context`
- `public_known`
### Daemon-Konfiguration
- täglicher Falukant-Familienjob
- monatlicher Falukant-Familienjob
## MVP-Empfehlung
Für die erste produktive Version empfehle ich diesen Schnitt:
### Enthalten
- aktive Liebschaften
- Monatskosten
- tägliche Ansehensänderung
- Ehezufriedenheit
- standesabhängige Unterschiede
- Kinderchance aus Liebschaften
- sichtbare Darstellung in FamilyView
### Noch nicht im MVP
- Erpressungsketten
- kirchliche Sonderereignisse
- gerichtliche Konflikte
- Sonderrechte unehelicher Kinder
- komplexe Hofintrigen
- Dienerschaft als eigenes System
- finales Balancing aller Zahlenwerte
## Abnahmekriterien
Die erste technische Umsetzung gilt als korrekt, wenn:
1. jede aktive Liebschaft monatlich Kosten erzeugt
2. jede aktive Liebschaft täglich das Ansehen verändert
3. verheiratete Figuren täglich Ehezufriedenheit verändern
4. hohe Stände bei geordneter Mätresse/Favorit einen neutralen oder leicht positiven Eheeffekt haben können
5. sichtbare oder schlecht versorgte Liebschaften zu Ansehensverlust führen
6. Kinder aus Liebschaften entstehen können
7. weibliche und männliche Spielfiguren regelgleich behandelt werden
## Offene Implementierungsfrage
Vor dem Coden sollte noch genau entschieden werden:
- ob die Zusatzwerte direkt in `relationship` landen
- oder in eine neue Tabelle wie `falukant_data.relationship_state`
Fachlich ist beides möglich. Für Wartbarkeit und spätere Ereignisse ist eine eigene Zustands-/Detailtabelle sauberer.

View File

@@ -0,0 +1,478 @@
# Falukant: Implementierungs-Backlog für Liebhaber, Mätressen, Ehezufriedenheit und Kinder aus Liebschaften
## Zweck
Dieses Backlog übersetzt die Fach- und Technikdokumente in konkrete Umsetzungspakete.
Grundlagen:
- [FALUKANT_LOVERS_CONCEPT.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_CONCEPT.md)
- [FALUKANT_LOVERS_DAEMON_SPEC.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_SPEC.md)
- [FALUKANT_LOVERS_TECHNICAL_CONCEPT.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_TECHNICAL_CONCEPT.md)
Das Backlog ist absichtlich in Reihenfolge angeordnet. Spätere Pakete bauen auf früheren auf.
## Rahmen
Nicht Teil der ersten Umsetzung:
- eigenes Dienerschaftssystem
- finales Balancing
- große Ereignisketten rund um Kirche, Gericht oder Hofintrigen
## Paket B1: Datenmodell vorbereiten
### Ziel
Die Datenbasis für Ehezufriedenheit, Liebschaftsstatus und Kinder aus Liebschaften anlegen.
### Aufgaben
1. Migration für `falukant_data.relationship_state` anlegen
2. Modell [relationship_state.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/relationship_state.js) anlegen
3. Associations in [associations.js](/mnt/share/torsten/Programs/YourPart3/backend/models/associations.js) ergänzen
4. `child_relation` um `legitimacy`, `birth_context`, `public_known` erweitern
5. Modell [child_relation.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/child_relation.js) anpassen
### Betroffene Dateien
- [backend/models/associations.js](/mnt/share/torsten/Programs/YourPart3/backend/models/associations.js)
- [backend/models/falukant/data/child_relation.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/child_relation.js)
- neue Migrationen in [backend/migrations](/mnt/share/torsten/Programs/YourPart3/backend/migrations)
- neue Datei [backend/models/falukant/data/relationship_state.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/relationship_state.js)
### Abhängigkeiten
- keine
### Done
- Datenbank kann die neuen Felder speichern
- Sequelize kann `Relationship` plus `state` laden
- `ChildRelation` kennt neue Legitimitätsfelder
## Paket B2: Backfill und Defaults
### Ziel
Bestehende Ehen und Liebschaften mit Startwerten versorgen.
### Aufgaben
1. Backfill-Migration oder Reparaturskript für bestehende `married`-Beziehungen
2. Backfill-Migration oder Reparaturskript für bestehende `lover`-Beziehungen
3. Fallback-Logik im Backend ergänzen, falls für alte Datensätze noch kein State existiert
### Betroffene Dateien
- neue Migration oder Tool in [backend/migrations](/mnt/share/torsten/Programs/YourPart3/backend/migrations) oder [backend/tools](/mnt/share/torsten/Programs/YourPart3/backend/tools)
- [backend/services/falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js)
### Abhängigkeiten
- B1
### Done
- alle alten `married`- und `lover`-Beziehungen haben nutzbare Zustandswerte
- Family-Lesezugriffe brechen nicht bei fehlendem State
## Paket B3: Family-Lesepfade erweitern
### Ziel
Die bestehenden API-Daten für Familie so erweitern, dass das Frontend sofort lesen und anzeigen kann.
### Aufgaben
1. `getFamily()` in [falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js) um `state`-Daten erweitern
2. für Ehebeziehungen `marriageSatisfaction` und `marriageState` liefern
3. für `lovers` Rollen-, Kosten-, Sichtbarkeits- und Risikofelder liefern
4. Hilfsmethoden für Standesgruppe und Vorschauwerte ergänzen
### Betroffene Dateien
- [backend/services/falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js)
### Abhängigkeiten
- B1
- B2
### Done
- `GET /api/falukant/family` liefert die neuen Datenfelder
- keine UI-Aktion nötig, aber Daten sind vollständig lesbar
## Paket B4: Family-UI lesend ausbauen
### Ziel
Die neuen Daten im Familienbereich sichtbar machen, ohne schon alle Interaktionen einzubauen.
### Aufgaben
1. Ehebereich in [FamilyView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/FamilyView.vue) um `Ehe-Zufriedenheit` ergänzen
2. `lovers`-Bereich mit Rolle, Sichtbarkeit, Diskretion, Unterhalt, Reputationseffekt und Eheeffekt erweitern
3. Kinderkennzeichnung für `legitimate`, `hidden_bastard`, `acknowledged_bastard` ergänzen
4. I18n-Schlüssel in den Falukant-Locales ergänzen
### Betroffene Dateien
- [frontend/src/views/falukant/FamilyView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/FamilyView.vue)
- [frontend/src/i18n/locales/de/falukant.json](/mnt/share/torsten/Programs/YourPart3/frontend/src/i18n/locales/de/falukant.json)
- [frontend/src/i18n/locales/en/falukant.json](/mnt/share/torsten/Programs/YourPart3/frontend/src/i18n/locales/en/falukant.json)
- [frontend/src/i18n/locales/es/falukant.json](/mnt/share/torsten/Programs/YourPart3/frontend/src/i18n/locales/es/falukant.json)
### Abhängigkeiten
- B3
### Done
- FamilyView zeigt neue Zustände lesbar an
- uneheliche Kinder sind UI-seitig unterscheidbar
## Paket B5: Berechnungslogik im Service kapseln
### Ziel
Alle Formeln in wiederverwendbare Backend-Helfer auslagern, bevor Daemon-Jobs gebaut werden.
### Aufgaben
1. `getRankGroup(...)` implementieren
2. `calculateLoverMonthlyCost(...)` implementieren
3. `calculateMarriageDelta(...)` implementieren
4. `calculateReputationDeltaFromLover(...)` implementieren
5. `calculateDailyVisibilityDelta(...)` und `calculateDailyDiscretionDelta(...)` implementieren
6. `calculateDailyScandalChance(...)` implementieren
7. `calculateMonthlyPregnancyChance(...)` implementieren
### Betroffene Dateien
- [backend/services/falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js)
### Abhängigkeiten
- B1
- B2
### Done
- Daemon-Jobs können auf zentrale Helper zugreifen
- keine Formel liegt verstreut in mehreren Jobs
## Paket B6: Daily-Tick-Übergabe an externen Daemon
### Ziel
Die tägliche Spiellogik so spezifizieren und übergeben, dass der externe Daemon sie korrekt ausführen kann.
### Aufgaben
1. Übergabedokument für den externen Daemon erstellen
2. Daily Input- und Output-Felder festlegen
3. Idempotenzanforderungen für `last_daily_processed_at` festlegen
4. Datenabhängigkeiten für Ehe, Liebschaften und Stand definieren
5. Benachrichtigungs- und Ereignisfolgen beschreiben
### Betroffene Dateien
- [docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md)
- [docs/FALUKANT_LOVERS_DAEMON_SPEC.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_SPEC.md)
### Abhängigkeiten
- B5
### Done
- der externe Daemon hat eine vollständige Daily-Tick-Übergabe
- Daily-Logik ist ohne Rückfragen implementierbar
## Paket B7: Monthly-Tick-Übergabe an externen Daemon
### Ziel
Die monatliche Spiellogik so spezifizieren und übergeben, dass der externe Daemon sie korrekt ausführen kann.
### Aufgaben
1. Monthly Input- und Output-Felder festlegen
2. Geldabbuchung und Moneyflow-Anforderungen beschreiben
3. Unterversorgung und Zustandsänderungen beschreiben
4. Kind-Erzeugung und Folgeeffekte beschreiben
5. Idempotenzanforderungen für `last_monthly_processed_at` festlegen
6. Transaktionsanforderungen definieren
### Betroffene Dateien
- [docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md)
- [docs/FALUKANT_LOVERS_DAEMON_SPEC.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_SPEC.md)
### Abhängigkeiten
- B5
### Done
- der externe Daemon hat eine vollständige Monthly-Tick-Übergabe
- Monatslogik ist ohne Rückfragen implementierbar
## Paket B8: Kinder aus Liebschaften technisch ermöglichen
### Ziel
Kinder aus aktiven Liebschaften erzeugen und korrekt markieren.
### Aufgaben
1. `createChildFromLoverRelationship(...)` implementieren
2. `processLoverBirths(...)` in den Monthly Tick integrieren
3. `ChildRelation` korrekt mit `birthContext = lover` anlegen
4. `legitimacy = hidden_bastard` als Startwert setzen
5. erste Folgeeffekte auf Ansehen und Ehezufriedenheit anwenden
### Betroffene Dateien
- [backend/services/falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js)
- [backend/models/falukant/data/child_relation.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/child_relation.js)
- [docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md)
### Abhängigkeiten
- B7
### Done
- Kinder aus Liebschaften können entstehen
- sie sind von legitimen Kindern technisch unterscheidbar
## Paket B9: Notifications und Folgeereignisse MVP
### Ziel
Die wichtigsten Ergebnisse für Spieler sichtbar machen.
### Aufgaben
1. Notifikationstypen für Kosten, Unterversorgung, Gerücht, Skandal und Kind ergänzen
2. Benachrichtigungstexte definieren
3. Daily- und Monthly-Tick an die Notification-Logik anbinden
### Betroffene Dateien
- [docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md)
- bestehende Notification-Modelle oder Services im Backend
- ggf. [frontend/src/views/falukant/OverviewView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/OverviewView.vue) indirekt, falls Benachrichtigungen dort auftauchen
### Abhängigkeiten
- B6
- B7
- B8
### Done
- Spieler sehen relevante Familienfolgen aktiv
## Paket B10: Lover-Aktionen im Backend
### Ziel
Interaktive Steuerung von Liebschaften serverseitig ermöglichen.
### Aufgaben
1. `setLoverMaintenance(...)`
2. `setLoverDiscretionMode(...)`
3. `acknowledgeLover(...)`
4. `endLoverRelationship(...)`
5. `giftLover(...)`
6. Router- und Controller-Anbindung
### Betroffene Dateien
- [backend/services/falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js)
- [backend/controllers/falukantController.js](/mnt/share/torsten/Programs/YourPart3/backend/controllers/falukantController.js)
- [backend/routers/falukantRouter.js](/mnt/share/torsten/Programs/YourPart3/backend/routers/falukantRouter.js)
### Abhängigkeiten
- B3
- B5
### Done
- Backend bietet alle Kernaktionen für Lovers an
## Paket B11: Lover-Aktionen im Frontend
### Ziel
Die neuen Interaktionen in `FamilyView` und ggf. Dialogen bedienbar machen.
### Aufgaben
1. Action-Buttons in [FamilyView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/FamilyView.vue) ergänzen
2. API-Aufrufe anbinden
3. Feedback- und Confirm-Dialoge integrieren
4. Zustandsänderungen direkt im UI sichtbar machen
### Betroffene Dateien
- [frontend/src/views/falukant/FamilyView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/FamilyView.vue)
- ggf. neue API-Helfer in `frontend/src/api`
### Abhängigkeiten
- B10
### Done
- Unterhalt, Anerkennung, Diskretion und Beenden sind im UI nutzbar
## Paket B12: Anerkennung unehelicher Kinder
### Ziel
Uneheliche Kinder später sichtbar anerkennen können.
### Aufgaben
1. Backend-Methode `acknowledgeLoverChild(...)`
2. Route und Controller
3. UI-Aktion im Familienbereich
4. direkte Folgeeffekte auf Ansehen und Ehe einbauen
### Betroffene Dateien
- [backend/services/falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js)
- [backend/controllers/falukantController.js](/mnt/share/torsten/Programs/YourPart3/backend/controllers/falukantController.js)
- [backend/routers/falukantRouter.js](/mnt/share/torsten/Programs/YourPart3/backend/routers/falukantRouter.js)
- [frontend/src/views/falukant/FamilyView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/FamilyView.vue)
### Abhängigkeiten
- B8
- B11
### Done
- uneheliche Kinder können anerkannt werden
- Status und Folgen ändern sich sichtbar
## Paket B13: Admin- und Testhilfen
### Ziel
Die neue Mechanik testbar und debugbar machen.
### Aufgaben
1. Admin- oder Tool-Zugriff auf `relationship_state`
2. Debug-Skript für `30 Tage simulieren`
3. Plausibilitätsprüfungen für fehlende States
4. Reparaturskript für inkonsistente Kinderdaten
### Betroffene Dateien
- [backend/tools](/mnt/share/torsten/Programs/YourPart3/backend/tools)
- ggf. [backend/services/adminService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/adminService.js)
- [docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md)
### Abhängigkeiten
- B6
- B7
- B8
### Done
- Entwickler können Systemzustände nachvollziehen und korrigieren
## Paket B14: QA und Balancing-Vorbereitung
### Ziel
Noch kein finales Balancing, aber die technische Basis für spätere Feinjustierung schaffen.
### Aufgaben
1. Konfigurationspunkte für Kosten- und Reputationswerte zentralisieren
2. Grundtests für Daily- und Monthly-Tick definieren
3. Testfälle für Standesgruppen definieren
4. Testfälle für weibliche und männliche Spielfiguren spiegeln
5. Testfälle für Kinder aus Liebschaften definieren
### Betroffene Dateien
- [docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md)
- ggf. Testverzeichnis im Backend
### Abhängigkeiten
- B6
- B7
- B8
### Done
- Werte sind zentral auffindbar
- spätere Balancing-Runden können auf Testfällen aufsetzen
## Empfohlene Reihenfolge
Für eine saubere erste Lieferung:
1. B1
2. B2
3. B3
4. B4
5. B5
6. B6
7. B7
8. B8
9. B9
10. B10
11. B11
12. B12
13. B13
14. B14
## MVP-Schnitt
Wenn eine erste spielbare Version schneller geliefert werden soll, reicht zunächst:
1. B1
2. B2
3. B3
4. B4
5. B5
6. B6
7. B7
8. B8
Damit wären bereits vorhanden:
- sichtbare Liebhaber-Details
- Ehezufriedenheit
- laufende Kosten
- laufende Ansehensänderung
- Kinder aus Liebschaften
Noch nicht enthalten im MVP:
- volle Interaktionssteuerung
- Anerkennung unehelicher Kinder
- Admin-Tools
- spätere Balancing-Infrastruktur
## Nächster konkreter Schritt
Wenn direkt implementiert werden soll, ist der erste technische Einstieg:
- B1 Datenmodell vorbereiten
Das ist der sauberste Startpunkt, weil danach alle weiteren Pakete darauf aufbauen können.

View File

@@ -0,0 +1,503 @@
# Falukant: Technisches Konzept für Liebhaber, Mätressen, Ehezufriedenheit und Kinder aus Liebschaften
## Ziel
Dieses Dokument beschreibt die technische Umsetzung für Backend, Daemon und UI. Es basiert auf:
- [FALUKANT_LOVERS_CONCEPT.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_CONCEPT.md)
- [FALUKANT_LOVERS_DAEMON_SPEC.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_SPEC.md)
Es soll als direkte Arbeitsgrundlage für Migrationen, Modelle, Service-Methoden, Daemon-Jobs und Frontend-Anpassungen dienen.
## Umsetzungsstrategie
Die Umsetzung sollte in drei technischen Stufen erfolgen:
### Stufe 1: Datenbasis und Lesepfade
- neue Beziehungsdetaildaten anlegen
- Ehezufriedenheit technisch einführen
- Family-API erweitern
- UI nur lesend erweitern
### Stufe 2: Externe Daemon-Logik
- tägliche und monatliche Falukant-Familienlogik an den externen Daemon übergeben
- Kosten, Ansehen, Sichtbarkeit, Diskretion und Ehezufriedenheit laufen dort automatisch
- Kinder aus Liebschaften werden über den externen Daemon ermöglicht
### Stufe 3: Interaktive Spielmechanik
- UI-Aktionen für Unterhalt, Diskretion, Anerkennung, Beenden
- Ereignisse, Warnungen und Benachrichtigungen
- spätere Vertiefung wie Skandal- oder Kirchenereignisse
## Datenmodell
## 1. Bestehende Tabellen, die genutzt werden
- `falukant_data.relationship`
- `falukant_data.child_relation`
- `falukant_data.character`
## 2. Empfohlene neue Tabelle
Empfohlen wird eine neue Detailtabelle:
- `falukant_data.relationship_state`
Begründung:
- `relationship` enthält aktuell nur die grobe Beziehung
- Liebhaber-/Ehedaten sind zustandsorientiert
- die Detailwerte wachsen voraussichtlich weiter
- eine Nebentabelle ist sauberer als `relationship` mit vielen Spezialspalten zu überladen
## 3. Tabelle `relationship_state`
### Primärbezug
- `relationship_id`
### Spalten für Ehe
- `marriage_satisfaction` integer not null default `55`
- `marriage_public_stability` integer not null default `55`
`marriage_public_stability` ist optional, aber sinnvoll für spätere Ereignisse. Für MVP kann er schon angelegt, aber noch wenig genutzt werden.
### Spalten für Liebschaften
- `lover_role` string nullable
- `secret_affair`
- `lover`
- `mistress_or_favorite`
- `affection` integer not null default `50`
- `visibility` integer not null default `15`
- `discretion` integer not null default `50`
- `maintenance_level` integer not null default `50`
- `status_fit` integer not null default `0`
- `monthly_base_cost` integer not null default `0`
- `months_underfunded` integer not null default `0`
- `active` boolean not null default `true`
- `acknowledged` boolean not null default `false`
- `exclusive_flag` boolean not null default `false`
- `last_monthly_processed_at` date nullable
- `last_daily_processed_at` date nullable
### Spalten für spätere Erweiterung
- `notes_json` jsonb nullable
- `flags_json` jsonb nullable
## 4. Erweiterung `child_relation`
Neue Spalten:
- `legitimacy` string not null default `legitimate`
- `legitimate`
- `acknowledged_bastard`
- `hidden_bastard`
- `birth_context` string not null default `marriage`
- `marriage`
- `lover`
- `public_known` boolean not null default `false`
## 5. Optionale Erweiterung `relationship`
Für bessere Auswertung kann zusätzlich sinnvoll sein:
- `ended_at`
- `ended_reason`
Das ist für MVP nicht zwingend, aber nützlich.
## Migrationen
Benötigte Migrationen:
### Migration 1
- neue Tabelle `falukant_data.relationship_state`
### Migration 2
- neue Spalten an `falukant_data.child_relation`
### Migration 3 optional
- Backfill für bestehende Beziehungen
Regeln für Backfill:
- bei `relationshipType = married`
- `marriage_satisfaction = 55`
- bei `relationshipType = lover`
- `lover_role = lover`
- `affection = 50`
- `visibility = 20`
- `discretion = 45`
- `maintenance_level = 50`
- `status_fit = 0`
- `monthly_base_cost = 30`
## Sequelize-Modelle
## 1. Neues Modell
Neue Datei:
- [relationship_state.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/relationship_state.js)
## 2. Associations
In [associations.js](/mnt/share/torsten/Programs/YourPart3/backend/models/associations.js):
- `Relationship.hasOne(RelationshipState, { foreignKey: 'relationshipId', as: 'state' })`
- `RelationshipState.belongsTo(Relationship, { foreignKey: 'relationshipId', as: 'relationship' })`
## 3. ChildRelation-Erweiterung
In [child_relation.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/child_relation.js):
- `legitimacy`
- `birthContext`
- `publicKnown`
## Backend-Service-Konzept
Hauptort:
- [falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js)
## 1. Neue interne Hilfsmethoden
Empfohlene neue interne Methoden:
- `getRankGroup(titleLabelTr)`
- `calculateLoverMonthlyCost(relationship, state, character)`
- `calculateMarriageDelta(relationship, state, character, spouseCharacter, context)`
- `calculateReputationDeltaFromLover(relationship, state, character, context)`
- `calculateDailyVisibilityDelta(state, context)`
- `calculateDailyDiscretionDelta(state, context)`
- `calculateDailyScandalChance(relationship, state, character, context)`
- `calculateMonthlyPregnancyChance(relationship, state, charA, charB)`
- `applyLoverMonthlyCosts(transactionDate?)`
- `applyLoverDailyEffects(transactionDate?)`
- `processLoverBirths(transactionDate?)`
- `triggerLoverScandalEvent(...)`
## 2. Erweiterung `getFamily`
Die Familienausgabe in [falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js) muss `lovers` detaillierter liefern.
Zusätzliche API-Felder je Lover:
- `relationshipId`
- `role`
- `affection`
- `visibility`
- `discretion`
- `maintenanceLevel`
- `statusFit`
- `monthlyCost`
- `reputationEffect`
- `marriageEffect`
- `acknowledged`
- `canBecomePublic`
- `riskState`
Zusätzliche API-Felder für Ehe:
- `marriageSatisfaction`
- `marriageState`
- `stable`
- `strained`
- `crisis`
## 3. Neue Service-Aktionen
Für spätere UI-Steuerung:
- `setLoverMaintenance(hashedUserId, relationshipId, maintenanceLevel)`
- `setLoverDiscretionMode(hashedUserId, relationshipId, mode)`
- `acknowledgeLover(hashedUserId, relationshipId)`
- `endLoverRelationship(hashedUserId, relationshipId)`
- `giftLover(hashedUserId, relationshipId, giftType)`
Diese Methoden müssen in Stufe 1 noch nicht voll sichtbar sein, sollten aber im Konzept vorgesehen werden.
## API-Konzept
## 1. Bestehende Family-Route erweitern
Bestehender Endpunkt:
- `GET /api/falukant/family`
Erweitern um:
- `marriageSatisfaction`
- `lovers` mit Detailfeldern
- `householdTension` optional
## 2. Neue Endpunkte
Empfohlene neue Endpunkte:
- `POST /api/falukant/family/lover/:relationshipId/maintenance`
- `POST /api/falukant/family/lover/:relationshipId/discretion`
- `POST /api/falukant/family/lover/:relationshipId/acknowledge`
- `POST /api/falukant/family/lover/:relationshipId/end`
- `POST /api/falukant/family/lover/:relationshipId/gift`
## 3. Antwortschema
Jede mutierende Aktion sollte zurückgeben:
- aktualisierte Liebhaber-Daten
- aktualisierte Ehe-Zufriedenheit
- aktualisierte Geldmenge
- aktualisiertes Ansehen
- optionale Nachricht für UI
## Daemon-Integration
## 1. Tatsächliche Daemon-Lage
Der operative Daemon ist nicht Teil dieses Projekts. Dieses Projekt stellt daher:
- Datenmodell
- Backfill
- Family-API
- UI-Anzeige
- Fach- und Übergabedokumente
Der externe Daemon ist zuständig für:
- Daily Tick
- Monthly Tick
- Geldabbuchung
- Ansehensänderung
- Ehezufriedenheit
- Kinder aus Liebschaften
Die operative Übergabe dafür steht in:
- [FALUKANT_LOVERS_DAEMON_HANDOFF.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md)
## 2. Neue Benachrichtigungstypen
Es sollten neue Falukant-Benachrichtigungen eingeführt werden:
- `loverCostPaid`
- `loverUnderfunded`
- `loverRumor`
- `loverScandal`
- `loverChildHidden`
- `loverChildKnown`
- `marriageCrisis`
## Kindererzeugung technisch
## 1. Vorhandene Strukturen nutzen
Neue Kinder aus Liebschaften sollen dieselbe Charakter- und `ChildRelation`-Logik nutzen wie bestehende Kinder.
## 2. Erzeugungsort
Die Kind-Erzeugung soll vom externen Daemon ausgeführt oder angestoßen werden. Dieses Backend muss dafür die Zielstruktur stabil bereitstellen.
## 3. Anerkennung eines Kindes
Spätere Service-Methode:
- `acknowledgeLoverChild(hashedUserId, childCharacterId)`
Wirkung:
- `legitimacy = acknowledged_bastard`
- `publicKnown = true`
- Ansehen anpassen
- Ehe-Zufriedenheit anpassen
## Frontend-Konzept
Hauptansicht:
- [FamilyView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/FamilyView.vue)
## 1. Ehebereich erweitern
Im Ehe- oder Partnerbereich anzeigen:
- `Ehe-Zufriedenheit`
- textlicher Status
- `stabil`
- `angespannt`
- `krisenhaft`
- kurzer Hinweis, ob aktive Liebschaften die Ehe belasten oder stabilisieren
## 2. Lovers-Bereich ausbauen
Aktuell existiert nur Name plus Zuneigung. Neu anzeigen:
- Rolle
- Zuneigung
- Sichtbarkeit
- Diskretion
- Unterhaltsniveau
- Monatskosten
- aktueller Einfluss auf Ansehen
- aktueller Einfluss auf Ehe-Zufriedenheit
- Risikostatus
## 3. Aktionen im UI
Pro Liebschaft:
- Unterhalt erhöhen oder senken
- Diskretion priorisieren
- öffentlich anerkennen
- beschenken
- Beziehung beenden
## 4. Farbliche Zustände
### Grün
- geordnet
- geringe Sichtbarkeit
- Ehe stabil oder neutral
### Gelb
- steigende Sichtbarkeit
- mittlere Ehebelastung
- Unterhalt knapp
### Rot
- Skandalrisiko hoch
- Ehekrise
- unterfinanziert
- Kind öffentlich geworden
## 5. Kinder aus Liebschaften in FamilyView
Im Kinderbereich kenntlich machen:
- legitimes Kind
- uneheliches verborgenes Kind
- anerkanntes uneheliches Kind
Es braucht keine sensationelle Darstellung, aber klare Kennzeichnung.
## I18n-Bedarf
Benötigte neue Übersetzungsbereiche in:
- [frontend/src/i18n/locales/de/falukant.json](/mnt/share/torsten/Programs/YourPart3/frontend/src/i18n/locales/de/falukant.json)
- [frontend/src/i18n/locales/en/falukant.json](/mnt/share/torsten/Programs/YourPart3/frontend/src/i18n/locales/en/falukant.json)
- [frontend/src/i18n/locales/es/falukant.json](/mnt/share/torsten/Programs/YourPart3/frontend/src/i18n/locales/es/falukant.json)
Neue Schlüsselgruppen:
- `falukant.family.marriageSatisfaction.*`
- `falukant.family.lovers.role.*`
- `falukant.family.lovers.visibility`
- `falukant.family.lovers.discretion`
- `falukant.family.lovers.maintenance`
- `falukant.family.lovers.monthlyCost`
- `falukant.family.lovers.reputationEffect`
- `falukant.family.lovers.marriageEffect`
- `falukant.family.lovers.risk.*`
- `falukant.family.children.legitimacy.*`
## Admin- und Debug-Bedarf
Für Entwicklung und Balancing später sinnvoll:
- Admin-Sicht auf `relationship_state`
- Möglichkeit, `marriageSatisfaction`, `visibility`, `discretion`, `maintenanceLevel` zu setzen
- optionales Debug-Tool zum Simulieren von 30 Tagen
Das sollte nicht Teil des ersten Spieler-UI sein, aber früh mitgedacht werden.
## Technische Risiken
### 1. Tick-Duplikate
Wenn Daily- oder Monthly-Ticks mehrfach laufen, werden Kosten und Ansehen doppelt verrechnet.
Gegenmaßnahme:
- `last_daily_processed_at`
- `last_monthly_processed_at`
- idempotente Verarbeitung pro Beziehung und Tag/Monat
### 2. Dateninkonsistenz
Eine `lover`-Beziehung ohne `relationship_state` würde Berechnungen brechen.
Gegenmaßnahme:
- beim Lesen fehlende States automatisch erzeugen
- oder beim Start ein Reparaturskript
### 3. Kindererzeugung doppelt
Bei konkurrierenden Prozessen könnte ein Kind zweimal entstehen.
Gegenmaßnahme:
- Transaktion
- Sperre pro Beziehung und Tick
- eindeutige Monatsverarbeitung
## Empfohlene Implementierungsreihenfolge
### Paket 1
- Migrationen
- Modell `RelationshipState`
- Associations
- Backfill
### Paket 2
- Family-Service erweitert lesen
- API-Felder ausliefern
- UI in `FamilyView` lesend erweitern
### Paket 3
- Übergabe Daily Tick
- Übergabe Monthly Tick
- Abstimmung zu Geldabbuchung
- Abstimmung zu Ansehen und Ehezufriedenheit
### Paket 4
- Kinder aus Liebschaften
- Benachrichtigungen
- UI-Aktionen
### Paket 5
- Ereignisse
- spätere Dienerschaft
- Balancing-Phase
## Definition of Done
Die technische Erstumsetzung ist abgeschlossen, wenn:
1. `lover`-Beziehungen Detailzustand besitzen
2. Ehezufriedenheit technisch existiert
3. Family-API alle neuen Daten ausliefert
4. Daily- und Monthly-Tick für den externen Daemon vollständig beschrieben sind
5. Monatskosten- und Statuslogik extern ausführbar definiert sind
6. Kinder aus Liebschaften technisch entstehen können
7. `FamilyView` die neuen Daten sichtbar macht
8. weibliche und männliche Spielfiguren regelgleich behandelt werden

View File

@@ -0,0 +1,476 @@
# Falukant: Daemon-Spezifikation für die Beziehung zwischen Liebschaften und Untergrund
Dieses Dokument beschreibt die konkrete Daemon-Logik für die Verbindung zwischen:
- aktiven Liebschaften
- Sichtbarkeit und Diskretion
- Untergrundaktivitäten vom Typ `investigate_affair`
- Aufdeckung, Skandal und Erpressung
Es ist die technische Spezifikation für den externen Daemon und ergänzt:
- [FALUKANT_LOVERS_DAEMON_SPEC.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_SPEC.md)
- [FALUKANT_LOVERS_DAEMON_HANDOFF.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md)
- [FALUKANT_UNDERGROUND_AFFAIR_DAEMON_HANDOFF.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_UNDERGROUND_AFFAIR_DAEMON_HANDOFF.md)
- [FALUKANT_SERVANTS_IMPLEMENTATION_SPEC.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_SERVANTS_IMPLEMENTATION_SPEC.md)
## 1. Ziel
Der Untergrund soll Liebschaften nicht nur "finden oder nicht finden", sondern auf dieselben Zustände zugreifen, die das Lovers-System ohnehin täglich verarbeitet:
- `visibility`
- `discretion`
- `acknowledged`
- `loverRole`
- `publicKnown` unehelicher Kinder
- Ruflage und Stand
- Haushalts- und Dienerschaftseffekte
Dadurch entsteht ein gemeinsames System statt zweier getrennter Minigames.
## 2. Grundprinzip
Untergrundaktivitäten gegen Liebschaften sind keine völlig unabhängigen Zufallstests.
Sie hängen ab von:
- wie sichtbar die Beziehung ohnehin schon ist
- wie diskret sie geführt wird
- wie groß und unruhig der Haushalt ist
- ob Kinder oder Anerkennung die Sache bereits schwer verbergen machen
- wie gut das Opfer sozial abgesichert ist
Faustregel:
- hohe Sichtbarkeit + geringe Diskretion + schlechte Dienerschaft = gute Aufdeckungschance
- niedrige Sichtbarkeit + hohe Diskretion + geordneter Haushalt = geringe Aufdeckungschance
## 3. Betroffene Aktivitäten
In Phase 1 betrifft diese Spezifikation nur:
- `investigate_affair`
mit den Zielen:
- `expose`
- `blackmail`
## 4. Pflichtdaten für den Daemon
## 4.1 Aktivität
Aus `falukant_data.underground`:
- `id`
- `performer_id`
- `victim_id`
- `type_id`
- `parameters.goal`
- `result`
- `created_at`
Aus `falukant_type.underground`:
- `tr`
## 4.2 Opferdaten
Für das Opfer:
- `falukant_user.id`
- `user.hashed_id`
- `character.id`
- `character.reputation`
- `character.birthdate`
- `character.title_of_nobility`
## 4.3 Liebschaftsdaten
Für alle aktiven Liebschaften des Opfers:
- `relationship.id`
- `relationship.character1_id`
- `relationship.character2_id`
- `relationship_type.tr`
- `relationship_state.lover_role`
- `relationship_state.visibility`
- `relationship_state.discretion`
- `relationship_state.affection`
- `relationship_state.acknowledged`
- `relationship_state.status_fit`
- `relationship_state.active`
## 4.4 Kinderdaten
Optional, aber empfohlen:
- `child_relation.legitimacy`
- `child_relation.birth_context`
- `child_relation.public_known`
## 4.5 Haushalts-/Dienerdaten
Wenn das Dienersystem aktiv ist:
- `user_house.servant_count`
- `user_house.servant_quality`
- `user_house.servant_pay_level`
- `user_house.household_order`
- daraus abgeleitete Haushalts-/Diskretionswerte
## 5. Auswahl des Zielobjekts
Wenn ein Opfer mehrere aktive Liebschaften hat, muss der Daemon eine Ziellogik verwenden.
Empfohlene Reihenfolge:
1. Nur aktive `lover`-Beziehungen betrachten
2. je Beziehung einen `discoveryScore` berechnen
3. die Beziehung mit dem höchsten `discoveryScore` als primäres Ziel verwenden
4. bei fast gleichen Werten darf der Daemon zufällig zwischen den besten Kandidaten wählen
## 6. Discovery-Score
Der `discoveryScore` bestimmt, wie leicht eine konkrete Liebschaft durch den Untergrund verwertbar wird.
## 6.1 Formel
```text
discoveryScore =
visibility * 0.45
+ (100 - discretion) * 0.30
+ acknowledgedBonus
+ childBonus
+ ageMalusVisibilityBonus
+ householdLeakBonus
+ multipleAffairBonus
+ statusMismatchBonus
```
## 6.2 Teilwerte
### acknowledgedBonus
```text
if acknowledged = true => +10
sonst => +0
```
### childBonus
```text
if hidden bastard exists => +8
if public_known bastard exists => +18
```
Wenn mehrere Kinder existieren:
- maximal `+20` in Summe
### ageMalusVisibilityBonus
Wenn die Liebschaft wegen jungen Alters bereits reputationsschädlich ist:
```text
minAge <= 13 => +18
minAge <= 15 => +12
minAge <= 17 => +6
sonst => +0
```
### householdLeakBonus
Aus dem Dienersystem:
```text
if no house data => +0
if householdOrder <= 35 => +8
if servantPayLevel = low => +5
if servantCount > expectedMax + 1 => +4
if servantQuality <= 35 => +6
```
Deckel:
- maximal `+15`
### multipleAffairBonus
```text
if victim has 2 active lovers => +8
if victim has 3 or more active lovers => +14
```
### statusMismatchBonus
Wenn die Beziehung standesmäßig auffällig ist:
```text
status_fit = -2 => +10
status_fit = -1 => +5
sonst => +0
```
## 7. Erfolgswahrscheinlichkeit
## 7.1 Grundwurf
Auf Basis des höchsten `discoveryScore`:
```text
successChance = clamp(20 + discoveryScore * 0.55, 5, 95)
```
Interpretation:
- selbst sehr diskrete Beziehungen bleiben mit kleinem Restrisiko auffindbar
- offen geführte, chaotische Beziehungen werden fast sicher entdeckt
## 7.2 Ergebnisstufen
```text
roll <= successChance * 0.55 => full success
roll <= successChance => partial success
sonst => failure
```
## 8. Behandlung von `goal = expose`
## 8.1 Ziel
Die Beziehung soll öffentlich sichtbar und reputationsschädlich werden.
## 8.2 Wirkung bei vollem Erfolg
Auf Ziel-Liebschaft:
- `visibility += 18..30`
- `discretion -= 8..18`
Auf Opfer:
- `reputationDelta = -2 .. -6`
Zusätzlich:
- wenn `visibility >= 60` nach Anpassung: Skandalprüfung sofort auslösen
- wenn `public_known` uneheliches Kind bereits existiert: zusätzlicher Rufschaden `-1`
## 8.3 Wirkung bei Teilerfolg
Auf Ziel-Liebschaft:
- `visibility += 8..15`
- `discretion -= 3..8`
Auf Opfer:
- `reputationDelta = -1 .. -3`
Kein garantierter sofortiger Skandal, aber deutlich erhöhte Folgewahrscheinlichkeit.
## 8.4 Wirkung bei Fehlschlag
Keine öffentliche Wirkung, aber optional:
- kleines Gegenrisiko für den Untergrund später
- oder `notes` mit "no proof"
Für Phase 1 genügt:
- `status = failed`
- `outcome = failure`
## 9. Behandlung von `goal = blackmail`
## 9.1 Ziel
Belastendes Wissen beschaffen, ohne sofort volle Öffentlichkeit zu erzeugen.
## 9.2 Wirkung bei vollem Erfolg
Auf Ziel-Liebschaft:
- `visibility += 4..9`
- `discretion -= 2..6`
Auf Aktivität:
- `blackmailAmount` setzen
- `discoveries` mit verwertbaren Details befüllen
Auf Opfer:
- kein großer Sofort-Rufschaden
- optional `reputationDelta = 0 .. -1`
## 9.3 Wirkung bei Teilerfolg
Auf Ziel-Liebschaft:
- `visibility += 2..5`
Auf Aktivität:
- kleinerer `blackmailAmount`
- `outcome = partial`
## 9.4 Wirkung bei Fehlschlag
- keine verwertbare Entdeckung
- `status = failed`
- `outcome = failure`
## 10. Berechnung der Erpressungssumme
Die Erpressungssumme soll aus sozialer Fallhöhe und Beweiswert entstehen.
## 10.1 Formel
```text
base =
500
+ visibility * 12
+ max(0, reputation) * 15
+ titleGroupBonus
+ childBlackmailBonus
blackmailAmount = round(base * outcomeFactor)
```
## 10.2 titleGroupBonus
Aus der Standesgruppe des Lovers-Systems:
```text
group 0 => +0
group 1 => +600
group 2 => +1800
group 3 => +4200
```
## 10.3 childBlackmailBonus
```text
hidden_bastard exists => +900
public_known bastard exists => +1600
```
## 10.4 outcomeFactor
```text
full success => 1.0
partial success => 0.55
failure => 0
```
## 11. Sofortige Skandalprüfung
Bei `goal = expose` und starker Sichtbarkeitssteigerung darf der Untergrund direkt einen Skandal anstoßen.
## 11.1 Triggerschwelle
```text
if visibility_after >= 60:
trigger scandal check
```
Zusatzbonus:
- `+10` Punkte auf die reguläre Skandalchance bei sehr jungem Alter `<= 15`
- `+6` Punkte bei `public_known` Kind
- `+5` Punkte bei `householdOrder <= 35`
## 11.2 Socket-Events
Wenn daraus ein Skandal resultiert:
- `falukant_family_scandal_hint`
- `falukantUpdateFamily` mit `reason = scandal`
- `falukantUpdateStatus`
## 12. Struktur von `underground.result`
Der Daemon schreibt mindestens:
```json
{
"status": "resolved",
"outcome": "success",
"discoveries": {
"relationshipId": 123,
"loverRole": "secret_affair",
"visibility": 58,
"acknowledged": false,
"publicKnownChild": false,
"householdLeak": true
},
"visibilityDelta": 14,
"reputationDelta": -3,
"blackmailAmount": 2400,
"notes": "Servants and low discretion made the affair easy to trace."
}
```
## 13. Pflichtregeln für `discoveries`
`discoveries` soll mindestens enthalten:
- `relationshipId`
- `loverRole`
- `visibility`
- `acknowledged`
Optional, aber sehr nützlich:
- `publicKnownChild`
- `hiddenChild`
- `householdLeak`
- `minAgeBracket`
- `multipleAffairs`
## 14. Interaktion mit Dienerschaft
Das Dienersystem ist ein eigenständiger Modifikator, kein Ersatz für Sichtbarkeit oder Diskretion.
Der Untergrund soll Dienerschaft nur als Verstärker oder Dämpfer nutzen:
Günstig für Aufdeckung:
- niedrige Bezahlung
- schlechte Qualität
- chaotischer Haushalt
- übergroße Dienerschaft
Ungünstig für Aufdeckung:
- hohe Qualität
- großzügige Bezahlung
- geordneter Haushalt
- passende, nicht zu große Dienerschaft
## 15. Interaktion mit Lover-Daily
Wichtig:
- Der Untergrund darf Lovers-Zustände verändern.
- Danach verarbeitet das normale Daily-System diese Zustände weiter.
Das heißt:
- `visibility`-Erhöhungen aus dem Untergrund laufen später in Daily-Skandale und Rufdrift hinein.
- Untergrund ersetzt nicht die Daily-Logik, sondern stößt sie an.
## 16. Idempotenz
Jede `investigate_affair`-Aktivität darf genau einmal verarbeitet werden.
Verarbeitbar nur wenn:
- `underground_type.tr = investigate_affair`
- `result.status = pending`
Nach Verarbeitung:
- `result.status` auf `resolved` oder `failed`
Der Daemon darf keine Aktivität erneut anfassen, deren `result.status` nicht mehr `pending` ist.
## 17. Transaktionsgrenze
Folgendes soll atomar laufen:
- Ziel-Liebschaft bestimmen
- Erfolgswurf
- Sichtbarkeit/Diskretion ändern
- Rufänderung anwenden
- `underground.result` schreiben
- optionale Skandalereignisse vorbereiten
Empfehlung:
- eine DB-Transaktion pro Aktivität
## 18. Definition of Done
Die Daemon-Umsetzung ist ausreichend, wenn:
1. `investigate_affair` für `expose` und `blackmail` verschieden behandelt wird
2. nicht beliebige, sondern die plausibelste aktive Liebschaft des Opfers gewählt wird
3. Sichtbarkeit und Diskretion aus dem Lovers-System als Eingangsgrößen verwendet werden
4. Dienerschaft optional als Leck-/Diskretionsfaktor einfließt
5. `expose` Sichtbarkeit und Ruf spürbar verschieben kann
6. `blackmail` belastbare `blackmailAmount`-Werte produziert
7. Skandale bei starken Fällen sofort ausgelöst werden können
8. `underground.result` vollständig und UI-lesbar gefüllt wird
## 19. Empfehlung für die Implementierungsreihenfolge
1. Ziel-Liebschaft und `discoveryScore` implementieren
2. `success / partial / failure` für `expose`
3. `blackmailAmount` für `blackmail`
4. `discoveries`-Füllung
5. Sofort-Skandalprüfung
6. Dienerschaftsmodifikator ergänzen
7. Balancing nach ersten Tests

View File

@@ -0,0 +1,569 @@
# Falukant: Steuerung von Ehezustand und Hausfrieden
Dieses Dokument beschreibt:
- wie Spieler `Ehezustand` und `Hausfrieden` direkt beeinflussen können
- welche Werte dafür im Backend sichtbar und änderbar sein müssen
- was der externe Daemon täglich und monatlich berechnen soll
Die Datei ist bewusst als gemeinsame Arbeitsgrundlage für UI, Backend und externen Daemon formuliert.
## 1. Zielbild
Es soll zwei getrennte, aber gekoppelte Systeme geben:
- `marriageSatisfaction`
- numerisch `0..100`
- individueller Kernwert der Ehe
- `householdTension`
- aggregierter Haushaltszustand
- nach außen in UI als `low | medium | high`
- intern sinnvollerweise als numerischer Spannungswert `0..100`
Interpretation:
- `marriageSatisfaction` beschreibt die Qualität der Paarbeziehung
- `householdTension` beschreibt Spannungen im gesamten Haus
- Liebschaften
- Unterversorgung
- Ordnung
- Kinderkonflikte
- Dienerschaft
## 2. Werte und Ableitungen
## 2.1 Ehe
Bestehend:
- `relationship_state.marriage_satisfaction`
Neu sinnvoll:
- `relationship_state.marriage_public_stability`
- besteht bereits
- soll aktiv genutzt werden
- optional später:
- `last_affection_action_at`
- `last_conflict_action_at`
- `last_shared_time_at`
UI-Ableitung:
- `0..19` => `broken`
- `20..39` => `fragile`
- `40..59` => `strained`
- `60..79` => `stable`
- `80..100` => `harmonious`
## 2.2 Hausfrieden
Der bisherige reine UI-Helfer
- `low`
- `medium`
- `high`
reicht für eine echte Steuerung nicht aus.
Neu sinnvoll:
- interner Wert `householdTensionScore`
- Bereich `0..100`
- `0` = sehr ruhig
- `100` = offener Hauskonflikt
UI-Ableitung:
- `0..24` => `low`
- `25..59` => `medium`
- `60..100` => `high`
Falls kein eigener Persistenzwert angelegt werden soll, darf der Daemon den Score auch nur berechnen und als API-Feld zurückgeben.
## 3. Direkte Spieleraktionen
Es braucht direkte Spielzüge, die der Spieler bewusst auslösen kann.
Wichtig:
- nicht jede Aktion muss sofort große Werte ändern
- direkte Aktionen sollen kleine, klare Effekte haben
- der Daemon übernimmt Drift, Gegenkräfte und Folgewirkungen
## 3.1 Ehe-Aktionen
Diese Aktionen gehören fachlich in `FamilyView`.
### A. Zeit mit Ehepartner verbringen
Zweck:
- Standardaktion zur Pflege der Beziehung
Regel:
- verfügbar nur bei aktiver Ehe
- Cooldown: `1x pro Tag`
- Kosten: `0` oder sehr klein
Soforteffekt:
- `marriageSatisfaction +2`
- `householdTensionScore -1`
Modifikatoren:
- wenn aktive sichtbare Liebschaft `visibility >= 45`: nur `+1`
- wenn `householdOrder <= 35`: kein Bonus auf Hausfrieden
- wenn `marriageSatisfaction < 25`: stattdessen nur `+1`
### B. Geschenk an Ehepartner
Zweck:
- Geld gegen schnellere Stabilisierung
Regel:
- verfügbar nur bei aktiver Ehe
- Stufen: `small`, `decent`, `lavish`
- Cooldown: `1x pro 3 Tage`
Soforteffekt:
- `small`: `marriageSatisfaction +2`
- `decent`: `marriageSatisfaction +4`
- `lavish`: `marriageSatisfaction +7`
Nebeneffekt:
- `marriagePublicStability +1/+2/+3`
Malus:
- bei gleichzeitig unterfinanzierter Liebschaft halbierter Effekt
### C. Streit schlichten
Zweck:
- gezielte Krisenintervention
Regel:
- verfügbar nur wenn `householdTensionScore >= 35` oder `marriageSatisfaction <= 50`
- Cooldown: `1x pro 2 Tage`
Soforteffekt:
- `householdTensionScore -4`
- `marriageSatisfaction +1`
Malus:
- wenn `visibility` einer aktiven Liebschaft `>= 60`, dann nur `householdTensionScore -2`
### D. Fest nur für den Haushalt
Zweck:
- Hausfrieden über Geld und Repräsentation stützen
Regel:
- verfügbar bei vorhandenem Haus
- kleiner interner Hausakt, nicht großes Reputationsfest
- Cooldown: `1x pro Monat`
Soforteffekt:
- `householdTensionScore -6`
- `marriageSatisfaction +2`
- `householdOrder +2`
Malus:
- bei unterbesetzter Dienerschaft nur halbe Wirkung
## 3.2 Haus-Aktionen
Diese Aktionen gehören fachlich in `HouseView`.
### A. Haus ordnen
Zweck:
- kleine direkte Ordnungsmaßnahme
Regel:
- Cooldown: `1x pro Tag`
- Kosten: niedrig
Soforteffekt:
- `householdOrder +3`
- wenn `householdOrder > 70`: stattdessen nur `+1`
Indirekter Effekt:
- besserer Daily-Wert für `householdTensionScore`
### B. Diener einstellen
Bereits vorhanden.
Neue fachliche Wirkung:
- wenn vorher `servantCount < expectedMin`
- sofort `householdTensionScore -2`
### C. Diener entlassen
Bereits vorhanden.
Neue fachliche Wirkung:
- wenn danach `servantCount < expectedMin`
- sofort `householdTensionScore +3`
### D. Bezahlung erhöhen
Bereits vorhanden.
Neue fachliche Wirkung:
- wenn von `low -> normal` oder `normal -> high`
- sofort `householdOrder +2`
- `householdTensionScore -1`
## 3.3 Familien-/Kinder-Aktionen
### A. Uneheliches Kind anerkennen
Zweck:
- offenere, geordnetere Lösung statt versteckter Konfliktlage
Soforteffekt:
- `publicKnown = true`
- `legitimacy = acknowledged_bastard`
- `householdTensionScore -2`, wenn Beziehung bereits öffentlich geordnet
- `householdTensionScore +3`, wenn Ehe schwach und Beziehung skandalös
Eheeffekt:
- `marriageSatisfaction -2` bis `-6` je nach Sichtbarkeit und Stand
### B. Erbenfrage regeln
Wenn uneheliche Kinder sichtbar werden, kann die UI später eine Handlung
`Erbfolge klären` bekommen.
Erste Version:
- nur vorgemerkt
- noch keine direkte Aktion nötig
## 3.4 Liebschafts-Aktionen mit Einfluss auf Ehe und Haus
Bestehend:
- Unterhalt ändern
- Beziehung anerkennen
- Beziehung beenden
Diese Aktionen sollen explizit folgende Sofortwirkung haben:
### Unterhalt erhöhen
- `monthsUnderfunded` baut sich später im Daemon ab
- sofort kein großer Ehebonus
- aber `householdTensionScore -1`, wenn vorher Unterversorgung bestand
### Beziehung anerkennen
- `visibility` steigt nicht automatisch hart, aber öffentlicher Charakter nimmt zu
- bei hohen Ständen geordnet eher neutral bis leicht positiv für Ehe-Stabilität
- bei niedrigen Ständen eher negativ
Sofortregel:
- Standesgruppe `0-1`: `marriageSatisfaction -3`, `householdTensionScore +2`
- Standesgruppe `2`: `marriageSatisfaction -1`, `householdTensionScore +1`
- Standesgruppe `3`: `marriagePublicStability +1`, `householdTensionScore -1`, wenn Diskretion und Versorgung gut sind
### Beziehung beenden
- sofort `householdTensionScore -3`, wenn Liebschaft riskant war
- sofort `marriageSatisfaction +1`, wenn aktive Ehe existiert
- aber bei hoher `affection >= 70` auch möglicher Malus auf Stimmungssystem später
## 4. Daemon-Berechnung
## 4.1 Daily-Input
Der externe Daemon braucht pro Spielerfigur:
- aktive Ehebeziehung mit `marriageSatisfaction`, `marriagePublicStability`
- aktive Liebschaften mit:
- `loverRole`
- `visibility`
- `discretion`
- `maintenanceLevel`
- `statusFit`
- `monthsUnderfunded`
- `acknowledged`
- Kinderdaten:
- `legitimacy`
- `birthContext`
- `publicKnown`
- Hausdaten:
- `servantCount`
- `servantQuality`
- `servantPayLevel`
- `householdOrder`
- Charakterdaten:
- `titleOfNobility`
- `reputation`
## 4.2 Daily-Berechnung für Ehe
Grunddrift:
```text
marriageDelta = 0
if marriageSatisfaction > 55: marriageDelta -= 1 every 3 days
if marriageSatisfaction < 55: marriageDelta += 1 every 5 days
```
Liebschaften:
```text
for each active lover:
if visibility >= 60: marriageDelta -= 2
else if visibility >= 35: marriageDelta -= 1
if monthsUnderfunded >= 2: marriageDelta -= 1
if acknowledged = true and statusGroup <= 1: marriageDelta -= 1
if acknowledged = true and statusGroup = 3 and visibility <= 35 and maintenanceLevel >= 60:
marriageDelta += 0 or +1 every few days
```
Zu jung:
```text
if minAge <= 15: marriageDelta -= 1
if minAge <= 13: marriageDelta -= 2
```
Haus:
```text
if householdOrder >= 75: marriageDelta += 1
if householdOrder <= 35: marriageDelta -= 1
if householdTensionScore >= 60: marriageDelta -= 1
```
Dienerschaft:
```text
if servantCount < expectedMin: marriageDelta -= 1
if servantPayLevel = high and servantQuality >= 70 and householdOrder >= 70:
marriageDelta += 1 every 3 days
```
Danach:
- clamp `0..100`
## 4.3 Daily-Berechnung für Hausfrieden
Interner Wert:
```text
householdTensionScore = base
```
Empfohlene Berechnung:
```text
base = 10
for each active lover:
if visibility >= 60: base += 18
else if visibility >= 35: base += 10
else: base += 4
if monthsUnderfunded >= 1: base += 6
if monthsUnderfunded >= 2: base += 6
if acknowledged = true: base += 4
if statusFit = -1: base += 3
if statusFit = -2: base += 6
```
Kinder:
```text
for each child where birthContext = 'lover':
if publicKnown = true: base += 6
else: base += 2
if legitimacy = 'acknowledged_bastard': base += 2
if legitimacy = 'hidden_bastard': base += 4
```
Haus:
```text
if householdOrder >= 80: base -= 6
else if householdOrder >= 65: base -= 3
if householdOrder <= 35: base += 8
else if householdOrder <= 50: base += 4
```
Dienerschaft:
```text
if servantCount < expectedMin: base += 5
if servantPayLevel = low: base += 2
if servantQuality >= 70 and servantPayLevel = high: base -= 3
```
Ehe:
```text
if marriageSatisfaction <= 35: base += 6
if marriageSatisfaction >= 75: base -= 2
```
Danach:
- clamp `0..100`
- UI-Ableitung auf `low/medium/high`
## 4.4 Monthly-Berechnung
Monatlich soll der Daemon zusätzlich:
- Dienerkosten abbuchen
- Liebschaftskosten abbuchen
- bei Unterversorgung `householdTensionScore` stärker erhöhen
- langfristige Ordnungs- oder Eheboni addieren
Empfohlene Zusatzregeln:
```text
if a lover was underfunded this month:
householdTensionScore += 4
if servantCount far below expectedMin for full month:
householdTensionScore += 3
if householdOrder >= 80 for full month:
marriageSatisfaction += 1
if householdOrder <= 30 for full month:
marriageSatisfaction -= 2
```
## 5. UI-Anforderungen
## 5.1 FamilyView
Neu sinnvolle Aktionen:
- `Zeit miteinander verbringen`
- `Geschenk machen`
- `Streit schlichten`
- `Liebschaft beenden`
- `Uneheliches Kind anerkennen`
Zusätzlich hilfreiche Anzeige:
- kurze Ursachenliste für `Hausfrieden`
- z. B. `sichtbare Liebschaft`
- `Unruhe im Haus`
- `zu wenig Diener`
- `anerkanntes uneheliches Kind`
## 5.2 HouseView
Neu sinnvolle Aktionen:
- `Haus ordnen`
- vorhandene Dieneraktionen mit klarer Auswirkungstextzeile
Anzeige:
- `Haushaltsordnung`
- `erwartete Dienerzahl`
- `Auswirkung auf Hausfrieden`
## 6. Backend-Anforderungen
## 6.1 Direktaktionen
Dieses Projekt sollte Endpunkte für direkte Einflussaktionen bereitstellen:
- `POST /api/falukant/family/marriage/spend-time`
- `POST /api/falukant/family/marriage/gift`
- `POST /api/falukant/family/marriage/reconcile`
- `POST /api/falukant/houses/order`
- später optional:
- `POST /api/falukant/family/children/acknowledge`
## 6.2 API-Rückgabe
Family-API sollte zusätzlich liefern:
- `marriageSatisfaction`
- `marriageState`
- `marriagePublicStability`
- `householdTension`
- `householdTensionScore`
- optional:
- `householdTensionReasons[]`
House-API sollte zusätzlich liefern:
- `householdOrder`
- `expectedServantsMin`
- `expectedServantsMax`
- `marriageComfortModifier`
## 7. Priorisierte Umsetzung
## Phase A
- `statusFit`-Fehler korrigieren
- direkte Ehe-Aktionen `Zeit`, `Geschenk`, `Streit schlichten`
- direkte Haus-Aktion `Haus ordnen`
- Family-API um `householdTensionScore` erweitern
## Phase B
- externer Daemon berechnet Daily-Drift für Ehe und Hausfrieden
- Dienerschaft fließt in Hausfrieden ein
- Liebschaften und Unterversorgung wirken vollständig auf Hausfrieden
## Phase C
- uneheliche Kinder als aktiver Konfliktfaktor
- Anerkennungsaktion
- genauere Ursachenlisten in der UI
## 8. Offene Balancing-Punkte
Diese Werte sind absichtlich noch nicht final:
- exakte Geldkosten für Ehe-Aktionen
- Stärke der Boni für hohe Stände
- Stärke des Malus bei sichtbaren Liebschaften
- Stärke der Dienerwirkung auf Ehe und Haus
Die Struktur sollte jetzt aber stabil genug sein, damit UI und Daemon unabhängig voneinander anfangen können.

View File

@@ -0,0 +1,137 @@
# Falukant: Daemon-Handoff für Ehe und Hausfrieden
Dieses Dokument beschreibt den Stand nach Phase A.
## 1. Was im Projekt jetzt vorhanden ist
Backend-/API-seitig vorhanden:
- `relationship_state.marriage_satisfaction`
- `relationship_state.marriage_public_stability`
- aktive Liebschaften mit:
- `visibility`
- `discretion`
- `maintenance_level`
- `status_fit`
- `months_underfunded`
- `acknowledged`
- `user_house` mit:
- `servant_count`
- `servant_quality`
- `servant_pay_level`
- `household_order`
- `household_tension_score`
- `household_tension_reasons_json`
- Family-API liefert jetzt zusätzlich:
- `householdTension`
- `householdTensionScore`
- `householdTensionReasons`
Direkte Spieleraktionen vorhanden:
- `POST /api/falukant/family/marriage/spend-time`
- `POST /api/falukant/family/marriage/gift`
- `POST /api/falukant/family/marriage/reconcile`
- `POST /api/falukant/houses/order`
## 2. Daily-Input für den externen Daemon
Pro betroffenem Falukant-User:
- `falukant_user.id`
- `user.id` / `user.hashed_id`
- aktive Ehe-`relationship` mit `relationship_state`
- aktive Liebschaften mit `relationship_state`
- Kinder mit:
- `birth_context`
- `legitimacy`
- `public_known`
- Haus mit:
- `servant_count`
- `servant_quality`
- `servant_pay_level`
- `household_order`
- Charakter mit:
- `reputation`
- `title_of_nobility`
## 3. Was der Daemon täglich berechnen soll
### Ehe
- Drift von `marriage_satisfaction`
- Drift von `marriage_public_stability`
- Einfluss aus:
- sichtbaren Liebschaften
- unterfinanzierten Liebschaften
- Standesunterschieden
- Dienerschaft / Haushaltsordnung
- zu jungen Liebschaften
### Hausfrieden
Der Daemon soll intern einen numerischen Spannungswert pflegen oder berechnen:
- `householdTensionScore` `0..100`
Einflussfaktoren:
- sichtbare Liebschaften
- anerkannte Liebschaften
- unterfinanzierte Liebschaften
- Kinder aus Liebschaften
- Haushaltsordnung
- Dienerschaft
- schwache Ehe
UI-Ableitung:
- `0..24` => `low`
- `25..59` => `medium`
- `60..100` => `high`
## 4. Was der Daemon zurückschreiben soll
Pflicht:
- `relationship_state.marriage_satisfaction`
- `relationship_state.marriage_public_stability`
- `user_house.household_tension_score`
- `user_house.household_tension_reasons_json`
- lover-state-Felder bei Änderungen:
- `visibility`
- `discretion`
- `months_underfunded`
- optional `notes_json` / `flags_json`
## 5. Socket-/Refresh-Verhalten
Wenn Daily-/Monthly-Verarbeitung Ehe oder Hausfrieden betrifft:
- `falukantUpdateFamily` mit `reason: "daily"` oder `reason: "monthly"`
- danach `falukantUpdateStatus`
Wenn ein Sonderereignis entsteht:
- `reason: "scandal"` zusätzlich
## 6. Wichtige Phase-A-Regel
Die neuen Direktaktionen geben nur Sofortimpulse:
- `spend-time`
- `gift`
- `reconcile`
- `house/order`
Der Daemon ist weiterhin verantwortlich für:
- Rückdrift
- Gegenkräfte
- Langzeiteffekte
- Balancing
Kurz:
- UI/Backend setzen kleine direkte Impulse
- der Daemon bestimmt die dauerhafte Entwicklung

View File

@@ -0,0 +1,431 @@
# Falukant: Sozialstatus / Standesaufstieg Erweiterte Spezifikation
## 1. Ziel
Der Aufstieg im Sozialstatus soll ab den höheren Ständen nicht mehr nur von Geld und einzelnen festen Anforderungen abhängen, sondern von einer Mischung aus gesellschaftlicher Stellung, öffentlicher Wahrnehmung und repräsentativem Lebensstandard.
Gleichzeitig soll das System:
- die frühen Aufstiege einfach halten
- spätere Aufstiege spürbar schwieriger machen
- mehr Geldbindung erzeugen
- nicht bei jedem Stand dieselben Faktoren verlangen
## 2. Grundprinzip
Der Standesaufstieg bleibt ein aktiver Spielzug des Nutzers.
Das heißt:
- der Spieler beantragt den nächsten Stand weiterhin aktiv in der UI
- das Backend prüft die Voraussetzungen
- bei Erfolg wird der Titel erhöht und die Kosten werden abgezogen
Neu ist:
- spätere Titel haben einen größeren und variableren Anforderungskatalog
- nicht jeder Titel prüft alle möglichen Faktoren
- pro nächstem Titel wird nur eine Auswahl relevanter Faktoren verlangt
- Kosten und Schwellen steigen schwach exponentiell
## 3. Bestehender Zustand
Aktuell:
- der nächste Titel wird über `level + 1` bestimmt
- Anforderungen kommen aus `TitleRequirement`
- geprüft werden bisher vor allem:
- `money`
- `cost`
- `branches`
- Aufstieg läuft manuell über `POST /api/falukant/nobility`
- Cooldown: 7 Tage
Das bleibt als technisches Grundmuster erhalten.
## 4. Fachliche Leitentscheidung
### 4.1 Frühe Titel
- Erster Aufstieg:
- darf weiterhin direkt kaufbar sein
- keine komplexen sozialen Bedingungen nötig
- Zweiter Aufstieg:
- bleibt wie bisher
- keine Änderung nötig
### 4.2 Ab dem dritten relevanten Standessprung
Ab dann sollen zusätzliche Faktoren einbezogen werden:
- höchstes bisheriges politisches Amt
- höchstes bisheriges kirchliches Amt
- Beliebtheit / Ansehen
- derzeitiges Haus
- Hauszustand
- Anzahl Liebhaber / Mätressen
Wichtig:
- nicht jeder spätere Stand nutzt alle Faktoren gleichzeitig
- pro Titel wird nur eine Auswahl davon aktiv
- dadurch bleibt das System lebendig statt schematisch
## 5. Neue Einflussfaktoren
## 5.1 Höchstes bisheriges politisches Amt
Nicht nur das aktuelle Amt zählt, sondern das höchste jemals gehaltene politische Amt.
Begründung:
- frühere Machtstellung bleibt gesellschaftlich wirksam
- ehemalige Amtsinhaber profitieren weiter vom Status ihrer Laufbahn
Empfohlene Auswertung:
- pro `political_office_type.name` existiert ein Rangwert
- für den Spieler zählt das Maximum aus aktiven und historischen politischen Ämtern
## 5.2 Höchstes bisheriges kirchliches Amt
Analog zur Politik:
- nicht nur aktuell besetzte Kirchenämter
- sondern höchstes jemals erreiches Kirchenamt
Bevorzugt anhand von:
- `church_office_type.hierarchy_level`
## 5.3 Beliebtheit / Ansehen
Der Stand soll nicht nur gekauft oder verwaltet, sondern auch öffentlich getragen werden.
Dafür wird genutzt:
- `character.reputation`
Das ist bewusst derselbe soziale Hauptwert, der auch an anderen Stellen bereits funktioniert.
## 5.4 Derzeitiges Haus
Das aktuelle Haus ist sichtbarer Ausdruck des Standes.
Relevanter Wert:
- `house.position`
Je höher das Haus, desto plausibler ein höherer sozialer Aufstieg.
## 5.5 Hauszustand
Nicht nur Hausgröße, auch sein Zustand zählt.
Zu berücksichtigen:
- Dach
- Wände
- Boden
- Fenster
Empfohlener abgeleiteter Wert:
- `houseConditionAverage = AVG(roofCondition, wallCondition, floorCondition, windowCondition)`
## 5.6 Anzahl Liebhaber / Mätressen
Dieser Faktor ist bewusst nicht rein negativ.
Er soll je nach Stand unterschiedlich gewertet werden:
- niedrige und mittlere Stände:
- viele offene Nebenbeziehungen schaden
- höhere Stände:
- eine gepflegte repräsentative Nebenbeziehung kann toleriert oder sogar sozial passend wirken
- zu viele Beziehungen bleiben aber auch dort schädlich
Darum soll die Anforderung nicht als starres „je mehr, desto besser/schlechter“ funktionieren, sondern titelabhängig.
Empfohlene Grundlage:
- aktive Beziehungen mit Rollen:
- `secret_affair`
- `lover`
- `mistress_or_favorite`
## 6. Titelabhängige Anforderungssets
## 6.1 Keine starre Vollprüfung
Ab den späteren Ständen wird pro nächstem Titel nicht alles geprüft, sondern ein Set aus:
- Pflichtfaktoren
- Auswahlfaktoren
### Pflichtfaktoren
Immer:
- `cost`
Je nach Stand meistens auch:
- Mindestansehen oder Hausniveau
### Auswahlfaktoren
Aus einem Pool von:
- Politik
- Kirche
- Ansehen
- Hausposition
- Hauszustand
- Liebhaber-/Mätressensituation
- ggf. weiter weiterhin Niederlassungen
## 6.2 Titelprofil statt Zufall pro Klick
Die Auswahl soll nicht bei jedem Aufruf neu würfeln.
Stattdessen:
- jeder Zieltitel hat ein fest definiertes Profil
- dieses Profil wirkt aber so, als ob nicht immer dieselben gesellschaftlichen Dinge zählen
Beispiel:
- Titel A prüft:
- Geld
- Ansehen
- Hausposition
- Titel B prüft:
- Geld
- politisches oder kirchliches Spitzenamt
- Hauszustand
- Titel C prüft:
- Geld
- Ansehen
- Haus
- kontrollierte repräsentative Nebenbeziehung
Das ist besser als echter Zufall, weil:
- nachvollziehbar
- testbar
- balancierbar
## 7. Schwellenlogik
## 7.1 Schwach exponentiell steigend
Die Anforderungen sollen nicht linear, sondern schwach exponentiell steigen.
Das betrifft vor allem:
- Kosten
- Mindestansehen
- Mindesthausniveau
- Mindestwert für Amtseinfluss
Empfohlene Denkweise:
- frühe Stände: leicht erreichbar
- mittlere Stände: merklich teurer
- hohe Stände: deutlich selektiver, aber nicht absurd
## 7.2 Beispielhafte Entwicklung
### Früh
- Geld dominiert
- kaum oder keine sozialen Zusatzbedingungen
### Mittel
- Geld plus 1 bis 2 soziale Bedingungen
### Hoch
- Geld plus 2 bis 3 soziale Bedingungen
- mindestens eine Repräsentationsbedingung:
- Haus oder Hauszustand
- mindestens eine Anerkennungsbedingung:
- Ansehen oder Amt
### Sehr hoch
- Geld plus 3 bis 4 Bedingungen
- politische oder kirchliche Laufbahn gewinnt an Gewicht
- Haus und Ansehen werden praktisch unverzichtbar
## 8. Faktorlogik im Detail
## 8.1 Politik / Kirche
Für spätere Stände genügt nicht jedes Amt.
Empfohlen:
- niedrige hohe Titel:
- irgendein relevantes Amt reicht
- spätere hohe Titel:
- nur hohe Ränge zählen
Regel:
- erfüllt, wenn `maxPoliticalRank` oder `maxChurchRank` über Titel-Schwelle liegt
## 8.2 Beliebtheit
Empfohlen:
- frühe soziale Titel: moderate Rufschwelle
- hohe Titel: Ruf wird Pflichtfaktor
## 8.3 Haus
Empfohlen:
- `house.position` wird ab mittleren Ständen wichtig
- `houseConditionAverage` wird ab späteren Ständen zusätzlich relevant
Das verhindert:
- „großes Haus, aber verwahrlost“
- oder „hoher Titel im ruinösen Haushalt“
## 8.4 Liebhaber / Mätressen
Dieser Faktor soll nur bei manchen Titeln überhaupt aktiv sein.
Mögliche Logik:
- niedrige/mittlere Stände:
- `0` oder `1` toleriert
- `>= 2` negativ
- höhere Stände:
- genau `1` repräsentative Nebenbeziehung kann neutral oder positiv sein
- `0` ist ebenfalls zulässig
- `>= 2` oder hohe Sichtbarkeit negativ
Das eignet sich besonders als optionaler Profilfaktor, nicht als Universalregel.
## 9. Vorgeschlagene Aufstiegsarchitektur
## 9.1 Stufe 1
- direkt kaufbar
- nur Kosten
## 9.2 Stufe 2
- bleibt wie bisher
## 9.3 Ab Stufe 3
Jeder Zieltitel bekommt:
- `baseCost`
- `costExponentFactor`
- `requiredChecks`
- `optionalCheckPool`
- `optionalCheckCount`
Die Prüfung lautet dann:
1. Kosten erfüllt
2. alle Pflichtchecks erfüllt
3. aus dem Auswahlpool mindestens `optionalCheckCount` erfüllt
Damit bleibt das System flexibel, aber klar.
## 10. UI-Auswirkung
Die Adel-/Standesansicht sollte nicht nur „fehlend/erfüllt“ zeigen, sondern künftig:
- aktive Pflichtanforderungen
- optionale Faktoren
- wie viele davon erfüllt werden müssen
- bereits erfüllte Faktoren optisch markieren
Beispiel:
- „Erfülle 2 von 3 gesellschaftlichen Voraussetzungen“
Dadurch versteht der Nutzer:
- warum der Aufstieg noch blockiert ist
- wo er am effizientesten investieren kann
## 11. Geldbindung
Mehr Geldinvestition soll ausdrücklich Teil des Systems sein.
Darum:
- jeder höhere Stand hat eine klar steigende Aufstiegskostenbasis
- Hauspflege und Hausgröße binden zusätzlich Kapital
- politische und kirchliche Karriere kostet indirekt ebenfalls Ressourcen
- repräsentative Liebhaber-/Mätressenführung kann bei manchen Profilen als teure, aber hilfreiche soziale Form auftreten
## 12. Verhältnis zu Daemon und Echtzeit
Der Standesaufstieg selbst bleibt weiterhin eine direkte Backend-Prüfung beim Spieler-Klick, nicht ein Daily-Daemon-Aufstieg.
Der Daemon kann aber vorbereitende Werte beeinflussen:
- Ruf
- Hauszustand
- aktive Amtshistorie
- Beziehungen / Sichtbarkeit
Das heißt:
- der Daemon verändert die Voraussetzungen
- der eigentliche Standesaufstieg bleibt ein aktiver Kauf-/Antragsvorgang
## 13. Empfohlene technische Erweiterung
Die aktuelle reine `TitleRequirement`-Logik ist für das erweiterte Modell zu schmal.
Empfohlen ist eine zusätzliche Titelprofil-Logik im Backend:
- je Titel ein Profilobjekt mit:
- Pflichtfaktoren
- Auswahlfaktoren
- Mindestanzahl erfüllter Auswahlfaktoren
- Kostenbasis
- Progressionsfaktor
Dabei kann das bestehende Requirements-Modell weiterhin für einfache Titel dienen.
## 14. Umsetzungsreihenfolge
### Phase 1
- ersten Aufstieg unverändert kaufbar lassen
- zweiten Aufstieg unverändert lassen
- ab späteren Titeln Backend-Profilprüfung einführen
### Phase 2
- UI um Pflicht-/Optionsanzeige erweitern
- soziale Faktoren sichtbar machen
### Phase 3
- Balancing
- feinere Titelprofile
- stärkere Verzahnung mit Politik, Kirche und Liebschaften
## 15. Kernaussage
Das System soll nicht „jeder Titel verlangt alles“ sein, sondern:
- frühe Aufstiege simpel
- spätere Aufstiege gesellschaftlich glaubwürdig
- steigende Kosten
- wechselnde, aber definierte Faktorprofile
- Haus, Ruf, Amt und Nebenbeziehungen werden echte Standeswerkzeuge

View File

@@ -0,0 +1,487 @@
# Falukant: Produktionszertifikate Fach- und Integrationsspezifikation
## 1. Ziel
Das Produktionssystem soll stärker an den tatsächlichen gesellschaftlichen und fachlichen Fortschritt eines Spielers gebunden werden. Ein Spieler darf nur Produkte herstellen, deren Zertifikatsstufe seiner aktuellen Produktionsfreigabe entspricht.
Die Zertifikatsstufe steigt nicht sofort bei jeder Einzelaktion, sondern wird ausschließlich im externen Daemon einmal täglich neu berechnet.
Dieses Dokument beschreibt:
- das fachliche Modell der Produktionszertifikate
- die Faktoren für Aufstieg und Begrenzung
- die Daily-Berechnung im Daemon
- die Kommunikation zwischen Daemon und UI
- die Einbindung in die bestehende Backend-/UI-Struktur
Wichtig:
- Die nötigen DB-Grundlagen sind bereits vorhanden.
- Der Daemon muss keine neuen Schemaänderungen erwarten.
- Bestehende Felder wie `falukant_data.falukant_user.certificate` und `falukant_type.product.category` bleiben die führende Basis.
## 2. Bestehende technische Basis
Bereits vorhanden:
- `falukant_data.falukant_user.certificate`
- aktuelle Produktionsfreigabe des Spielers
- `falukant_type.product.category`
- erforderliche Zertifikatsstufe des Produkts
- `falukant_data.knowledge`
- Produktwissen je Charakter und Produkt
- `falukant_data.production`
- Produktionsvorgänge
- `falukant_data.character.reputation`
- Ansehen des Spielercharakters
- `falukant_data.character.title_of_nobility`
- Adelstitel
- `falukant_data.user_house.house_type_id`
- aktuelles Haus
- politische und kirchliche Ämter
- `falukant_data.political_office`
- `falukant_data.church_office`
- `falukant_log.political_office_history`
- `falukant_type.church_office_type.hierarchy_level`
Bestehende Produktfreigabe im Backend:
- Produkte werden bereits in `falukantService.getProducts()` über `product.category <= user.certificate` gefiltert.
Das heißt:
- Zertifikatslogik muss nicht neu in die Produktionsfreigabe eingebaut werden.
- Es muss nur die Berechnung und Fortschreibung von `falukant_user.certificate` sauber geregelt werden.
## 3. Fachmodell
### 3.1 Zertifikatsstufen
Die Zertifikatsstufe bleibt eine einfache ganze Zahl im Feld `falukant_user.certificate`.
Empfohlene Bedeutung:
| Stufe | Bedeutung |
|------|-----------|
| `1` | Grundproduktion, einfache Güter |
| `2` | Gehobene Alltagsproduktion |
| `3` | Fortgeschrittene Manufaktur |
| `4` | Anspruchsvolle Qualitätsproduktion |
| `5` | Hochwertige oder prestigegebundene Produktion |
Wenn im Typensystem bereits höhere `product.category`-Werte existieren, gilt dieselbe Logik entsprechend weiter.
### 3.2 Führungsprinzip
Die Zertifikatsstufe ist kein reines Wissenslevel.
Sie soll ausdrücken, ob ein Haushalt/Betrieb gesellschaftlich und fachlich als ausreichend etabliert gilt, um komplexere Produktion zu verantworten.
Darum fließen mehrere Faktoren ein:
- Durchschnittliches Produktwissen
- Anzahl abgeschlossener Produktionen
- höchstes politisches oder kirchliches Amt
- Adelstitel
- Ansehen
- derzeitiges Haus
## 4. Berechnungslogik
## 4.1 Grundidee
Der Daemon berechnet einmal täglich einen `certificateScore`.
Aus diesem `certificateScore` wird eine Zielstufe `targetCertificate` abgeleitet.
Die gespeicherte Stufe `falukant_user.certificate` wird dann höchstens um `+1` pro Tag angehoben. Senkungen sind optional und in der ersten Version nicht vorgesehen.
Dadurch gilt:
- Aufstieg ist spürbar, aber nicht sprunghaft
- kurzfristige Schwankungen führen nicht zu hektischen Freischaltungen
- Balancing bleibt später leichter
## 4.2 Eingangsgrößen
Für jeden Spielercharakter mit `falukant_user`:
- `avgKnowledge`
- Durchschnitt aus `falukant_data.knowledge.knowledge` des Spielercharakters
- `completedProductions`
- Anzahl abgeschlossener Produktionen des Spielers
- `highestPoliticalOfficeRank`
- höchster politischer Amtsrang
- `highestChurchOfficeRank`
- höchster kirchlicher Amtsrang
- `highestOfficeRank`
- Maximum aus politischem und kirchlichem Rang
- `nobilityLevel`
- aus `title_of_nobility`
- `reputation`
- aus `character.reputation`
- `housePosition`
- aus `house.position`
## 4.3 Normalisierung der Faktoren
### Produktwissen
`knowledgePoints`:
- `0`, wenn `avgKnowledge < 20`
- `1`, wenn `avgKnowledge >= 20`
- `2`, wenn `avgKnowledge >= 35`
- `3`, wenn `avgKnowledge >= 50`
- `4`, wenn `avgKnowledge >= 65`
- `5`, wenn `avgKnowledge >= 80`
### Produktionsmenge
`productionPoints`:
- `0`, wenn `completedProductions < 5`
- `1`, wenn `completedProductions >= 5`
- `2`, wenn `completedProductions >= 20`
- `3`, wenn `completedProductions >= 50`
- `4`, wenn `completedProductions >= 100`
- `5`, wenn `completedProductions >= 200`
### Politische / kirchliche Stellung
`officePoints`:
- politische Ämter:
- über definierte Mapping-Tabelle im Daemon von `office_type.name -> rank`
- kirchliche Ämter:
- bevorzugt `church_office_type.hierarchy_level`
- dann:
- `highestOfficeRank = max(highestPoliticalOfficeRank, highestChurchOfficeRank)`
- `officePoints = min(5, highestOfficeRank)`
Empfehlung für politische Mapping-Tabelle:
- einfache Kommunalämter: `1`
- regionale Ämter: `2`
- hohe Regionalämter: `3`
- reichs- oder königsnahe Spitzenämter: `4` bis `5`
Das Mapping lebt im Daemon und kann balanciert werden, ohne DB-Änderungen.
### Adel
`nobilityPoints`:
- aus `title_of_nobility.level`
- `nobilityPoints = clamp(level - 1, 0, 5)`
### Ansehen
`reputationPoints`:
- `0`, wenn `reputation < 20`
- `1`, wenn `reputation >= 20`
- `2`, wenn `reputation >= 40`
- `3`, wenn `reputation >= 60`
- `4`, wenn `reputation >= 75`
- `5`, wenn `reputation >= 90`
### Haus
`housePoints`:
- aus `house.position`
- Vorschlag:
- `0`, wenn `position <= 1`
- `1`, wenn `position >= 2`
- `2`, wenn `position >= 4`
- `3`, wenn `position >= 6`
- `4`, wenn `position >= 8`
- `5`, wenn `position >= 10`
Die genauen Schwellen können im Balancing später angepasst werden.
## 4.4 Gesamtwert
Der Daemon berechnet:
```text
certificateScore =
knowledgePoints * 0.45 +
productionPoints * 0.30 +
officePoints * 0.08 +
nobilityPoints * 0.05 +
reputationPoints * 0.07 +
housePoints * 0.05
```
Zusätzlich gelten Mindestanforderungen je Stufe.
Balancing-Grundsatz:
- frühe und mittlere Zertifikate sollen primär über Wissen und Produktionspraxis erreichbar sein
- gesellschaftliche Faktoren wirken vor allem als Beschleuniger oder als Zugang zu hohen Zertifikaten
- vorübergehende wirtschaftliche Verlustphasen blockieren den normalen Aufstieg nicht automatisch
- ein normaler Produktionsverlust ist kein Downgrade-Grund
## 4.5 Mindestanforderungen je Zertifikatsstufe
Eine höhere Zielstufe darf nur erreicht werden, wenn neben dem `certificateScore` auch harte Mindestgrenzen erfüllt sind.
### Für Zertifikat 2
- `avgKnowledge >= 15`
- `completedProductions >= 4`
### Für Zertifikat 3
- `avgKnowledge >= 28`
- `completedProductions >= 15`
- kein harter Statuszwang
- Statusfaktoren dürfen den Score verbessern, sind hier aber noch nicht Pflicht
### Für Zertifikat 4
- `avgKnowledge >= 45`
- `completedProductions >= 45`
- mindestens einer der Statusfaktoren erfüllt:
- `officePoints >= 1`
- oder `nobilityPoints >= 1`
- oder `reputationPoints >= 2`
- oder `housePoints >= 2`
### Für Zertifikat 5
- `avgKnowledge >= 60`
- `completedProductions >= 110`
- `reputationPoints >= 2`
- mindestens zwei der folgenden:
- `officePoints >= 2`
- `nobilityPoints >= 1`
- `housePoints >= 2`
## 4.6 Ableitung der Zielstufe
Vorschlag:
- `targetCertificate = 1`, wenn `certificateScore < 0.9`
- `targetCertificate = 2`, wenn `certificateScore >= 0.9`
- `targetCertificate = 3`, wenn `certificateScore >= 1.8`
- `targetCertificate = 4`, wenn `certificateScore >= 2.8`
- `targetCertificate = 5`, wenn `certificateScore >= 3.8`
Danach werden die Mindestanforderungen geprüft.
Wenn eine Schwelle rechnerisch erreicht ist, die Mindestanforderungen aber fehlen, bleibt der Spieler auf der niedrigeren Zielstufe.
## 4.7 Fortschreibung
Daily-Regel:
- wenn `targetCertificate > currentCertificate`
- dann `newCertificate = currentCertificate + 1`
- sonst
- `newCertificate = currentCertificate`
Für die erste Version keine automatische Herabstufung.
Ausnahmen, die bereits im Daemon berücksichtigt werden dürfen:
- `Bankrott`
- Wenn der Spieler wirtschaftlich zusammenbricht, darf das Zertifikat gesenkt werden.
- Die genaue Definition von Bankrott lebt im Daemon bzw. im Wirtschaftssystem.
- `Tod ohne Kinder`
- Stirbt der Spielercharakter und es gibt keinen erbberechtigten Nachfolger, darf das Zertifikat auf den Grundzustand des Nachfolgesystems bzw. auf eine definierte niedrige Stufe zurückgesetzt werden.
- Dieser Fall darf bereits jetzt im Daemon umgesetzt werden.
Nicht vorgesehen für die erste Version:
- Downgrade wegen normaler Alltagsschwankungen
- Downgrade wegen vorübergehend schlechter Werte bei Wissen, Ruf, Haus oder Amt
## 5. Daemon-Verhalten
## 5.1 Ausführungszeitpunkt
Die Zertifikatsprüfung läuft ausschließlich im Daily-Tick.
Nicht bei:
- Produktionsstart
- Produktionsende
- Wissensänderung
- Hauswechsel
- Amtswechsel
Diese Aktionen verändern nur die Eingangsgrößen. Die eigentliche Zertifikatsanpassung erfolgt erst im nächsten Daily-Lauf.
## 5.2 Daemon-Hinweis
Für den Daemon gilt ausdrücklich:
- die relevanten DB-Felder sind bereits vorhanden
- es müssen für diese Funktion keine zusätzlichen Schemaänderungen mehr eingeplant werden
- der Daemon soll direkt mit den vorhandenen Tabellen arbeiten
## 5.3 Empfohlener Daily-Ablauf
Für jeden Spieler mit `falukant_user`:
1. Spielercharakter laden
2. `avgKnowledge` berechnen
3. Anzahl abgeschlossener Produktionen laden
4. höchstes aktives oder historisches politisches Amt ermitteln
5. höchstes aktives kirchliches Amt ermitteln
6. Adelstitel, Ruf und Haus laden
7. `certificateScore` und `targetCertificate` berechnen
8. falls Aufstieg möglich:
- `falukant_user.certificate` um genau `+1` erhöhen
9. Event an UI senden
Balancing-Hinweis für den Daemon:
- Wenn ein Spieler bereits regelmäßig produziert und verkauft, soll Zertifikat `2` früh stabil erreichbar sein.
- Zertifikat `3` soll bei solider Produktionspraxis und mittlerem Wissen ebenfalls ohne hohen Adels- oder Amtsstatus erreichbar sein.
- Hohe gesellschaftliche Faktoren sollen vor allem Zertifikat `4` und `5` deutlich erleichtern, nicht Zertifikat `2` und `3` künstlich blockieren.
- Reine Verlustphasen in der Geldhistorie sind kein eigener Gegenfaktor, solange kein echter Bankrottfall vorliegt.
## 6. Event-Kommunikation zwischen Daemon und UI
## 6.1 Neues Event
Zusätzlich zum allgemeinen `falukantUpdateStatus` wird ein gezieltes Zertifikats-Event empfohlen:
### `falukantUpdateProductionCertificate`
Payload:
```json
{
"event": "falukantUpdateProductionCertificate",
"user_id": 123,
"reason": "daily_recalculation",
"old_certificate": 2,
"new_certificate": 3
}
```
`reason` ist in der ersten Version fest:
- `daily_recalculation`
## 6.2 Wann senden
Wenn sich die Zertifikatsstufe geändert hat:
1. `falukantUpdateProductionCertificate`
2. danach `falukantUpdateStatus`
Wenn sich die Stufe nicht geändert hat:
- kein spezielles Zertifikats-Event nötig
- normales `falukantUpdateStatus` bleibt anderen Systemen vorbehalten
## 6.3 UI-Reaktion
### BranchView
Bei `falukantUpdateProductionCertificate`:
- Produkte neu laden
- Produktionsbereich neu laden
- optional kurzer Hinweis:
- „Neues Handelszertifikat erreicht“
### OverviewView
Bei `falukantUpdateProductionCertificate`:
- Falukant-Status neu laden
- Produktionsüberblick neu laden
- Zertifikatsaufstieg visuell hervorheben
### StatusBar / DashboardWidget
- auf denselben Nutzer filtern
- Zertifikat/Produktionsstatus neu laden
## 6.4 Deduplizierung
Da direkt nach dem Zertifikats-Event oft `falukantUpdateStatus` folgt, soll die UI wie bei anderen Falukant-Events entprellen:
- beide Events dürfen denselben kurzen Refresh-Puffer nutzen
- ein Zertifikatsaufstieg darf keinen doppelten Reload-Sturm auslösen
## 7. API- und UI-Empfehlungen
## 7.1 Sichtbare Anzeige
Die UI sollte mittelfristig anzeigen:
- aktuelle Zertifikatsstufe
- nächste Stufe
- Fortschrittsfaktoren
- Wissen
- Produktionen
- Amt
- Adel
- Ruf
- Haus
## 7.2 Empfohlene Backend-Ausgabe
Zusätzlich zur bestehenden User-/Overview-API ist später sinnvoll:
- `certificate`
- `nextCertificate`
- `certificateFactors`
- `avgKnowledge`
- `completedProductions`
- `highestOfficeRank`
- `nobilityLevel`
- `reputation`
- `housePosition`
Das ist für die erste Daemon-Integration aber optional.
## 8. Balancing-Hinweis
Die genannten Schwellen und Gewichte sind bewusst als Spielregelrahmen zu verstehen, nicht als endgültiges Balancing.
Für die erste produktive Version gilt:
- keine zusätzlichen Stufen oder Nebensysteme
- keine normale Herabstufung im Alltagsbetrieb
- Herabstufung nur in Sonderfällen wie `Bankrott` oder `Tod ohne Kinder`
- Daily-Aufstieg maximal `+1`
Balancing erst nach Live-Erfahrung.
## 9. Umsetzungsreihenfolge
### P1
- Daemon: Daily-Berechnung von `certificate`
- Event `falukantUpdateProductionCertificate`
- UI: gezielter Refresh in Branch/Overview
### P2
- UI: Sichtbarer Zertifikatsstatus und Aufstiegshinweis
- Backend/API: optionale Faktor-Ausgabe
### P3
- Balancing
- feinere Sonderfallregeln für `Bankrott`
- feinere politische Mapping-Tabelle
## 10. Done-Kriterien
Fertig ist die erste Version, wenn:
- nur Produkte mit `product.category <= falukant_user.certificate` produzierbar sind
- der Daemon die Zertifikatsprüfung genau einmal täglich ausführt
- der Daemon bei Aufstieg das Zertifikat fortschreibt
- die UI auf das Zertifikats-Event gezielt reagiert
- keine neuen DB-Änderungen für diese Funktion nötig sind

View File

@@ -0,0 +1,336 @@
# Falukant: Konzept Dienerschaft
Dieses Dokument beschreibt ein eigenständiges Dienerschaftssystem für Falukant. Die Dienerschaft hängt bewusst am Haus und nicht primär an Familie oder Liebschaften.
## 1. Grundentscheidung
Dienerschaft ist Teil des Hausstands.
Warum:
- Diener versorgen Haushalt, Gebäude, Gäste und Repräsentation.
- Die Größe und Qualität der Dienerschaft hängt stärker an Hausgröße und Stand als an einzelnen Familienbeziehungen.
- Spätere Systeme wie Diskretion, Skandalabwehr, Botengänge, Schutz und Festkultur lassen sich so an einer Stelle bündeln.
Folgerung:
- Hauptansicht: `HouseView`
- Datenträger: `user_house` plus eigene Dienerstruktur
- Familie, Liebschaften, Ruf und Untergrund nutzen die Effekte mit, besitzen das System aber nicht selbst.
## 2. Spielziel
Die Dienerschaft soll vier Dinge leisten:
- laufende Kosten und Standesdruck erzeugen
- Komfort und Ordnung des Haushalts darstellen
- Repräsentation und Ansehen beeinflussen
- Diskretion und Risiko in Familien- und Liebschaftsfragen mitsteuern
Die erste Ausbaustufe bleibt bewusst einfach und abstrahiert. Einzelne Namen oder tiefes Personalmanagement kommen erst später.
## 3. Kernmodell
### 3.1 Erste Ausbaustufe: abstrakte Dienerschaft
Der Spieler verwaltet keine einzelnen Diener, sondern einen Haushalt mit wenigen Zuständen:
- `servantCount`
- `servantQuality`
- `servantPayLevel`
- `householdOrder`
Empfohlene Bedeutung:
- `servantCount`: tatsächliche Zahl der Bediensteten
- `servantQuality`: Ausbildungs- und Verlässlichkeitsniveau
- `servantPayLevel`: wie gut der Haushalt bezahlt und versorgt wird
- `householdOrder`: Ergebniswert für Disziplin, Sauberkeit, Organisation
### 3.2 Spätere Ausbaustufe
Erst später werden Rollen differenziert:
- Hausverwalter / Haushofmeister
- Kammerdiener / Zofen
- Küchenpersonal
- Stallpersonal
- Kinder- und Pflegepersonal
- Wachen / Torpersonal
Diese zweite Stufe ist ausdrücklich nicht Teil des ersten Implementierungspakets.
## 4. Verbindung zum Haus
Die Dienerschaft ist an das Haus gekoppelt.
Das Haus bestimmt:
- maximal sinnvolle Dienerzahl
- erwartete Mindestzahl je nach Stand
- Ansehenswirkung von Über- oder Unterbesetzung
- Kostenfaktor
Ein kleines Haus mit zu großer Dienerschaft wirkt verschwenderisch.
Ein großes oder nobles Haus mit zu wenig Dienern wirkt ungeordnet, geizig oder standeswidrig.
## 5. Haus- und Standeslogik
Die Zielgröße der Dienerschaft entsteht aus zwei Faktoren:
- Hausgröße / Haustyp
- gesellschaftlicher Stand
### 5.1 Erwartungswert
Jeder Haushalt hat einen erwarteten Bereich:
- `expectedServantsMin`
- `expectedServantsMax`
Dieser Bereich wird aus Haus und Titel abgeleitet.
Beispielhafte Richtung:
- Holzhaus, niedriger Stand: 0 bis 1
- kleines Familienhaus: 1 bis 3
- Stadthaus oder höherer Adel: 3 bis 8
- Hochadel und Hofnähe: deutlich darüber
Wichtig:
- Das sind keine finalen Balancing-Zahlen.
- Das Balancing bleibt eine spätere Phase.
## 6. Zentrale Spielwerte
### 6.1 Dienerzahl
Die Dienerzahl ist der wichtigste Primärwert.
Zu wenig Diener:
- schlechtere Haushaltsordnung
- negativer Einfluss auf Ansehen in hohen Ständen
- höhere Spannungen im Haus
- weniger Diskretion und schwächerer Schutz vor Gerüchten
Zu viele Diener:
- unnötige Kosten
- bei niedrigem Stand möglicher Vorwurf von Verschwendung oder Anmaßung
- höheres Risiko für Klatsch, weil mehr Personen Wissen tragen
### 6.2 Qualität
Qualität beschreibt Verlässlichkeit und Niveau.
Niedrige Qualität:
- Haus funktioniert nur grob
- Diskretion schlecht
- Feste und Repräsentation schwächer
- höheres Risiko für Gerede, Unordnung, Pannen
Hohe Qualität:
- besserer Hauszustand im Alltag
- stärkere Diskretion
- besserer Eindruck bei Gästen
- positive Wirkung auf Ehekomfort und Familienruhe
### 6.3 Bezahlung
Die Bezahlung ist ein Steuerungswert.
Niedrige Bezahlung:
- spart kurzfristig Geld
- senkt Loyalität und Qualität
- erhöht Gerüchte- und Diebstahlrisiko
Hohe Bezahlung:
- kostet mehr
- verbessert Loyalität, Qualität und Diskretion
### 6.4 Haushaltsordnung
`householdOrder` ist ein abgeleiteter Zustand.
Er hängt ab von:
- Dienerzahl im Verhältnis zur Sollgröße
- Qualität
- Bezahlung
- Hauszustand
Auswirkungen:
- bessere Ordnung stabilisiert Ehe- und Familienwerte
- schlechte Ordnung verschlechtert Komfort und Ansehen
- sie beeinflusst spätere Fest- und Besuchssysteme
## 7. Systemwirkungen
### 7.1 Geld
Dienerschaft erzeugt laufende Kosten.
Monatliche Kosten hängen ab von:
- Dienerzahl
- Qualitätsniveau
- Bezahlungsstufe
- Hausgröße
Später kann darin auch Nahrung, Kleidung und Ausstattung enthalten sein.
### 7.2 Ansehen
Ansehen wird nicht direkt nur durch „mehr Diener = besser“ berechnet.
Stattdessen wirkt:
- Passung zum Stand
- Ordnung und Auftreten
- offensichtliche Unterversorgung
- offensichtliche Verschwendung
Faustregel:
- hohe Stände werden stärker nach Hausführung beurteilt
- niedrige Stände dürfen einfacher leben
- extreme Abweichungen nach oben oder unten wirken negativ
### 7.3 Familie und Ehe
Die Familie nutzt die Hauswirkung mit.
Positive Effekte guter Dienerschaft:
- mehr Komfort
- geringere Alltagsbelastung
- bessere Ehezufriedenheit
- geringerer Haushaltsstress
Negative Effekte schlechter Dienerschaft:
- Unruhe im Haus
- Streit über Kosten und Ordnung
- zusätzliche Spannungen bei Ehe und Kindern
### 7.4 Liebschaften und Skandale
Dienerschaft beeinflusst Diskretion.
Gut bezahlte, loyale und kleine, passende Dienerschaft:
- schützt Geheimnisse besser
- senkt Skandal- und Gerüchterisiko
Unzufriedene oder zu große Dienerschaft:
- erhöht Klatsch
- macht verdeckte Beziehungen sichtbarer
- verbessert die Chancen von Untergrundaktivitäten, etwas aufzudecken
### 7.5 Untergrund / Aufdeckung
Das Untergrundsystem soll später auf Dienerschaft zugreifen können.
Beispiel:
- unzufriedenes Personal erhöht Erfolg bei `investigate_affair`
- sehr diskreter Haushalt erschwert Aufdeckung und Erpressung
## 8. Standeslogik
Die Bewertung der Dienerschaft ist standesabhängig.
### Niedrige Stände
Erlaubt:
- kleine oder keine Dienerschaft
Negativ:
- zu große Dienerschaft bei kleinem Haus
- demonstrative Übertreibung
### Mittlere Stände
Erwartet:
- geordneter kleiner Haushalt
- passende Grundversorgung
Negativ:
- sichtbare Unordnung
- geizige Unterbesetzung
- übertriebener Luxus
### Hohe Stände
Erwartet:
- repräsentative, funktionierende Dienerschaft
Negativ:
- zu wenig Personal
- schlechter Hauszustand trotz Rang
- öffentlich erkennbare Überforderung im Haushalt
## 9. UI-Richtung
Die erste Oberfläche gehört in `HouseView`.
Empfohlene Elemente:
- Überblickskarte „Dienerschaft“
- Ist-Zahl, Sollbereich, Qualität, Bezahlungsstufe, Haushaltsordnung
- einfache Aktionen:
- Diener einstellen
- Diener entlassen
- Bezahlung anheben
- Bezahlung senken
Zusätzliche Anzeigen:
- erwarteter Bereich nach Haus und Stand
- Monatskosten
- Haupteffekte auf Ordnung, Ansehen und Diskretion
Wichtig:
- kein Mikromanagement pro Diener in der ersten Version
- keine Personallisten im MVP
## 10. Daemon-/Tick-Sicht
Die eigentliche Veränderung der Zustände soll durch den externen Daemon laufen.
Daily:
- Drift von Loyalität und Ordnung
- kleine Folgen schlechter Versorgung
- Diskretionswirkung auf Familien- und Liebschaftssysteme
Monthly:
- Kosten abbuchen
- Unterversorgung bewerten
- Qualität und Loyalität nachziehen
- Ansehenswirkung aus Passung und Ordnung anwenden
## 11. MVP-Schnitt
Erste spielbare Version:
- Dienerschaft ist ein Hauswert
- nur aggregierte Werte, keine Einzelrollen
- UI in `HouseView`
- monatliche Kosten
- grobe Effekte auf:
- Haushaltsordnung
- Ansehen
- Ehezufriedenheit
- Diskretion bei Liebschaften
Noch nicht im MVP:
- benannte Diener
- Intrigen einzelner Bediensteter
- eigene Dienerereignisse mit langen Ketten
- tiefes Rollenmanagement
## 12. Spätere Ausbauten
Später interessant:
- Dienerschaft als Voraussetzung für bestimmte Feste
- Spezialrollen wie Amme, Leibdiener, Spion im Haushalt
- interne Konflikte unter Dienern
- Diebstahl, Bestechung, Illoyalität
- Hauspersonal als Quelle für Gerüchte oder Schutz
- Untergrund kann Personal bestechen
## 13. Offene Designentscheidungen
1. Soll die erste Version mit einer absoluten `servantCount` arbeiten oder mit Stufen wie klein / passend / groß?
2. Soll `householdOrder` direkt gespeichert oder komplett aus anderen Werten berechnet werden?
3. Soll Bezahlung als Prozentwert, feste Stufe oder Freitext-Enum geführt werden?
4. Wie stark soll Dienerschaft bereits in der ersten Version auf Liebschaften und Untergrund wirken?
5. Sollen Feste weiter ihr eigenes `servantRatio` behalten oder später an das neue System angebunden werden?
## 14. Empfehlung
Empfohlene erste Umsetzung:
- `servantCount` als absolute Zahl
- `servantQuality` als einfacher Wert 0 bis 100
- `servantPayLevel` als feste Stufen `low`, `normal`, `high`
- `householdOrder` als gespeicherter, vom Daemon gepflegter Zustand
Diese Variante ist einfach genug für ein erstes Spielsystem, aber stark genug, um später Familie, Ruf, Untergrund und Feste daran anzuschließen.

View File

@@ -0,0 +1,636 @@
# Falukant: Dienerschaft Daemon-, Technik- und Umsetzungs-Spezifikation
Dieses Dokument bündelt die umsetzungsreife Spezifikation für das Dienerschaftssystem in einer Datei.
Es ersetzt für die technische Umsetzung die sonst übliche Aufteilung in:
- Daemon-Spec
- Daemon-Handoff
- technisches Konzept
- Implementierungs-Backlog
Die fachliche Grundidee bleibt in [FALUKANT_SERVANTS_CONCEPT.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_SERVANTS_CONCEPT.md) beschrieben. Dieses Dokument hier ist die Arbeitsgrundlage für Implementierung und Daemon-Anbindung.
## 1. Zielbild
Die Dienerschaft ist ein Haussystem mit vier Kernwerten:
- `servantCount`
- `servantQuality`
- `servantPayLevel`
- `householdOrder`
Diese Werte wirken auf:
- monatliche Kosten
- Repräsentation und Ansehen
- Komfort und Ordnung des Haushalts
- Ehezufriedenheit und Haushaltsfrieden
- Diskretion bei Liebschaften
- spätere Untergrund-Aufdeckungen
## 2. Systemgrenzen
In Scope der ersten Version:
- Dienerschaft hängt an `user_house`
- House-UI zeigt und verändert Dienerwerte
- externer Daemon verarbeitet Daily- und Monthly-Effekte
- Familie, Liebschaften und Untergrund nutzen die resultierenden Werte mit
Nicht in Scope der ersten Version:
- einzelne benannte Diener
- eigene Dienerrollen wie Küchenpersonal, Wachen, Zofen
- eigene Eventketten nur für Diener
- finales Balancing
## 3. Datenmodell
### 3.1 Bereits vorhandene Hausfelder
In `falukant_data.user_house`:
- `servant_count integer not null default 0`
- `servant_quality integer not null default 50`
- `servant_pay_level varchar(20) not null default 'normal'`
- `household_order integer not null default 55`
### 3.2 Wertebereiche
- `servant_count`: `0..999`
- `servant_quality`: `0..100`
- `servant_pay_level`: `low | normal | high`
- `household_order`: `0..100`
### 3.3 Abgeleitete Werte
Diese Werte müssen nicht persistent gespeichert werden, sondern können im Backend oder Daemon berechnet werden:
- `expectedServantsMin`
- `expectedServantsMax`
- `staffingState`
- `orderState`
- `monthlyServantCost`
- `discretionModifier`
- `servantReputationModifier`
- `marriageComfortModifier`
## 4. Erwartungswert der Dienerschaft
Die Sollgröße hängt von Haus und Stand ab.
### 4.1 Basis nach Hausposition
`house.house_type.position` ist die grobe Hausklasse.
Empfohlene erste Regel:
| Hausposition | Basis Min | Basis Max |
|-------------|-----------|-----------|
| `<= 2` | 0 | 1 |
| `3` | 1 | 2 |
| `4` | 2 | 4 |
| `5` | 3 | 6 |
| `>= 6` | 4 | 8 |
### 4.2 Standesbonus
Aus `character.noble_title.level`:
```text
titleBonus = floor(level / 3), mindestens 0
expectedMin = baseMin + titleBonus
expectedMax = baseMax + titleBonus
```
### 4.3 Zustandsklassen
```text
if servantCount < expectedMin => understaffed
if servantCount > expectedMax => overstaffed
sonst => fitting
```
## 5. Daily-Regeln für den externen Daemon
## 5.1 Daily-Input
Pro Falukant-User mit Haus braucht der Daemon:
- `falukant_user.id`
- `user.id` bzw. `user.hashed_id` für Benachrichtigung
- `character.id`
- `character.reputation`
- `character.noble_title_id` und idealerweise `character.nobleTitle.level`
- `user_house.house_type_id`
- `house_type.position`
- `house_type.cost`
- `servant_count`
- `servant_quality`
- `servant_pay_level`
- `household_order`
- optional für Verknüpfungen:
- `marriage_satisfaction` oder `relationship_state.marriage_satisfaction`
- aktive Liebschaften mit `visibility`, `discretion`, `risk`
## 5.2 Daily-Hilfswerte
```text
payShift(low) = -6
payShift(normal) = 0
payShift(high) = +6
missing = max(0, expectedMin - servantCount)
excessive = max(0, servantCount - expectedMax)
qualityPart = round((servantQuality - 50) * 0.35)
payPart = payShift(servantPayLevel)
fitPenalty = missing * 10 + excessive * 4
```
## 5.3 Daily-Zielwert für Haushaltsordnung
```text
targetHouseholdOrder = clamp(
55 + qualityPart + payPart - fitPenalty,
0,
100
)
```
## 5.4 Daily-Drift der Haushaltsordnung
Die Ordnung springt nicht hart, sondern driftet langsam:
```text
newHouseholdOrder = oldHouseholdOrder
if oldHouseholdOrder < targetHouseholdOrder:
newHouseholdOrder += min(2, targetHouseholdOrder - oldHouseholdOrder)
if oldHouseholdOrder > targetHouseholdOrder:
newHouseholdOrder -= min(2, oldHouseholdOrder - targetHouseholdOrder)
```
Zusatzregel:
- bei `servantPayLevel = low` und `servantCount < expectedMin` zusätzlich `-1`
- bei `servantPayLevel = high` und `servantQuality >= 65` zusätzlich `+1`
Danach clamp auf `0..100`.
## 5.5 Daily-Drift der Dienerqualität
Die Qualität ändert sich langsam:
```text
qualityDelta = 0
if servantPayLevel = low: qualityDelta -= 1
if servantPayLevel = high: qualityDelta += 1
if servantCount < expectedMin: qualityDelta -= 1
if servantCount > expectedMax + 2: qualityDelta -= 1
if householdOrder >= 80: qualityDelta += 1
if householdOrder <= 30: qualityDelta -= 1
```
Danach:
- auf `-2..+2` pro Tag begrenzen
- `servantQuality = clamp(servantQuality + qualityDelta, 0, 100)`
## 5.6 Daily-Effekt auf Ansehen
Der Daily-Rufeffekt ist klein, damit Monats- und Ereigniseffekte wichtiger bleiben.
```text
reputationDelta = 0
if titleLevel >= 4 and servantCount < expectedMin:
reputationDelta -= 0.15 * missing
if titleLevel <= 1 and servantCount > expectedMax:
reputationDelta -= 0.10 * excessive
if householdOrder >= 85 and servantCount between expectedMin and expectedMax:
reputationDelta += 0.05
if householdOrder <= 25:
reputationDelta -= 0.20
```
Rundung:
- intern als Dezimalwert möglich
- falls nur Ganzzahlen gespeichert werden, über Tagespuffer oder Rundungsregel aggregieren
## 5.7 Daily-Effekt auf Ehe / Haushalt
Wenn ein Ehe-Zufriedenheitssystem vorhanden ist:
```text
marriageDelta = 0
if householdOrder >= 75: marriageDelta += 0.10
if householdOrder <= 35: marriageDelta -= 0.15
if servantCount < expectedMin: marriageDelta -= 0.10
```
Wenn noch kein eigener Wert gespeichert wird:
- diese Regel für später vormerken
- aktuell nur `householdTension` oder UI-Ableitungen beeinflussen
## 5.8 Daily-Effekt auf Liebschaften / Diskretion
Der Daemon berechnet einen Diskretionsmodifikator:
```text
discretionModifier = 0
if servantQuality >= 70 and servantPayLevel = high and servantCount <= expectedMax:
discretionModifier -= 8
if servantPayLevel = low:
discretionModifier += 6
if servantCount > expectedMax + 1:
discretionModifier += 4
if householdOrder <= 35:
discretionModifier += 5
```
Bedeutung:
- negativer Wert verbessert Geheimhaltung
- positiver Wert erhöht Entdeckungsrisiko
Anwendung:
- bei aktiven Liebschaften auf Sichtbarkeit/Skandalchance
- bei Untergrundaktivitäten als Erfolgsmodifikator
## 5.9 Daily-Notifications
Daily sendet nicht für jede Teildrift ein eigenes Event.
Wenn sich einer dieser Punkte relevant verändert:
- `household_order`
- `servant_quality`
- `reputation`
- Ehe-/Liebschaftsfolgen über Diener
dann:
- `falukantUpdateFamily` mit `reason: "daily"`
- danach `falukantUpdateStatus`
Es gibt keinen separaten `reason` für Dienerschaft.
## 6. Monthly-Regeln für den externen Daemon
## 6.1 Monthly-Input
Wie Daily, zusätzlich:
- aktuelles Geld `falukant_user.money`
## 6.2 Monatskosten
Änderungsnotiz für den externen Daemon:
- Falukant verwendet einen stark komprimierten Zeitmaßstab: `1 Tag = 1 Jahr`.
- Der Monatslauf des Daemons entspricht damit ungefähr einem 2-Stunden-Schritt.
- Dienerkosten dürfen deshalb nicht wie ein realistischer Vollmonatslohn skaliert werden.
- Der hier definierte Monatswert ist ein abstrahierter Unterhalts- und Bindungsbetrag pro Monatstick.
- Die Datenbank ist für diese Änderung bereits vorbereitet; es sind keine zusätzlichen DB-Anpassungen nötig.
```text
basePerServant = max(3, round((houseType.cost / 10000) + 6))
qualityFactor = 1 + ((servantQuality - 50) / 200)
payFactor(low) = 0.8
payFactor(normal) = 1.0
payFactor(high) = 1.3
monthlyServantCost = servantCount * basePerServant * qualityFactor * payFactor
```
Auf 2 Nachkommastellen runden.
## 6.3 Abbuchung
Wenn genügend Geld vorhanden:
- Geld abziehen
- Aktivität z. B. `servants_monthly`
Wenn nicht genügend Geld vorhanden:
- so viel wie möglich abziehen oder auf 0 fallen lassen, je nach vorhandener Gesamtlogik
- Unterversorgung markieren
Empfehlung für die erste Version:
- vollständige Abbuchung nur wenn genug Geld da
- sonst `underfunded = true`
## 6.4 Folgen von Unterversorgung
Bei Unterversorgung im Monat:
```text
servantQuality -= 4
householdOrder -= 6
```
Zusätzlich:
- wenn `titleLevel >= 4`: `reputation -= 1`
- wenn aktive Liebschaften vorhanden: Diskretionsmalus für den Folgemonat
## 6.5 Monatsbonus bei gutem Haushalt
Wenn gleichzeitig:
- `servantCount` innerhalb Sollbereich
- `servantQuality >= 70`
- `householdOrder >= 80`
- `servantPayLevel != low`
dann:
- `reputation += 1` für hohe Stände ab `titleLevel >= 3`
- kleiner Ehe-/Komfortbonus, falls System vorhanden
## 6.6 Monthly-Notifications
Nach Monatsverarbeitung:
- `falukantUpdateFamily` mit `reason: "monthly"`
- danach `falukantUpdateStatus`
## 7. Handoff an den externen Daemon
## 7.1 Der externe Daemon muss lesen
Aus Backend/DB:
- `falukant_data.user_house`
- `falukant_type.house`
- `falukant_data.falukant_user`
- `falukant_data.character`
- Titel/Stand
- optional aktive Ehe-/Liebschaftsdaten
## 7.2 Der externe Daemon muss schreiben
Mindestens:
- `user_house.servant_quality`
- `user_house.household_order`
- `character.reputation` oder entsprechender Rufwert
Optional, falls vorhanden:
- `relationship_state.marriage_satisfaction`
- Hilfs-/Logtabellen für Monatskosten und Unterversorgung
## 7.3 Der externe Daemon muss senden
Bei relevanten Änderungen:
- `falukantUpdateFamily`
- `falukantUpdateStatus`
`reason` nur:
- `daily`
- `monthly`
Keine zusätzlichen Diener-Reason-Werte.
## 7.4 Idempotenz
Der Daemon muss verhindern, dass Daily/Monthly doppelt auf denselben Tick laufen.
Empfohlen:
- eigene Tick-Marker außerhalb dieses Projekts
- oder Zeitstempel in Worker-Logs
## 8. Backend-Aufgaben in diesem Projekt
## 8.1 Bereits erledigt
- Hausfelder in `user_house`
- Migration
- Produktions-SQL
- House-API mit Dienerwerten
- UI in `HouseView`
- direkte Spieleraktionen:
- einstellen
- entlassen
- Bezahlungsstufe ändern
## 8.2 Noch sinnvolle Backend-Nacharbeiten
- eigenes Money-Label für Monatskosten, z. B. `servants_monthly`
- optional eigener Read-Endpunkt nur für Dienerschaft
- optionale Validierungsgrenzen serverseitig weiter schärfen
- später: Ableitung von `householdTension` stärker an Diener koppeln
## 9. UI-Anforderungen
Die House-UI soll anzeigen:
- aktuelle Dienerzahl
- Sollbereich
- Monatskosten
- Qualität
- Haushaltsordnung
- Bezahlungsstufe
- Besetzungsstatus
- Ordnungsstatus
Die UI soll direkt erlauben:
- `+1` Diener
- `-1` Diener
- Pay-Level wechseln
Die UI braucht keine Daemon-Sonderlogik außer normalen House-/Status-Refresh.
## 10. API-Schnittstellen
Bereits vorgesehen:
- `GET /api/falukant/houses`
- `POST /api/falukant/houses/servants/hire`
- `POST /api/falukant/houses/servants/dismiss`
- `POST /api/falukant/houses/servants/pay-level`
### Beispiel-Response für `GET /houses`
```json
{
"roofCondition": 100,
"wallCondition": 100,
"floorCondition": 100,
"windowCondition": 100,
"servantCount": 3,
"servantQuality": 58,
"servantPayLevel": "normal",
"householdOrder": 63,
"houseType": {
"id": 5,
"position": 5,
"cost": 273000,
"labelTr": "family_house"
},
"servantSummary": {
"expectedMin": 3,
"expectedMax": 6,
"monthlyCost": 98.1,
"staffingState": "fitting",
"orderState": "stable"
}
}
```
## 11. Technische Architektur
### 11.1 Quelle der Wahrheit
Quelle der Wahrheit für:
- Stammdaten und persistente Hauswerte: dieses Backend / Datenbank
- Tick-Ausführung: externer Daemon
### 11.2 Verantwortungstrennung
Dieses Projekt:
- speichert Werte
- bietet UI und API
- berechnet einfache Hilfswerte für Anzeige
Externer Daemon:
- tägliche und monatliche Veränderung
- Kostenabbuchung
- Reputationseffekte
- Verknüpfung mit Familie, Liebschaften und Untergrund
### 11.3 Warum so
Damit:
- Spiellogik nicht doppelt tickt
- UI trotzdem schon benutzbar ist
- der Daemon später nur auf stabile Felder aufsetzen muss
## 12. Implementierungs-Backlog
## B1 Datenbasis
Status: erledigt
Aufgaben:
- Hausfelder in `user_house`
- Migration
- Produktions-SQL
Done:
- Felder vorhanden
- Model aktualisiert
## B2 Haus-Service
Status: erledigt
Aufgaben:
- Sollbereich berechnen
- Monatskosten berechnen
- Zustandslabels ableiten
Done:
- `servantSummary` wird im House-Read geliefert
## B3 Spieleraktionen
Status: erledigt
Aufgaben:
- einstellen
- entlassen
- Bezahlung ändern
Done:
- Endpunkte vorhanden
- UI verdrahtet
## B4 House-UI
Status: erledigt
Aufgaben:
- Anzeige in `HouseView`
- Aktionen
- Locale-Texte
Done:
- HouseView zeigt den Dienerblock
## B5 Daemon Daily
Status: offen
Aufgaben:
- `expectedMin/Max` im Worker berechnen
- `householdOrder` driften
- `servantQuality` driften
- kleinen Reputationseffekt anwenden
- Diskretionsmodifikator für Liebschaften ableiten
- `daily`-Refresh senden
Done-Kriterien:
- täglicher Tick verändert Hauswerte nachvollziehbar
- keine zusätzlichen UI-Reason-Werte nötig
## B6 Daemon Monthly
Status: offen
Aufgaben:
- Monatskosten berechnen
- Geld abbuchen
- Unterversorgung behandeln
- Monatsrufeffekte anwenden
- `monthly`-Refresh senden
Done-Kriterien:
- Monatskosten und Unterversorgung sind im Spiel spürbar
## B7 Integration mit Familie / Liebschaften
Status: offen
Aufgaben:
- `householdOrder` auf Ehekomfort mappen
- Diskretionsmodifikator in Skandal-/Liebschaftslogik einbeziehen
- schlechte Bezahlung oder Überbesetzung als Gerüchtefaktor nutzen
Done-Kriterien:
- Dienerschaft beeinflusst Familien- und Liebschaftssystem real
## B8 Integration mit Untergrund
Status: offen
Aufgaben:
- `investigate_affair` nutzt Dienerwerte
- schlechter Haushalt erhöht Aufdeckungschance
- guter, diskreter Haushalt senkt Erfolgswahrscheinlichkeit
Done-Kriterien:
- Untergrund spürt Dienerschaft in Erfolgsmodifikatoren
## B9 Balancing
Status: offen, bewusst spätere Phase
Aufgaben:
- Kosten, Rufwerte, Driftgeschwindigkeiten und Schwellwerte feinjustieren
## 13. Produktionshinweise
Wenn keine Migrationen laufen:
- [add_servants_to_user_house.sql](/mnt/share/torsten/Programs/YourPart3/backend/sql/add_servants_to_user_house.sql) ausführen
Der externe Daemon muss erst danach aktiviert werden, damit die Felder sicher vorhanden sind.
## 14. Empfehlung für die nächste Reihenfolge
Empfohlene Reihenfolge ab jetzt:
1. Produktions-SQL einspielen
2. B5 Daily im externen Daemon
3. B6 Monthly im externen Daemon
4. B7 Familie/Liebschaften anbinden
5. B8 Untergrund anbinden
6. B9 Balancing
## 15. Kurzfazit
Die Haus- und UI-Basis ist bereits eingebaut. Für eine vollständige Spielwirkung fehlen jetzt vor allem die beiden externen Worker-Blöcke:
- tägliche Drift
- monatliche Kosten und Folgen
Mit dieser Datei sollte der externe Daemon direkt implementierbar sein, ohne weitere Konzeptdokumente zu benötigen.

View File

@@ -0,0 +1,412 @@
# Falukant: Überfälle auf Transporte und Transportwachen
Dieses Dokument beschreibt das Zielmodell für einen neuen Untergrundtyp **Überfälle auf Transporte** sowie das ergänzende Schutzsystem **Transportwachen**.
Ziel:
- Untergrundspieler können bewaffnete Banden anheuern
- Banden lauern in geeigneten Regionen und überfallen dort zufällige Transporte
- Beute wird nicht vollständig, sondern nur teilweise erlangt
- Beute landet im nächstgelegenen Lager des Auftraggebers
- Opfer und Auftraggeber spüren wirtschaftliche und soziale Folgen
- Transporte können mit Wachen geschützt werden
- Überfallserfolg hängt später im Daemon an Bandengröße, Wachzahl, Region und Zufall
## 1. Bestandsaufnahme
Bereits vorhanden:
- Untergrundaktivitäten im Backend und UI
- Transportsystem mit Fahrzeugen, Routen, Start- und Zielniederlassung
- Lager-/Bestandssystem in Niederlassungen
- Fahrzeug- und Transportverwaltung in [BranchView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/BranchView.vue)
- Untergrundformular in [UndergroundView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/UndergroundView.vue)
Noch nicht vorhanden:
- Untergrundtyp `raid_transport`
- Bandengröße / Bandenkosten
- Wachen auf Transporten
- Kampfauflösung zwischen Überfall und Eskorte
- Beutetransfer in Lager
- Overworld-/Socket-Kommunikation für Überfälle
## 2. Kernidee
Ein Untergrundspieler kann eine Bande für einen Transportüberfall anheuern.
Die Bande:
- wird einer Region zugewiesen
- darf nur in Regionen vom Typ `4` oder `5` operieren
- darf nicht in `town` operieren
- lauert dort auf zufällige Transporte
Bei einem Überfall:
- wird nicht der gesamte Transport geraubt
- nur ein Teil der transportierten Ware wird erbeutet
- nur ein Teil kann tatsächlich abtransportiert werden
- die Beute wird in das nächstgelegene Lager des Auftraggebers eingebucht
Der Überfall wirkt:
- auf das Opfer wirtschaftlich und reputativ
- auf den Auftraggeber als Gewinnchance, aber auch als Risiko
## 3. Neuer Untergrundtyp
Neuer Typ:
- `raid_transport`
Geplanter UI-Name:
- `Überfälle auf Transporte`
Grundparameter:
- `type`: `raid_transport`
- `regionId`: Region, in der gelauert wird
- `bandSize`: Stärke der angeheuerten Bande
- optional später:
- `focus`: eher Waren, eher Fahrzeuge, eher schwache Transporte
## 4. Regionsregeln
Die Aktivität darf nur in Regionen starten, die:
- Regionstyp `4` oder `5` haben
- nicht `town` sind
Begründung:
- Überfälle sollen auf Wegen, Randregionen oder schlecht gesicherten Zonen stattfinden
- nicht direkt im Stadtkern
UI-Regel:
- im Untergrundformular nur zulässige Regionen anbieten
Backend-Regel:
- Region validieren
- unzulässige Region serverseitig ablehnen
## 5. Bandensystem
### 5.1 Bandengröße
Der Spieler wählt eine Bandengröße, z. B.:
- `small`
- `medium`
- `large`
Alternativ numerisch:
- `3`
- `6`
- `10`
Empfehlung für Version 1:
- numerischer Wert `bandSize`
- UI zeigt zusätzlich Stufenbezeichnung
### 5.2 Kosten
Die Kosten steigen überproportional, damit große Überfälle nicht trivial werden.
Beispielmodell:
- Grundkosten: `20`
- pro Bandit: `+12`
- Risikozuschlag: `bandSize * 2`
Beispiel:
- `3` Banditen: `62`
- `6` Banditen: `104`
- `10` Banditen: `160`
Die finalen Werte sind Balancing und können später angepasst werden.
## 6. Transportwachen
### 6.1 Grundidee
Für Transporte sollen Wachen mitgeschickt werden können.
Wirkung:
- geringere Überfallchance
- höhere Abwehrchance
- geringere Beute bei erfolgreichem Überfall
### 6.2 UI-Verhalten
Beim Erstellen eines Transports:
- zusätzliches Feld `wachen`
- nur positive ganze Zahl
- sichtbare Zusatzkosten
In der Transportübersicht:
- Wachenanzahl anzeigen
### 6.3 Kosten
Wachen verursachen direkte Transportmehrkosten.
Beispiel:
- `guardCount * 4`
Optional später:
- bessere Wachenstufe
- bewaffnete Eskorte
## 7. Überfallauflösung im Daemon
Der externe Daemon bleibt die führende Quelle für die eigentliche Auflösung.
Der Worker prüft periodisch:
1. aktive `raid_transport`-Aktivitäten
2. Transporte, die gerade durch passende Regionen laufen
3. ob eine Kollision zwischen Aktivität und Transport zustande kommt
### 7.1 Kandidatenprüfung
Ein Transport ist überfallbar, wenn:
- er aktiv ist
- seine Route durch die Zielregion der Bande führt oder dort endet
- er dem Auftraggeber nicht selbst gehört
### 7.2 Begegnungschance
Die Basiswahrscheinlichkeit hängt u. a. ab von:
- Bandengröße
- Regionstyp
- Transportfrequenz / Zufall
- ggf. Diskretions- oder Untergrundfaktoren
### 7.3 Kampfwert
Für Version 1 reicht ein abstrahierter Vergleich:
- `raidPower = bandSize + random(0..bandSize)`
- `guardPower = guardCount + random(0..guardCount)`
Modifikatoren:
- bessere Fahrzeuge können leicht entkommen
- große Transporte sind leichter sichtbar
Ergebnis:
- `repelled`
- `partial_success`
- `major_success`
## 8. Beute
Es darf niemals der komplette Transport verloren gehen.
### 8.1 Grundregel
Bei erfolgreichem Überfall:
- nur ein Teil der transportierten Menge wird geraubt
- nur ein Teil dieser Menge erreicht als Beute den Auftraggeber
Empfohlene Formel:
- `baseLootShare = 0.15 bis 0.45`
- bei `major_success` bis `0.60`
- Wachen senken den Wert
Zusätzlich:
- Abrunden auf ganze Mengeneinheiten
- mindestens `1`, wenn überhaupt Erfolg
### 8.2 Einlagerung
Die Beute wird in das nächstgelegene Lager des Auftraggebers eingebucht.
Priorität:
1. nächstgelegene Niederlassung des Auftraggebers
2. nur wenn dort Lager für den Produkttyp vorhanden oder anlegbar
3. falls kein geeignetes Lager existiert:
- Beute teilweise verfallen lassen
- Rest als `lost_due_to_storage`
Wichtig:
- nie stillschweigend alles gutschreiben
- Lagerkapazität berücksichtigen
## 9. Folgen
### 9.1 Für das Opfer
- Warenverlust
- optional kleiner Reputationsschaden
- Hinweis in Geld-/Transporthistorie
- evtl. Routenanpassung oder Absicherungsdruck
### 9.2 Für den Auftraggeber
- Kosten der Bande
- möglicher Beutegewinn
- optional leichter Reputations- oder Verdachtsanstieg
- Risiko von Gegenmaßnahmen in späteren Ausbaustufen
## 10. Datenmodell
Für eine erste technische Umsetzung werden voraussichtlich neue Felder benötigt.
### 10.1 Underground-Aktivität
In `underground.result` bzw. Payload:
- `bandSize`
- `attempts`
- `successes`
- `lastTargetTransportId`
- `lastLoot`
- `lastOutcome`
### 10.2 Transport
Neu empfohlen:
- `guardCount`
- optional später `guardQuality`
### 10.3 Transport-/Überfall-Log
Optional, aber sinnvoll:
- eigener Logeintrag oder JSON-Protokoll mit:
- Opfer
- Auftraggeber
- Region
- Bandengröße
- Wachen
- geraubte Mengen
- eingelagerte Mengen
## 11. Socket-Events
Empfohlene Events für die UI:
### 11.1 Überfall auf Opferseite
```json
{
"event": "falukantTransportRaid",
"user_id": 123,
"reason": "transport_raided"
}
```
### 11.2 Überfall auf Auftraggeberseite
```json
{
"event": "falukantUndergroundUpdate",
"user_id": 456,
"reason": "raid_success"
}
```
Mögliche `reason`:
- `transport_raided`
- `raid_repelled`
- `raid_success`
- `raid_partial_success`
- `raid_loot_stored`
Begleitende Events:
- `falukantUpdateStatus`
- `falukantBranchUpdate`
- optional `falukantUpdateDebt` nicht nötig
## 12. UI-Anforderungen
### 12.1 Underground
In [UndergroundView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/UndergroundView.vue):
- neuer Typ `raid_transport`
- Regionsauswahl mit erlaubten Regionstypen
- Wahl der Bandengröße
- Kostenanzeige
- spätere Ergebnisanzeige:
- Erfolg / Misserfolg
- Beute
- Zielregion
### 12.2 Transport
In [BranchView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/BranchView.vue):
- Wachenfeld beim Transportstart
- Wachenanzahl in Transportliste
- Hinweis, dass Wachen Überfälle erschweren, aber Kosten erhöhen
## 13. Technische Reihenfolge
### TRA1. Konzept und Typerweiterung
- `raid_transport` als Underground-Typ anlegen
- Produktions-SQL für Bestandsdatenbank
### TRA2. Lokale Projektbasis
- API akzeptiert `bandSize`
- UI unterstützt Bandengröße und erlaubte Regionen
- Transporte erhalten `guardCount`
### TRA3. Daemon-Auflösung
- Worker prüft Kollisionen zwischen Aktivität und aktiven Transporten
- Überfallausgang berechnen
- Beute teilweise einlagern
- Events senden
### TRA4. UI-Feinschliff
- Ergebnisflächen
- Logs
- klarere Rückmeldungen für Opfer und Auftraggeber
## 14. Hinweis für den Daemon
Der Daemon soll später explizit berücksichtigen:
- DB-Änderungen für `guardCount` und den neuen Underground-Typ werden projektseitig vorbereitet
- Überfälle dürfen nie Totalverlust erzeugen
- Lagerkapazität begrenzt reale Beute
- Wachen reduzieren Erfolgsquote und Beutemenge
## 15. Definition of Done
Die erste vollständige Version gilt als fertig, wenn:
1. `raid_transport` im Untergrund auswählbar ist
2. Transporte mit Wachen gestartet werden können
3. der Daemon aktive Überfälle gegen echte Transporte auflösen kann
4. das Opfer nie die komplette Fracht verliert
5. Beute im nächstgelegenen Lager des Auftraggebers landet
6. Opfer- und Auftraggeber-UI per Socket aktualisiert werden

View File

@@ -0,0 +1,316 @@
# Falukant: UI-Anpassung - WebSocket & Familie / Liebschaften
Dieses Dokument beschreibt die Nachrichten, die der externe Falukant-Daemon über den WebSocket-Broadcast sendet, damit die UI gezielt reagieren kann.
Transport:
- Alle Clients erhalten denselben Broadcast.
- Die UI muss nach `user_id` filtern und nur Events für die eingeloggte Session verarbeiten.
## 1. Übersicht der Events
| `event` | Pflichtfelder | Typische UI-Reaktion |
|---------|----------------|----------------------|
| `falukantUpdateFamily` | `user_id`, `reason` | Gezielter Refresh Familie/Liebe/Geld je nach `reason` |
| `falukantUpdateStatus` | `user_id` | Allgemeiner Status-/Spielstands-Refresh |
| `falukantUpdateProductionCertificate` | `user_id`, `reason`, `old_certificate`, `new_certificate` | Produkte / Produktions-UI / Zertifikat neu laden |
| `children_update` | `user_id` | Kinderliste / FamilyView aktualisieren |
| `falukant_family_scandal_hint` | `relationship_id` | Optionaler Toast oder Log; kein `user_id` |
| `falukantUpdateChurch` | `user_id`, `reason` | Kirchenämter, Bewerbungen, Ernennungen |
| `falukantUpdateDebt` | `user_id`, `reason` | Schuldturm, Verzug, Pfändung, Freilassung |
## 2. JSON-Payloads
### 2.1 `falukantUpdateFamily`
```json
{
"event": "falukantUpdateFamily",
"user_id": 123,
"reason": "daily"
}
```
`reason` ist immer genau einer dieser festen Strings:
- `daily`
- `monthly`
- `lover_installment`
- `scandal`
- `lover_birth`
### 2.2 `falukantUpdateChurch`
```json
{
"event": "falukantUpdateChurch",
"user_id": 123,
"reason": "applications"
}
```
Mögliche `reason`:
- `applications`
- `npc_decision`
- `appointment`
- `vacancy_fill`
- `promotion`
### 2.3 `falukantUpdateStatus`
```json
{
"event": "falukantUpdateStatus",
"user_id": 123
}
```
Dieses Event wird typischerweise direkt nach einem fachlichen Falukant-Event mit derselben `user_id` gesendet.
### 2.4 `falukantUpdateProductionCertificate`
```json
{
"event": "falukantUpdateProductionCertificate",
"user_id": 123,
"reason": "daily_recalculation",
"old_certificate": 2,
"new_certificate": 3
}
```
### 2.5 `children_update`
```json
{
"event": "children_update",
"user_id": 123
}
```
Dieses Event tritt bei Geburt aus einer Liebschaft auf, meist zusammen mit:
- `falukantUpdateFamily` mit `reason: "lover_birth"`
- `falukantUpdateStatus`
### 2.6 `falukant_family_scandal_hint`
```json
{
"event": "falukant_family_scandal_hint",
"relationship_id": 456
}
```
Hinweis:
- Dieses Event enthält kein `user_id`.
- Die UI kann es ignorieren oder optional nur für Log-/Toast-Zwecke verwenden.
- Die eigentliche nutzerbezogene Aktualisierung läuft über `falukantUpdateFamily` mit `reason: "scandal"`.
### 2.7 `falukantUpdateDebt`
```json
{
"event": "falukantUpdateDebt",
"user_id": 123,
"reason": "debtors_prison_entered"
}
```
Mögliche `reason`:
- `delinquency`
- `debtors_prison_entered`
- `asset_seizure`
- `vehicle_liquidation`
- `house_seizure`
- `branch_closure`
- `debtors_prison_released`
## 3. Fachliche Bedeutung von `reason`
### 3.1 `falukantUpdateFamily`
#### `reason: "daily"`
`daily` ist der Sammelgrund für tägliche Änderungen im Familien- und Liebschaftssystem.
Darunter fallen insbesondere:
- tägliche Drift und Änderung der Ehezufriedenheit
- `marriage_public_stability`
- `household_tension_score`
- Ehe-Buffs und temporäre Zähler wie Geschenk-, Fest- oder Haus-Effekte
- tägliche Liebschaftslogik für aktive Beziehungen
- Rufverlust bei zwei oder mehr sichtbaren Liebschaften
- Zufalls-Mali wie Gerücht oder Tadel
Wichtig:
- Es gibt kein separates Event für „nur Ehe-Buff“.
- Es gibt kein separates Event für „nur zwei sichtbare Liebschaften“.
- Es gibt kein separates Event für „nur Gerücht/Tadel“.
- Alles davon erscheint in der UI ausschließlich als `falukantUpdateFamily` mit `reason: "daily"`.
#### `reason: "monthly"`
`monthly` steht für monatliche Verarbeitung, insbesondere:
- Dienerschaftskosten
- laufende Kosten
- Unterversorgung
- Geldänderungen
#### `reason: "lover_installment"`
`lover_installment` steht für die 2-Stunden-Unterhaltsbelastung von Liebschaften.
Die UI sollte dafür mindestens:
- Geld neu laden
- Family-/Liebschaftsstatus neu laden
#### `reason: "scandal"`
`scandal` wird zusätzlich zu einem gelungenen Skandalwurf gesendet.
Typischer Ablauf:
- optional `falukant_family_scandal_hint`
- `falukantUpdateFamily` mit `reason: "scandal"`
- `falukantUpdateStatus`
Danach kann für denselben Nutzer am selben Tag zusätzlich noch `daily` folgen.
#### `reason: "lover_birth"`
`lover_birth` signalisiert ein neues Kind aus einer Liebschaft.
Meist folgen zusammen:
- `falukantUpdateFamily` mit `reason: "lover_birth"`
- `children_update`
- `falukantUpdateStatus`
### 3.2 `falukantUpdateChurch`
- `applications`: Spieler ist kirchlicher Vorgesetzter; offene Bewerbungen warten
- `npc_decision`: NPC-Vorgesetzter hat entschieden
- `appointment`: automatische Annahme älterer Bewerbung
- `vacancy_fill`: Interimsbesetzung
- `promotion`: reserviert / zukünftig
### 3.3 `falukantUpdateProductionCertificate`
- `daily_recalculation`: Zertifikat nach täglicher Prüfung geändert
### 3.4 `falukantUpdateDebt`
- `delinquency`: Mahnstufe oder Verzug aktualisiert
- `debtors_prison_entered`: Eintritt in den Schuldturm
- `asset_seizure`: Geld, Waren oder sonstige Vermögenswerte eingezogen
- `vehicle_liquidation`: Fahrzeuge zwangsverkauft
- `house_seizure`: Haus gepfändet
- `branch_closure`: Niederlassung geschlossen
- `debtors_prison_released`: Freilassung
## 4. Empfohlene Handler-Logik
```text
onMessage(json):
if json.user_id exists and json.user_id != currentUserId:
return
switch json.event:
case "falukantUpdateStatus":
refreshPlayerStatus()
return
case "falukantUpdateProductionCertificate":
refreshProductsAndProductionUi()
return
case "children_update":
refreshChildrenAndFamilyView()
return
case "falukantUpdateChurch":
refreshChurchContextByReason(json.reason)
return
case "falukantUpdateDebt":
refreshDebtAndAffectedViews(json.reason)
return
case "falukantUpdateFamily":
switch json.reason:
case "daily":
refreshFamilyAndRelationships()
refreshReputationIfNeeded()
break
case "monthly":
refreshMoney()
refreshFamilyAndRelationships()
break
case "lover_installment":
refreshMoney()
refreshFamilyAndRelationships()
break
case "scandal":
showScandalToastOptional()
refreshFamilyAndRelationships()
refreshReputationIfNeeded()
break
case "lover_birth":
refreshChildrenAndFamilyView()
break
return
case "falukant_family_scandal_hint":
// optional: nur als Hinweis verarbeiten
return
```
## 5. Deduplizierung
Ein Nutzer kann kurz hintereinander mehrere relevante Events erhalten, zum Beispiel:
- `scandal`
- danach `daily`
- danach `falukantUpdateStatus`
oder:
- `falukantUpdateDebt`
- direkt danach `falukantUpdateStatus`
- zusätzlich `falukantUpdateFamily`
Die UI sollte deshalb:
- Refreshes bündeln oder entprellen
- idempotente Reloads verwenden
- nicht davon ausgehen, dass jeder fachliche Effekt einen eigenen Spezial-Eventpfad hat
## 6. Welche Daten sollten neu geladen werden?
| Situation | Sinnvolle Reaktion |
|-----------|--------------------|
| Jede `falukantUpdateFamily` | Family-/Relationship-Daten neu laden |
| `reason: "monthly"` | Family-Daten plus Geld/Status neu laden |
| `reason: "lover_installment"` | Geld plus Family-Daten neu laden |
| `reason: "daily"` | Family-Daten neu laden, bei Bedarf auch Ruf-/Statusdaten |
| `reason: "scandal"` | Family-Daten plus Ruf-/Statusdaten neu laden |
| `children_update` / `lover_birth` | Kinderdaten und FamilyView neu laden |
| `falukantUpdateChurch` | Kirchenämter, Bewerbungen, freie Positionen je nach `reason` |
| `falukantUpdateProductionCertificate` | User-Status, Zertifikat, Produkte, Produktions-UI |
| `falukantUpdateDebt` | Bank, Overview, House, Branch, ggf. Family |
## 7. Sonderfälle
| Fall | Verhalten |
|------|-----------|
| NPC ohne `user_id` | Keine nutzerbezogenen Socket-Events |
| Mehrere Events kurz hintereinander | Normal; UI sollte damit robust umgehen |
| Nur `falukantUpdateStatus` ohne Fach-Event | Kann von anderen Falukant-Workern kommen |

View File

@@ -0,0 +1,180 @@
# Falukant: Daemon-Handoff für `investigate_affair`
## Zweck
Dieses Dokument beschreibt die Auswertung der Untergrundaktivität `investigate_affair` im externen Daemon.
Es ergänzt:
- [FALUKANT_UNDERGROUND_AFFAIR_PLAN.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_UNDERGROUND_AFFAIR_PLAN.md)
- [FALUKANT_LOVERS_DAEMON_SPEC.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_SPEC.md)
- [FALUKANT_LOVERS_DAEMON_HANDOFF.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md)
## Betroffene Daten
Der externe Daemon liest:
- `falukant_data.underground`
- `falukant_type.underground`
- `falukant_data.relationship`
- `falukant_data.relationship_state`
- `falukant_data.character`
- optional `falukant_data.child_relation`
Relevant ist jeweils:
- Untergrundaktivität vom Typ `investigate_affair`
- `performer_id`
- `victim_id`
- `parameters.goal`
- `expose`
- `blackmail`
- `result`
## Erwarteter Input
Eine Aktivität ist für den Daemon verarbeitbar, wenn:
- `underground_type.tr = investigate_affair`
- `result.status = pending`
Der Daemon soll dann beim Opfer prüfen:
- aktive Liebschaften
- Sichtbarkeit und Diskretion dieser Liebschaften
- evtl. bekannte uneheliche Kinder
- Stand und Ansehen des Opfers
## Ergebnis-Schema in `underground.result`
Der Daemon schreibt nach der Auswertung ein JSON mit folgender Struktur:
```json
{
"status": "resolved",
"outcome": "success",
"discoveries": {
"relationshipId": 123,
"loverRole": "secret_affair",
"visibility": 42,
"acknowledged": false,
"publicKnownChild": false
},
"visibilityDelta": 12,
"reputationDelta": -3,
"blackmailAmount": 1500,
"notes": "Affair was uncovered and partially exposed."
}
```
Erlaubte Werte:
- `status`
- `pending`
- `resolved`
- `failed`
- `outcome`
- `success`
- `partial`
- `failure`
## Auswertung: `goal = expose`
Ziel:
- Liebschaft öffentlich machen
- Sichtbarkeit stark erhöhen
- ggf. Skandal auslösen
Empfohlene Wirkung:
- `relationship_state.visibility +10..25`
- optional `relationship_state.discretion -5..15`
- sofortiger Reputationsschaden beim Opfer
- bei sehr sichtbarer oder junger Beziehung zusätzliche Skandalchance
Zusätzliche Empfehlung:
- wenn die Beziehung bereits fast öffentlich war, darf `outcome = partial` gesetzt werden statt voller Erfolg
## Auswertung: `goal = blackmail`
Ziel:
- belastendes Wissen gewinnen
- keinen sofortigen vollen öffentlichen Effekt erzeugen
- ein Erpressungspotenzial vorbereiten
Empfohlene Wirkung:
- `relationship_state.visibility +3..10`
- `blackmailAmount` setzen
- kleiner oder kein sofortiger Reputationsschaden
- optional separates Log oder spätere Forderung
Wenn ihr noch kein echtes Erpressungssystem habt:
- `blackmailAmount` trotzdem setzen
- `notes` befüllen
- UI zeigt den Vorgang als abgeschlossen mit Erpressungssumme
## Mindestregeln für Erfolg
Erfolgswahrscheinlichkeit sollte steigen bei:
- hoher vorhandener Sichtbarkeit
- niedriger Diskretion
- mehreren aktiven Liebschaften
- bereits bekannten unehelichen Kindern
Erfolgswahrscheinlichkeit sollte sinken bei:
- hoher Diskretion
- niedriger Sichtbarkeit
- standesgemäß geführter Mätresse/Favorit auf hohem Rang
## Folgewirkungen auf Lovers-System
Bei Erfolg darf der Daemon auslösen:
- `falukant_family_scandal_hint`
- `falukantUpdateFamily` mit `reason = scandal`
- zusätzlich normale Status-Updates
Wenn `goal = expose`, sollte mindestens eine dieser Wirkungen eintreten:
- Sichtbarkeit steigt
- Ruf sinkt
- Skandalwahrscheinlichkeit steigt
Wenn `goal = blackmail`, sollte mindestens eine dieser Wirkungen eintreten:
- `blackmailAmount > 0`
- kleiner Sichtbarkeitsanstieg
- interner Merker für spätere Forderung
## UI-Erwartung
Das Frontend dieses Projekts erwartet derzeit:
- `type`
- `goal`
- `status`
- `additionalInfo.blackmailAmount`
Optional nutzbar später:
- `discoveries`
- `visibilityDelta`
- `reputationDelta`
- `notes`
## Definition of Done
Die externe Umsetzung gilt als ausreichend, wenn:
1. `investigate_affair`-Einträge mit `status = pending` verarbeitet werden
2. `result.status` danach nicht mehr `pending` ist
3. `goal = expose` und `goal = blackmail` verschieden behandelt werden
4. mindestens Sichtbarkeit oder Reputationswirkung zurückgeschrieben wird
5. `blackmailAmount` bei Erpressung gesetzt werden kann

View File

@@ -0,0 +1,67 @@
# Falukant: Restplan für Liebschafts-Ermittlung im Untergrund
## Ziel
Die neue Untergrundaktivität `investigate_affair` soll nicht nur auswählbar sein, sondern einen vollständigen technischen Pfad bekommen:
- Aktivität anlegen
- Aktivität in der UI sichtbar machen
- Ergebnisstruktur vorbereiten
- externe Daemon-Auswertung eindeutig beschreiben
## Arbeitspakete
## UGA1. Aktivitätstyp im System verankern
Status: abgeschlossen
- Untergrundtyp `investigate_affair` anlegen
- Ziele `expose` und `blackmail` definieren
- UI-Auswahl in `UndergroundView` ergänzen
- Produktions-SQL für Bestandsdatenbank bereitstellen
## UGA2. Aktivitätenliste im Frontend nutzbar machen
Status: abgeschlossen
- echten GET-Endpunkt für Untergrundaktivitäten bereitstellen
- `UndergroundView.loadActivities()` aktivieren
- Aktivitäten mit Typ, Ziel, Status und Zusatzinformation anzeigen
## UGA3. Ergebnisstruktur für spätere Auswertung definieren
Status: abgeschlossen
- Ergebnisformat für `underground.result` dokumentieren
- Zustände `pending`, `resolved`, `failed` festlegen
- Felder für `discoveries`, `visibilityDelta`, `reputationDelta`, `blackmailAmount` vorbereiten
## UGA4. Externe Daemon-Übergabe für Liebschafts-Ermittlung
Status: abgeschlossen
- Handoff-Dokument für den externen Daemon ergänzen
- beschreiben, wie `investigate_affair` gelesen und aufgelöst wird
- beschreiben, welche Folgewirkungen auf Liebschaften, Ansehen und Erpressung entstehen dürfen
## UGA5. Spätere Ausbaustufe
Status: bewusst offen
- echte Erpressungszustände im Spielmodell
- UI für Forderungen, Schweigegeld, Gegenmaßnahmen
- eigene WebSocket-Events für abgeschlossene Untergrund-Ergebnisse
## Definition of Done
Der lokale Teil gilt als fertig, wenn:
1. `investigate_affair` im Untergrundformular auswählbar ist
2. neue Aktivitäten in der Aktivitätenliste sichtbar sind
3. Typ, Ziel und Status in der UI lesbar sind
4. ein eindeutiges Result-Schema für den externen Daemon dokumentiert ist
5. die externe Daemon-Übergabe die neue Aktivität vollständig beschreibt
## Restgrenze
Die tatsächliche Erfolgs-/Misserfolgsberechnung, das Aufdecken von Liebschaften und die Erpressungswirkung werden nicht in diesem Projekt ausgeführt, sondern im externen Daemon.

230
docs/UI_REDESIGN_PLAN.md Normal file
View File

@@ -0,0 +1,230 @@
# UI Redesign Plan
## Status
- Prioritaet 1 ist umgesetzt.
- Prioritaet 2 ist umgesetzt.
- Phase 3 ist abgeschlossen.
- Phase 4 ist abgeschlossen.
- Phase 5 ist abgeschlossen.
- Das Redesign ist damit insgesamt abgeschlossen.
- Optionaler technischer Nachlauf:
- weiterer Performance-Feinschliff rund um den separaten three-Chunk
## Ziel
Das Frontend von YourPart soll visuell und strukturell modernisiert werden, ohne die bestehende Funktionsbreite zu verlieren. Der Fokus liegt auf einem klareren Designsystem, besserer Informationshierarchie, konsistenter Navigation, responsiver Nutzung und einer deutlich hochwertigeren Wahrnehmung auf Startseite, Community-Bereichen und Spiele-/Lernseiten.
## Ausgangslage
Aktueller Stand aus dem Code:
- Globale Styles in `frontend/src/assets/styles.scss` nutzen ein sehr einfaches Basisset mit `Arial`, wenigen Tokens und kaum skalierbarer Designsystem-Logik.
- `AppHeader.vue`, `AppNavigation.vue` und `AppFooter.vue` sind funktional, aber visuell eher wie ein klassisches Webportal aufgebaut.
- Farben, Abstaende, Border-Radius, Buttons und Typografie wirken nicht konsistent genug fuer ein modernes Produktbild.
- Navigation und Fensterleiste sind stark desktop-zentriert und sollten auf mobile Nutzung und klare Priorisierung neu gedacht werden.
- Die Landing- und Content-Bereiche haben unterschiedliche visuelle Sprachen statt eines durchgaengigen Systems.
## Ziele des Redesigns
- Moderner, eigenstaendiger Look statt generischer Standard-UI.
- Einheitliches Designsystem mit Tokens fuer Farben, Typografie, Spacing, Schatten, Radius und States.
- Saubere responsive Struktur fuer Desktop, Tablet und Mobile.
- Bessere Orientierung in Community, Blog, Vokabeltrainer und Falukant.
- Hoeherer wahrgenommener Qualitaetsstandard bei gleicher oder besserer Performance.
- Reduktion visueller Altlasten, Inline-Anmutung und inkonsistenter Bedienelemente.
## Nicht-Ziele
- Kein kompletter Funktionsumbau im ersten Schritt.
- Keine grossflaechige Backend-Aenderung.
- Kein unkontrolliertes Ersetzen aller bestehenden Komponenten auf einmal.
## Prioritaet 1: Fundament schaffen
### 1. Designsystem definieren
- Neue Design-Tokens in einer zentralen Schicht aufbauen:
- Farben mit klarer Primar-, Sekundar-, Surface- und Statuslogik.
- Typografiesystem mit moderner Schriftfamilie, Skalierung und konsistenten Headline-/Body-Stilen.
- Spacing-System, Radius-System und Schatten-System standardisieren.
- Zustandsdefinitionen fuer Hover, Focus, Active, Disabled.
### 2. Globale Styling-Basis modernisieren
- `frontend/src/assets/styles.scss` in ein wartbares Fundament ueberfuehren.
- Einheitliche Defaults fuer `body`, `a`, `button`, Inputs, Listen, Headlines und Fokus-Stati.
- Gemeinsame Utility-Klassen nur dort einfuehren, wo sie echten Wiederverwendungswert haben.
- Barrierefreiheit von Kontrasten und Fokus-Indikatoren direkt mitdenken.
### 3. Layout-Architektur festziehen
- Feste Regeln fuer Header, Navigation, Content-Flaeche, Footer und Dialog-Layer definieren.
- Maximale Content-Breiten und Raster fuer Marketing-, Dashboard- und Formularseiten festlegen.
- Dialoge, Window-Bar und Overlay-Logik visuell harmonisieren.
## Prioritaet 2: Kernnavigation neu gestalten
### 4. Header ueberarbeiten
- `frontend/src/components/AppHeader.vue` neu strukturieren.
- Logo, Produktidentitaet, Systemstatus und optionaler Utility-Bereich klarer anordnen.
- Die derzeitige Werbeflaeche kritisch pruefen und nur behalten, wenn sie produktseitig wirklich gewollt ist.
- Statusindikatoren moderner, diskreter und semantisch staerker gestalten.
### 5. Hauptnavigation neu denken
- `frontend/src/components/AppNavigation.vue` vereinfachen und priorisieren.
- Mehrstufige Menues visuell und interaction-seitig robuster aufbauen.
- Mobile Navigation als eigenes Muster mit Drawer/Sheet oder kompaktem Menuekonzept planen.
- Wichtige Bereiche zuerst sichtbar machen, seltene Admin- oder Tiefenfunktionen entlasten.
- Forum, Freunde und Vokabeltrainer-Untermenues gestalterisch besser lesbar machen.
### 6. Footer und Fensterleiste modernisieren
- `frontend/src/components/AppFooter.vue` als funktionale Systemleiste neu fassen.
- Geoeffnete Dialoge, statische Links und Systemaktionen visuell sauber trennen.
- Footer nicht nur als Restflaeche behandeln, sondern in das Gesamtsystem integrieren.
## Prioritaet 3: Visuelle Produktidentitaet schaerfen
### 7. Startseite neu positionieren
- Startseite in zwei Modi denken:
- Nicht eingeloggt: klare Landingpage mit Nutzen, Produktbereichen, Vertrauen und Handlungsaufforderungen.
- Eingeloggt: dashboard-artiger Einstieg mit hoher Informationsdichte, aber klarer Ordnung.
- Die bisherige Startseite braucht eine deutlich staerkere visuelle Hierarchie und bessere Inhaltsblöcke.
### 8. Einheitliche Oberflaechen fuer Kernbereiche
- Community-/Social-Bereiche: ruhiger, strukturierter, content-orientierter.
- Blogs: lesefreundlicher, mehr Editorial-Charakter.
- Vokabeltrainer: lernorientiert, klar, fokussiert.
- Falukant: spielweltbezogen, aber nicht altmodisch; eigene Atmosphaere innerhalb des Systems.
- Minispiele: kompakter, energischer, aber im selben visuellen Dach.
### 9. Komponentenbibliothek aufraeumen
- Buttons, Tabs, Cards, Inputs, Dialogtitel, Listen, Badges und Status-Chips vereinheitlichen.
- Bestehende Komponenten auf Dopplungen und Stilbrueche pruefen.
- Komponenten nach Rollen statt nach Einzelseiten standardisieren.
## Prioritaet 4: Responsivitaet und UX-Qualitaet
### 10. Mobile First nachziehen
- Brechpunkte und Layout-Verhalten klar festlegen.
- Navigation, Dialoge, Tabellen, Formulare und Dashboard-Bloecke fuer kleine Screens neu validieren.
- Hover-abhaengige Interaktionen fuer Touch-Nutzung absichern.
### 11. Bewegungen und visuelles Feedback
- Subtile, hochwertige Motion fuer Menues, Dialoge, Hover-Stati und Seitenwechsel einbauen.
- Keine generischen Effekte; Animationen muessen Orientierung verbessern.
### 12. Accessibility und Lesbarkeit
- Tastaturbedienung in Navigation und Dialogen pruefen.
- Farbkontraste, Fokus-Ringe und Textgroessen ueberarbeiten.
- Inhaltsstruktur mit klaren Headline-Ebenen und besserer Lesefuehrung absichern.
## Umsetzung in Phasen
### Phase 1: Audit und visuelle Richtung
- Bestehende Screens inventarisieren.
- Wiederkehrende UI-Muster erfassen.
- Zielrichtung fuer Markenbild definieren: warm, modern, eigenstaendig, leicht spielerisch.
- Moodboard bzw. 2-3 Stilrouten festlegen.
### Phase 2: Designsystem und Shell
- Tokens und globale Styles erstellen.
- Header, Navigation, Footer und Content-Layout neu bauen.
- Dialog- und Formular-Basis angleichen.
### Phase 3: Startseite und Kernseiten
- Home, Blog-Liste, Blog-Detail und ein zentraler Community-Bereich ueberarbeiten.
- Danach Vokabeltrainer-Landing/Kernseiten.
- Danach Falukant- und Minigame-Einstiege.
Aktueller Stand:
- abgeschlossen
- umgesetzt fuer Home, Blogs, zentrale Social-/Community-Flaechen, Vokabeltrainer-Kernseiten, Kalender und zentrale Falukant-Einstiege
- Restpunkte in tieferen Vokabel-, Minigame-, Settings- und Admin-Ansichten sind nachgezogen
### Phase 4: Tiefere Produktbereiche
- Sekundaere Ansichten und Admin-Bereiche nachziehen.
- Visuelle Altlasten in Randbereichen bereinigen.
Aktueller Stand:
- abgeschlossen
- zentrale Produktbereiche, tiefere Community-/Vokabel-/Kalender-Ansichten sowie zentrale Falukant-Einstiege und Dialog-Basis sind modernisiert
- verbleibende Rand- und Spezialansichten aus Settings, Admin und Minigames sind visuell angeglichen
### Phase 5: QA und Verfeinerung
- Responsive Review.
- Accessibility Review.
- Performance-Pruefung auf unnötige visuelle Last.
- Konsistenz-Check ueber das gesamte Produkt.
Aktueller Stand:
- abgeschlossen
- globale Bewegungsreduktion, verbesserte Fokusfuehrung und Tastaturzugang in der Hauptnavigation umgesetzt
- Build-Chunking verbessert; Haupt-Chunk bereits reduziert
- 3D-Runtime in Character3D auf Lazy-Loading umgestellt; verbleibende Warnung betrifft den separaten three-Chunk selbst
## Empfohlene technische Arbeitspakete
### Paket A: Design Tokens
- Neue CSS-Variablenstruktur aufbauen.
- Alte Farbwerte und Ad-hoc-Stile schrittweise ersetzen.
### Paket B: App Shell
- `AppHeader.vue`
- `AppNavigation.vue`
- `AppFooter.vue`
- `App.vue`
- `frontend/src/assets/styles.scss`
### Paket C: Content-Komponenten
- Gemeinsame Card-, Section-, Button- und Dialogmuster erstellen oder konsolidieren.
- Stark genutzte Widgets und Listen zuerst migrieren.
### Paket D: Seitenweise Migration
- Startseite
- Blogs
- Community
- Vokabeltrainer
- Falukant
- Minispiele
## Reihenfolge fuer die Umsetzung
1. Designrichtung und Token-System festlegen.
2. App-Shell modernisieren.
3. Startseite und oeffentliche Einstiege erneuern.
4. Kern-Komponentenbibliothek vereinheitlichen.
5. Hauptbereiche seitenweise migrieren.
6. Mobile, Accessibility und Feinschliff abschliessen.
## Risiken
- Ein rein visuelles Redesign ohne Systembasis fuehrt wieder zu inkonsistenten Einzelpatches.
- Navigation und Dialog-Logik sind funktional verflochten; dort braucht es saubere schrittweise Migration.
- Falukant und Community haben unterschiedliche Produktcharaktere; die Klammer muss bewusst gestaltet werden.
- Zu viele parallele Einzelumbauten wuerden das Styling kurzfristig uneinheitlicher machen.
## Empfehlung
Das Redesign sollte nicht als einzelne Seitenkosmetik umgesetzt werden, sondern als kontrollierte Migration mit Designsystem zuerst. Der erste konkrete Umsetzungsschritt sollte daher ein kleines, aber verbindliches UI-Fundament sein: neue Tokens, neue Typografie, neue App-Shell und eine modernisierte Startseite. Danach koennen die restlichen Bereiche kontrolliert nachgezogen werden.

View File

@@ -0,0 +1,178 @@
# Umlaut-Normalisierung Plan
## Ziel
Alle sichtbaren deutschsprachigen UI-Texte sollen konsistent echte Umlaute und korrektes `ß` verwenden.
Beispiele:
- `ae` -> `ä`
- `oe` -> `ö`
- `ue` -> `ü`
- `ss` -> `ß`, wenn orthografisch korrekt
Nicht Teil dieses Schritts:
- technische Bezeichner, Dateinamen, Keys, Routen, API-Felder
- bewusst ASCII-basierte interne Kennungen
- englische, spanische oder backendseitige maschinennahe Werte
- bestehende Konzept-/Audit-Dokumente, sofern nicht explizit gewünscht
## Leitregeln
- Nur sichtbare Texte anfassen.
- Keine Übersetzungs-Keys umbenennen, wenn nur der angezeigte Wert falsch ist.
- Keine Logikänderung mit Sprachkorrekturen vermischen.
- `ss` nur dort zu `ß` ändern, wo es sprachlich korrekt ist.
- Neue Texte immer direkt mit echter deutscher Schreibweise anlegen.
## Scope
### 1. Direkte UI-Texte in Vue-Dateien
Prüfen und korrigieren in:
- `frontend/src/components/**/*.vue`
- `frontend/src/views/**/*.vue`
- `frontend/src/dialogues/**/*.vue`
Typische Problemfälle:
- Überschriften
- Buttons
- Statushinweise
- Hilfetexte
- Leerzustände
- Fehlermeldungen
### 2. i18n-Inhalte
Prüfen und korrigieren in:
- `frontend/src/i18n/locales/de/**/*.json`
Besonders relevant:
- Navigation
- Header/Footer
- Home
- Blog
- Forum
- Vokabeltrainer
- Minigames
- Einstellungen
- Admin
### 3. Gemeinsame Shell- und Systemtexte
Zuerst prüfen:
- `frontend/src/components/AppSectionBar.vue`
- `frontend/src/components/AppNavigation.vue`
- `frontend/src/components/AppHeader.vue`
- `frontend/src/components/AppFooter.vue`
- `frontend/src/components/DialogWidget.vue`
- `frontend/src/components/MessageboxWidget.vue`
### 4. Produktbereiche mit hoher Sichtbarkeit
Danach prüfen:
- `frontend/src/views/home/**/*`
- `frontend/src/views/social/**/*`
- `frontend/src/views/falukant/**/*`
- `frontend/src/views/minigames/**/*`
- `frontend/src/views/settings/**/*`
- `frontend/src/views/blog/**/*`
- `frontend/src/views/admin/**/*`
## Abarbeitung
### Phase A: Inventur
1. Fundstellen mit Suchmustern sammeln.
2. Treffer in drei Klassen sortieren:
- `sichtbarer UI-Text`
- `i18n-Wert`
- `nicht anfassen` wie Variablen, Klassen, Keys, Pfade
Empfohlene Suchmuster:
- `Persoen`
- `Gaeste`
- `Zurueck`
- `Uebersicht`
- `Loesch`
- `Fuer`
- `Oeff`
- `Schli`
- `groess`
- `aend`
- `moeg`
- `ueber`
- `uebrig`
- `fuer`
- `waehr`
- `muess`
- `koenn`
### Phase B: Shell zuerst
Zuerst alle global sichtbaren Texte korrigieren:
- Bereichsleisten
- Navigation
- Header
- Footer
- Standarddialoge
Ziel:
- zentrale UI sofort sprachlich konsistent
### Phase C: i18n-DE bereinigen
Danach alle deutschen Locale-Dateien durchgehen.
Vorgehen:
- nur Werte ändern, nicht die Key-Namen
- orthografische Einzelprüfung bei `ss` -> `ß`
- HTML-haltige Texte mit prüfen, damit keine alten ASCII-Umschreibungen stehen bleiben
### Phase D: Direkttexte in Views und Dialogen
Dann alle nicht-i18n-basierten sichtbaren Texte korrigieren.
Priorität:
1. Home, Navigation, Auth
2. Social, Blog, Settings
3. Falukant, Minigames, Admin
### Phase E: Konsistenzreview
Zum Schluss ein kompletter Review auf typische Restfehler:
- `ue` in sichtbaren Labels
- `oe` in Überschriften
- `ae` in Buttons und Hinweisen
- `ss` statt `ß` in Wörtern wie `dass`, `groß`, `außer`, `heißen`, `Fuß`, `Maß`
## Abnahmekriterien
Der Schritt gilt als abgeschlossen, wenn:
- in allen sichtbaren deutschen UI-Texten keine ASCII-Umschreibungen mehr verbleiben
- zentrale Shell-Texte vollständig normalisiert sind
- `de`-Locale-Dateien keine falschen Umschreibungen mehr enthalten
- Builds weiterhin sauber laufen
- keine technischen Keys oder internen Bezeichner versehentlich geändert wurden
## Risiken
- versehentliche Änderung von technischen Strings statt UI-Texten
- falsche `ß`-Korrekturen in Fällen, in denen `ss` korrekt ist
- Mischung aus i18n-Texten und hart codierten Texten kann zu doppelter Pflege führen
## Umsetzungsempfehlung
Die eigentliche Umsetzung sollte in zwei Arbeitsblöcken passieren:
1. `UN1`
Shell + i18n-DE + hochsichtbare Bereiche
2. `UN2`
Restliche Views/Dialoge + Abschlussreview
## Ergebnisdokumentation
Nach Abschluss sollte kurz dokumentiert werden:
- welche Dateien geändert wurden
- ob nur sichtbare Texte geändert wurden
- ob noch bewusst ASCII-basierte technische Strings bestehen

389
docs/USABILITY_AUDIT_U1.md Normal file
View File

@@ -0,0 +1,389 @@
# UX Audit U1
## Ziel
Dieser Audit bildet Phase U1 des Bedienbarkeitskonzepts ab. Er dient als priorisierte Arbeitsgrundlage fuer die eigentliche UX-Ueberarbeitung.
Untersucht wurden:
- Shell und Navigation
- Einstieg ohne Login
- Registrierung/Login
- Forum
- Vokabeltrainer
- Falukant
- Admin
- Match3/Minigames
## Bewertungslogik
- `P1`: blockiert Kernnutzung oder fuehrt sehr leicht zu Fehlbedienung
- `P2`: verlangsamt oder verkompliziert Kernnutzung merklich
- `P3`: Konsistenz-, Lesbarkeits- oder Komfortproblem
- `P4`: Feinschliff
## Gepruefte Hauptaufgaben
1. Einloggen und Einstieg verstehen
2. Registrieren
3. Forum finden, Thema erstellen und lesen
4. Vokabelsprache finden, anlegen, abonnieren und lernen
5. Falukant-Status erfassen und Folgeaktion auswaehlen
6. Admin-Nutzer oder Match3-Daten bearbeiten
7. Match3 starten, pausieren und Kampagnenstatus verstehen
## Ergebnisuebersicht
- P1: 4 Punkte
- P2: 11 Punkte
- P3: 13 Punkte
- P4: 6 Punkte
## P1-Probleme
### P1-1: Historische innere Scrollkonzepte in Teilbereichen
Bereiche:
- Falukant
- Admin
- Minigames
Beobachtung:
- Mehrere Views arbeiten weiterhin mit eigenen `contenthidden/contentscroll`-Strukturen innerhalb des bereits scrollbaren App-Contents.
- Das fuehrt zu falschen Sticky-Bezuegen, abgeschnittenen Bereichen und inkonsistenter Scrolllogik.
Risiko:
- Nutzer verlieren Orientierung.
- statische Leisten oder Footer/Header verhalten sich unerwartet.
Empfehlung:
- alle View-internen Scrollcontainer systematisch inventarisieren
- entscheiden, welche Bereiche echte lokale Scrollflaechen brauchen und welche komplett auf die Shell-Scrolllogik gehen
### P1-2: Fehler- und Erfolgsfeedback ist nicht konsistent genug
Bereiche:
- Auth
- Settings
- Admin
- Vokabeltrainer
Beobachtung:
- Mischung aus `alert`, MessageDialog, ErrorDialog, DialogWidget-internem Feedback und stillen `console.error`-Pfaden.
- Beispiel: [RegisterDialog.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/dialogues/auth/RegisterDialog.vue) nutzt fehleranfaellig `errrorDialog` statt eines klar vereinheitlichten Feedbackpfads.
Risiko:
- Nutzer verstehen nicht sicher, ob eine Aktion fehlgeschlagen ist oder erfolgreich war.
- Fehler werden in einzelnen Flows inkonsistent oder gar nicht sichtbar.
Empfehlung:
- gemeinsames Feedbacksystem definieren
- `success`, `warning`, `error`, `info`, `loading` als einheitliche Muster durchziehen
### P1-3: Formvalidierung erfolgt oft zu spaet oder zu unsichtbar
Bereiche:
- Registrierung
- Account-Settings
- Admin
- Falukant-Formulare
Beobachtung:
- viele Formulare validieren erst beim Absenden
- Feldfehler sitzen selten direkt am Eingabepunkt
- Pflichtlogik ist nicht durchgaengig erkennbar
Risiko:
- hohe Reibung beim Ausfuellen
- Wiederholschleifen und Frust bei laengeren Formularen
Empfehlung:
- Validierung naeher ans Feld bringen
- Pflichtfelder, Eingabeformat und Fehltext systematisch sichtbar machen
### P1-4: Komplexe Arbeitsflaechen haben zu wenig gefuehrte Primaeraktionen
Bereiche:
- Falukant
- Admin Match3
Beobachtung:
- Fachflaechen zeigen viele Optionen gleichzeitig, ohne klare Priorisierung.
- Beispiel: [MinigamesView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/admin/MinigamesView.vue) ist funktionsreich, aber als Bearbeitungsfluss kaum gefuehrt.
Risiko:
- Nutzer verstehen den naechsten sinnvollen Schritt nicht.
- Bedienfehler in fachlich dichten Bereichen steigen.
Empfehlung:
- pro komplexer View den eigentlichen Arbeitsfluss definieren
- Primaeraktionen, Sekundaeraktionen und Expertenaktionen sichtbarer trennen
## P2-Probleme
### P2-1: Hauptnavigation ist funktional, aber noch nicht ausreichend auf Aufgaben priorisiert
Beobachtung:
- Navigation ist technisch konsistenter als vorher, aber die inhaltliche Priorisierung der Menuepunkte ist noch stark historisch gewachsen.
- Der Unterschied zwischen Kernzielen und Tiefenfunktionen ist nicht deutlich genug.
Empfehlung:
- Menueaufbau gegen echte Nutzungsszenarien pruefen
- seltene Spezialpunkte staerker entlasten
### P2-2: Login-Einstieg ist reich an Inhalt, aber nicht maximal fokussiert
Beobachtung:
- [NoLoginView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/home/NoLoginView.vue) erzaehlt Produkt und Zugang parallel.
- Fuer Erstnutzer ist die Seite informativ, aber die eigentliche Primaerhandlung konkurriert mit vielen Begleitinhalten.
Empfehlung:
- Zugangspfad noch klarer vom Story-Bereich trennen
- Login, Registrierung und Passwort-Reset als zusammenhaengenden Entscheidungsraum denken
### P2-3: Registrierung ist bedienbar, aber noch nicht robust genug gefuehrt
Beobachtung:
- [RegisterDialog.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/dialogues/auth/RegisterDialog.vue) ist funktional, aber nutzt wenig Hilfetext, kaum Inline-Validierung und schwer lesbare Fehlerpfade.
Empfehlung:
- Feldgruppen strukturieren
- Passwortlogik und Sprachwahl klarer erklaeren
- Fehler am Feld statt nur global rueckmelden
### P2-4: Forum hat guten Einstieg, aber zu wenig Orientierung im Schreiben-und-Lesen-Wechsel
Beobachtung:
- [ForumView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/social/ForumView.vue) mischt Themenliste und Neuerstellung in einem einfachen Toggle.
- Es fehlt eine staerkere Fuehrung, wann man lesen und wann man schreiben soll.
Empfehlung:
- Schreibmodus staerker vom Lesemodus absetzen
- Entwurf, Abbruch und Rueckkehr klarer machen
### P2-5: Vokabeltrainer ist funktional breit, aber als Lernpfad noch nicht klar genug
Beobachtung:
- Anlegen, Abonnieren, Kapitel, Kurs, Lektion und Uebung sind einzeln verbessert, bilden aber noch keinen vollkommen klaren End-to-End-Lernpfad.
Empfehlung:
- Informationsarchitektur entlang von:
- entdecken
- beitreten
- lernen
- ueben
- bearbeiten
### P2-6: Falukant ist visuell stark, aber in taeglichen Routineablaeufen noch nicht effizient genug
Beobachtung:
- Statusleiste und Einstiege sind besser, aber Arbeitsablaeufe ueber mehrere Unterseiten bleiben kognitiv schwer.
Empfehlung:
- Top-5-Routinen definieren
- dafuer direkte Folgeaktionen aus Status und Uebersichten anbieten
### P2-7: Admin-Flaechen haben zu wenig gemeinsame Bedienlogik
Beobachtung:
- einzelne Admin-Seiten sind moderner, aber Such-, Editier- und Speichermuster unterscheiden sich weiterhin.
Empfehlung:
- gemeinsames Admin-Muster fuer Suche, Detailansicht, Editieren und Speichern definieren
### P2-8: Match3 hat gute Spieloberflaeche, aber Meta-Interaktion ist noch zu verstreut
Beobachtung:
- Spielstatus, Levelbeschreibung, Statistiken und Steuerung konkurrieren um Aufmerksamkeit.
Empfehlung:
- klare Prioritaet:
- Spielbrett
- aktuelles Ziel
- verbleibende Zuege
- Meta-Infos nur sekundär
### P2-9: Rueckwege sind nicht ueberall gleich gut sichtbar
Bereiche:
- Vokabel-Unterseiten
- Forum-Themen
- Falukant-Unterseiten
- Admin-Details
Empfehlung:
- gemeinsames Rueckwegmuster definieren
- nicht nur einzelne Buttons, sondern konsistente Bereichsorientierung
### P2-10: Leere Zustände sind nicht systematisch genug
Beobachtung:
- teilweise vorhanden, aber sehr uneinheitlich in Ton, Handlungsangebot und Sichtbarkeit.
Empfehlung:
- Standard fuer:
- keine Daten
- keine Treffer
- noch nicht gestartet
- keine Berechtigung
### P2-11: Mobile Nutzbarkeit ist verbessert, aber nicht abschließend entlang echter Kernaufgaben geprüft
Empfehlung:
- echter Geräte-/Viewport-Durchgang entlang der Kernszenarien
## P3-Probleme
### P3-1: Button-Semantik und Farblogik sind noch nicht vollkommen systematisch
Beobachtung:
- globale Buttons sind modernisiert, lokale Altvarianten bestehen weiter.
### P3-2: Teilweise alte Feld- und Listenmuster in Spezialbereichen
### P3-3: Headline-Hierarchien sind nicht in allen Ansichten gleich klar
### P3-4: Dialoginhalte wirken je nach Bereich unterschiedlich dicht
### P3-5: Tabellen haben uneinheitliche Leselogik und Aktionsplatzierung
### P3-6: In einigen Bereichen ist unklar, welche Aktion primaer und welche optional ist
### P3-7: Beta-/Systemhinweise koennen in Teilen ruhiger und weniger redundant werden
### P3-8: Einige Views nutzen sehr lange vertikale Inhaltsflaechen ohne Zwischenanker
### P3-9: Inline-Hilfen und Tooltips sind in komplexen Bereichen noch unterentwickelt
### P3-10: Touch-/Hover-Verhalten ist nicht in allen Spezialviews gleich robust
### P3-11: Message- und Error-Wording ist noch nicht konsistent
### P3-12: Manche Dialoge und Formulare sind auf Desktop gut, aber auf enger Breite nur ausreichend
### P3-13: Such- und Filterbereiche koennten staerker standardisiert werden
## P4-Punkte
### P4-1: Mikrointeraktionen in Karten, Listen und Toolbars weiter harmonisieren
### P4-2: Badge- und Statusdarstellungen semantisch weiter schaerfen
### P4-3: Fokus- und Hover-Zustaende in Spezialkomponenten weiter angleichen
### P4-4: Editorbereiche visuell und bedienlogisch weiter vereinheitlichen
### P4-5: Leichte Textkuerzungen fuer schnellere Scanbarkeit in Hero-Bereichen
### P4-6: Weitere Feinarbeit an Footer/Fensterleiste in langen Nutzungssessions
## Bereichsspezifische Kurzbewertung
### Shell und Navigation
- deutlich verbessert
- noch offen: inhaltliche Priorisierung, Rueckwege, Endabnahme kleiner Screens
### Einstieg ohne Login
- stark verbessert
- noch offen: Zugangspfad fokussierter gegen Story-Inhalt absetzen
### Registrierung/Login
- funktional ok
- UX-seitig noch zu wenig gefuehrt und rueckmeldearm
### Forum
- guter Ueberblick, aber Schreib-/Lesefluss noch nicht ideal getrennt
### Vokabeltrainer
- optisch konsistent, aber noch kein vollkommen klarer End-to-End-Lernpfad
### Falukant
- visuell stark, fachlich noch der anspruchsvollste Bedienbereich
### Admin
- einzelne Ansichten verbessert, gemeinsame Admin-Bedienlogik fehlt noch
### Minigames
- Match3 solide, aber Status-/Metaebene kann bedienlogisch weiter fokussiert werden
## Priorisierte Umsetzung nach U1
### Paket U1-A
- Feedbacksystem vereinheitlichen
- Formularvalidierung sichtbar machen
- Scroll- und Sticky-Logik historischer Sonderfaelle bereinigen
### Paket U1-B
- Navigation und Rueckwege nach Aufgaben priorisieren
- Leere Zustände und Systemzustände standardisieren
### Paket U1-C
- Vokabeltrainer-Lernpfad schärfen
- Falukant-Routinen entschlacken
- Admin-Bedienmuster vereinheitlichen
### Paket U1-D
- Mobile Kernaufgaben-Endabnahme
- Konsistenz-Feinschliff auf P3/P4-Niveau
## Fazit
Die App ist gestalterisch deutlich weiter als vor dem Redesign, aber bedienlogisch noch nicht auf demselben Reifegrad. Die größten UX-Hebel liegen nicht mehr in Farben oder Layout, sondern in:
- konsistentem Feedback
- klareren Aufgabenflüssen
- sichtbarerer Validierung
- entschlackten Fachbereichen
- sauberer Priorisierung von Aktionen
Phase U1 ist damit abgeschlossen. Die naechste sinnvolle Arbeitsphase ist `U2: Shell, Navigation und Feedback`.

440
docs/USABILITY_CONCEPT.md Normal file
View File

@@ -0,0 +1,440 @@
# Bedienbarkeitskonzept
## Ziel
Die Bedienbarkeit von YourPart soll systematisch verbessert werden, ohne die vorhandene Funktionsbreite oder den neuen UI-Stand wieder aufzubrechen. Der Fokus liegt auf Orientierung, Vorhersagbarkeit, Aufgabenfluss, Fehlertoleranz und effizienter Nutzung auf Desktop und Mobile.
Das Dokument ist bewusst als Arbeitsgrundlage aufgebaut:
- Was genau verbessert werden soll
- nach welchen Prinzipien entschieden wird
- in welcher Reihenfolge gearbeitet wird
- woran ein Punkt als erledigt gilt
## Leitprinzipien
### 1. Weniger Reibung
- Haeufige Aufgaben muessen mit moeglichst wenig Entscheidungen und moeglichst wenig Klicks erreichbar sein.
- Sekundaere Funktionen duerfen nicht die Kernaufgabe stoeren.
### 2. Klare Orientierung
- Nutzer muessen jederzeit erkennen:
- wo sie sich befinden
- was hier moeglich ist
- was der naechste sinnvolle Schritt ist
### 3. Konsistente Interaktion
- Gleiche Interaktionsmuster muessen sich in der gesamten App gleich verhalten.
- Buttons, Dialoge, Tabs, Listen, Formulare und Menues duerfen nicht je Bereich eigene Bedienlogiken entwickeln.
### 4. Fehlertoleranz statt Bestrafung
- Fehlbedienungen muessen auffangbar sein.
- Kritische Aktionen brauchen klare Rueckmeldung, wo sinnvoll Bestätigung und wenn moeglich Undo oder sichere Rueckwege.
### 5. Geschwindigkeit fuer geuebte Nutzer
- Power-User sollen die App schnell nutzen koennen.
- Haeufige Wege duerfen nicht durch uebermaessige Zwischenschritte ausgebremst werden.
## Nicht-Ziele
- Keine komplette Informationsarchitektur-Neuerfindung in einem Schritt.
- Kein sofortiger Umbau jedes Workflows gleichzeitig.
- Keine reine Accessibility-Checklistenarbeit ohne echten Nutzwert.
## Ausgangsprobleme
Aus dem aktuellen Projektstand und den bisherigen Umbauten ergeben sich fuer die Bedienbarkeit vor allem diese Problemklassen:
- Uneinheitliche Bedienmuster zwischen alten und neueren Bereichen
- zu viele tiefe Menues und bereichsspezifische Sonderlogiken
- einige Seiten mit hoher Funktionsdichte, aber schwacher Priorisierung
- Mischformen aus statischen Shell-Bereichen und historischen inneren Scrollkonzepten
- lokale Alt-Dialoge und Formmuster mit uneinheitlicher Rueckmeldung
- Desktop-zentrierte Denkweise in Teilen von Admin, Minigames und Falukant
## Zielbild
Am Ende soll die App folgendes Nutzungsgefuehl bieten:
- Shell, Navigation und Dialoge verhalten sich vorhersagbar
- jede Hauptseite zeigt klar eine Primaeraufgabe und erkennbare Sekundaeraufgaben
- Formulare fuehren sauber durch Eingabe, Validierung und Abschluss
- Statusaenderungen und Systemreaktionen sind sichtbar und verstaendlich
- Falukant, Community, Blog und Lernen bleiben fachlich unterschiedlich, aber bedienbar aus einem Guss
## Arbeitsbereiche
### Bereich A: Navigation und Orientierung
Ziel:
- Nutzer finden schneller zum Ziel und verstehen Menuehierarchien besser.
Arbeitspunkte:
- Hauptnavigation auf tatsaechliche Primaerbereiche pruefen
- Untermenues auf Redundanzen und Leerpfade pruefen
- aktive Position und Kontext pro Bereich klarer machen
- in tiefen Bereichen Rueckwege, Breadcrumb-artige Hinweise oder Bereichstitel konsistent gestalten
- Schnellzugriffe nur dort einsetzen, wo sie echte Beschleunigung bringen
Abnahmekriterien:
- jeder Hauptmenuepunkt hat einen klaren Zweck
- Menuepunkte ohne Untermenue verhalten sich direkt
- tiefe Ansichten haben einen klaren Rueckweg
- Nutzer muessen nicht raten, in welchem Bereich sie sich befinden
### Bereich B: Seitenhierarchie und Aufgabenfluss
Ziel:
- jede Seite hat einen erkennbaren Einstieg, eine Hauptaufgabe und einen lesbaren Aufbau
Arbeitspunkte:
- pro View Primaeraktion definieren
- Informationsdichte dort reduzieren, wo gleichrangige Bloecke konkurrieren
- visuelle Prioritaet zwischen Lesen, Auswaehlen, Bearbeiten und Absenden schaerfen
- Tabellen, Listen und Karten jeweils auf ihren eigentlichen Einsatzzweck pruefen
Abnahmekriterien:
- Nutzer erkennen in wenigen Sekunden den Zweck einer Seite
- Hauptaktionen sind oberhalb oder in unmittelbarer Naehe des relevanten Inhalts
- Nebenfunktionen dominieren die Seite nicht mehr
### Bereich C: Formulare und Eingaben
Ziel:
- Formulare sollen verstaendlich, fehlertolerant und effizient sein
Arbeitspunkte:
- Labels, Hilfetexte, Pflichtfelder und Fehlermeldungen vereinheitlichen
- Validierung naeher an den Eingabepunkt bringen
- unklare Zustandswechsel vermeiden
- Speichern, Abbrechen und gefaehrliche Aktionen konsistent platzieren
- Checkboxen, Radios und Selects auf Lesbarkeit und Trefferflaechen pruefen
Abnahmekriterien:
- jeder Eingabefehler ist erkennbar und nachvollziehbar
- Speichern fuehlt sich in allen Bereichen gleich an
- keine Form wirkt wie ein historischer Sonderfall
### Bereich D: Dialoge, Feedback und Systemstatus
Ziel:
- Systemreaktionen muessen sichtbar, verstaendlich und nicht stoerend sein
Arbeitspunkte:
- Dialogarten unterscheiden:
- bestaetigend
- informierend
- bearbeitend
- kritisch
- Messagebox/Dialog-Rueckmeldungen vereinheitlichen
- Ladezustaende, Erfolg, Fehler und Leere Zustaende standardisieren
- offene Dialoge und Fensterleiste auf Priorisierung pruefen
Abnahmekriterien:
- Nutzer verstehen, was gerade passiert ist oder noch passiert
- kritische Aktionen sind klar markiert
- Modale Interaktionen blockieren nur, wenn es wirklich noetig ist
### Bereich E: Mobile und kleine Viewports
Ziel:
- zentrale Aufgaben muessen auch auf kleineren Screens robust funktionieren
Arbeitspunkte:
- Shell mit Header, Navigation, Content und Footer auf reale Nutzungsszenarien pruefen
- Tabellen und breite Steuermasken auf mobile Alternativen oder horizontale Strategien prüfen
- Touch-Ziele und Abstaende angleichen
- Hover-abhaengige Muster fuer Touch absichern
Abnahmekriterien:
- Kernaufgaben sind auf Tablet und Smartphone ohne Layoutbruch nutzbar
- keine kritische Funktion ist nur via Hover oder Pixel-Präzision erreichbar
### Bereich F: Komplexe Produktbereiche
Ziel:
- Falukant, Vokabeltrainer, Minigames und Admin sollen fachlich komplex bleiben, aber leichter steuerbar werden
Arbeitspunkte:
- Falukant:
- Schnellzugriffsleiste, Tab-Struktur, Statusfeedback und Arbeitsablaeufe priorisieren
- Vokabeltrainer:
- Lernpfad, Bearbeitung, Suche, Uebung und Fortschritt klarer trennen
- Minigames:
- Einstieg, Pause, Statusanzeige und Kampagnenfluss vereinfachen
- Admin:
- Such-, Editier- und Bestaetigungsfluesse entlasten
Abnahmekriterien:
- komplexe Bereiche sind ohne Einlernen nicht sofort trivial, aber deutlich besser fuehrend
- wiederkehrende Aktionen sind schneller und sicherer bedienbar
## Methodik
### 1. Bedienbarkeits-Audit
Pro Hauptbereich wird ein kurzer Audit gemacht:
- Primaeraufgabe der Seite
- haeufigste Nutzeraktion
- groesste Reibung
- groesstes Fehlerrisiko
- mobile Schwachstelle
Empfohlene Cluster:
- Shell und Navigation
- Home und Einstieg
- Community/Social
- Blog
- Vokabeltrainer
- Falukant
- Admin
- Minigames
- Dialoge/Formulare
### 2. Aufgabenorientierte Review-Szenarien
Die App wird nicht nur nach Komponenten, sondern nach Aufgaben geprueft:
- registrieren und einloggen
- Profil/Freunde finden
- Forumsthema finden und beantworten
- Vokabelsprache erstellen, abonnieren, lernen
- Falukant-Status pruefen und Folgeaktion ausfuehren
- Admin-Nutzer suchen und aendern
- Match3 starten, pausieren, neu starten
### 3. Fix-Kategorien
Alle Probleme werden in vier Kategorien eingeordnet:
- P1: blockiert oder verwirrt Kernnutzung deutlich
- P2: verlangsamt Nutzung oder erzeugt Fehlbedienung
- P3: stoert Konsistenz oder Lesbarkeit
- P4: Feinschliff ohne unmittelbaren Schaden
## Umsetzungsphasen
### Phase U1: Audit und Problemkatalog
Ergebnis:
- kompakte Liste realer Bedienprobleme pro Hauptbereich
- priorisiert nach P1 bis P4
Arbeit:
- 1 Durchgang Desktop
- 1 Durchgang kleiner Viewport
- 1 Durchgang fuer Tastatur-/Dialognutzung
Aktueller Stand:
- abgeschlossen
- Audit dokumentiert in `docs/USABILITY_AUDIT_U1.md`
- priorisierte Folgephase: `U2 Shell, Navigation und Feedback`
### Phase U2: Shell, Navigation, Feedback
Ergebnis:
- globale Bedienmuster sind konsistent
Arbeit:
- Navigation
- Rueckwege
- Fokuslogik
- Dialog-/Feedbacksystem
- Lade- und Leerezustaende
Aktueller Stand:
- abgeschlossen
- Shell-Kontextbereich mit Bereichstitel und Rueckweg umgesetzt
- Navigation um klareren Seitenkontext ergaenzt
- zentrales Feedback-API eingefuehrt
- Standard-Feedbackdialoge visuell und technisch vereinheitlicht
- Kernfluesse aus Auth und Settings auf das neue Feedbackmuster umgestellt
### Phase U3: Formulare und Abschlussfluesse
Ergebnis:
- Eingaben, Speichern, Validierung und Rueckmeldung sind vereinheitlicht
Arbeit:
- Auth
- Settings
- Admin
- Falukant-Formulare
- Vokabel-Bearbeitungsfluesse
Aktueller Stand:
- abgeschlossen
- gemeinsames Formularmuster fuer Hinweise, Fehler und Action-Row eingefuehrt
- Dialogbuttons respektieren Disabled-Zustaende
- Auth-Dialoge, Account-Settings, zentrale Admin-/Falukant-Formulare und Vokabel-Bearbeitungsfluesse auf sichtbarere Validierung und konsistentere Abschlusslogik umgestellt
### Phase U4: Komplexe Fachbereiche
Ergebnis:
- Falukant, Vokabeltrainer, Minigames und Admin sind auf Nutzbarkeit statt nur Funktion geprüft
Arbeit:
- Arbeitsablaeufe entlasten
- Primaeraktionen schaerfen
- Informationslast reduzieren
Aktueller Stand:
- abgeschlossen
- Vokabeltrainer als Aufgabenhub mit getrennten Bereichen fuer eigene und abonnierte Sprachen
- Falukant-Uebersicht um Routinekarten, verdichtete Kennzahlen und schnellere Folgeaktionen erweitert
- Match3-Spiel um Ziel-/Statusleiste fuer den naechsten sinnvollen Schritt ergaenzt
- Match3-Admin um klaren 3-Schritt-Arbeitsfluss, Formzusammenfassung und sicherere Speicherlogik erweitert
### Phase U5: Mobile und Endabnahme
Ergebnis:
- Kernaufgaben sind auf Standard-Viewports belastbar
Arbeit:
- letzte Layoutkorrekturen
- Touch und Fokus
- Abschlussreview entlang echter Nutzerszenarien
Aktueller Stand:
- abgeschlossen
- Hauptnavigation auf kleinen Viewports zu einer verlässlich aufklappbaren Mobilnavigation mit Touch-gerechten Zielgroessen umgebaut
- Header und Footer auf kleine Breiten mit stabileren Status- und Linkblöcken angepasst
- Dialoge fuer kleine Viewports auf sichere Maximalgroessen und mobile Button-Stacks begrenzt
- breite Tabellen auf kleinen Screens per horizontalem Scroll-Fallback abgesichert
- globale Touch-Ziele fuer Buttons leicht vergroessert und letzte Shell-Kanten geglaettet
### Phase U6: Vereinfachung und Restentruempelung
Ergebnis:
- die letzten spuerbaren Bedienhuerden aus Altmustern, Scrolllogik und funktionslastigen Ansichten werden systematisch entfernt
Arbeit:
- verbliebene `alert`-/`confirm`-Fluesse auf das zentrale Feedbacksystem umstellen
- verschachtelte Scrollcontainer in Falukant, Admin und Minigames entfernen oder entkoppeln
- tabellenlastige Kernansichten auf klarere Aufgabenreihenfolge pruefen
- Debug-/Altinteraktionen aus grossen Kernviews reduzieren, wenn sie Bedienbarkeit oder Folgepflege stoeren
- Direktwege, Rueckspruenge und Fokusverhalten in den haeufigsten Hauptpfaden nachziehen
Aktueller Stand:
- `U6.1` abgeschlossen
- `U6.2` abgeschlossen
- `U6.3` abgeschlossen
- `U6.4` abgeschlossen
- `U6.5` abgeschlossen
- aus der Review nach U5 als eigener Nachlauf identifiziert
- Fokus bewusst nicht mehr auf Redesign, sondern auf Reibungsabbau in realen Nutzungswegen
- priorisierte Teilpakete:
- `U6.1 Feedback vereinheitlichen`
- `U6.2 Scroll- und Layoutfallen entfernen`
- `U6.3 Tabellen- und Arbeitsflaechen vereinfachen`
- `U6.4 Interaktionsaltlasten reduzieren`
- `U6.5 Direktwege und Ruecklogik polieren`
## Konkreter Arbeitskatalog
### 1. Shell und Navigation
- Menuepunkte auf Nutzungsprioritaet sortieren
- Untermenues auf direkte Zielerreichung pruefen
- Bereichskontext pro Seite konsistent machen
- globale Ruecksprunglogik definieren
### 2. Dialog- und Feedbacksystem
- Dialogtypen definieren und dokumentieren
- Standard fuer Erfolg, Fehler, Warnung, Leere, Laden festlegen
- Inline-Feedback vor modalem Feedback bevorzugen, wenn kein harter Block noetig ist
### 3. Formsystem
- ein gemeinsames Muster fuer Label, Hilfetext, Fehlermeldung, Pflichtfeld
- ein gemeinsames Muster fuer Save/Cancel/Delete
- ein gemeinsames Muster fuer Tabellenfilter und Suchformulare
### 4. Bereichsreviews
- Social/Friends/Search/Forum entlang echter Aufgaben pruefen
- Vokabeltrainer entlang des Lernpfads pruefen
- Falukant entlang taeglicher Routinen pruefen
- Admin entlang Such-/Editier-Routinen pruefen
- Minigames entlang Einstieg/Pause/Neustart pruefen
### 5. Mobile Review
- Header/Nav/Footer mit realen Hoehen pruefen
- breite Inhalte auf kleine Screens pruefen
- Dialoge und Tabellen fuer Touch pruefen
### 6. Vereinfachungsreview
- Restbestände an `alert`, `confirm` und lokalen Sonderdialogen abbauen
- komplexe Tabellenbereiche in Aufgabenfolge statt nur Datenanzeige gliedern
- verschachtelte Scrollbereiche konsequent entfernen
- Debug-/Sonderlogik in Kerninteraktionen auf Bedienrelevanz pruefen
## Definition of Done
Das Bedienbarkeitsprojekt gilt als abgeschlossen, wenn:
- fuer alle Hauptbereiche ein Audit stattgefunden hat
- P1- und P2-Probleme aus dem Audit abgearbeitet sind
- Navigation, Formulare, Dialoge und Feedback nach gemeinsamen Regeln funktionieren
- Kernaufgaben auf Desktop und kleinem Viewport ohne strukturelle Reibung moeglich sind
- verbleibende Altinteraktionen in Kernpfaden keine zusaetzliche Bedienlogik mehr erzwingen
- Restpunkte nur noch P3/P4-Feinschliff sind
## Empfohlene Reihenfolge
1. Audit ueber Kernaufgaben
2. Shell/Navigation/Feedback
3. Formulare und Abschlusslogik
4. Falukant, Vokabeltrainer, Admin, Minigames
5. Mobile Endabnahme
6. Vereinfachungsnachlauf ueber Feedback, Scrolllogik und tabellenlastige Restbereiche
## Naechster konkreter Schritt
Der naechste sinnvolle Umsetzungsschritt ist `U6.1 Feedback vereinheitlichen`: alle verbliebenen `alert`-/`confirm`-Fluesse in Kernpfaden auf das zentrale Feedback- und Bestätigungssystem ziehen und dabei zugleich die groebsten Altinteraktionen in Falukant, Kalender, Vokabeln und Admin bereinigen.

View File

@@ -1,4 +1,5 @@
VITE_API_BASE_URL=http://localhost:3001
VITE_API_BASE_URL=http://127.0.0.1:2020
VITE_PUBLIC_BASE_URL=http://localhost:5173
VITE_TINYMCE_API_KEY=xjqnfymt2wd5q95onkkwgblzexams6l6naqjs01x72ftzryg
VITE_DAEMON_SOCKET=ws://localhost:4551
VITE_DAEMON_SOCKET=ws://127.0.0.1:4551
VITE_CHAT_WS_URL=ws://127.0.0.1:1235

View File

@@ -1,4 +1,5 @@
VITE_API_BASE_URL=https://www.your-part.de
VITE_PUBLIC_BASE_URL=https://www.your-part.de
VITE_TINYMCE_API_KEY=xjqnfymt2wd5q95onkkwgblzexams6l6naqjs01x72ftzryg
VITE_DAEMON_SOCKET=wss://www.your-part.de:4551
VITE_CHAT_WS_URL=wss://www.your-part.de:1235

View File

@@ -1,6 +1,6 @@
VITE_API_BASE_URL=https://www.your-part.de
VITE_PUBLIC_BASE_URL=https://www.your-part.de
VITE_TINYMCE_API_KEY=xjqnfymt2wd5q95onkkwgblzexams6l6naqjs01x72ftzryg
VITE_DAEMON_SOCKET=wss://www.your-part.de:4551
VITE_CHAT_WS_URL=wss://www.your-part.de:1235
VITE_SOCKET_IO_URL=https://www.your-part.de:4443

View File

@@ -8,25 +8,25 @@
<meta name="description" content="YourPart vereint Community, Chat, Forum, soziales Netzwerk mit Bildergalerie, Vokabeltrainer, das Aufbauspiel Falukant sowie Minispiele wie Match3 und Taxi. Die Plattform befindet sich in der BetaPhase und wird laufend erweitert." />
<meta name="keywords" content="YourPart, Community, Chat, Forum, soziales Netzwerk, Vokabeltrainer, Sprachen lernen, Falukant, Aufbauspiel, Minispiele, Match3, Taxi, Bildergalerie, Tagebuch, Freundschaften" />
<meta name="robots" content="index, follow" />
<link rel="canonical" href="https://www.your-part.de/" />
<link rel="canonical" href="%VITE_PUBLIC_BASE_URL%/" />
<meta name="author" content="YourPart" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="YourPart" />
<meta property="og:title" content="YourPart Community, Chat, Forum, Vokabeltrainer, Falukant & Minispiele" />
<meta property="og:description" content="Community, Chat, Forum, soziales Netzwerk, Vokabeltrainer zum Sprachen lernen, das Aufbauspiel Falukant sowie Minispiele jetzt in der Beta testen." />
<meta property="og:url" content="https://www.your-part.de/" />
<meta property="og:url" content="%VITE_PUBLIC_BASE_URL%/" />
<meta property="og:locale" content="de_DE" />
<meta property="og:image" content="https://www.your-part.de/images/logos/logo.png" />
<meta property="og:image" content="%VITE_PUBLIC_BASE_URL%/images/logos/logo.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="YourPart Community, Chat, Forum, Vokabeltrainer, Falukant & Minispiele" />
<meta name="twitter:description" content="Community, Chat, Forum, soziales Netzwerk, Vokabeltrainer zum Sprachen lernen, das Aufbauspiel Falukant sowie Minispiele jetzt in der Beta testen." />
<meta name="twitter:image" content="https://www.your-part.de/images/logos/logo.png" />
<meta name="twitter:image" content="%VITE_PUBLIC_BASE_URL%/images/logos/logo.png" />
<meta name="theme-color" content="#FF8C5A" />
<link rel="alternate" hreflang="de" href="https://www.your-part.de/" />
<link rel="alternate" hreflang="x-default" href="https://www.your-part.de/" />
<link rel="alternate" hreflang="de" href="%VITE_PUBLIC_BASE_URL%/" />
<link rel="alternate" hreflang="x-default" href="%VITE_PUBLIC_BASE_URL%/" />
</head>

View File

@@ -9,36 +9,36 @@
"optimize-models": "node scripts/optimize-glb.mjs"
},
"dependencies": {
"@tiptap/extension-color": "^2.14.0",
"@tiptap/extension-image": "^2.14.0",
"@tiptap/extension-text-align": "^2.14.0",
"@tiptap/extension-text-style": "^2.14.0",
"@tiptap/extension-underline": "^2.14.0",
"@tiptap/starter-kit": "^2.14.0",
"@tiptap/vue-3": "^2.14.0",
"axios": "^1.7.2",
"@tiptap/extension-color": "^2.27.2",
"@tiptap/extension-image": "^2.27.2",
"@tiptap/extension-text-align": "^2.27.2",
"@tiptap/extension-text-style": "^2.27.2",
"@tiptap/extension-underline": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"@tiptap/vue-3": "^2.27.2",
"axios": "^1.13.6",
"date-fns": "^3.6.0",
"dompurify": "^3.2.6",
"dotenv": "^16.4.5",
"dompurify": "^3.3.3",
"dotenv": "^16.6.1",
"mitt": "^3.0.1",
"socket.io-client": "^4.8.1",
"three": "^0.169.0",
"vue": "~3.4.31",
"vue-i18n": "^10.0.0-beta.2",
"vue-multiselect": "^3.1.0",
"vue-router": "^4.0.13",
"vuetify": "^3.7.4",
"socket.io-client": "^4.8.3",
"three": "^0.183.2",
"vue": "~3.5.30",
"vue-i18n": "^10.0.8",
"vue-multiselect": "^3.5.0",
"vue-router": "^4.6.4",
"vuetify": "^3.12.3",
"vuex": "^4.1.0"
},
"devDependencies": {
"@gltf-transform/cli": "^4.3.0",
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@vitejs/plugin-vue": "^5.1.3",
"@vitejs/plugin-vue": "^5.2.4",
"assert": "^2.1.0",
"sass": "^1.77.8",
"sass": "^1.98.0",
"stream-browserify": "^3.0.0",
"util": "^0.12.5",
"vite": "^6.3.5"
"vite": "^6.4.1"
}
}

View File

@@ -1,8 +1,8 @@
<template>
<div id="app">
<AppHeader />
<AppNavigation v-if="isLoggedIn && user.active" />
<AppContent />
<div id="app" class="app-shell">
<AppHeader class="app-shell__header" />
<AppNavigation v-if="isLoggedIn && user.active" class="app-shell__nav" />
<AppContent class="app-shell__content" />
<AppFooter />
<AnswerContact ref="answerContactDialog" />
<RandomChatDialog ref="randomChatDialog" />
@@ -71,10 +71,10 @@ export default {
</script>
<style>
#app {
.app-shell {
display: flex;
flex-direction: column;
height: 100%;
height: 100vh;
overflow: hidden;
}
</style>

View File

@@ -1,50 +1,355 @@
:root {
/* Moderne Farbpalette für bessere Lesbarkeit */
--color-primary-orange: #FFB84D; /* Gelbliches, sanftes Orange */
--color-primary-orange-hover: #FFC966; /* Noch helleres gelbliches Orange für Hover */
--color-primary-orange-light: #FFF8E1; /* Sehr helles gelbliches Orange für Hover-Hintergründe */
--color-primary-green: #4ADE80; /* Helles, freundliches Grün - passt zum Orange */
--color-primary-green-hover: #6EE7B7; /* Noch helleres Grün für Hover */
--color-text-primary: #1F2937; /* Dunkles Grau für Haupttext (bessere Lesbarkeit) */
--color-text-secondary: #5D4037; /* Dunkles Braun für sekundären Text/Hover */
--color-text-on-orange: #000000; /* Schwarz auf Orange */
--color-text-on-green: #000000; /* Schwarz auf Grün */
color-scheme: light;
--font-display: "Trebuchet MS", "Segoe UI", sans-serif;
--font-body: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
--color-bg: #f4f1ea;
--color-bg-elevated: #faf7f1;
--color-bg-muted: #f5eee2;
--color-surface: rgba(255, 251, 246, 0.94);
--color-surface-strong: #fffdfa;
--color-surface-accent: #fff4e5;
--color-border: rgba(93, 64, 55, 0.12);
--color-border-strong: rgba(93, 64, 55, 0.24);
--color-text-primary: #211910;
--color-text-secondary: #5f4b39;
--color-text-muted: #7a6857;
--color-text-on-accent: #fffaf4;
--color-primary: #f8a22b;
--color-primary-hover: #ea961f;
--color-primary-soft: rgba(248, 162, 43, 0.14);
--color-secondary: #78c38a;
--color-secondary-soft: rgba(120, 195, 138, 0.18);
--color-highlight: #ffcf74;
--color-success: #287d5a;
--color-warning: #c9821f;
--color-danger: #b13b35;
--shell-max-width: 1440px;
--content-max-width: 1200px;
--header-height: 62px;
--nav-height: 52px;
--footer-height: 46px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--radius-sm: 5px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-pill: 999px;
--shadow-soft: 0 12px 30px rgba(47, 29, 14, 0.08);
--shadow-medium: 0 20px 50px rgba(47, 29, 14, 0.12);
--shadow-inset: inset 0 1px 0 rgba(255, 255, 255, 0.7);
--transition-fast: 140ms ease;
--transition-base: 220ms ease;
--color-primary-orange: var(--color-primary);
--color-primary-orange-hover: var(--color-primary-hover);
--color-primary-orange-light: #f9ece1;
--color-primary-green: #84c6a3;
--color-primary-green-hover: #95d1b0;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
body,
#app {
height: 100%;
}
html {
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.85), transparent 30%),
linear-gradient(180deg, #f8f2e8 0%, #f3ebdd 100%);
}
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f4f4f4;
font-family: var(--font-body);
color: var(--color-text-primary);
background: transparent;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow: hidden;
}
a {
text-decoration: none;
color: inherit;
text-decoration: none;
transition: color var(--transition-fast);
}
button {
margin-left: 10px;
padding: 5px 12px;
cursor: pointer;
background: var(--color-primary-orange);
color: var(--color-text-on-orange);
border: 1px solid var(--color-primary-orange);
border-radius: 4px;
transition: background 0.05s;
a:hover {
color: var(--color-primary);
}
button,
input,
select,
textarea {
font: inherit;
}
button,
.button,
span.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: 44px;
padding: 0 18px;
border: 1px solid transparent;
border-radius: var(--radius-pill);
background: var(--color-primary);
color: #2b1f14;
box-shadow: 0 6px 14px rgba(248, 162, 43, 0.2);
cursor: pointer;
transition:
transform var(--transition-fast),
box-shadow var(--transition-fast),
background var(--transition-fast),
border-color var(--transition-fast);
}
button:hover {
background: var(--color-primary-orange-light);
button:hover,
.button:hover,
span.button:hover {
transform: translateY(-1px);
background: var(--color-primary-hover);
box-shadow: 0 10px 18px rgba(248, 162, 43, 0.24);
}
button:active,
.button:active,
span.button:active {
transform: translateY(0);
}
button:disabled,
.button:disabled,
span.button:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
a:focus-visible,
[role="button"]:focus-visible,
[role="menuitem"]:focus-visible,
[tabindex]:focus-visible {
outline: 3px solid rgba(120, 195, 138, 0.32);
outline-offset: 2px;
}
input:not([type="checkbox"]):not([type="radio"]),
select,
textarea {
width: 100%;
min-height: 46px;
padding: 0 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.9);
color: var(--color-text-primary);
box-shadow: var(--shadow-inset);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast);
}
textarea {
min-height: 120px;
padding: 14px;
resize: vertical;
}
input:not([type="checkbox"]):not([type="radio"]):hover,
select:hover,
textarea:hover {
border-color: var(--color-border-strong);
}
input:not([type="checkbox"]):not([type="radio"]):focus,
select:focus,
textarea:focus {
border-color: rgba(120, 195, 138, 0.65);
box-shadow: 0 0 0 4px rgba(120, 195, 138, 0.16);
}
input[type="checkbox"],
input[type="radio"] {
width: auto;
min-height: 0;
padding: 0;
margin: 0;
border: 0;
box-shadow: none;
accent-color: var(--color-primary);
}
input[type="checkbox"] {
inline-size: 16px;
block-size: 16px;
}
input[type="radio"] {
inline-size: 16px;
block-size: 16px;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0 0 var(--space-3);
font-family: var(--font-display);
line-height: 1.08;
letter-spacing: -0.02em;
}
h1 {
font-size: clamp(2rem, 3.4vw, 3.6rem);
}
h2 {
font-size: clamp(1.5rem, 2vw, 2.4rem);
}
h3 {
font-size: clamp(1.15rem, 1.5vw, 1.5rem);
}
p,
ul,
ol {
margin: 0 0 var(--space-4);
}
ul,
ol {
padding-left: 1.25rem;
}
img {
max-width: 100%;
display: block;
}
main,
.contenthidden {
width: 100%;
height: 100%;
overflow: hidden;
}
.contentscroll {
width: 100%;
height: 100%;
overflow: auto;
}
.app-content__inner > .contenthidden {
height: auto;
overflow: visible;
}
.app-content__inner > .contenthidden > .contentscroll {
height: auto;
overflow: visible;
}
.surface-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
overflow: hidden;
}
.form-stack {
display: grid;
gap: 14px;
}
.form-field {
display: grid;
gap: 8px;
}
.form-field > label,
.form-field > span:first-child {
font-weight: 600;
color: var(--color-text-secondary);
border: 1px solid var(--color-text-secondary);
}
.form-hint {
font-size: 0.88rem;
color: var(--color-text-muted);
}
.form-error {
font-size: 0.88rem;
color: var(--color-danger);
}
.field-error {
border-color: rgba(177, 59, 53, 0.44) !important;
box-shadow: 0 0 0 4px rgba(177, 59, 53, 0.12) !important;
}
.form-actions-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
}
.button-secondary {
background: rgba(255, 255, 255, 0.86);
color: var(--color-text-primary);
border-color: var(--color-border);
box-shadow: none;
}
.button-secondary:hover {
background: rgba(255, 255, 255, 0.96);
box-shadow: none;
}
.link {
color: var(--color-primary);
cursor: pointer;
}
.link:hover {
color: var(--color-primary-hover);
}
.rc-system {
@@ -52,25 +357,13 @@ button:hover {
}
.rc-self {
color: #ff0000;
font-weight: bold;
color: #c0412c;
font-weight: 700;
}
.rc-partner {
color: #0000ff;
font-weight: bold;
}
.link {
color: var(--color-primary-orange);
cursor: pointer;
}
h1,
h2,
h3 {
margin: 0;
display: block;
color: #2357b5;
font-weight: 700;
}
.multiselect__option--highlight,
@@ -80,61 +373,65 @@ h3 {
.multiselect__option--highlight[data-selected],
.multiselect__option--highlight[data-deselect] {
background: none;
background-color: var(--color-primary-orange);
color: var(--color-text-on-orange);
}
span.button {
padding: 2px 2px;
margin-left: 4px;
cursor: pointer;
background: var(--color-primary-orange);
color: var(--color-text-on-orange);
border: 1px solid var(--color-primary-orange);
border-radius: 4px;
transition: background 0.05s;
border: 1px solid transparent;
width: 1.2em;
height: 1.2em;
display: inline-block;
text-align: center;
line-height: 1.2em;
}
span.button:hover {
background: var(--color-primary-orange-light);
color: var(--color-text-secondary);
border: 1px solid var(--color-text-secondary);
background-color: var(--color-primary);
color: var(--color-text-on-accent);
}
.font-color-gender-male {
color: #1E90FF;
color: #1e90ff;
}
.font-color-gender-female {
color: #FF69B4;
color: #d14682;
}
.font-color-gender-transmale {
color: #00CED1;
color: #1f8b9b;
}
.font-color-gender-transfemale {
color: #FFB6C1;
color: #d78398;
}
.font-color-gender-nonbinary {
color: #DAA520;
color: #ba7c1f;
}
main,
.contenthidden {
@media (max-width: 960px) {
:root {
--header-height: 56px;
--nav-height: auto;
--footer-height: auto;
}
h1 {
font-size: clamp(1.8rem, 8vw, 2.8rem);
}
h2 {
font-size: clamp(1.35rem, 5vw, 2rem);
}
.contentscroll table {
display: block;
width: 100%;
height: 100%;
overflow: hidden;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
}
.contentscroll {
width: 100%;
height: 100%;
overflow: auto;
@media (prefers-reduced-motion: reduce) {
html:focus-within {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -1,26 +1,59 @@
<template>
<main class="contenthidden">
<div class="contentscroll">
<main class="app-content contenthidden">
<div class="app-content__scroll contentscroll">
<div class="app-content__inner">
<AppSectionBar />
<router-view></router-view>
</div>
</div>
</main>
</template>
</template>
<script>
import AppSectionBar from './AppSectionBar.vue';
export default {
name: 'AppContent'
name: 'AppContent',
components: {
AppSectionBar
}
};
</script>
<style scoped>
main {
padding: 0;
background-color: #ffffff;
<style scoped>
.app-content {
flex: 1;
}
height: auto;
min-height: 0;
padding: 0;
overflow: hidden;
}
.contentscroll {
padding: 20px;
}
</style>
.app-content__scroll {
background: transparent;
height: 100%;
min-height: 0;
}
.app-content__inner {
max-width: var(--shell-max-width);
min-height: 100%;
height: 100%;
margin: 0 auto;
padding: 14px 18px 12px;
display: flex;
flex-direction: column;
}
.app-content__inner > :last-child {
flex: 1 1 auto;
min-height: 0;
}
@media (max-width: 960px) {
.app-content__inner {
padding: 12px 12px 10px;
}
}
</style>

View File

@@ -1,24 +1,43 @@
<template>
<footer>
<div class="logo" @click="showFalukantDaemonStatus"><img src="/images/icons/logo_color.png"></div>
<div class="window-bar">
<button v-for="dialog in openDialogs" :key="dialog.dialog.name" class="dialog-button"
@click="toggleDialogMinimize(dialog.dialog.name)" :title="dialog.dialog.localTitle">
<footer class="app-footer">
<div class="app-footer__inner">
<div class="footer-system">
<button class="footer-brand" type="button" @click="showFalukantDaemonStatus">
<img src="/images/icons/logo_color.png" alt="YourPart" />
<span>System</span>
</button>
<span class="footer-caption">
{{ openDialogs.length === 0 ? 'Keine offenen Dialoge' : `${openDialogs.length} Fenster aktiv` }}
</span>
</div>
<div class="window-bar" :class="{ 'window-bar--empty': openDialogs.length === 0 }">
<button
v-for="dialog in openDialogs"
:key="dialog.dialog.name"
class="dialog-button"
@click="toggleDialogMinimize(dialog.dialog.name)"
:title="dialog.dialog.localTitle"
>
<img v-if="dialog.dialog.icon" :src="'/images/icons/' + dialog.dialog.icon" />
<span class="button-text">{{ dialog.dialog.isTitleTranslated ? $t(dialog.dialog.localTitle) :
dialog.dialog.localTitle }}</span>
</button>
<span v-if="openDialogs.length === 0" class="window-bar__empty">System bereit</span>
</div>
<div class="static-block">
<a href="#" @click.prevent="openImprintDialog">{{ $t('imprint.button') }}</a>
<a href="#" @click.prevent="openDataPrivacyDialog">{{ $t('dataPrivacy.button') }}</a>
<a href="#" @click.prevent="openContactDialog">{{ $t('contact.button') }}</a>
</div>
</div>
</footer>
</template>
<script>
import { mapGetters, mapState } from 'vuex';
import { showInfo } from '@/utils/feedback.js';
export default {
name: 'AppFooter',
@@ -53,28 +72,69 @@ export default {
},
// Daemon WebSocket deaktiviert - diese Funktionen sind nicht mehr verfügbar
async showFalukantDaemonStatus() {
console.log('⚠️ Daemon WebSocket deaktiviert - Status nicht verfügbar');
showInfo(this, 'Der Systemstatus ist in dieser Ansicht derzeit nicht direkt verfügbar.');
},
handleDaemonMessage(event) {
console.log('⚠️ Daemon WebSocket deaktiviert - keine Nachrichten verarbeitet');
handleDaemonMessage() {
// Status-Events werden hier bewusst nicht verarbeitet.
}
}
};
</script>
<style scoped>
footer {
display: flex;
background-color: var(--color-primary-green);
height: 38px;
width: 100%;
color: #1F2937; /* Dunkles Grau für besseren Kontrast auf hellem Grün */
.app-footer {
flex: 0 0 auto;
padding: 0;
}
.logo,
.window-bar,
.static-block {
text-align: center;
.app-footer__inner {
max-width: var(--shell-max-width);
margin: 0 auto;
display: flex;
align-items: center;
gap: 16px;
min-height: 44px;
padding: 6px 12px;
border-radius: 0;
background:
linear-gradient(180deg, rgba(242, 248, 243, 0.96) 0%, rgba(224, 238, 227, 0.98) 100%);
border-top: 1px solid rgba(120, 195, 138, 0.28);
box-shadow: 0 -6px 18px rgba(93, 64, 55, 0.06);
}
.footer-system {
display: flex;
align-items: center;
gap: 10px;
}
.footer-brand {
min-height: 32px;
padding: 0 10px 0 8px;
background: rgba(120, 195, 138, 0.12);
border: 1px solid rgba(120, 195, 138, 0.22);
color: #24523a;
box-shadow: none;
}
.footer-brand:hover {
background: rgba(120, 195, 138, 0.18);
box-shadow: none;
}
.footer-brand img {
width: 22px;
height: 22px;
}
.footer-brand span {
font-weight: 700;
}
.footer-caption {
font-size: 0.76rem;
color: var(--color-text-secondary);
white-space: nowrap;
}
.window-bar {
@@ -83,24 +143,39 @@ footer {
align-items: center;
justify-content: flex-start;
gap: 10px;
padding-left: 10px;
overflow: auto;
min-width: 0;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.42);
border: 1px solid rgba(120, 195, 138, 0.16);
}
.window-bar--empty {
justify-content: center;
}
.window-bar__empty {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.dialog-button {
max-width: 12em;
max-width: 15em;
min-height: 30px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 5px 10px;
border: none;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
background: none;
height: 1.8em;
border: 1px solid #0a4337;
box-shadow: 1px 1px 2px #484949;
padding: 0 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.7);
color: var(--color-text-primary);
border: 1px solid rgba(120, 195, 138, 0.18);
box-shadow: none;
}
.dialog-button:hover {
background: rgba(255, 255, 255, 0.92);
}
.dialog-button>img {
@@ -111,16 +186,71 @@ footer {
margin-left: 5px;
}
.logo>img {
width: 36px;
height: 36px;
}
.static-block {
line-height: 38px;
display: flex;
align-items: center;
gap: 18px;
white-space: nowrap;
padding-left: 8px;
border-left: 1px solid rgba(120, 195, 138, 0.22);
}
.static-block>a {
padding-right: 1.5em;
color: #42634e;
font-weight: 600;
}
.static-block > a:hover {
color: #24523a;
}
@media (max-width: 960px) {
.app-footer__inner {
flex-wrap: wrap;
}
.footer-system,
.window-bar,
.static-block {
width: 100%;
}
.footer-system {
justify-content: space-between;
}
.static-block {
justify-content: space-between;
gap: 12px;
padding-left: 0;
border-left: 0;
border-top: 1px solid rgba(120, 195, 138, 0.2);
padding-top: 6px;
}
}
@media (max-width: 640px) {
.app-footer__inner {
gap: 10px;
padding: 8px 10px 10px;
}
.footer-system {
flex-wrap: wrap;
}
.window-bar {
border-radius: var(--radius-md);
padding: 6px;
}
.dialog-button {
min-height: 34px;
}
.static-block {
flex-wrap: wrap;
justify-content: flex-start;
}
}
</style>

View File

@@ -1,15 +1,27 @@
<template>
<header>
<div class="logo"><img src="/images/logos/logo.png" /></div>
<div class="advertisement">Advertisement</div>
<header class="app-header">
<div class="app-header__inner">
<div class="brand">
<div class="logo"><img src="/images/logos/logo.png" alt="YourPart" /></div>
<div class="brand-copy">
<strong>YourPart</strong>
<span>Community-Plattform</span>
</div>
</div>
<div class="header-meta">
<div class="header-meta__context">
<span class="header-pill">Beta</span>
</div>
<div class="connection-status" v-if="isLoggedIn">
<div class="status-indicator" :class="backendStatusClass">
<span class="status-dot"></span>
<span class="status-text">B</span>
<span class="status-text">Backend</span>
</div>
<div class="status-indicator" :class="daemonStatusClass">
<span class="status-dot"></span>
<span class="status-text">D</span>
<span class="status-text">Daemon</span>
</div>
</div>
</div>
</div>
</header>
@@ -43,43 +55,118 @@ export default {
</script>
<style scoped>
header {
.app-header {
position: relative;
flex: 0 0 auto;
padding: 6px 14px;
background:
linear-gradient(180deg, rgba(255, 249, 240, 0.96) 0%, rgba(246, 236, 220, 0.98) 100%);
color: #2b1f14;
border-bottom: 1px solid rgba(93, 64, 55, 0.12);
box-shadow: 0 5px 14px rgba(93, 64, 55, 0.06);
}
.app-header__inner {
max-width: var(--shell-max-width);
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
background-color: #f8a22b;
gap: 16px;
}
.logo, .title, .advertisement {
text-align: center;
.brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.advertisement {
flex: 1;
.logo {
width: 40px;
height: 40px;
padding: 5px;
border-radius: 12px;
background:
linear-gradient(180deg, rgba(248, 162, 43, 0.18) 0%, rgba(255, 255, 255, 0.76) 100%);
border: 1px solid rgba(248, 162, 43, 0.22);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
}
.logo > img {
max-height: 50px;
width: 100%;
height: 100%;
object-fit: contain;
}
.brand-copy {
display: flex;
flex-direction: column;
gap: 0;
min-width: 0;
}
.brand-copy strong {
font-size: 1rem;
line-height: 1.1;
color: #3a2a1b;
}
.brand-copy span {
font-size: 0.74rem;
color: rgba(95, 75, 57, 0.78);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-meta {
display: flex;
align-items: center;
gap: 12px;
}
.header-meta__context {
display: flex;
align-items: center;
gap: 10px;
}
.header-pill {
padding: 5px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.14);
border: 1px solid rgba(248, 162, 43, 0.24);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #8a5411;
}
.connection-status {
display: flex;
align-items: center;
margin-left: 10px;
gap: 5px;
gap: 8px;
}
.status-indicator {
display: flex;
align-items: center;
padding: 2px 6px;
border-radius: 4px;
font-size: 6pt;
font-weight: 500;
gap: 8px;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
border: 1px solid rgba(93, 64, 55, 0.1);
background: rgba(255, 255, 255, 0.62);
}
.status-dot {
width: 6px;
height: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
animation: pulse 2s infinite;
}
@@ -100,23 +187,23 @@ header {
}
.status-connected {
background-color: rgba(76, 175, 80, 0.1);
color: #2e7d32;
background-color: rgba(76, 175, 80, 0.12);
color: #245b2c;
}
.status-connecting {
background-color: rgba(255, 152, 0, 0.1);
color: #f57c00;
background-color: rgba(255, 152, 0, 0.12);
color: #8b5e0d;
}
.status-disconnected {
background-color: rgba(244, 67, 54, 0.1);
color: #d32f2f;
background-color: rgba(244, 67, 54, 0.12);
color: #8f2c27;
}
.status-error {
background-color: rgba(244, 67, 54, 0.1);
color: #d32f2f;
background-color: rgba(244, 67, 54, 0.12);
color: #8f2c27;
}
@keyframes pulse {
@@ -124,4 +211,53 @@ header {
50% { opacity: 0.5; }
100% { opacity: 1; }
}
@media (max-width: 960px) {
.app-header {
padding: 6px 10px;
}
.app-header__inner {
gap: 10px;
flex-wrap: wrap;
}
.header-meta {
width: 100%;
justify-content: space-between;
flex-wrap: wrap;
}
.header-meta__context {
flex-wrap: wrap;
}
.brand-copy span {
font-size: 0.76rem;
white-space: normal;
}
}
@media (max-width: 640px) {
.app-header__inner {
align-items: flex-start;
}
.brand {
width: 100%;
}
.header-meta {
gap: 8px;
}
.connection-status {
width: 100%;
flex-wrap: wrap;
}
.status-indicator {
min-height: 32px;
}
}
</style>

View File

@@ -1,26 +1,44 @@
<template>
<nav>
<nav
ref="navRoot"
class="app-navigation"
:class="{ 'app-navigation--suppress-hover': suppressHover }"
>
<div class="nav-primary">
<ul>
<!-- Hauptmenü -->
<li
v-for="(item, key) in menu"
:key="key"
class="mainmenuitem"
@click="handleItem(item, $event)"
:class="{ 'mainmenuitem--active': isItemActive(item), 'mainmenuitem--expanded': isMainExpanded(key) }"
tabindex="0"
role="button"
:aria-haspopup="hasTopLevelSubmenu(item) ? 'menu' : undefined"
:aria-expanded="hasTopLevelSubmenu(item) ? String(isMainExpanded(key)) : undefined"
@click="handleItem(item, $event, key)"
@keydown.enter.prevent="handleItem(item, $event, key)"
@keydown.space.prevent="handleItem(item, $event, key)"
>
<span
v-if="item.icon"
:style="`background-image:url('/images/icons/${item.icon}')`"
class="menu-icon"
>&nbsp;</span>
<span>{{ $t(`navigation.${key}`) }}</span>
<span class="mainmenuitem__label">{{ $t(`navigation.${key}`) }}</span>
<span v-if="hasTopLevelSubmenu(item)" class="mainmenuitem__caret">&#x25BE;</span>
<!-- Untermenü Ebene 1 -->
<ul v-if="item.children" class="submenu1">
<!-- Untermenü Ebene 1 -->
<ul v-if="hasTopLevelSubmenu(item)" class="submenu1" :class="{ 'submenu1--open': isMainExpanded(key) }">
<li
v-for="(subitem, subkey) in item.children"
:key="subkey"
@click="handleItem(subitem, $event)"
tabindex="0"
role="menuitem"
:class="{ 'submenu1__item--expanded': isSubExpanded(`${key}:${subkey}`) }"
@click="handleSubItem(subitem, subkey, key, $event)"
@keydown.enter.prevent="handleSubItem(subitem, subkey, key, $event)"
@keydown.space.prevent="handleSubItem(subitem, subkey, key, $event)"
>
<span
v-if="subitem.icon"
@@ -29,7 +47,7 @@
>&nbsp;</span>
<span>{{ subitem?.label || $t(`navigation.m-${key}.${subkey}`) }}</span>
<span
v-if="subkey === 'forum' || subkey === 'vocabtrainer' || subitem.children"
v-if="hasSecondLevelSubmenu(subitem, subkey)"
class="subsubmenu"
>&#x25B6;</span>
@@ -37,11 +55,16 @@
<ul
v-if="subkey === 'forum' && forumList.length"
class="submenu2"
:class="{ 'submenu2--open': isSubExpanded(`${key}:${subkey}`) }"
>
<li
v-for="forum in forumList"
:key="forum.id"
tabindex="0"
role="menuitem"
@click="handleItem({ action: 'openForum', params: forum.id }, $event)"
@keydown.enter.prevent="handleItem({ action: 'openForum', params: forum.id }, $event)"
@keydown.space.prevent="handleItem({ action: 'openForum', params: forum.id }, $event)"
>
{{ forum.name }}
</li>
@@ -51,16 +74,25 @@
<ul
v-else-if="subkey === 'vocabtrainer' && vocabLanguagesList.length"
class="submenu2"
:class="{ 'submenu2--open': isSubExpanded(`${key}:${subkey}`) }"
>
<li
tabindex="0"
role="menuitem"
@click="handleItem({ path: '/socialnetwork/vocab/new' }, $event)"
@keydown.enter.prevent="handleItem({ path: '/socialnetwork/vocab/new' }, $event)"
@keydown.space.prevent="handleItem({ path: '/socialnetwork/vocab/new' }, $event)"
>
{{ $t('navigation.m-sprachenlernen.m-vocabtrainer.newLanguage') }}
</li>
<li
v-for="lang in vocabLanguagesList"
:key="lang.id"
tabindex="0"
role="menuitem"
@click="handleItem({ path: `/socialnetwork/vocab/${lang.id}` }, $event)"
@keydown.enter.prevent="handleItem({ path: `/socialnetwork/vocab/${lang.id}` }, $event)"
@keydown.space.prevent="handleItem({ path: `/socialnetwork/vocab/${lang.id}` }, $event)"
>
{{ lang.name }}
</li>
@@ -70,11 +102,16 @@
<ul
v-else-if="subitem.children"
class="submenu2"
:class="{ 'submenu2--open': isSubExpanded(`${key}:${subkey}`) }"
>
<li
v-for="(subsubitem, subsubkey) in subitem.children"
:key="subsubkey"
tabindex="0"
role="menuitem"
@click="handleItem(subsubitem, $event)"
@keydown.enter.prevent="handleItem(subsubitem, $event)"
@keydown.space.prevent="handleItem(subsubitem, $event)"
>
<span
v-if="subsubitem.icon"
@@ -91,17 +128,29 @@
v-if="item.showLoggedinFriends === 1 && friendsList.length"
v-for="friend in friendsList"
:key="friend.id"
tabindex="0"
role="menuitem"
@click="handleItem({ action: 'openChat', params: friend.id }, $event)"
@keydown.enter.prevent="handleItem({ action: 'openChat', params: friend.id }, $event)"
@keydown.space.prevent="handleItem({ action: 'openChat', params: friend.id }, $event)"
>
{{ friend.username }}
<ul class="submenu2">
<li
tabindex="0"
role="menuitem"
@click="handleItem({ action: 'openChat', params: friend.id }, $event)"
@keydown.enter.prevent="handleItem({ action: 'openChat', params: friend.id }, $event)"
@keydown.space.prevent="handleItem({ action: 'openChat', params: friend.id }, $event)"
>
{{ $t('navigation.m-friends.chat') }}
</li>
<li
tabindex="0"
role="menuitem"
@click="handleItem({ action: 'openProfile', params: friend.id }, $event)"
@keydown.enter.prevent="handleItem({ action: 'openProfile', params: friend.id }, $event)"
@keydown.space.prevent="handleItem({ action: 'openProfile', params: friend.id }, $event)"
>
{{ $t('navigation.m-friends.profile') }}
</li>
@@ -110,12 +159,13 @@
</ul>
</li>
</ul>
</div>
<div class="right-block">
<span @click="accessMailbox" class="mailbox"></span>
<button type="button" @click="accessMailbox" class="mailbox" aria-label="Mailbox"></button>
<span class="logoutblock">
<span class="username">{{ user.username }}</span>
<span @click="logout" class="menuitem">
<span class="menuitem" @click="logout">
{{ $t('navigation.logout') }}
</span>
</span>
@@ -125,28 +175,23 @@
<script>
import { mapGetters, mapActions } from 'vuex';
import { createApp } from 'vue';
import apiClient from '@/utils/axios.js';
import RandomChatDialog from '../dialogues/chat/RandomChatDialog.vue';
import MultiChatDialog from '../dialogues/chat/MultiChatDialog.vue';
// Wichtig: die zentrale Instanzen importieren
import store from '@/store';
import router from '@/router';
import i18n from '@/i18n';
import { EventBus } from '@/utils/eventBus.js';
export default {
name: 'AppNavigation',
components: {
RandomChatDialog,
MultiChatDialog
},
data() {
return {
forumList: [],
friendsList: [],
vocabLanguagesList: []
vocabLanguagesList: [],
expandedMainKey: null,
expandedSubKey: null,
pinnedMainKey: null,
pinnedSubKey: null,
suppressHover: false,
hoverReleaseTimer: null,
isMobileNav: false
};
},
computed: {
@@ -156,6 +201,9 @@ export default {
menuNeedsUpdate(newVal) {
if (newVal) this.loadMenu();
},
$route() {
this.collapseMenus();
},
socket(newSocket) {
if (newSocket) {
newSocket.on('forumschanged', this.fetchForums);
@@ -171,6 +219,10 @@ export default {
this.fetchFriends();
this.fetchVocabLanguages();
}
this.updateViewportState();
window.addEventListener('resize', this.updateViewportState);
document.addEventListener('click', this.handleDocumentClick);
document.addEventListener('keydown', this.handleDocumentKeydown);
},
beforeUnmount() {
const sock = this.socket;
@@ -179,10 +231,131 @@ export default {
sock.off('friendloginchanged');
sock.off('reloadmenu');
}
window.removeEventListener('resize', this.updateViewportState);
document.removeEventListener('click', this.handleDocumentClick);
document.removeEventListener('keydown', this.handleDocumentKeydown);
if (this.hoverReleaseTimer) {
clearTimeout(this.hoverReleaseTimer);
}
},
methods: {
...mapActions(['loadMenu', 'logout']),
updateViewportState() {
this.isMobileNav = window.innerWidth <= 960;
if (!this.isMobileNav) {
this.expandedMainKey = null;
this.expandedSubKey = null;
}
},
isMainExpanded(key) {
return this.isMobileNav
? this.expandedMainKey === key
: this.pinnedMainKey === key;
},
isSubExpanded(key) {
return this.isMobileNav
? this.expandedSubKey === key
: this.pinnedSubKey === key;
},
toggleMain(key) {
this.expandedMainKey = this.expandedMainKey === key ? null : key;
this.expandedSubKey = null;
},
toggleSub(key) {
this.expandedSubKey = this.expandedSubKey === key ? null : key;
},
togglePinnedMain(key) {
this.pinnedMainKey = this.pinnedMainKey === key ? null : key;
this.pinnedSubKey = null;
},
togglePinnedSub(key) {
this.pinnedSubKey = this.pinnedSubKey === key ? null : key;
},
collapseMenus(options = {}) {
const { blurActiveElement = true } = options;
this.expandedMainKey = null;
this.expandedSubKey = null;
this.pinnedMainKey = null;
this.pinnedSubKey = null;
this.suppressHover = true;
if (this.hoverReleaseTimer) {
clearTimeout(this.hoverReleaseTimer);
}
this.hoverReleaseTimer = window.setTimeout(() => {
this.suppressHover = false;
this.hoverReleaseTimer = null;
}, 180);
if (blurActiveElement) {
this.$nextTick(() => {
if (document.activeElement && typeof document.activeElement.blur === 'function') {
document.activeElement.blur();
}
});
}
},
handleDocumentClick(event) {
const root = this.$refs.navRoot;
if (!root || root.contains(event.target)) {
return;
}
this.collapseMenus({ blurActiveElement: false });
},
handleDocumentKeydown(event) {
if (event.key === 'Escape') {
this.collapseMenus();
}
},
hasChildren(item) {
if (!item?.children) {
return false;
}
if (Array.isArray(item.children)) {
return item.children.length > 0;
}
return Object.keys(item.children).length > 0;
},
hasTopLevelSubmenu(item) {
return this.hasChildren(item) || (item?.showLoggedinFriends === 1 && this.friendsList.length > 0);
},
hasSecondLevelSubmenu(subitem, subkey) {
if (subkey === 'forum') {
return this.forumList.length > 0;
}
if (subkey === 'vocabtrainer') {
return this.vocabLanguagesList.length > 0;
}
return this.hasChildren(subitem);
},
isItemActive(item) {
if (!item?.path || !this.$route?.path) {
return false;
}
if (item.path === '/') {
return this.$route.path === '/';
}
return this.$route.path === item.path || this.$route.path.startsWith(`${item.path}/`);
},
openMultiChat() {
// Räume können später dynamisch geladen werden, hier als Platzhalter ein Beispiel:
const exampleRooms = [
@@ -199,6 +372,21 @@ export default {
}
},
accessMailbox() {
const openMessages = () => {
EventBus.emit('open-falukant-messages');
};
if (this.$route?.path?.startsWith('/falukant')) {
openMessages();
return;
}
this.$router.push({ name: 'FalukantOverview' }).then(() => {
window.setTimeout(openMessages, 150);
});
},
async fetchForums() {
try {
const res = await apiClient.get('/api/forum');
@@ -237,10 +425,21 @@ export default {
},
openChat(userId) {
console.log('openChat:', userId);
// Datei erstellen und ans body anhängen
const container = document.createElement('div');
document.body.appendChild(container);
const dialogRef = this.$root.$refs.multiChatDialog;
const friend = this.friendsList.find((entry) => entry.id === userId);
if (!dialogRef || typeof dialogRef.open !== 'function') {
this.openProfile(userId);
return;
}
dialogRef.open();
if (!friend?.username) {
return;
}
window.setTimeout(() => {
if (dialogRef.usersInRoom?.some((user) => user.name === friend.username)) {
dialogRef.selectedTargetUser = friend.username;
}
}, 250);
},
/**
@@ -250,11 +449,19 @@ export default {
* 3) Bei `action`: custom action aufrufen
* 4) Sonst: normale Router-Navigation
*/
handleItem(item, event) {
handleItem(item, event, key = null) {
event.stopPropagation();
// 1) nur aufklappen, wenn es echte Untermenüs gibt (nicht bei leerem children wie bei Startseite)
if (item.children && Object.keys(item.children).length > 0) return;
if (key && this.hasTopLevelSubmenu(item)) {
if (this.isMobileNav) {
this.toggleMain(key);
} else {
this.togglePinnedMain(key);
}
return;
}
if (this.hasChildren(item)) return;
// 2) view → Dialog/Window
if (item.view) {
@@ -271,18 +478,38 @@ export default {
} else {
console.error(`Dialog '${item.class}' gefunden, aber keine open()-Methode verfügbar.`);
}
this.collapseMenus();
return;
}
// 3) custom action (openForum, openChat, ...)
if (item.action && typeof this[item.action] === 'function') {
return this[item.action](item.params, event);
this[item.action](item.params, event);
this.collapseMenus();
return;
}
// 4) StandardNavigation
if (item.path) {
this.$router.push(item.path);
this.collapseMenus();
}
},
handleSubItem(item, subkey, parentKey, event) {
event.stopPropagation();
const compoundKey = `${parentKey}:${subkey}`;
if (this.hasSecondLevelSubmenu(item, subkey)) {
if (this.isMobileNav) {
this.toggleSub(compoundKey);
} else {
this.togglePinnedSub(compoundKey);
}
return;
}
this.handleItem(item, event);
}
}
};
@@ -291,42 +518,105 @@ export default {
<style lang="scss" scoped>
@import '../assets/styles.scss';
nav,
nav > ul {
.app-navigation,
.nav-primary > ul {
display: flex;
justify-content: space-between;
background-color: #f8a22b;
color: #000;
padding: 0;
margin: 0;
cursor: pointer;
flex-direction: row;
}
.app-navigation {
width: 100%;
max-width: none;
margin: 0 auto;
align-items: center;
gap: 10px;
padding: 6px 12px;
flex-wrap: wrap;
border-radius: 0;
background:
linear-gradient(180deg, rgba(249, 236, 225, 0.98) 0%, rgba(246, 228, 212, 0.98) 100%);
border-top: 1px solid rgba(93, 64, 55, 0.08);
border-bottom: 1px solid rgba(93, 64, 55, 0.12);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.46);
color: var(--color-text-primary);
z-index: 999;
}
.nav-primary {
flex: 1;
min-width: 0;
overflow: visible;
position: relative;
z-index: 1;
}
.nav-primary > ul {
min-width: 0;
justify-content: flex-start;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
ul {
list-style-type: none;
padding: 0;
margin: 0;
}
nav > ul > li {
padding: 0 1em;
line-height: 2.5em;
transition: background-color 0.25s;
.mainmenuitem {
display: flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 12px;
line-height: 1;
cursor: pointer;
border-radius: 999px;
border: 1px solid transparent;
transition: background-color 0.25s, color 0.25s, transform 0.2s, border-color 0.25s, box-shadow 0.25s;
}
nav > ul > li:hover {
background-color: #f8a22b;
.mainmenuitem:focus-visible,
.submenu1 > li:focus-visible,
.submenu2 > li:focus-visible,
.mailbox:focus-visible,
.menuitem:focus-visible {
outline: 3px solid rgba(120, 195, 138, 0.34);
outline-offset: 2px;
}
.mainmenuitem:hover {
background-color: rgba(248, 162, 43, 0.16);
border-color: rgba(248, 162, 43, 0.2);
transform: translateY(-1px);
}
.mainmenuitem:hover > span {
color: var(--color-primary);
}
.mainmenuitem--expanded {
background-color: rgba(248, 162, 43, 0.16);
border-color: rgba(248, 162, 43, 0.2);
}
.mainmenuitem--active {
background: rgba(255, 255, 255, 0.72);
border-color: rgba(248, 162, 43, 0.22);
box-shadow: 0 6px 14px rgba(93, 64, 55, 0.05);
}
.mainmenuitem__label {
white-space: nowrap;
}
nav > ul > li:hover > span {
color: #000;
}
nav > ul > li:hover > ul {
display: inline-block;
.mainmenuitem__caret {
margin-left: 6px;
font-size: 0.7rem;
color: rgba(95, 75, 57, 0.7);
}
a {
@@ -335,17 +625,29 @@ a {
.right-block {
display: flex;
gap: 10px;
align-items: center;
gap: 12px;
padding-left: 10px;
margin-left: auto;
flex: 0 0 auto;
border-left: 1px solid rgba(93, 64, 55, 0.12);
position: relative;
z-index: 3;
background:
linear-gradient(180deg, rgba(249, 236, 225, 0.98) 0%, rgba(246, 228, 212, 0.98) 100%);
}
.logoutblock {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.menuitem {
cursor: pointer;
color: #5D4037;
color: var(--color-primary);
font-weight: 700;
}
.mailbox {
@@ -353,20 +655,29 @@ a {
background-size: contain;
background-repeat: no-repeat;
background-position: center;
padding-left: 24px;
text-align: left;
width: 38px;
height: 38px;
border-radius: 999px;
background-color: rgba(120, 195, 138, 0.12);
border: 1px solid rgba(93, 64, 55, 0.1);
box-shadow: none;
min-height: 0;
padding: 0;
}
.mainmenuitem {
position: relative;
}
.mainmenuitem { position: relative; font-weight: 700; }
.submenu1 {
position: absolute;
border: 1px solid #5D4037;
background-color: #f8a22b;
display: block;
border: 1px solid rgba(93, 64, 55, 0.12);
background: rgba(255, 252, 247, 0.99);
left: 0;
top: 2.5em;
top: calc(100% + 10px);
min-width: 240px;
padding: 10px;
border-radius: var(--radius-lg);
box-shadow: 0 18px 30px rgba(93, 64, 55, 0.14);
max-height: 0;
overflow: visible;
opacity: 0;
@@ -385,16 +696,27 @@ a {
visibility 0s;
}
.mainmenuitem--expanded .submenu1 {
max-height: 500px;
opacity: 1;
visibility: visible;
transition: max-height 0.25s ease-in-out,
opacity 0.05s ease-in-out,
visibility 0s;
}
.submenu1 > li {
padding: 0.5em;
line-height: 1em;
color: #5D4037;
display: block;
padding: 0.75em 0.9em;
line-height: 1.1em;
color: var(--color-text-secondary);
position: relative;
border-radius: 14px;
}
.submenu1 > li:hover {
color: #000;
background-color: #f8a22b;
color: var(--color-text-primary);
background-color: rgba(248, 162, 43, 0.12);
}
.menu-icon,
@@ -407,7 +729,7 @@ a {
.menu-icon {
width: 24px;
height: 24px;
margin-right: 3px;
margin-right: 8px;
}
.submenu-icon {
@@ -419,10 +741,15 @@ a {
.submenu2 {
position: absolute;
background-color: #f8a22b;
left: 100%;
display: block;
background: rgba(255, 252, 247, 0.98);
left: calc(100% + 8px);
top: 0;
border: 1px solid #5D4037;
min-width: 230px;
padding: 8px;
border-radius: var(--radius-lg);
border: 1px solid rgba(71, 52, 35, 0.12);
box-shadow: 0 14px 24px rgba(93, 64, 55, 0.12);
max-height: 0;
overflow: hidden;
opacity: 0;
@@ -441,15 +768,43 @@ a {
visibility 0s;
}
.submenu1__item--expanded .submenu2 {
max-height: 500px;
opacity: 1;
visibility: visible;
transition: max-height 0.25s ease-in-out,
opacity 0.05s ease-in-out,
visibility 0s;
}
.app-navigation--suppress-hover .mainmenuitem:hover .submenu1,
.app-navigation--suppress-hover .submenu1 > li:hover .submenu2 {
max-height: 0;
opacity: 0;
visibility: hidden;
}
.submenu1__item--expanded {
color: var(--color-text-primary);
background-color: rgba(248, 162, 43, 0.08);
}
.submenu2 > li {
padding: 0.5em;
padding: 0.75em 0.9em;
line-height: 1em;
color: #5D4037;
color: var(--color-text-secondary);
border-radius: 14px;
}
.submenu2 > li:hover {
color: #000;
background-color: #f8a22b;
color: var(--color-text-primary);
background-color: rgba(120, 195, 138, 0.14);
}
.submenu1 > li:focus-visible,
.submenu2 > li:focus-visible {
color: var(--color-text-primary);
background-color: rgba(248, 162, 43, 0.12);
}
.subsubmenu {
@@ -457,4 +812,103 @@ a {
font-size: 8pt;
margin-right: -4px;
}
.username {
font-weight: 800;
color: var(--color-text-secondary);
}
@media (max-width: 960px) {
.app-navigation {
margin: 0;
flex-direction: column;
flex-wrap: nowrap;
padding: 8px 10px;
align-items: stretch;
}
.nav-primary,
.nav-primary > ul,
.right-block {
width: 100%;
}
.nav-primary {
overflow-x: auto;
overflow-y: visible;
}
.nav-primary > ul {
min-width: 0;
flex-wrap: wrap;
gap: 8px;
}
.right-block {
justify-content: space-between;
padding-left: 0;
margin-left: 0;
border-left: 0;
padding-top: 6px;
border-top: 1px solid rgba(93, 64, 55, 0.1);
}
.logoutblock {
align-items: flex-start;
}
.mainmenuitem {
min-height: 42px;
width: calc(50% - 4px);
justify-content: flex-start;
padding: 0 14px;
}
.submenu1,
.submenu2 {
position: static;
min-width: 100%;
margin-top: 8px;
max-height: 0;
overflow: hidden;
opacity: 0;
visibility: hidden;
padding: 0 10px;
}
.submenu1--open,
.submenu2--open {
max-height: 1200px;
opacity: 1;
visibility: visible;
padding: 10px;
}
.submenu1 > li,
.submenu2 > li {
min-height: 42px;
display: flex;
align-items: center;
}
.mailbox {
width: 42px;
height: 42px;
}
}
@media (max-width: 640px) {
.mainmenuitem {
width: 100%;
}
.right-block {
flex-wrap: wrap;
gap: 10px;
}
.logoutblock {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,206 @@
<template>
<section v-if="isVisible" class="app-section-bar surface-card">
<div class="app-section-bar__copy">
<span class="app-section-bar__eyebrow">{{ sectionLabel }}</span>
<h1 class="app-section-bar__title">{{ pageTitle }}</h1>
</div>
<button
v-if="backTarget"
type="button"
class="app-section-bar__back"
@click="navigateBack"
>
Zurück
</button>
</section>
</template>
<script>
const SECTION_LABELS = [
{ test: (path) => path.startsWith('/falukant'), label: 'Falukant' },
{ test: (path) => path.startsWith('/socialnetwork/vocab'), label: 'Vokabeltrainer' },
{ test: (path) => path.startsWith('/socialnetwork/forum'), label: 'Forum' },
{ test: (path) => path.startsWith('/socialnetwork'), label: 'Community' },
{ test: (path) => path.startsWith('/friends'), label: 'Community' },
{ test: (path) => path.startsWith('/settings'), label: 'Einstellungen' },
{ test: (path) => path.startsWith('/admin'), label: 'Administration' },
{ test: (path) => path.startsWith('/minigames'), label: 'Minispiele' },
{ test: (path) => path.startsWith('/personal'), label: 'Persönlich' },
{ test: (path) => path.startsWith('/blogs'), label: 'Blog' }
];
const TITLE_MAP = {
Friends: 'Freunde',
Guestbook: 'Gästebuch',
'Search users': 'Suche',
Gallery: 'Galerie',
Forum: 'Forum',
ForumTopic: 'Thema',
Diary: 'Tagebuch',
VocabTrainer: 'Sprachen',
VocabNewLanguage: 'Neue Sprache',
VocabSubscribe: 'Sprache abonnieren',
VocabLanguage: 'Sprache',
VocabChapter: 'Kapitel',
VocabCourses: 'Kurse',
VocabCourse: 'Kurs',
VocabLesson: 'Lektion',
FalukantCreate: 'Charakter erstellen',
FalukantOverview: 'Übersicht',
BranchView: 'Niederlassung',
MoneyHistoryView: 'Geldverlauf',
FalukantFamily: 'Familie',
HouseView: 'Haus',
NobilityView: 'Adel',
ReputationView: 'Ansehen',
ChurchView: 'Kirche',
EducationView: 'Bildung',
BankView: 'Bank',
DirectorView: 'Direktoren',
HealthView: 'Gesundheit',
PoliticsView: 'Politik',
UndergroundView: 'Untergrund',
'Personal settings': 'Persönliche Daten',
'View settings': 'Ansicht',
'Sexuality settings': 'Sexualität',
'Flirt settings': 'Flirt',
'Account settings': 'Account',
Interests: 'Interessen',
AdminInterests: 'Interessenverwaltung',
AdminUsers: 'Benutzer',
AdminUserStatistics: 'Benutzerstatistik',
AdminContacts: 'Kontaktanfragen',
AdminUserRights: 'Rechte',
AdminForums: 'Forumverwaltung',
AdminChatRooms: 'Chaträume',
AdminFalukantEditUserView: 'Falukant-Nutzer',
AdminFalukantMapRegionsView: 'Falukant-Karte',
AdminFalukantCreateNPCView: 'NPC erstellen',
AdminMinigames: 'Match3-Verwaltung',
AdminTaxiTools: 'Taxi-Tools',
AdminServicesStatus: 'Service-Status'
};
export default {
name: 'AppSectionBar',
computed: {
routePath() {
return this.$route?.path || '';
},
isVisible() {
return Boolean(this.$route?.meta?.requiresAuth) && this.routePath !== '/';
},
sectionLabel() {
const found = SECTION_LABELS.find((entry) => entry.test(this.routePath));
return found?.label || 'Bereich';
},
pageTitle() {
return TITLE_MAP[this.$route?.name] || this.sectionLabel;
},
backTarget() {
const params = this.$route?.params || {};
if (this.routePath.startsWith('/socialnetwork/vocab/courses/') && params.lessonId && params.courseId) {
return `/socialnetwork/vocab/courses/${params.courseId}`;
}
if (this.routePath.startsWith('/socialnetwork/vocab/') && params.chapterId && params.languageId) {
return `/socialnetwork/vocab/${params.languageId}`;
}
if (this.routePath.startsWith('/socialnetwork/vocab/new') || this.routePath.startsWith('/socialnetwork/vocab/subscribe')) {
return '/socialnetwork/vocab';
}
if (this.routePath.startsWith('/socialnetwork/vocab/courses/') && params.courseId) {
return '/socialnetwork/vocab/courses';
}
if (this.routePath.startsWith('/admin/users/statistics')) {
return '/admin/users';
}
if (this.routePath.startsWith('/falukant/') && this.routePath !== '/falukant/home') {
return '/falukant/home';
}
if (this.routePath.startsWith('/settings/') && this.routePath !== '/settings/personal') {
return '/settings/personal';
}
if (this.routePath.startsWith('/admin/') && this.routePath !== '/admin/users') {
return '/admin/users';
}
if (window.history.length > 1) {
return '__history_back__';
}
return null;
}
},
methods: {
navigateBack() {
if (this.backTarget === '__history_back__') {
this.$router.back();
return;
}
if (this.backTarget) {
this.$router.push(this.backTarget);
}
}
}
};
</script>
<style scoped>
.app-section-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
margin-bottom: 16px;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.98), rgba(248, 240, 231, 0.94));
}
.app-section-bar__copy {
min-width: 0;
}
.app-section-bar__eyebrow {
display: inline-flex;
margin-bottom: 6px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: var(--color-secondary-soft);
color: var(--color-text-secondary);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.app-section-bar__title {
margin: 0;
font-size: clamp(1.15rem, 1.6vw, 1.6rem);
}
.app-section-bar__back {
flex: 0 0 auto;
background: rgba(255, 255, 255, 0.82);
box-shadow: none;
border: 1px solid var(--color-border);
}
@media (max-width: 760px) {
.app-section-bar {
flex-direction: column;
align-items: flex-start;
}
.app-section-bar__back {
width: 100%;
}
}
</style>

View File

@@ -1,16 +1,48 @@
<template>
<div ref="container" class="character-3d-container"></div>
<div class="character-3d-shell">
<div v-show="!showFallback" ref="container" class="character-3d-container"></div>
<img
v-if="showFallback"
class="character-fallback"
:src="fallbackImageSrc"
:alt="`Character ${actualGender}`"
/>
</div>
</template>
<script>
import { markRaw } from 'vue';
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { getApiBaseURL } from '@/utils/axios.js';
/** Backend-Route: GET /api/models/3d/falukant/characters/:filename (Proxy mit Draco-Optimierung) */
const MODELS_API_PATH = '/api/models/3d/falukant/characters';
let threeRuntimePromise = null;
let threeLoadersPromise = null;
let threeModelRuntimePromise = null;
async function loadThreeRuntime() {
if (!threeRuntimePromise) {
threeRuntimePromise = import('@/utils/threeRuntime.js');
}
return threeRuntimePromise;
}
async function loadThreeLoaders() {
if (!threeLoadersPromise) {
threeLoadersPromise = import('@/utils/threeLoaders.js');
}
return threeLoadersPromise;
}
async function loadThreeModelRuntime() {
if (!threeModelRuntimePromise) {
threeModelRuntimePromise = import('@/utils/threeModelRuntime.js');
}
return threeModelRuntimePromise;
}
export default {
name: 'Character3D',
@@ -28,6 +60,10 @@ export default {
noBackground: {
type: Boolean,
default: false
},
lightweight: {
type: Boolean,
default: false
}
},
data() {
@@ -40,8 +76,12 @@ export default {
model: null,
animationId: null,
mixer: null,
clock: markRaw(new THREE.Clock()),
baseYPosition: 0 // Basis-Y-Position für Animation
clock: null,
baseYPosition: 0,
showFallback: false,
threeRuntime: null,
threeLoaders: null,
threeModelRuntime: null
};
},
computed: {
@@ -93,65 +133,97 @@ export default {
const base = getApiBaseURL();
const prefix = base ? `${base}${MODELS_API_PATH}` : MODELS_API_PATH;
return `${prefix}/${this.actualGender}_${age}y.glb`;
},
fallbackImageSrc() {
return this.actualGender === 'female'
? '/images/mascot/mascot_female.png'
: '/images/mascot/mascot_male.png';
}
},
watch: {
actualGender() {
this.loadModel();
async actualGender() {
await this.loadModel();
},
ageGroup() {
this.loadModel();
async ageGroup() {
await this.loadModel();
}
},
mounted() {
this.init3D();
this.loadModel();
async mounted() {
await this.init3D();
await this.loadModel();
this.animate();
},
beforeUnmount() {
this.cleanup();
},
methods: {
init3D() {
async ensureThreeRuntime() {
if (!this.threeRuntime) {
this.threeRuntime = markRaw(await loadThreeRuntime());
}
return this.threeRuntime;
},
async ensureThreeLoaders() {
if (!this.threeLoaders) {
this.threeLoaders = markRaw(await loadThreeLoaders());
}
return this.threeLoaders;
},
async ensureThreeModelRuntime() {
if (!this.threeModelRuntime) {
this.threeModelRuntime = markRaw(await loadThreeModelRuntime());
}
return this.threeModelRuntime;
},
async init3D() {
const container = this.$refs.container;
if (!container) return;
this.showFallback = false;
const runtime = await this.ensureThreeRuntime();
this.clock = markRaw(new runtime.Clock());
// Scene erstellen - markRaw verwenden, um Vue's Reactivity zu vermeiden
this.scene = markRaw(new THREE.Scene());
this.scene = markRaw(new runtime.Scene());
if (!this.noBackground) {
this.scene.background = new THREE.Color(0xf0f0f0);
this.loadBackground();
this.scene.background = new runtime.Color(0xf0f0f0);
await this.loadBackground();
}
// Camera erstellen
const aspect = container.clientWidth / container.clientHeight;
this.camera = markRaw(new THREE.PerspectiveCamera(50, aspect, 0.1, 1000));
this.camera = markRaw(new runtime.PerspectiveCamera(50, aspect, 0.1, 1000));
this.camera.position.set(0, 1.5, 3);
this.camera.lookAt(0, 1, 0);
// Renderer erstellen
this.renderer = markRaw(new THREE.WebGLRenderer({ antialias: true, alpha: true }));
this.renderer = markRaw(new runtime.WebGLRenderer({ antialias: true, alpha: true }));
this.renderer.setSize(container.clientWidth, container.clientHeight);
this.renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(this.renderer.domElement);
// Verbesserte Beleuchtung für hellere Modelle
// Mehr ambient light für gleichmäßigere Ausleuchtung
const ambientLight = new THREE.AmbientLight(0xffffff, 1.0);
const ambientLight = new runtime.AmbientLight(0xffffff, 1.0);
this.scene.add(ambientLight);
// Hauptlicht von vorne oben - stärker
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
const directionalLight = new runtime.DirectionalLight(0xffffff, 1.2);
directionalLight.position.set(5, 10, 5);
this.scene.add(directionalLight);
// Zusätzliches Licht von hinten - heller
const backLight = new THREE.DirectionalLight(0xffffff, 0.75);
const backLight = new runtime.DirectionalLight(0xffffff, 0.75);
backLight.position.set(-5, 5, -5);
this.scene.add(backLight);
// Zusätzliches Seitenlicht für mehr Tiefe
const sideLight = new THREE.DirectionalLight(0xffffff, 0.5);
const sideLight = new runtime.DirectionalLight(0xffffff, 0.5);
sideLight.position.set(-5, 5, 5);
this.scene.add(sideLight);
@@ -159,13 +231,14 @@ export default {
window.addEventListener('resize', this.onWindowResize);
},
loadBackground() {
async loadBackground() {
const runtime = await this.ensureThreeRuntime();
// Optimierte Versionen (512×341, ~130 KB); Originale ~3 MB
const backgrounds = ['bg1_opt.png', 'bg2_opt.png'];
const randomBg = backgrounds[Math.floor(Math.random() * backgrounds.length)];
const bgPath = `/images/falukant/backgrounds/${randomBg}`;
const loader = new THREE.TextureLoader();
const loader = new runtime.TextureLoader();
loader.load(
bgPath,
(texture) => {
@@ -179,7 +252,7 @@ export default {
console.warn('Fehler beim Laden des Hintergrunds:', error);
// Fallback auf Standardfarbe bei Fehler
if (this.scene) {
this.scene.background = new THREE.Color(0xf0f0f0);
this.scene.background = new runtime.Color(0xf0f0f0);
}
}
);
@@ -187,6 +260,8 @@ export default {
async loadModel() {
if (!this.scene) return;
const modelRuntime = await this.ensureThreeModelRuntime();
const loaders = await this.ensureThreeLoaders();
// Altes Modell entfernen
if (this.model) {
@@ -210,38 +285,44 @@ export default {
}
try {
const dracoLoader = new DRACOLoader();
const dracoLoader = new loaders.DRACOLoader();
dracoLoader.setDecoderPath('/draco/gltf/');
const loader = new GLTFLoader();
const loader = new loaders.GLTFLoader();
loader.setDRACOLoader(dracoLoader);
const base = getApiBaseURL();
const prefix = base ? `${base}${MODELS_API_PATH}` : MODELS_API_PATH;
// Fallback-Hierarchie:
// 1. Zuerst versuchen, Modell für genaues Alter zu laden (z.B. female_1y.glb)
// 2. Falls nicht vorhanden, Altersbereich verwenden (z.B. female_toddler.glb)
// 3. Falls auch nicht vorhanden, Basis-Modell verwenden (z.B. female.glb)
// Standard:
// 1. Exaktes Altersmodell
// 2. Altersbereich
// 3. Basis-Modell
// Lightweight:
// 1. Altersbereich
// 2. Basis-Modell
const exactAgePath = this.exactAgeModelPath;
const ageGroupPath = this.modelPath;
const fallbackPath = `${prefix}/${this.actualGender}.glb`;
let gltf;
try {
// Versuche zuerst genaues Alter
try {
gltf = await loader.loadAsync(exactAgePath);
console.log(`Loaded exact age model: ${exactAgePath}`);
} catch (exactAgeError) {
// Falls genaues Alter nicht existiert, versuche Altersbereich
if (this.lightweight) {
try {
gltf = await loader.loadAsync(ageGroupPath);
console.log(`Loaded age group model: ${ageGroupPath}`);
} catch (ageGroupError) {
// Falls Altersbereich nicht existiert, verwende Basis-Modell
console.warn(`Could not load ${ageGroupPath}, trying fallback model`);
gltf = await loader.loadAsync(fallbackPath);
}
} else {
try {
gltf = await loader.loadAsync(exactAgePath);
} catch (exactAgeError) {
try {
gltf = await loader.loadAsync(ageGroupPath);
} catch (ageGroupError) {
gltf = await loader.loadAsync(fallbackPath);
}
}
}
} finally {
dracoLoader.dispose();
@@ -251,8 +332,8 @@ export default {
this.model = markRaw(gltf.scene);
// Initiale Bounding Box für Größenberechnung (vor Skalierung)
const initialBox = new THREE.Box3().setFromObject(this.model);
const initialSize = initialBox.getSize(new THREE.Vector3());
const initialBox = new modelRuntime.Box3().setFromObject(this.model);
const initialSize = initialBox.getSize(new modelRuntime.Vector3());
// Skalierung basierend auf Alter
const age = this.actualAge;
@@ -276,8 +357,8 @@ export default {
this.model.scale.set(modelScale, modelScale, modelScale);
// Bounding Box NACH dem Skalieren neu berechnen
const scaledBox = new THREE.Box3().setFromObject(this.model);
const scaledCenter = scaledBox.getCenter(new THREE.Vector3());
const scaledBox = new modelRuntime.Box3().setFromObject(this.model);
const scaledCenter = scaledBox.getCenter(new modelRuntime.Vector3());
// Modell zentrieren basierend auf der skalierten Bounding Box
// Position direkt setzen statt zu subtrahieren, um Proxy-Probleme zu vermeiden
@@ -289,7 +370,7 @@ export default {
// Animationen laden falls vorhanden
if (gltf.animations && gltf.animations.length > 0) {
this.mixer = markRaw(new THREE.AnimationMixer(this.model));
this.mixer = markRaw(new modelRuntime.AnimationMixer(this.model));
gltf.animations.forEach((clip) => {
this.mixer.clipAction(clip).play();
});
@@ -301,12 +382,17 @@ export default {
}
} catch (error) {
console.error('Error loading 3D model:', error);
this.showFallback = true;
}
},
animate() {
this.animationId = requestAnimationFrame(this.animate);
if (!this.clock) {
return;
}
const delta = this.clock.getDelta();
// Animation-Mixer aktualisieren
@@ -375,10 +461,25 @@ export default {
</script>
<style scoped>
.character-3d-shell {
width: 100%;
height: 100%;
min-height: 0;
position: relative;
}
.character-3d-container {
width: 100%;
height: 100%;
min-height: 0;
position: relative;
overflow: hidden;
}
.character-fallback {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center bottom;
}
</style>

View File

@@ -57,11 +57,12 @@ export default {
loading: false,
error: null,
isDragging: false,
_daemonMessageHandler: null
_daemonMessageHandler: null,
pendingFetchTimer: null
};
},
computed: {
...mapState(['socket', 'daemonSocket']),
...mapState(['socket', 'daemonSocket', 'user']),
isFalukantWidget() {
return this.endpoint && String(this.endpoint).includes('falukant');
},
@@ -89,25 +90,63 @@ export default {
if (this.isFalukantWidget) this.setupSocketListeners();
},
beforeUnmount() {
if (this.pendingFetchTimer) {
clearTimeout(this.pendingFetchTimer);
this.pendingFetchTimer = null;
}
if (this.isFalukantWidget) this.teardownSocketListeners();
},
methods: {
matchesCurrentUser(eventData) {
if (eventData?.user_id == null) {
return true;
}
const currentIds = [this.user?.id, this.user?.hashedId]
.filter(Boolean)
.map((value) => String(value));
return currentIds.includes(String(eventData.user_id));
},
setupSocketListeners() {
this.teardownSocketListeners();
const daemonEvents = ['falukantUpdateStatus', 'stock_change', 'familychanged'];
const daemonEvents = ['falukantUpdateStatus', 'falukantUpdateFamily', 'falukantUpdateChurch', 'falukantUpdateDebt', 'children_update', 'falukantUpdateProductionCertificate', 'stock_change', 'familychanged'];
if (this.daemonSocket) {
this._daemonMessageHandler = (event) => {
if (event.data === 'ping') return;
try {
const data = JSON.parse(event.data);
if (daemonEvents.includes(data.event)) this.fetchData();
if (daemonEvents.includes(data.event) && this.matchesCurrentUser(data)) this.queueFetchData();
} catch (_) {}
};
this.daemonSocket.addEventListener('message', this._daemonMessageHandler);
}
if (this.socket) {
this.socket.on('falukantUpdateStatus', () => this.fetchData());
this.socket.on('falukantBranchUpdate', () => this.fetchData());
this._statusSocketHandler = (data) => {
if (this.matchesCurrentUser(data)) this.queueFetchData();
};
this._familySocketHandler = (data) => {
if (this.matchesCurrentUser(data)) this.queueFetchData();
};
this._churchSocketHandler = (data) => {
if (this.matchesCurrentUser(data)) this.queueFetchData();
};
this._debtSocketHandler = (data) => {
if (this.matchesCurrentUser(data)) this.queueFetchData();
};
this._childrenSocketHandler = (data) => {
if (this.matchesCurrentUser(data)) this.queueFetchData();
};
this._productionCertificateSocketHandler = (data) => {
if (this.matchesCurrentUser(data)) this.queueFetchData();
};
this._branchSocketHandler = () => this.queueFetchData();
this.socket.on('falukantUpdateStatus', this._statusSocketHandler);
this.socket.on('falukantUpdateFamily', this._familySocketHandler);
this.socket.on('falukantUpdateChurch', this._churchSocketHandler);
this.socket.on('falukantUpdateDebt', this._debtSocketHandler);
this.socket.on('children_update', this._childrenSocketHandler);
this.socket.on('falukantUpdateProductionCertificate', this._productionCertificateSocketHandler);
this.socket.on('falukantBranchUpdate', this._branchSocketHandler);
}
},
teardownSocketListeners() {
@@ -116,10 +155,24 @@ export default {
this._daemonMessageHandler = null;
}
if (this.socket) {
this.socket.off('falukantUpdateStatus');
this.socket.off('falukantBranchUpdate');
if (this._statusSocketHandler) this.socket.off('falukantUpdateStatus', this._statusSocketHandler);
if (this._familySocketHandler) this.socket.off('falukantUpdateFamily', this._familySocketHandler);
if (this._churchSocketHandler) this.socket.off('falukantUpdateChurch', this._churchSocketHandler);
if (this._debtSocketHandler) this.socket.off('falukantUpdateDebt', this._debtSocketHandler);
if (this._childrenSocketHandler) this.socket.off('children_update', this._childrenSocketHandler);
if (this._productionCertificateSocketHandler) this.socket.off('falukantUpdateProductionCertificate', this._productionCertificateSocketHandler);
if (this._branchSocketHandler) this.socket.off('falukantBranchUpdate', this._branchSocketHandler);
}
},
queueFetchData() {
if (this.pendingFetchTimer) {
clearTimeout(this.pendingFetchTimer);
}
this.pendingFetchTimer = setTimeout(() => {
this.pendingFetchTimer = null;
this.fetchData();
}, 120);
},
async fetchData() {
if (!this.endpoint || this.pauseFetch) return;
this.loading = true;

View File

@@ -16,7 +16,7 @@
<slot></slot>
</div>
<div class="dialog-footer">
<button v-for="button in buttons" :key="button.text" @click="buttonClick(button.action)" class="dialog-button">
<button v-for="button in buttons" :key="button.text" @click="buttonClick(button.action)" class="dialog-button" :disabled="button.disabled">
{{ isTitleTranslated ? $t(button.text) : button.text }}
</button>
</div>
@@ -142,6 +142,9 @@ export default {
return this.minimized;
},
startDragging(event) {
if (window.innerWidth <= 760) {
return;
}
this.isDragging = true;
const dialog = this.$refs.dialog;
this.dragOffsetX = event.clientX - dialog.offsetLeft;
@@ -186,7 +189,8 @@ export default {
align-items: center;
justify-content: center;
z-index: 1000;
background: rgba(0, 0, 0, 0.5);
background: rgba(24, 18, 11, 0.44);
backdrop-filter: blur(10px);
}
.dialog-overlay.non-modal {
@@ -195,14 +199,17 @@ export default {
}
.dialog {
background: white;
background:
linear-gradient(180deg, rgba(255, 252, 247, 0.98) 0%, rgba(249, 242, 232, 0.98) 100%);
display: flex;
flex-direction: column;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
border-radius: 8px;
box-shadow: var(--shadow-medium);
border-radius: var(--radius-lg);
border: 1px solid rgba(93, 64, 55, 0.12);
pointer-events: all;
position: absolute;
transform: translate(-50%, -50%);
overflow: hidden;
}
.dialog.minimized {
@@ -214,64 +221,112 @@ export default {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 5px;
border-bottom: 1px solid #ddd;
background-color: var(--color-primary-orange);
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid rgba(93, 64, 55, 0.1);
background:
linear-gradient(180deg, rgba(248, 162, 43, 0.16) 0%, rgba(255, 255, 255, 0.56) 100%);
cursor: move;
}
.dialog-icon {
padding: 2px 5px 0 0;
padding: 2px 6px 0 0;
}
.dialog-icon img {
width: 20px;
height: 20px;
object-fit: contain;
}
.dialog-title {
flex-grow: 1;
font-size: 1.5em;
font-weight: bold;
font-size: 1.08rem;
font-weight: 800;
color: var(--color-text-primary);
}
.dialog-close,
.dialog-minimize {
cursor: pointer;
font-size: 1.5em;
margin-left: 10px;
font-size: 1.1rem;
margin-left: 0;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
color: var(--color-text-secondary);
transition: background-color var(--transition-fast), color var(--transition-fast);
}
.dialog-close:hover,
.dialog-minimize:hover {
background: rgba(255, 255, 255, 0.72);
color: var(--color-text-primary);
}
.dialog-body {
flex-grow: 1;
padding: 20px;
padding: 18px 20px;
overflow-y: auto;
display: var(--dialog-display);
color: var(--color-text-primary);
&[style*="--dialog-display: flex"] {
flex-direction: column;
}
}
dialog-footer {
.dialog-footer {
display: flex;
justify-content: flex-end;
padding: 10px 20px;
border-top: 1px solid #ddd;
gap: 10px;
padding: 14px 20px 18px;
border-top: 1px solid rgba(93, 64, 55, 0.08);
background: rgba(255, 255, 255, 0.46);
}
.dialog-button {
margin-left: 10px;
padding: 5px 10px;
cursor: pointer;
background: var(--color-primary-orange);
color: #000000;
border: none;
border-radius: 4px;
transition: background 0.02s;
margin-left: 0;
min-height: 38px;
}
.dialog-button:hover {
background: #FFF4F0;
color: #5D4037;
border: 1px solid #5D4037;
color: #2b1f14;
}
.is-active {
z-index: 1100;
}
@media (max-width: 760px) {
.dialog {
width: calc(100vw - 16px) !important;
max-width: calc(100vw - 16px);
height: auto !important;
max-height: calc(100dvh - 16px);
}
.dialog-header {
cursor: default;
padding: 10px 12px;
}
.dialog-title {
font-size: 1rem;
}
.dialog-body {
padding: 14px;
}
.dialog-footer {
flex-direction: column;
}
.dialog-button {
width: 100%;
}
}
</style>

View File

@@ -105,7 +105,8 @@ export default {
align-items: center;
justify-content: center;
z-index: 1000;
background: rgba(0, 0, 0, 0.5);
background: rgba(24, 18, 11, 0.44);
backdrop-filter: blur(10px);
}
.dialog-overlay.non-modal {
@@ -114,12 +115,14 @@ export default {
}
.dialog {
background: white;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.98) 0%, rgba(249, 242, 232, 0.98) 100%);
display: flex;
flex-direction: column;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
border-radius: 8px;
box-shadow: var(--shadow-medium);
border-radius: var(--radius-lg);
border: 1px solid rgba(93, 64, 55, 0.12);
pointer-events: all;
overflow: hidden;
}
.dialog.minimized {
@@ -131,9 +134,9 @@ export default {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
border-bottom: 1px solid #ddd;
background-color: var(--color-primary-orange);
padding: 12px 16px;
border-bottom: 1px solid rgba(93, 64, 55, 0.1);
background: linear-gradient(180deg, rgba(248, 162, 43, 0.16) 0%, rgba(255, 255, 255, 0.56) 100%);
}
.dialog-icon {
@@ -142,42 +145,46 @@ export default {
.dialog-title {
flex-grow: 1;
font-size: 1.5em;
font-weight: bold;
font-size: 1.08rem;
font-weight: 800;
color: var(--color-text-primary);
}
.dialog-close,
.dialog-minimize {
cursor: pointer;
font-size: 1.5em;
margin-left: 10px;
font-size: 1.1rem;
margin-left: 0;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
color: var(--color-text-secondary);
}
.dialog-body {
flex-grow: 1;
padding: 20px;
padding: 18px 20px;
overflow-y: auto;
color: var(--color-text-primary);
}
.dialog-footer {
display: flex;
justify-content: flex-end;
padding: 10px 20px;
border-top: 1px solid #ddd;
padding: 14px 20px 18px;
border-top: 1px solid rgba(93, 64, 55, 0.08);
background: rgba(255, 255, 255, 0.46);
}
.dialog-button {
margin-left: 10px;
padding: 10px 20px;
cursor: pointer;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
transition: background 0.3s;
margin-left: 0;
min-height: 38px;
}
.dialog-button:hover {
background: #0056b3;
color: #2b1f14;
}
</style>

View File

@@ -85,6 +85,7 @@ import InputNumberWidget from '@/components/form/InputNumberWidget.vue';
import FloatInputWidget from '@/components/form/FloatInputWidget.vue';
import CheckboxWidget from '@/components/form/CheckboxWidget.vue';
import MultiselectWidget from '@/components/form/MultiselectWidget.vue';
import { showApiError, showError } from '@/utils/feedback.js';
export default {
name: "SettingsWidget",
@@ -158,7 +159,7 @@ export default {
// Prüfe ob das Setting unveränderlich ist
const setting = this.settings.find(s => s.id === settingId);
if (setting && setting.immutable && setting.value) {
alert(this.$t('settings.immutable.tooltip'));
showError(this, this.$t('settings.immutable.tooltip'));
return;
}
@@ -172,9 +173,7 @@ export default {
this.fetchSettings();
} catch (err) {
console.error('Error updating setting:', err);
if (err.response && err.response.data && err.response.data.error) {
alert(err.response.data.error);
}
showApiError(this, err, 'Änderung konnte nicht gespeichert werden.');
}
},
languagesList() {
@@ -208,6 +207,7 @@ export default {
});
} catch (err) {
console.error('Error updating visibility:', err);
showApiError(this, err, 'Sichtbarkeit konnte nicht aktualisiert werden.');
}
},
openContactDialog() {

View File

@@ -18,15 +18,19 @@
</div>
</div>
<div>
<button @click="openCreateBranchDialog">
<button
@click="openCreateBranchDialog"
:disabled="blocked"
>
{{ $t('falukant.branch.actions.create') }}
</button>
<button
@click="$emit('upgradeBranch')"
:disabled="!localSelectedBranch"
:disabled="!localSelectedBranch || blocked"
>
{{ $t('falukant.branch.actions.upgrade') }}
</button>
<span v-if="blocked && blockedReason" class="blocked-hint">{{ blockedReason }}</span>
</div>
</div>
@@ -51,6 +55,8 @@ export default {
props: {
branches: { type: Array, required: true },
selectedBranch: { type: Object, default: null },
blocked: { type: Boolean, default: false },
blockedReason: { type: String, default: '' },
},
data() {
return {
@@ -82,6 +88,7 @@ export default {
},
openCreateBranchDialog() {
if (this.blocked) return;
this.$refs.createBranchDialog.open();
},
@@ -131,4 +138,13 @@ button {
.weather-value {
text-transform: capitalize;
}
.blocked-hint {
display: inline-flex;
align-items: center;
margin-left: 8px;
color: #8b2f23;
font-size: 0.88rem;
font-weight: 600;
}
</style>

View File

@@ -147,6 +147,11 @@
</select>
</label>
<label>
{{ $t('falukant.branch.transport.guardCount') }}
<input v-model.number="emptyTransportForm.guardCount" type="number" min="0" max="20" @input="loadEmptyTransportRoute">
</label>
<div v-if="emptyTransportForm.costLabel" class="transport-cost">
{{ $t('falukant.branch.director.emptyTransport.cost', { cost: emptyTransportForm.costLabel }) }}
</div>
@@ -179,6 +184,7 @@
<script>
import apiClient from '@/utils/axios.js';
import NewDirectorDialog from '@/dialogues/falukant/NewDirectorDialog.vue';
import { showError, showInfo, showSuccess } from '@/utils/feedback.js';
export default {
name: "DirectorInfo",
@@ -199,6 +205,7 @@ export default {
emptyTransportForm: {
vehicleTypeId: null,
targetBranchId: null,
guardCount: 0,
distance: null,
durationHours: null,
eta: null,
@@ -278,7 +285,6 @@ export default {
},
openNewDirectorDialog() {
console.log('openNewDirectorDialog');
this.$refs.newDirectorDialog.open(this.branchId);
},
@@ -307,11 +313,11 @@ export default {
},
fireDirector() {
alert(this.$t('falukant.branch.director.fireAlert'));
showInfo(this, this.$t('falukant.branch.director.fireAlert'));
},
teachDirector() {
alert(this.$t('falukant.branch.director.teachAlert'));
showInfo(this, this.$t('falukant.branch.director.teachAlert'));
},
vehicleTypeOptions() {
@@ -390,8 +396,8 @@ export default {
this.emptyTransportForm.routeNames = (data.regions || []).map(r => r.name);
}
// Kosten für leeren Transport: 0.1
this.emptyTransportForm.cost = 0.1;
this.emptyTransportForm.costLabel = this.formatMoney(0.1);
this.emptyTransportForm.cost = 0.1 + ((this.emptyTransportForm.guardCount || 0) * 4);
this.emptyTransportForm.costLabel = this.formatMoney(this.emptyTransportForm.cost);
} catch (error) {
console.error('Error loading transport route:', error);
this.emptyTransportForm.distance = null;
@@ -426,11 +432,13 @@ export default {
productId: null,
quantity: 0,
targetBranchId: this.emptyTransportForm.targetBranchId,
guardCount: this.emptyTransportForm.guardCount || 0,
});
// Formular zurücksetzen
this.emptyTransportForm = {
vehicleTypeId: null,
targetBranchId: null,
guardCount: 0,
distance: null,
durationHours: null,
eta: null,
@@ -440,11 +448,11 @@ export default {
cost: 0.1,
costLabel: '',
};
alert(this.$t('falukant.branch.director.emptyTransport.success'));
showSuccess(this, this.$t('falukant.branch.director.emptyTransport.success'));
this.$emit('transportCreated');
} catch (error) {
console.error('Error creating empty transport:', error);
alert(this.$t('falukant.branch.director.emptyTransport.error'));
showError(this, this.$t('falukant.branch.director.emptyTransport.error'));
}
},
},

View File

@@ -63,6 +63,7 @@
<script>
import apiClient from '@/utils/axios.js';
import { showApiError } from '@/utils/feedback.js';
export default {
name: "ProductionSection",
props: {
@@ -166,7 +167,7 @@
});
this.loadProductions();
} catch (error) {
alert(this.$t(`falukant.branch.production.error${error.response.data.error}`));
showApiError(this, error, 'tr:error.network');
}
}
}

View File

@@ -90,6 +90,17 @@
</span>
</label>
<label>
{{ $t('falukant.branch.transport.guardCount') }}
<input
type="number"
v-model.number="transportForm.guardCount"
min="0"
max="20"
@input="recalcCost"
/>
</label>
<div v-if="transportForm.costLabel">
{{ $t('falukant.branch.sale.transportCost', { cost: transportForm.costLabel }) }}
</div>
@@ -138,6 +149,7 @@
<th>{{ $t('falukant.branch.sale.runningEta') }}</th>
<th>{{ $t('falukant.branch.sale.runningRemaining') }}</th>
<th>{{ $t('falukant.branch.sale.runningVehicleCount') }}</th>
<th>{{ $t('falukant.branch.sale.runningGuards') }}</th>
</tr>
</thead>
<tbody>
@@ -164,6 +176,7 @@
<td>{{ formatEta({ eta: group.eta }) }}</td>
<td>{{ formatRemaining({ eta: group.eta }) }}</td>
<td>{{ group.vehicleCount }}</td>
<td>{{ group.totalGuards || 0 }}</td>
</tr>
</tbody>
</table>
@@ -173,6 +186,7 @@
<script>
import apiClient from '@/utils/axios.js';
import { showError, showSuccess } from '@/utils/feedback.js';
export default {
name: "SaleSection",
props: {
@@ -188,6 +202,7 @@
vehicleTypeId: null,
targetBranchId: null,
quantity: 0,
guardCount: 0,
maxQuantity: 0,
distance: null,
durationHours: null,
@@ -231,12 +246,14 @@
eta: transport.eta,
vehicleCount: 0,
totalQuantity: 0,
totalGuards: 0,
transports: [],
});
}
const group = groups.get(key);
group.vehicleCount += 1;
group.totalGuards += transport.guardCount || 0;
if (transport.product && transport.size > 0) {
group.totalQuantity += transport.size || 0;
}
@@ -334,13 +351,13 @@
quantity: quantityToSell,
quality: item.quality,
}).catch(() => {
alert(this.$t('falukant.branch.sale.sellError'));
showError(this, this.$t('falukant.branch.sale.sellError'));
});
},
sellAll() {
apiClient.post(`/api/falukant/sell/all`, { branchId: this.branchId })
.catch(() => {
alert(this.$t('falukant.branch.sale.sellAllError'));
showError(this, this.$t('falukant.branch.sale.sellAllError'));
});
},
inventoryOptions() {
@@ -413,7 +430,8 @@
}
const unitValue = item.product.sellCost || 0;
const totalValue = unitValue * qty;
const cost = Math.max(0.1, totalValue * 0.01);
const guardCost = (this.transportForm.guardCount || 0) * 4;
const cost = Math.max(0.1, totalValue * 0.01) + guardCost;
this.transportForm.cost = cost;
this.transportForm.costLabel = this.formatMoney(cost);
},
@@ -499,14 +517,15 @@
productId: source.productId,
quantity: this.transportForm.quantity,
targetBranchId: this.transportForm.targetBranchId,
guardCount: this.transportForm.guardCount || 0,
});
await this.loadInventory();
await this.loadTransports();
alert(this.$t('falukant.branch.sale.transportStarted'));
showSuccess(this, this.$t('falukant.branch.sale.transportStarted'));
this.$emit('transportCreated');
} catch (error) {
console.error('Error creating transport:', error);
alert(this.$t('falukant.branch.sale.transportError'));
showError(this, this.$t('falukant.branch.sale.transportError'));
}
},
async loadTransports() {

View File

@@ -23,11 +23,31 @@
<img :src="'/images/icons/falukant/relationship-' + item.image + '.png'" class="relationship-icon" :title="$t(`falukant.statusbar.${item.key}`)" />
</div>
</template>
<span v-if="statusItems.length > 0 && menu.falukant && menu.falukant.children">
<div
v-if="statusItems.length > 0 && menu.falukant && menu.falukant.children"
class="quick-access"
>
<template v-for="(menuItem, key) in menu.falukant.children" :key="menuItem.id" >
<img :src="'/images/icons/falukant/shortmap/' + key + '.png'" class="menu-icon" @click="openPage(menuItem)" :title="$t(`navigation.m-falukant.${key}`)" />
<img
:src="'/images/icons/falukant/shortmap/' + key + '.png'"
class="menu-icon"
@click="openPage(menuItem)"
:title="$t(`navigation.m-falukant.${key}`)"
/>
</template>
</div>
<div v-if="debtorsPrison.active" class="statusbar-warning" :class="{ 'is-prison': debtorsPrison.inDebtorsPrison }">
<strong>
{{ debtorsPrison.inDebtorsPrison
? $t('falukant.bank.debtorsPrison.titlePrison')
: $t('falukant.bank.debtorsPrison.titleWarning') }}
</strong>
<span>
{{ debtorsPrison.inDebtorsPrison
? $t('falukant.debtorsPrison.globalLocked')
: $t('falukant.debtorsPrison.globalWarning') }}
</span>
</div>
<MessagesDialog ref="msgs" />
</div>
</template>
@@ -35,6 +55,7 @@
<script>
import { mapState, mapGetters } from "vuex";
import apiClient from "@/utils/axios.js";
import { EventBus } from '@/utils/eventBus.js';
import MessagesDialog from './MessagesDialog.vue';
export default {
@@ -51,10 +72,15 @@ export default {
{ key: "children", icon: "👶", value: null },
],
unreadCount: 0,
debtorsPrison: {
active: false,
inDebtorsPrison: false
},
pendingStatusRefresh: null,
};
},
computed: {
...mapState(["socket", "daemonSocket"]),
...mapState(["socket", "daemonSocket", "user"]),
...mapGetters(['menu']),
},
watch: {
@@ -86,10 +112,16 @@ export default {
// Socket.IO (Backend notifyUser) Hauptkanal für Falukant-Events
this.setupSocketListeners();
this.setupDaemonListeners();
EventBus.on('open-falukant-messages', this.openMessages);
},
beforeUnmount() {
this.teardownSocketListeners();
this.teardownDaemonListeners();
if (this.pendingStatusRefresh) {
clearTimeout(this.pendingStatusRefresh);
this.pendingStatusRefresh = null;
}
EventBus.off('open-falukant-messages', this.openMessages);
},
methods: {
preloadQuickAccessImages() {
@@ -130,6 +162,10 @@ export default {
const childCount = Number(response.data.childrenCount) || 0;
const unbaptisedCount = Number(response.data.unbaptisedChildrenCount) || 0;
this.unreadCount = Number(response.data.unreadNotifications) || 0;
this.debtorsPrison = response.data.debtorsPrison || {
active: false,
inDebtorsPrison: false
};
const childrenDisplay = `${childCount}${unbaptisedCount > 0 ? `(${unbaptisedCount})` : ''}`;
let healthStatus = '';
if (health > 90) {
@@ -158,15 +194,34 @@ export default {
setupSocketListeners() {
this.teardownSocketListeners();
if (!this.socket) return;
this.socket.on('falukantUpdateStatus', (data) => this.handleEvent({ event: 'falukantUpdateStatus', ...data }));
this.socket.on('stock_change', (data) => this.handleEvent({ event: 'stock_change', ...data }));
this.socket.on('familychanged', (data) => this.handleEvent({ event: 'familychanged', ...data }));
this._statusSocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateStatus', ...data });
this._familySocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateFamily', ...data });
this._churchSocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateChurch', ...data });
this._debtSocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateDebt', ...data });
this._childrenSocketHandler = (data) => this.handleEvent({ event: 'children_update', ...data });
this._productionCertificateSocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateProductionCertificate', ...data });
this._stockSocketHandler = (data) => this.handleEvent({ event: 'stock_change', ...data });
this._familyChangedSocketHandler = (data) => this.handleEvent({ event: 'familychanged', ...data });
this.socket.on('falukantUpdateStatus', this._statusSocketHandler);
this.socket.on('falukantUpdateFamily', this._familySocketHandler);
this.socket.on('falukantUpdateChurch', this._churchSocketHandler);
this.socket.on('falukantUpdateDebt', this._debtSocketHandler);
this.socket.on('children_update', this._childrenSocketHandler);
this.socket.on('falukantUpdateProductionCertificate', this._productionCertificateSocketHandler);
this.socket.on('stock_change', this._stockSocketHandler);
this.socket.on('familychanged', this._familyChangedSocketHandler);
},
teardownSocketListeners() {
if (this.socket) {
this.socket.off('falukantUpdateStatus');
this.socket.off('stock_change');
this.socket.off('familychanged');
if (this._statusSocketHandler) this.socket.off('falukantUpdateStatus', this._statusSocketHandler);
if (this._familySocketHandler) this.socket.off('falukantUpdateFamily', this._familySocketHandler);
if (this._churchSocketHandler) this.socket.off('falukantUpdateChurch', this._churchSocketHandler);
if (this._debtSocketHandler) this.socket.off('falukantUpdateDebt', this._debtSocketHandler);
if (this._childrenSocketHandler) this.socket.off('children_update', this._childrenSocketHandler);
if (this._productionCertificateSocketHandler) this.socket.off('falukantUpdateProductionCertificate', this._productionCertificateSocketHandler);
if (this._stockSocketHandler) this.socket.off('stock_change', this._stockSocketHandler);
if (this._familyChangedSocketHandler) this.socket.off('familychanged', this._familyChangedSocketHandler);
}
},
setupDaemonListeners() {
@@ -175,13 +230,22 @@ export default {
this._daemonHandler = (event) => {
try {
const data = JSON.parse(event.data);
if (['falukantUpdateStatus', 'stock_change', 'familychanged'].includes(data.event)) {
if (['falukantUpdateStatus', 'falukantUpdateFamily', 'falukantUpdateChurch', 'falukantUpdateDebt', 'children_update', 'falukantUpdateProductionCertificate', 'stock_change', 'familychanged'].includes(data.event)) {
this.handleEvent(data);
}
} catch (_) {}
};
this.daemonSocket.addEventListener('message', this._daemonHandler);
},
matchesCurrentUser(eventData) {
if (eventData?.user_id == null) {
return true;
}
const currentIds = [this.user?.id, this.user?.hashedId]
.filter(Boolean)
.map((value) => String(value));
return currentIds.includes(String(eventData.user_id));
},
teardownDaemonListeners() {
const sock = this.daemonSocket;
if (sock && this._daemonHandler) {
@@ -189,12 +253,29 @@ export default {
this._daemonHandler = null;
}
},
queueStatusRefresh() {
if (this.pendingStatusRefresh) {
clearTimeout(this.pendingStatusRefresh);
}
this.pendingStatusRefresh = setTimeout(async () => {
this.pendingStatusRefresh = null;
await this.fetchStatus();
}, 120);
},
handleEvent(eventData) {
if (!this.matchesCurrentUser(eventData)) {
return;
}
switch (eventData.event) {
case 'falukantUpdateStatus':
case 'falukantUpdateFamily':
case 'falukantUpdateChurch':
case 'falukantUpdateDebt':
case 'children_update':
case 'falukantUpdateProductionCertificate':
case 'stock_change':
case 'familychanged':
this.fetchStatus();
this.queueStatusRefresh();
break;
}
},
@@ -220,14 +301,40 @@ export default {
display: flex;
justify-content: center;
align-items: center;
background-color: #f4f4f4;
border: 1px solid #ccc;
border-radius: 4px;
width: calc(100% + 40px);
flex-wrap: wrap;
background:
linear-gradient(180deg, rgba(255, 251, 246, 0.96) 0%, rgba(247, 238, 224, 0.98) 100%);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-sizing: border-box;
width: 100%;
max-width: 100%;
gap: 1.2em;
margin: -21px -20px 1.5em -20px;
position: fixed;
padding: 0.55rem 0.9rem;
margin: 0 0 1.5em 0;
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow-soft);
}
.statusbar-warning {
flex: 1 1 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-md);
border: 1px solid rgba(180, 120, 40, 0.35);
background: rgba(255, 244, 223, 0.92);
color: #8a5411;
font-size: 0.92rem;
}
.statusbar-warning.is-prison {
border-color: rgba(146, 57, 40, 0.42);
background: rgba(255, 232, 225, 0.94);
color: #8b2f23;
}
.status-item {
@@ -235,6 +342,19 @@ export default {
cursor: pointer;
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 10px;
border-radius: 999px;
background: rgba(255,255,255,0.68);
border: 1px solid rgba(93, 64, 55, 0.08);
}
.quick-access {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.2rem;
}
.status-icon-wrapper {
@@ -254,6 +374,8 @@ export default {
.menu-icon {
width: 30px;
height: 30px;
display: block;
flex: 0 0 auto;
cursor: pointer;
padding: 4px 2px 0 0;
}

View File

@@ -76,6 +76,7 @@
<script>
import apiClient from '@/utils/axios.js';
import { showError } from '@/utils/feedback.js';
export default {
name: "StorageSection",
props: { branchId: { type: Number, required: true } },
@@ -164,12 +165,12 @@
});
} catch (err) {
console.error(err);
alert('Error buying storage for one part of the order');
showError(this, 'Fehler beim Kaufen eines Teils der Lagerkapazität.');
}
remainingAmount -= toBuy;
}
if (remainingAmount > 0) {
alert(this.$t('falukant.branch.storage.notEnoughAvailable'));
showError(this, this.$t('falukant.branch.storage.notEnoughAvailable'));
}
this.loadStorageData();
},
@@ -185,7 +186,7 @@
.then(() => this.loadStorageData())
.catch(err => {
console.error(err);
alert('Error selling storage');
showError(this, 'Fehler beim Verkaufen der Lagerkapazität.');
});
},
getCostOfType(labelTr) {

View File

@@ -43,7 +43,6 @@ export default {
},
methods: {
updateValue(value) {
console.log('changed to ', value)
this.$emit("input", parseInt(value));
}
}

View File

@@ -12,6 +12,14 @@
<dd>{{ falukantData.unreadNotificationsCount }}</dd>
<dt>{{ $t('falukant.statusbar.children') }}</dt>
<dd>{{ falukantData.childrenCount }}</dd>
<template v-if="falukantData.debtorsPrison?.active">
<dt>{{ $t('falukant.bank.debtorsPrison.titlePrison') }}</dt>
<dd class="falukant-debt" :class="{ 'falukant-debt--warning': !falukantData.debtorsPrison?.inDebtorsPrison }">
{{ falukantData.debtorsPrison?.inDebtorsPrison
? $t('falukant.bank.debtorsPrison.titlePrison')
: $t('falukant.bank.debtorsPrison.titleWarning') }}
</dd>
</template>
</dl>
<span v-else></span>
</template>
@@ -43,6 +51,7 @@ export default {
money: pick(raw, 'money', 'money'),
unreadNotificationsCount: pick(raw, 'unreadNotificationsCount', 'unread_notifications_count'),
childrenCount: pick(raw, 'childrenCount', 'children_count'),
debtorsPrison: pick(raw, 'debtorsPrison', 'debtors_prison'),
// keep all original keys as fallback for any other usage
...raw
};
@@ -173,4 +182,13 @@ export default {
color: #198754;
font-weight: 600;
}
.dashboard-widget__falukant dd.falukant-debt {
color: #8b2f23;
font-weight: 700;
}
.dashboard-widget__falukant dd.falukant-debt--warning {
color: #9a5a08;
}
</style>

View File

@@ -1,7 +1,12 @@
<template>
<DialogWidget ref="dialog" title="passwordReset.title" :isTitleTranslated="true" :show-close=true :buttons="buttons" @close="closeDialog" @reset="resetPassword" name="PasswordReset">
<div>
<label>{{ $t("passwordReset.email") }} <input type="email" v-model="email" required /></label>
<div class="form-stack">
<div class="form-field">
<label for="password-reset-email">{{ $t("passwordReset.email") }}</label>
<input id="password-reset-email" type="email" v-model="email" required :class="{ 'field-error': emailTouched && !isEmailValid }" />
<span class="form-hint">Wir senden den Link an die hinterlegte E-Mail-Adresse.</span>
<span v-if="emailTouched && !isEmailValid" class="form-error">Bitte eine gültige E-Mail-Adresse eingeben.</span>
</div>
</div>
</DialogWidget>
</template>
@@ -9,6 +14,7 @@
<script>
import apiClient from '@/utils/axios.js';
import DialogWidget from '@/components/DialogWidget.vue';
import { showApiError, showSuccess } from '@/utils/feedback.js';
export default {
name: 'PasswordResetDialog',
@@ -18,9 +24,21 @@ export default {
data() {
return {
email: '',
buttons: [{ text: 'passwordReset.reset', action: 'reset' }]
emailTouched: false,
buttons: [{ text: 'passwordReset.reset', action: 'reset', disabled: true }]
};
},
computed: {
isEmailValid() {
return /\S+@\S+\.\S+/.test(this.email);
}
},
watch: {
email() {
this.emailTouched = true;
this.buttons[0].disabled = !this.isEmailValid;
}
},
methods: {
open() {
this.$refs.dialog.open();
@@ -29,15 +47,18 @@ export default {
this.$refs.dialog.close();
},
async resetPassword() {
if (!this.isEmailValid) {
return;
}
try {
await apiClient.post('/api/users/requestPasswordReset', {
email: this.email
});
this.$refs.dialog.close();
alert(this.$t("passwordReset.success"));
showSuccess(this, 'tr:passwordReset.success');
} catch (error) {
console.error('Error resetting password:', error);
alert(this.$t("passwordReset.failure"));
showApiError(this, error, 'tr:passwordReset.failure');
}
}
}

View File

@@ -2,18 +2,27 @@
<DialogWidget ref="dialog" title="register.title" :show-close="true" :buttons="buttons" :modal="true"
@close="closeDialog" @register="register" width="35em" height="33em" name="RegisterDialog"
:isTitleTranslated="true">
<div class="form-content">
<div>
<label>{{ $t("register.email") }}<input type="email" v-model="email" /></label>
<div class="form-content form-stack">
<div class="form-field">
<label for="register-email">{{ $t("register.email") }}</label>
<input id="register-email" type="email" v-model="email" :class="{ 'field-error': emailTouched && !isEmailValid }" />
<span v-if="emailTouched && !isEmailValid" class="form-error">Bitte eine gültige E-Mail-Adresse eingeben.</span>
</div>
<div>
<label>{{ $t("register.username") }}<input type="text" v-model="username" /></label>
<div class="form-field">
<label for="register-username">{{ $t("register.username") }}</label>
<input id="register-username" type="text" v-model="username" :class="{ 'field-error': usernameTouched && !isUsernameValid }" />
<span v-if="usernameTouched && !isUsernameValid" class="form-error">Der Benutzername sollte mindestens 3 Zeichen haben.</span>
</div>
<div>
<label>{{ $t("register.password") }}<input type="password" v-model="password" /></label>
<div class="form-field">
<label for="register-password">{{ $t("register.password") }}</label>
<input id="register-password" type="password" v-model="password" :class="{ 'field-error': passwordTouched && !isPasswordValid }" />
<span class="form-hint">Mindestens 8 Zeichen.</span>
<span v-if="passwordTouched && !isPasswordValid" class="form-error">Das Passwort ist noch zu kurz.</span>
</div>
<div>
<label>{{ $t("register.repeatPassword") }}<input type="password" v-model="repeatPassword" /></label>
<div class="form-field">
<label for="register-repeat-password">{{ $t("register.repeatPassword") }}</label>
<input id="register-repeat-password" type="password" v-model="repeatPassword" :class="{ 'field-error': repeatPasswordTouched && !doPasswordsMatch }" />
<span v-if="repeatPasswordTouched && !doPasswordsMatch" class="form-error">Die Passwörter stimmen nicht überein.</span>
</div>
<SelectDropdownWidget labelTr="settings.personal.label.language" :v-model="language"
tooltipTr="settings.personal.tooltip.language" :list="languages" :value="language" />
@@ -26,6 +35,7 @@ import { mapActions } from 'vuex';
import apiClient from '@/utils/axios.js';
import DialogWidget from '@/components/DialogWidget.vue';
import SelectDropdownWidget from '@/components/form/SelectDropdownWidget.vue';
import { showApiError, showError } from '@/utils/feedback.js';
export default {
name: 'RegisterDialog',
@@ -41,6 +51,10 @@ export default {
repeatPassword: '',
language: null,
languages: [],
emailTouched: false,
usernameTouched: false,
passwordTouched: false,
repeatPasswordTouched: false,
buttons: [
{ text: 'register.close', action: 'close' },
{ text: 'register.register', action: 'register', disabled: !this.canRegister }
@@ -48,11 +62,35 @@ export default {
};
},
computed: {
isEmailValid() {
return /\S+@\S+\.\S+/.test(this.email);
},
isUsernameValid() {
return this.username.trim().length >= 3;
},
isPasswordValid() {
return this.password.length >= 8;
},
doPasswordsMatch() {
return Boolean(this.password) && this.password === this.repeatPassword;
},
canRegister() {
return this.password && this.repeatPassword && this.password === this.repeatPassword;
return this.isEmailValid && this.isUsernameValid && this.isPasswordValid && this.doPasswordsMatch && this.language;
}
},
watch: {
email() {
this.emailTouched = true;
},
username() {
this.usernameTouched = true;
},
password() {
this.passwordTouched = true;
},
repeatPassword() {
this.repeatPasswordTouched = true;
},
canRegister(newValue) {
this.buttons[1].disabled = !newValue;
}
@@ -82,7 +120,7 @@ export default {
},
async register() {
if (!this.canRegister) {
this.$root.$refs.errrorDialog.open('tr:register.passwordMismatch');
showError(this, 'tr:register.passwordMismatch');
return;
}
@@ -99,14 +137,14 @@ export default {
this.$refs.dialog.close();
this.$router.push('/activate');
} else {
this.$root.$refs.errrorDialog.open("tr:register.failure");
showError(this, 'tr:register.failure');
}
} catch (error) {
if (error.response && error.response.status === 409) {
this.$root.$refs.errrorDialog.open('tr:register.' + error.response.data.error);
showError(this, `tr:register.${error.response.data.error}`);
} else {
console.error('Error registering user:', error);
this.$root.$refs.errrorDialog.open('tr:register.' + error.response.data.error);
showApiError(this, error, 'tr:register.failure');
}
}
},
@@ -125,21 +163,11 @@ export default {
</script>
<style scoped>
.form-content>div {
margin-bottom: 1em;
}
label {
display: block;
margin-bottom: 0.5em;
}
input[type="email"],
input[type="text"],
input[type="password"],
select {
width: 100%;
padding: 0.5em;
box-sizing: border-box;
}
</style>

View File

@@ -239,6 +239,7 @@ import DialogWidget from '@/components/DialogWidget.vue';
import { fetchPublicRooms, fetchRoomCreateOptions, fetchOwnRooms } from '@/api/chatApi.js';
import { mapGetters } from 'vuex';
import { getChatWsUrl, getChatWsCandidates, getChatWsProtocols } from '@/services/chatWs.js';
import { confirmAction } from '@/utils/feedback.js';
export default {
name: 'MultiChat',
@@ -561,7 +562,10 @@ export default {
},
async deleteOwnedRoom(room) {
const title = room?.title || '';
const confirmed = window.confirm(this.$t('chat.multichat.createRoom.ownedRooms.confirmDelete', { room: title }));
const confirmed = await confirmAction(this, {
title: this.$t('chat.multichat.createRoom.ownedRooms.title'),
message: this.$t('chat.multichat.createRoom.ownedRooms.confirmDelete', { room: title })
});
if (!confirmed) return;
if (!this.transportConnected) {
this.messages.push({
@@ -698,7 +702,6 @@ export default {
// Mark as closed first so any async close events won't schedule reconnect
this.opened = false;
this.clearPendingRoomCreateTracking();
console.log('[Chat WS] dialog close — closing websocket');
this.disconnectChatSocket();
// Remove network event listeners
window.removeEventListener('online', this.onOnline);
@@ -715,16 +718,13 @@ export default {
this.showOptions = false;
},
onOnline() {
console.log('[Chat WS] Network online detected');
if (this.opened && !this.chatConnected && !this.connectRacing && (!this.chatWs || this.chatWs.readyState !== WebSocket.OPEN)) {
console.log('[Chat WS] online — attempting reconnect');
this.reconnectAttempts = 0; // Reset attempts on network recovery
this.reconnectIntervalMs = 3000; // Reset to base interval
this.connectChatSocket();
}
},
onOffline() {
console.log('[Chat WS] Network offline detected');
this.setStatus('disconnected');
},
async loadRooms() {

View File

@@ -60,7 +60,7 @@
{{ $t('falukant.branch.selection.selected') }}:
<strong>{{ selectedRegion.name }}</strong>
</div>
<label class="form-label">
<label class="form-label form-field">
{{ $t('falukant.branch.columns.type') }}
<select v-model="selectedType" class="form-control">
<option
@@ -72,8 +72,10 @@
({{ formatCost(computeBranchCost(type)) }})
</option>
</select>
<span class="form-hint">Wähle zuerst die Region und dann den Niederlassungstyp.</span>
</label>
</div>
<div v-else class="form-hint">Wähle auf der Karte eine freie Region aus.</div>
</div>
</div>
</div>
@@ -83,6 +85,7 @@
<script>
import DialogWidget from '@/components/DialogWidget.vue';
import apiClient from '@/utils/axios.js';
import { showApiError, showError, showSuccess } from '@/utils/feedback.js';
export default {
name: 'CreateBranchDialog',
@@ -109,7 +112,7 @@
dialogButtons() {
return [
{ text: this.$t('Cancel'), action: this.close },
{ text: this.$t('falukant.branch.actions.create'), action: this.onConfirm },
{ text: this.$t('falukant.branch.actions.create'), action: this.onConfirm, disabled: !this.selectedRegion || !this.selectedType },
];
},
},
@@ -144,7 +147,10 @@
},
async onConfirm() {
if (!this.selectedRegion || !this.selectedType) return;
if (!this.selectedRegion || !this.selectedType) {
showError(this, 'Bitte zuerst Region und Typ auswählen.');
return;
}
try {
await apiClient.post('/api/falukant/branches', {
@@ -152,13 +158,14 @@
branchTypeId: this.selectedType,
});
this.$emit('create-branch');
showSuccess(this, 'Niederlassung erfolgreich erstellt.');
this.close();
} catch (e) {
if (e?.response?.status === 412 && e?.response?.data?.error === 'insufficientFunds') {
alert(this.$t('falukant.branch.actions.insufficientFunds'));
showError(this, this.$t('falukant.branch.actions.insufficientFunds'));
} else {
console.error('Error creating branch', e);
alert(this.$t('falukant.error.generic') || 'Fehler beim Erstellen der Niederlassung.');
showApiError(this, e, this.$t('falukant.error.generic') || 'Fehler beim Erstellen der Niederlassung.');
}
}
},

View File

@@ -41,6 +41,7 @@ import DialogWidget from '@/components/DialogWidget.vue';
import { mapGetters } from 'vuex';
import apiClient from '@/utils/axios.js';
import { EventBus } from '@/utils/eventBus.js';
import { showError } from '@/utils/feedback.js';
export default {
name: 'CreateFolderDialog',
@@ -93,7 +94,7 @@ export default {
},
async createFolder() {
if (!this.folderTitle || !this.selectedVisibility.length) {
alert(this.$t('socialnetwork.gallery.errors.missing_fields'));
showError(this, this.$t('socialnetwork.gallery.errors.missing_fields'));
return;
}
const payload = {

View File

@@ -97,6 +97,7 @@ import FolderItem from '../../components/FolderItem.vue';
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import DOMPurify from 'dompurify';
import { showError } from '@/utils/feedback.js';
export default {
name: 'UserProfileDialog',
@@ -234,7 +235,7 @@ export default {
});
},
async fetchImage(image) {
const userId = localStorage.getItem('userid');
const userId = localStorage.getItem('userid') || sessionStorage.getItem('userid');
try {
const response = await apiClient.get(`/api/socialnetwork/image/${image.hash}`, {
headers: {
@@ -265,7 +266,10 @@ export default {
}
},
async submitGuestbookEntry() {
if (!this.newEntryContent) return alert(this.$t('socialnetwork.guestbook.emptyContent'));
if (!this.newEntryContent) {
showError(this, this.$t('socialnetwork.guestbook.emptyContent'));
return;
}
const formData = new FormData();
formData.append('htmlContent', this.newEntryContent);
formData.append('recipientName', this.userProfile.username);

View File

@@ -42,12 +42,10 @@ export default {
this.$refs.dialog.close();
},
confirmYes() {
console.log('ja');
this.resolve(true);
this.close();
},
confirmNo() {
console.log('nein');
this.resolve(false);
this.close();
},

View File

@@ -1,7 +1,8 @@
<template>
<DialogWidget ref="dialog" title="error.title" :show-close="true" :buttons="buttons" :modal="true" width="25em"
height="15em" name="ErrorDialog" :isTitleTranslated=true>
<DialogWidget ref="dialog" title="error.title" :show-close="true" :buttons="buttons" :modal="true" width="28em"
height="16em" name="ErrorDialog" :isTitleTranslated=true>
<div class="error-content">
<span class="error-content__badge">Fehler</span>
<p>{{ translatedErrorMessage }}</p>
</div>
</DialogWidget>
@@ -45,8 +46,27 @@ export default {
<style scoped>
.error-content {
padding: 1em;
color: red;
display: grid;
gap: 12px;
padding: 1.2em;
text-align: center;
}
.error-content__badge {
justify-self: center;
padding: 4px 10px;
border-radius: 999px;
background: rgba(177, 59, 53, 0.12);
color: var(--color-danger);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.error-content p {
margin: 0;
color: var(--color-text-primary);
line-height: 1.55;
}
</style>

View File

@@ -1,7 +1,8 @@
<template>
<DialogWidget ref="dialog" :title="translatedTitle" :show-close="true" :buttons="translatedButtons" :modal="true" width="25em"
height="15em" name="MessageDialog" :isTitleTranslated=false>
<DialogWidget ref="dialog" :title="translatedTitle" :show-close="true" :buttons="translatedButtons" :modal="true" width="28em"
height="16em" name="MessageDialog" :isTitleTranslated=false>
<div class="message-content">
<span class="message-content__badge">Hinweis</span>
<p>{{ translatedMessage }}</p>
</div>
</DialogWidget>
@@ -41,14 +42,6 @@ export default {
if (this.message.startsWith('tr:')) {
const i18nKey = this.message.substring(3);
const translation = this.$t(i18nKey);
console.log('translatedMessage:', {
i18nKey: i18nKey,
translation: translation,
parameters: this.parameters,
allMinigames: this.$t('minigames'),
crashSection: this.$t('minigames.taxi.crash')
});
// Ersetze Parameter in der Übersetzung
return this.interpolateParameters(translation);
}
return this.message;
@@ -89,26 +82,16 @@ export default {
}
},
interpolateParameters(text) {
// Ersetze {key} Platzhalter mit den entsprechenden Werten
let result = text;
console.log('interpolateParameters:', {
originalText: text,
parameters: this.parameters
});
for (const [key, value] of Object.entries(this.parameters)) {
const placeholder = `{${key}}`;
const regex = new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g');
result = result.replace(regex, value);
console.log(`Replaced ${placeholder} with ${value}:`, result);
}
console.log('Final result:', result);
return result;
}
},
beforeDestroy() {
// Stelle sicher, dass Event Listener entfernt wird
beforeUnmount() {
document.removeEventListener('keydown', this.handleKeyDown);
}
};
@@ -116,8 +99,27 @@ export default {
<style scoped>
.message-content {
padding: 1em;
color: #000000;
display: grid;
gap: 12px;
padding: 1.2em;
text-align: center;
}
.message-content__badge {
justify-self: center;
padding: 4px 10px;
border-radius: 999px;
background: rgba(120, 195, 138, 0.16);
color: #24523a;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.message-content p {
margin: 0;
color: var(--color-text-primary);
line-height: 1.55;
}
</style>

View File

@@ -1,7 +1,7 @@
{
"activate": {
"title": "Zugang aktivieren",
"message": "Hallo {username}. Bitte gib hier den Code ein, den wir Dir per Email zugesendet haben.",
"message": "Hallo {username}. Bitte gib hier den Code ein, den wir Dir per E-Mail zugesendet haben.",
"token": "Token:",
"submit": "Absenden",
"failure": "Die Aktivierung war nicht erfolgreich."

View File

@@ -24,6 +24,11 @@
"children": "Kinder",
"children_unbaptised": "ungetaufte Kinder"
},
"debtorsPrison": {
"actionBlocked": "Im Schuldturm kannst du diese Aktion derzeit nicht ausführen.",
"globalWarning": "Dein Kreditverzug schränkt dein Handeln ein. Zwangsmaßnahmen können bald folgen.",
"globalLocked": "Du bist im Schuldturm. Fast alle aktiven Falukant-Handlungen sind derzeit gesperrt."
},
"messages": {
"title": "Nachrichten",
"tooltip": "Nachrichten",
@@ -130,7 +135,8 @@
"years": "Jahre",
"days": "Tage",
"mainbranch": "Heimatstadt",
"nobleTitle": "Stand"
"nobleTitle": "Stand",
"certificate": "Zertifikat"
},
"productions": {
"title": "Produktionen"
@@ -219,6 +225,12 @@
},
"branch": {
"title": "Filiale",
"debtorsPrison": {
"branchLocked": "Im Schuldturm sind neue wirtschaftliche Schritte blockiert. Geschlossene oder gepfändete Standorte werden hier ebenfalls sichtbar.",
"branchRisk": "Dein Kreditverzug gefährdet Niederlassungen, Fahrzeuge und Lagerbestände.",
"selectionBlocked": "Neue Ausbauten sind im Schuldturm gesperrt."
},
"currentCertificate": "Derzeitiges Zertifikat",
"tabs": {
"director": "Direktor",
"inventory": "Inventar",
@@ -322,6 +334,7 @@
"runningEta": "Ankunft",
"runningRemaining": "Restzeit",
"runningVehicleCount": "Fahrzeuge",
"runningGuards": "Wachen",
"runningDirectionOut": "Ausgehend",
"runningDirectionIn": "Eingehend"
},
@@ -396,6 +409,8 @@
"transport": {
"title": "Transportmittel",
"placeholder": "Hier kannst du Transportmittel für deine Region kaufen oder bauen.",
"guardCount": "Wachen",
"guardHint": "Zusatzkosten für Wachen: {cost}",
"vehicleType": "Transportmittel",
"mode": "Art",
"modeBuy": "Kaufen (sofort verfügbar)",
@@ -481,11 +496,17 @@
},
"family": {
"title": "Familie",
"debtorsPrison": {
"familyWarning": "Anhaltender Kreditverzug belastet Ehe, Haushalt und Liebschaften.",
"familyImpact": "Der Schuldturm schadet Ehe, Hausfrieden und der Stabilität von Liebschaften."
},
"spouse": {
"title": "Beziehung",
"name": "Name",
"age": "Alter",
"status": "Status",
"marriageSatisfaction": "Ehe-Zufriedenheit",
"marriageState": "Ehezustand",
"none": "Kein Ehepartner vorhanden.",
"search": "Ehepartner suchen",
"found": "Ehepartner gefunden",
@@ -497,6 +518,7 @@
"gifts": "Werbegeschenke",
"sendGift": "Werbegeschenk senden",
"cancel": "Werbung abbrechen",
"cancelConfirm": "Willst du die Werbung wirklich abbrechen? Der Fortschritt geht dabei verloren.",
"cancelSuccess": "Die Werbung wurde abgebrochen.",
"cancelError": "Die Werbung konnte nicht abgebrochen werden.",
"cancelTooSoon": "Du kannst die Werbung erst nach 24 Stunden abbrechen.",
@@ -516,6 +538,42 @@
"progress": "Zuneigung",
"jumpToPartyForm": "Hochzeitsfeier veranstalten (Nötig für Hochzeit und Kinder)"
},
"marriageState": {
"stable": "Stabil",
"strained": "Angespannt",
"crisis": "Krise"
},
"householdTension": {
"label": "Hausfrieden",
"score": "Spannungswert",
"reasonsLabel": "Aktuelle Ursachen",
"low": "Ruhig",
"medium": "Unruhig",
"high": "Belastet",
"reasons": {
"visibleLover": "Sichtbare Liebschaft",
"noticeableLover": "Auffällige Liebschaft",
"underfundedLover": "Unterversorgte Liebschaft",
"acknowledgedAffair": "Anerkannte Liebschaft",
"statusMismatch": "Standesunterschied",
"loverChild": "Kind aus Liebschaft",
"disorder": "Unordnung im Haus",
"tooFewServants": "Zu wenig Diener",
"marriageCrisis": "Ehekrise"
}
},
"marriageActions": {
"title": "Ehe pflegen",
"spendTime": "Zeit miteinander verbringen",
"giftSmall": "Kleines Geschenk",
"giftDecent": "Gutes Geschenk",
"giftLavish": "Großzügiges Geschenk",
"reconcile": "Streit schlichten",
"spendTimeSuccess": "Die gemeinsame Zeit hat die Ehe stabilisiert.",
"giftSuccess": "Das Geschenk hat die Ehe verbessert.",
"reconcileSuccess": "Der Streit wurde fürs Erste geschlichtet.",
"actionError": "Die Aktion konnte nicht ausgeführt werden."
},
"relationships": {
"name": "Name"
},
@@ -537,14 +595,62 @@
"baptism": "Taufen",
"notBaptized": "Noch nicht getauft",
"baptismNotice": "Dieses Kind wurde noch nicht getauft und hat daher noch keinen Namen.",
"legitimacy": {
"legitimate": "Ehelich",
"acknowledged_bastard": "Anerkannt unehelich",
"hidden_bastard": "Unehelich"
},
"details": {
"title": "Kind-Details"
}
},
"lovers": {
"title": "Liebhaber",
"title": "Liebhaber und Mätressen",
"none": "Keine Liebhaber vorhanden.",
"affection": "Zuneigung"
"affection": "Zuneigung",
"visibility": "Sichtbarkeit",
"discretion": "Diskretion",
"maintenance": "Unterhalt",
"monthlyCost": "Monatskosten",
"statusFit": "Standespassung",
"acknowledged": "Anerkannt",
"underfunded": "{count} Monate unterversorgt",
"role": {
"secret_affair": "Heimliche Liebschaft",
"lover": "Geliebte Beziehung",
"mistress_or_favorite": "Mätresse oder Favorit"
},
"risk": {
"low": "Geringes Risiko",
"medium": "Mittleres Risiko",
"high": "Hohes Risiko"
},
"actions": {
"start": "Liebschaft beginnen",
"startSuccess": "Die neue Liebschaft wurde begonnen.",
"startError": "Die Liebschaft konnte nicht begonnen werden.",
"maintenanceLow": "Unterhalt 25",
"maintenanceMedium": "Unterhalt 50",
"maintenanceHigh": "Unterhalt 75",
"maintenanceSuccess": "Der Unterhalt wurde angepasst.",
"maintenanceError": "Der Unterhalt konnte nicht angepasst werden.",
"acknowledge": "Anerkennen",
"acknowledgeSuccess": "Die Beziehung wurde offiziell anerkannt.",
"acknowledgeError": "Die Beziehung konnte nicht anerkannt werden.",
"end": "Beenden",
"endConfirm": "Soll diese Beziehung wirklich beendet werden?",
"endSuccess": "Die Beziehung wurde beendet.",
"endError": "Die Beziehung konnte nicht beendet werden."
},
"candidates": {
"title": "Mögliche Liebschaften",
"roleLabel": "Form der Beziehung",
"none": "Derzeit gibt es keine passenden neuen Liebschaften."
}
},
"notifications": {
"scandal": "Ein Familienskandal erschüttert dein Haus.",
"loverBirth": "Aus einer Liebschaft ist ein Kind hervorgegangen."
},
"statuses": {
"wooing": "In Werbung",
@@ -650,7 +756,12 @@
"build_vehicles": "Transportmittel gebaut",
"transport": "Transport",
"Marriage cost": "Heiratskosten",
"marriage_gift": "Geschenk an Ehepartner",
"Gift cost": "Geschenk-Kosten",
"lover maintenance": "Unterhalt für Liebschaft",
"servants_monthly": "Dienerschaft bezahlt",
"servants_hired": "Diener eingestellt",
"household_order": "Haus geordnet",
"housebuy": "Hauskauf",
"Baptism": "Taufe",
"credit taken": "Kredit aufgenommen",
@@ -756,6 +867,10 @@
},
"house": {
"title": "Haus",
"debtorsPrison": {
"houseWarning": "Mit wachsendem Kreditverzug steigt das Risiko für Pfändung und erzwungenen Hausverlust.",
"houseRisk": "Dein Haus ist jetzt Teil der möglichen Zwangsverwertung."
},
"statusreport": "Zustand des Hauses",
"element": "Bereich",
"state": "Zustand",
@@ -764,8 +879,54 @@
"price": "Kaufpreis",
"worth": "Restwert",
"sell": "Verkaufen",
"sellConfirm": "Möchtest du dein Haus wirklich verkaufen?",
"sellSuccess": "Das Haus wurde verkauft.",
"sellError": "Das Haus konnte nicht verkauft werden.",
"buySuccess": "Das Haus wurde gekauft.",
"buyError": "Das Haus konnte nicht gekauft werden.",
"renovate": "Renovieren",
"renovateAll": "Komplett renovieren",
"servants": {
"title": "Dienerschaft",
"description": "Verwalte Hauspersonal, Ordnung und laufende Kosten deines Haushalts.",
"count": "Dienerzahl",
"expectedRange": "Erwarteter Bereich",
"monthlyCost": "Monatskosten",
"quality": "Qualität",
"householdOrder": "Haushaltsordnung",
"payLevel": "Bezahlung",
"payLevels": {
"low": "Niedrig",
"normal": "Normal",
"high": "Großzügig"
},
"staffingState": {
"label": "Besetzung",
"understaffed": "Unterbesetzt",
"fitting": "Passend",
"overstaffed": "Überbesetzt"
},
"orderState": {
"label": "Ordnungszustand",
"chaotic": "Chaotisch",
"strained": "Angespannt",
"stable": "Stabil",
"excellent": "Vorbildlich"
},
"actions": {
"hire": "1 Diener einstellen",
"dismiss": "1 Diener entlassen",
"tidy": "Haus ordnen",
"hireSuccess": "Die Dienerschaft wurde erweitert.",
"hireError": "Die Dienerschaft konnte nicht erweitert werden.",
"dismissSuccess": "Ein Diener wurde entlassen.",
"dismissError": "Der Diener konnte nicht entlassen werden.",
"payLevelSuccess": "Die Bezahlung der Dienerschaft wurde angepasst.",
"payLevelError": "Die Bezahlung konnte nicht angepasst werden.",
"tidySuccess": "Das Haus wurde geordnet.",
"tidyError": "Das Haus konnte nicht geordnet werden."
}
},
"status": {
"roofCondition": "Dach",
"wallCondition": "Wände",
@@ -786,11 +947,21 @@
"overview": "Übersicht",
"advance": "Erweitern"
},
"highestPoliticalOffice": "Höchstes politisches Amt",
"highestOfficeAny": "Höchstes Amt insgesamt",
"none": "keines",
"nextTitle": "Nächster möglicher Titel",
"requirement": {
"money": "Vermögen mindestens {amount}",
"cost": "Kosten: {amount}",
"branches": "Mindestens {amount} Niederlassungen"
"branches": "Mindestens {amount} Niederlassungen",
"reputation": "Beliebtheit mindestens {amount}",
"house_position": "Hausstand mindestens Stufe {amount}",
"house_condition": "Hauszustand mindestens {amount}",
"office_rank_any": "Höchstes politisches oder kirchliches Amt mindestens Rang {amount}",
"office_rank_political": "Höchstes politisches Amt mindestens Rang {amount}",
"lover_count_min": "Mindestens {amount} Liebhaber oder Mätressen",
"lover_count_max": "Höchstens {amount} Liebhaber oder Mätressen"
},
"advance": {
"confirm": "Aufsteigen beantragen"
@@ -879,10 +1050,18 @@
"church": {
"title": "Kirche",
"tabs": {
"baptism": "Taufen",
"current": "Aktuelle Positionen",
"available": "Verfügbare Positionen",
"applications": "Bewerbungen"
},
"summary": {
"highestCurrentOffice": "Höchstes aktuelles Amt",
"availableApplications": "Mögliche Bewerbungen",
"supervisedApplications": "Zu entscheidende Bewerbungen",
"guidance": "Kirchenämter steigen stufenweise auf. Über Bewerbungen entscheidet in der Regel das nächsthöhere Amt; falls dort kein Spieler sitzt, kann später ein NPC entscheiden.",
"none": "Noch kein Kirchenamt"
},
"current": {
"office": "Amt",
"region": "Region",
@@ -894,11 +1073,25 @@
"office": "Amt",
"region": "Region",
"supervisor": "Vorgesetzter",
"decision": "Entscheidung durch",
"decisionType": {
"entry": "Direkter Einstieg",
"player": "Spieler",
"npc": "NPC",
"interim": "Interim"
},
"seats": "Verfügbare Plätze",
"action": "Aktion",
"apply": "Bewerben",
"applySuccess": "Bewerbung erfolgreich eingereicht.",
"applyError": "Fehler beim Einreichen der Bewerbung.",
"errors": {
"characterNotFound": "Dein Charakter konnte nicht gefunden werden.",
"officeTypeNotFound": "Das Kirchenamt wurde nicht gefunden.",
"churchCareerTooLow": "Deine bisherige kirchliche Laufbahn reicht für dieses Amt noch nicht aus.",
"noAvailableSeats": "Für dieses Kirchenamt sind derzeit keine Plätze frei.",
"applicationAlreadyExists": "Für dieses Kirchenamt in dieser Region besteht bereits eine offene Bewerbung."
},
"none": "Keine verfügbaren Positionen."
},
"applications": {
@@ -970,6 +1163,23 @@
"maxCredit": "Maximaler Kredit",
"availableCredit": "Verfügbarer Kredit"
},
"debtorsPrison": {
"titleWarning": "Kreditverzug",
"titlePrison": "Schuldturm",
"descriptionWarning": "Deine Kredite sind im Verzug. Wenn du weiter nicht bedienst, drohen Zwangsmaßnahmen.",
"descriptionPrison": "Du sitzt im Schuldturm. Neue Kredite sind gesperrt und dein Vermögen wird schrittweise verwertet.",
"daysOverdue": "Verzugstage",
"creditworthiness": "Kreditwürdigkeit",
"nextForcedAction": "Nächste Zwangsmaßnahme",
"creditBlocked": "Im Schuldturm kannst du keine neuen Kredite aufnehmen.",
"creditError": "Der Kredit konnte nicht aufgenommen werden.",
"actions": {
"reminder": "Erste Mahnung",
"final_warning": "Letzte Mahnung",
"debtors_prison": "Einweisung in den Schuldturm",
"asset_seizure": "Pfändung von Vermögen"
}
},
"credits": {
"title": "Kredite",
"none": "Derzeit hast Du keinen Kredit aufgenommen.",
@@ -1119,10 +1329,26 @@
"type": "Aktivitätstyp",
"victim": "Zielperson",
"cost": "Kosten",
"status": "Status",
"additionalInfo": "Zusätzliche Informationen",
"blackmailAmount": "Erpressungssumme",
"discoveries": "Erkenntnisse",
"visibilityDelta": "Sichtbarkeit",
"reputationDelta": "Ansehen",
"victimPlaceholder": "Benutzername eingeben",
"sabotageTarget": "Sabotageziel",
"corruptGoal": "Ziel der Korruption"
"corruptGoal": "Ziel der Korruption",
"affairGoal": "Ziel der Untersuchung",
"raidRegion": "Überfallregion",
"raidRegionPlaceholder": "Region wählen",
"bandSize": "Bandengröße",
"raidSummary": "Bande ({bandSize}) in {region}",
"attempts": "Versuche",
"successes": "Erfolge",
"lastOutcome": "Letztes Ergebnis",
"raidResultTitle": "Letzter Überfall",
"lastTargetTransport": "Letzter Zieltransport",
"loot": "Beute"
},
"attacks": {
"target": "Angreifer",
@@ -1135,7 +1361,9 @@
"assassin": "Attentat",
"sabotage": "Sabotage",
"corrupt_politician": "Korruption",
"rob": "Raub"
"rob": "Raub",
"investigate_affair": "Liebschaft untersuchen",
"raid_transport": "Überfälle auf Transporte"
},
"targets": {
"house": "Wohnhaus",
@@ -1144,7 +1372,19 @@
"goals": {
"elect": "Amtseinsetzung",
"taxIncrease": "Steuern erhöhen",
"taxDecrease": "Steuern senken"
"taxDecrease": "Steuern senken",
"expose": "Aufdecken",
"blackmail": "Erpressen"
},
"status": {
"pending": "Ausstehend",
"resolved": "Abgeschlossen",
"failed": "Gescheitert"
},
"raidOutcomes": {
"repelled": "Abgewehrt",
"partial_success": "Teilweise erfolgreich",
"major_success": "Großer Erfolg"
}
}
}

View File

@@ -17,11 +17,11 @@
"info-title": "Information",
"dialog": {
"contact": {
"email": "Email-Adresse",
"email": "E-Mail-Adresse",
"name": "Name",
"message": "Deine Nachricht an uns",
"accept": "Deine Email-Adresse wird vorübergehend in unserem System gespeichert. Nachdem Deine Anfrage bearbeitet wurde, wird die Email-Adresse wieder aus dem System gelöscht.",
"acceptdatasave": "Ich stimme der vorübergehenden Speicherung meiner Email-Adresse zu.",
"accept": "Deine E-Mail-Adresse wird vorübergehend in unserem System gespeichert. Nachdem Deine Anfrage bearbeitet wurde, wird die E-Mail-Adresse wieder aus dem System gelöscht.",
"acceptdatasave": "Ich stimme der vorübergehenden Speicherung meiner E-Mail-Adresse zu.",
"accept2": "Ohne diese Zustimmung können wir Dir leider nicht antworten."
}
},

View File

@@ -34,9 +34,9 @@
"login": {
"name": "Login-Name",
"namedescription": "Gib hier Deinen Benutzernamen ein",
"password": "Paßwort",
"passworddescription": "Gib hier Dein Paßwort ein",
"lostpassword": "Paßwort vergessen",
"password": "Passwort",
"passworddescription": "Gib hier Dein Passwort ein",
"lostpassword": "Passwort vergessen",
"register": "Bei yourPart registrieren",
"stayLoggedIn": "Eingeloggt bleiben",
"submit": "Einloggen"

View File

@@ -1,10 +1,10 @@
{
"register": {
"title": "Bei yourPart registrieren",
"email": "Email-Adresse",
"email": "E-Mail-Adresse",
"username": "Benutzername",
"password": "Paßwort",
"repeatPassword": "Paßwort wiederholen",
"password": "Passwort",
"repeatPassword": "Passwort wiederholen",
"language": "Sprache",
"languages": {
"en": "Englisch",
@@ -13,9 +13,9 @@
"register": "Registrieren",
"close": "Schließen",
"failure": "Es ist ein Fehler aufgetreten.",
"success": "Du wurdest erfolgreich registriert. Bitte schaue jetzt in Dein Email-Postfach zum aktivieren Deines Zugangs.",
"passwordMismatch": "Die Paßwörter stimmen nicht überein.",
"emailinuse": "Die Email-Adresse wird bereits verwendet.",
"success": "Du wurdest erfolgreich registriert. Bitte schaue jetzt in Dein E-Mail-Postfach zum Aktivieren Deines Zugangs.",
"passwordMismatch": "Die Passwörter stimmen nicht überein.",
"emailinuse": "Die E-Mail-Adresse wird bereits verwendet.",
"usernameinuse": "Der Benutzername ist nicht verfügbar."
}
}

View File

@@ -141,14 +141,14 @@
"account": {
"title": "Account",
"username": "Benutzername",
"email": "Email-Adresse",
"email": "E-Mail-Adresse",
"newpassword": "Passwort",
"newpasswordretype": "Passwort wiederholen",
"deleteAccount": "Account löschen",
"language": "Sprache",
"showinsearch": "In Usersuchen anzeigen",
"changeaction": "Benutzerdaten ändern",
"oldpassword": "Altes Paßwort (benötigt)"
"oldpassword": "Altes Passwort (benötigt)"
},
"interests": {
"title": "Interessen",

View File

@@ -10,6 +10,11 @@
"windy": "Windy",
"clear": "Clear"
},
"debtorsPrison": {
"actionBlocked": "This action is blocked while you are in debtors' prison.",
"globalWarning": "Your credit delinquency is already restricting your actions. Forced measures may follow soon.",
"globalLocked": "You are in debtors' prison. Almost all active Falukant actions are currently blocked."
},
"messages": {
"title": "Messages",
"tooltip": "Messages",
@@ -111,7 +116,8 @@
"years": "Years",
"days": "Days",
"mainbranch": "Home city",
"nobleTitle": "Title"
"nobleTitle": "Title",
"certificate": "Certificate"
}
},
"health": {
@@ -165,7 +171,12 @@
"build_vehicles": "Transport vehicles built",
"transport": "Transport",
"Marriage cost": "Marriage cost",
"marriage_gift": "Gift for spouse",
"Gift cost": "Gift cost",
"lover maintenance": "Lover maintenance",
"servants_monthly": "Servants paid",
"servants_hired": "Servants hired",
"household_order": "Household ordered",
"housebuy": "House purchase",
"Baptism": "Baptism",
"credit taken": "Credit taken",
@@ -181,6 +192,82 @@
}
}
},
"house": {
"title": "House",
"debtorsPrison": {
"houseWarning": "As delinquency grows, the risk of seizure and forced loss of the house increases.",
"houseRisk": "Your house is now part of the possible forced liquidation."
},
"statusreport": "House condition",
"element": "Element",
"state": "Condition",
"buyablehouses": "Buy a house",
"buy": "Buy",
"price": "Purchase price",
"worth": "Residual value",
"sell": "Sell",
"sellConfirm": "Do you really want to sell your house?",
"sellSuccess": "The house has been sold.",
"sellError": "The house could not be sold.",
"buySuccess": "The house has been bought.",
"buyError": "The house could not be bought.",
"renovate": "Renovate",
"renovateAll": "Renovate completely",
"servants": {
"title": "Servants",
"description": "Manage household staff, order and recurring costs in your home.",
"count": "Servant count",
"expectedRange": "Expected range",
"monthlyCost": "Monthly cost",
"quality": "Quality",
"householdOrder": "Household order",
"payLevel": "Pay level",
"payLevels": {
"low": "Low",
"normal": "Normal",
"high": "Generous"
},
"staffingState": {
"label": "Staffing",
"understaffed": "Understaffed",
"fitting": "Fitting",
"overstaffed": "Overstaffed"
},
"orderState": {
"label": "Order state",
"chaotic": "Chaotic",
"strained": "Strained",
"stable": "Stable",
"excellent": "Excellent"
},
"actions": {
"hire": "Hire 1 servant",
"dismiss": "Dismiss 1 servant",
"tidy": "Tidy household",
"hireSuccess": "The household staff has been expanded.",
"hireError": "The staff could not be expanded.",
"dismissSuccess": "A servant has been dismissed.",
"dismissError": "The servant could not be dismissed.",
"payLevelSuccess": "Servant pay has been updated.",
"payLevelError": "Servant pay could not be updated.",
"tidySuccess": "The household has been put in order.",
"tidyError": "The household could not be put in order."
}
},
"status": {
"roofCondition": "Roof",
"wallCondition": "Walls",
"floorCondition": "Floors",
"windowCondition": "Windows"
},
"type": {
"backyard_room": "Backyard room",
"wooden_house": "Wooden house",
"straw_hut": "Straw hut",
"family_house": "Family house",
"townhouse": "Townhouse"
}
},
"newdirector": {
"title": "New Director",
"age": "Age",
@@ -192,6 +279,12 @@
"noProposals": "No director candidates available."
},
"branch": {
"debtorsPrison": {
"branchLocked": "While in debtors' prison, new economic steps are blocked. Closed or seized branches will also become visible here.",
"branchRisk": "Your delinquency puts branches, vehicles and stored goods at risk.",
"selectionBlocked": "New expansions are blocked while imprisoned for debt."
},
"currentCertificate": "Current certificate",
"selection": {
"title": "Branch Selection",
"selected": "Selected Branch",
@@ -201,7 +294,26 @@
},
"director": {
"income": "Income",
"incomeUpdated": "Salary has been successfully updated."
"incomeUpdated": "Salary has been successfully updated.",
"starttransport": "May start transports",
"emptyTransport": {
"title": "Transport without products",
"description": "Move vehicles from this branch to another to use them better.",
"vehicleType": "Vehicle type",
"selectVehicle": "Select vehicle type",
"targetBranch": "Target branch",
"selectTarget": "Select target branch",
"cost": "Cost: {cost}",
"duration": "Duration: {duration}",
"arrival": "Arrival: {datetime}",
"route": "Route",
"create": "Start transport",
"success": "Transport started successfully.",
"error": "Error starting the transport."
}
},
"sale": {
"runningGuards": "Guards"
},
"production": {
"title": "Production",
@@ -235,6 +347,10 @@
"raft": "Raft",
"sailing_ship": "Sailing ship"
},
"transport": {
"guardCount": "Guards",
"guardHint": "Additional cost for guards: {cost}"
},
"tabs": {
"director": "Director",
"inventory": "Inventory",
@@ -257,6 +373,21 @@
}
},
"nobility": {
"highestPoliticalOffice": "Highest political office",
"highestOfficeAny": "Highest office overall",
"none": "none",
"requirement": {
"money": "Wealth at least {amount}",
"cost": "Cost: {amount}",
"branches": "At least {amount} branches",
"reputation": "Popularity at least {amount}",
"house_position": "House status at least level {amount}",
"house_condition": "House condition at least {amount}",
"office_rank_any": "Highest political or church office at least rank {amount}",
"office_rank_political": "Highest political office at least rank {amount}",
"lover_count_min": "At least {amount} lovers or favorites",
"lover_count_max": "At most {amount} lovers or favorites"
},
"cooldown": "You can only advance again on {date}."
},
"mood": {
@@ -416,6 +547,10 @@
}
},
"family": {
"debtorsPrison": {
"familyWarning": "Ongoing debt delinquency puts strain on marriage, household and affairs.",
"familyImpact": "Debtors' prison damages marriage, household peace and the stability of affairs."
},
"children": {
"title": "Children",
"name": "Name",
@@ -434,6 +569,11 @@
"baptism": "Baptize",
"notBaptized": "Not yet baptized",
"baptismNotice": "This child has not been baptized yet and therefore has no name.",
"legitimacy": {
"legitimate": "Legitimate",
"acknowledged_bastard": "Acknowledged illegitimate",
"hidden_bastard": "Illegitimate"
},
"details": {
"title": "Child Details"
}
@@ -449,13 +589,100 @@
}
},
"spouse": {
"marriageSatisfaction": "Marriage Satisfaction",
"marriageState": "Marriage State",
"wooing": {
"cancel": "Cancel wooing",
"cancelConfirm": "Do you really want to cancel wooing? Progress will be lost.",
"cancelSuccess": "Wooing has been cancelled.",
"cancelError": "Wooing could not be cancelled.",
"cancelTooSoon": "You can only cancel wooing after 24 hours."
}
},
"marriageState": {
"stable": "Stable",
"strained": "Strained",
"crisis": "Crisis"
},
"householdTension": {
"label": "Household Tension",
"score": "Tension score",
"reasonsLabel": "Current causes",
"low": "Calm",
"medium": "Uneasy",
"high": "Strained",
"reasons": {
"visibleLover": "Visible affair",
"noticeableLover": "Noticeable affair",
"underfundedLover": "Underfunded affair",
"acknowledgedAffair": "Acknowledged affair",
"statusMismatch": "Status mismatch",
"loverChild": "Child from an affair",
"disorder": "Disorder in the house",
"tooFewServants": "Too few servants",
"marriageCrisis": "Marriage crisis"
}
},
"marriageActions": {
"title": "Support the marriage",
"spendTime": "Spend time together",
"giftSmall": "Small gift",
"giftDecent": "Decent gift",
"giftLavish": "Lavish gift",
"reconcile": "Reconcile dispute",
"spendTimeSuccess": "The time together has stabilized the marriage.",
"giftSuccess": "The gift has improved the marriage.",
"reconcileSuccess": "The dispute has been eased for now.",
"actionError": "The action could not be completed."
},
"lovers": {
"title": "Lovers and Mistresses",
"none": "No lovers present.",
"affection": "Affection",
"visibility": "Visibility",
"discretion": "Discretion",
"maintenance": "Maintenance",
"monthlyCost": "Monthly Cost",
"statusFit": "Status Fit",
"acknowledged": "Acknowledged",
"underfunded": "{count} months underfunded",
"role": {
"secret_affair": "Secret affair",
"lover": "Lover",
"mistress_or_favorite": "Mistress or favorite"
},
"risk": {
"low": "Low risk",
"medium": "Medium risk",
"high": "High risk"
},
"actions": {
"start": "Start affair",
"startSuccess": "The new affair has begun.",
"startError": "The affair could not be started.",
"maintenanceLow": "Maintenance 25",
"maintenanceMedium": "Maintenance 50",
"maintenanceHigh": "Maintenance 75",
"maintenanceSuccess": "Maintenance has been updated.",
"maintenanceError": "Maintenance could not be updated.",
"acknowledge": "Acknowledge",
"acknowledgeSuccess": "The relationship has been officially acknowledged.",
"acknowledgeError": "The relationship could not be acknowledged.",
"end": "End",
"endConfirm": "Do you really want to end this relationship?",
"endSuccess": "The relationship has been ended.",
"endError": "The relationship could not be ended."
},
"candidates": {
"title": "Possible affairs",
"roleLabel": "Relationship form",
"none": "There are currently no suitable new affairs."
}
},
"notifications": {
"scandal": "A family scandal is shaking your house.",
"loverBirth": "A child has been born from an affair."
},
"sendgift": {
"error": {
"nogiftselected": "Please select a gift.",
@@ -470,10 +697,18 @@
"church": {
"title": "Church",
"tabs": {
"baptism": "Baptism",
"current": "Current Positions",
"available": "Available Positions",
"applications": "Applications"
},
"summary": {
"highestCurrentOffice": "Highest current office",
"availableApplications": "Possible applications",
"supervisedApplications": "Applications to decide",
"guidance": "Church offices usually progress step by step. Applications are normally decided by the next higher office; if no player holds it, an NPC may later decide.",
"none": "No church office yet"
},
"current": {
"office": "Office",
"region": "Region",
@@ -485,11 +720,25 @@
"office": "Office",
"region": "Region",
"supervisor": "Supervisor",
"decision": "Decision by",
"decisionType": {
"entry": "Direct entry",
"player": "Player",
"npc": "NPC",
"interim": "Interim"
},
"seats": "Available Seats",
"action": "Action",
"apply": "Apply",
"applySuccess": "Application submitted successfully.",
"applyError": "Error submitting application.",
"errors": {
"characterNotFound": "Your character could not be found.",
"officeTypeNotFound": "The church office could not be found.",
"churchCareerTooLow": "Your previous church career is not yet sufficient for this office.",
"noAvailableSeats": "There are currently no free seats for this church office.",
"applicationAlreadyExists": "There is already an open application for this church office in this region."
},
"none": "No available positions."
},
"applications": {
@@ -533,6 +782,55 @@
"error": "The child could not be baptized."
}
},
"bank": {
"title": "Bank",
"account": {
"title": "Account",
"balance": "Balance",
"totalDebt": "Total debt",
"maxCredit": "Maximum credit",
"availableCredit": "Available credit"
},
"debtorsPrison": {
"titleWarning": "Credit delinquency",
"titlePrison": "Debtors' prison",
"descriptionWarning": "Your credits are overdue. If you continue to default, forced measures will follow.",
"descriptionPrison": "You are in debtors' prison. New credits are blocked and your assets will be liquidated step by step.",
"daysOverdue": "Days overdue",
"creditworthiness": "Creditworthiness",
"nextForcedAction": "Next forced action",
"creditBlocked": "You cannot take new credits while imprisoned for debt.",
"creditError": "The credit could not be taken.",
"actions": {
"reminder": "First reminder",
"final_warning": "Final warning",
"debtors_prison": "Commitment to debtors' prison",
"asset_seizure": "Asset seizure"
}
},
"credits": {
"title": "Credits",
"none": "You currently do not have any credits.",
"amount": "Amount",
"remaining": "Remaining",
"interestRate": "Interest rate",
"table": {
"name": "Name",
"amount": "Amount",
"reason": "Reason",
"date": "Date"
},
"payoff": {
"title": "Take a new credit",
"height": "Credit amount",
"remaining": "Remaining possible credit amount",
"fee": "Credit interest",
"feeHeight": "Installment (10 payments)",
"total": "Total",
"confirm": "Take credit"
}
}
},
"reputation": {
"title": "Reputation",
"overview": {
@@ -603,6 +901,76 @@
"cost": "Cost",
"date": "Date"
}
},
"underground": {
"title": "Underground",
"tabs": {
"activities": "Activities",
"attacks": "Attacks"
},
"activities": {
"none": "No activities available.",
"create": "Create new activity",
"type": "Activity type",
"victim": "Target person",
"cost": "Cost",
"status": "Status",
"additionalInfo": "Additional information",
"blackmailAmount": "Blackmail amount",
"discoveries": "Discoveries",
"visibilityDelta": "Visibility",
"reputationDelta": "Reputation",
"victimPlaceholder": "Enter username",
"sabotageTarget": "Sabotage target",
"corruptGoal": "Corruption goal",
"affairGoal": "Investigation goal",
"raidRegion": "Raid region",
"raidRegionPlaceholder": "Select region",
"bandSize": "Band size",
"raidSummary": "Gang ({bandSize}) in {region}",
"attempts": "Attempts",
"successes": "Successes",
"lastOutcome": "Last outcome",
"raidResultTitle": "Latest raid",
"lastTargetTransport": "Latest target transport",
"loot": "Loot"
},
"attacks": {
"target": "Attacker",
"date": "Date",
"success": "Success",
"none": "No attacks recorded."
},
"types": {
"spyin": "Espionage",
"assassin": "Assassination",
"sabotage": "Sabotage",
"corrupt_politician": "Corruption",
"rob": "Robbery",
"investigate_affair": "Investigate affair",
"raid_transport": "Raid transports"
},
"targets": {
"house": "House",
"storage": "Storage"
},
"goals": {
"elect": "Appointment",
"taxIncrease": "Raise taxes",
"taxDecrease": "Lower taxes",
"expose": "Expose",
"blackmail": "Blackmail"
},
"status": {
"pending": "Pending",
"resolved": "Resolved",
"failed": "Failed"
},
"raidOutcomes": {
"repelled": "Repelled",
"partial_success": "Partial success",
"major_success": "Major success"
}
}
}
}

View File

@@ -24,6 +24,11 @@
"children": "Hijos",
"children_unbaptised": "hijos no bautizados"
},
"debtorsPrison": {
"actionBlocked": "Esta acción está bloqueada mientras estés en la prisión por deudas.",
"globalWarning": "Tu mora crediticia ya restringe tus acciones. Pronto pueden llegar medidas forzosas.",
"globalLocked": "Estás en la prisión por deudas. Casi todas las acciones activas de Falukant están actualmente bloqueadas."
},
"messages": {
"title": "Mensajes",
"tooltip": "Mensajes",
@@ -120,7 +125,8 @@
"age": "Edad",
"years": "años",
"mainbranch": "Ciudad natal",
"nobleTitle": "Rango"
"nobleTitle": "Rango",
"certificate": "Certificado"
},
"productions": {
"title": "Producciones"
@@ -207,6 +213,12 @@
},
"branch": {
"title": "Sucursal",
"debtorsPrison": {
"branchLocked": "En la prisión por deudas se bloquean los nuevos pasos económicos. Las sucursales cerradas o embargadas también se reflejarán aquí.",
"branchRisk": "Tu mora pone en peligro sucursales, vehículos y mercancías almacenadas.",
"selectionBlocked": "Las nuevas ampliaciones están bloqueadas en la prisión por deudas."
},
"currentCertificate": "Certificado actual",
"tabs": {
"director": "Director",
"inventory": "Inventario",
@@ -310,6 +322,7 @@
"runningEta": "Llegada",
"runningRemaining": "Tiempo restante",
"runningVehicleCount": "Vehículos",
"runningGuards": "Guardias",
"runningDirectionOut": "Salida",
"runningDirectionIn": "Entrada"
},
@@ -381,6 +394,8 @@
"transport": {
"title": "Transporte",
"placeholder": "Aquí puedes comprar o construir medios de transporte para tu región.",
"guardCount": "Guardias",
"guardHint": "Coste adicional por guardias: {cost}",
"vehicleType": "Medio de transporte",
"mode": "Tipo",
"modeBuy": "Comprar (disponible de inmediato)",
@@ -465,11 +480,17 @@
},
"family": {
"title": "Familia",
"debtorsPrison": {
"familyWarning": "La mora continuada perjudica el matrimonio, el hogar y las relaciones.",
"familyImpact": "La prisión por deudas daña el matrimonio, la paz del hogar y la estabilidad de las relaciones."
},
"spouse": {
"title": "Relación",
"name": "Nombre",
"age": "Edad",
"status": "Estado",
"marriageSatisfaction": "Satisfacción matrimonial",
"marriageState": "Estado del matrimonio",
"none": "No hay cónyuge.",
"search": "Buscar pareja",
"found": "Pareja encontrada",
@@ -481,6 +502,7 @@
"gifts": "Regalos de cortejo",
"sendGift": "Enviar regalo",
"cancel": "Cancelar el cortejo",
"cancelConfirm": "¿Seguro que quieres cancelar el cortejo? Se perderá el progreso.",
"cancelSuccess": "El cortejo se ha cancelado.",
"cancelError": "No se pudo cancelar el cortejo.",
"cancelTooSoon": "Solo puedes cancelar el cortejo después de 24 horas.",
@@ -500,6 +522,42 @@
"progress": "Afecto",
"jumpToPartyForm": "Organizar banquete de boda (necesario para boda e hijos)"
},
"marriageState": {
"stable": "Estable",
"strained": "Tenso",
"crisis": "Crisis"
},
"householdTension": {
"label": "Tensión del hogar",
"score": "Valor de tensión",
"reasonsLabel": "Causas actuales",
"low": "Calmo",
"medium": "Inquieto",
"high": "Tenso",
"reasons": {
"visibleLover": "Relación visible",
"noticeableLover": "Relación llamativa",
"underfundedLover": "Relación infrafinanciada",
"acknowledgedAffair": "Relación reconocida",
"statusMismatch": "Desajuste social",
"loverChild": "Hijo de una relación",
"disorder": "Desorden en la casa",
"tooFewServants": "Muy pocos sirvientes",
"marriageCrisis": "Crisis matrimonial"
}
},
"marriageActions": {
"title": "Cuidar el matrimonio",
"spendTime": "Pasar tiempo juntos",
"giftSmall": "Regalo pequeño",
"giftDecent": "Buen regalo",
"giftLavish": "Regalo generoso",
"reconcile": "Resolver disputa",
"spendTimeSuccess": "El tiempo compartido ha estabilizado el matrimonio.",
"giftSuccess": "El regalo ha mejorado el matrimonio.",
"reconcileSuccess": "La disputa se ha calmado por ahora.",
"actionError": "No se pudo realizar la acción."
},
"relationships": {
"name": "Nombre"
},
@@ -521,14 +579,62 @@
"baptism": "Bautizar",
"notBaptized": "Aún no bautizado",
"baptismNotice": "Este niño aún no ha sido bautizado y por lo tanto todavía no tiene nombre.",
"legitimacy": {
"legitimate": "Legítimo",
"acknowledged_bastard": "Ilegítimo reconocido",
"hidden_bastard": "Ilegítimo"
},
"details": {
"title": "Detalles del hijo"
}
},
"lovers": {
"title": "Amantes",
"title": "Amantes y favoritas",
"none": "No hay amantes.",
"affection": "Afecto"
"affection": "Afecto",
"visibility": "Visibilidad",
"discretion": "Discreción",
"maintenance": "Mantenimiento",
"monthlyCost": "Coste mensual",
"statusFit": "Adecuación social",
"acknowledged": "Reconocido",
"underfunded": "{count} meses con fondos insuficientes",
"role": {
"secret_affair": "Aventura secreta",
"lover": "Amante",
"mistress_or_favorite": "Favorita o favorito"
},
"risk": {
"low": "Riesgo bajo",
"medium": "Riesgo medio",
"high": "Riesgo alto"
},
"actions": {
"start": "Iniciar relación",
"startSuccess": "La nueva relación ha comenzado.",
"startError": "No se pudo iniciar la relación.",
"maintenanceLow": "Mantenimiento 25",
"maintenanceMedium": "Mantenimiento 50",
"maintenanceHigh": "Mantenimiento 75",
"maintenanceSuccess": "Se ha ajustado el mantenimiento.",
"maintenanceError": "No se pudo ajustar el mantenimiento.",
"acknowledge": "Reconocer",
"acknowledgeSuccess": "La relación ha sido reconocida oficialmente.",
"acknowledgeError": "No se pudo reconocer la relación.",
"end": "Finalizar",
"endConfirm": "¿De verdad quieres finalizar esta relación?",
"endSuccess": "La relación ha finalizado.",
"endError": "No se pudo finalizar la relación."
},
"candidates": {
"title": "Posibles relaciones",
"roleLabel": "Forma de la relación",
"none": "Actualmente no hay nuevas relaciones adecuadas."
}
},
"notifications": {
"scandal": "Un escándalo familiar sacude tu casa.",
"loverBirth": "Ha nacido un hijo de una relación amorosa."
},
"statuses": {
"wooing": "En cortejo",
@@ -617,7 +723,12 @@
"build_vehicles": "Medios de transporte construidos",
"transport": "Transporte",
"Marriage cost": "Costes de matrimonio",
"marriage_gift": "Regalo para el cónyuge",
"Gift cost": "Coste de regalo",
"lover maintenance": "Manutención de amante",
"servants_monthly": "Pago de servidumbre",
"servants_hired": "Contratación de sirvientes",
"household_order": "Orden del hogar",
"housebuy": "Compra de casa",
"Baptism": "Bautizo",
"credit taken": "Crédito solicitado",
@@ -722,6 +833,10 @@
},
"house": {
"title": "Casa",
"debtorsPrison": {
"houseWarning": "A medida que aumenta la mora, crece el riesgo de embargo y pérdida forzosa de la casa.",
"houseRisk": "Tu casa forma ahora parte de la posible liquidación forzosa."
},
"statusreport": "Estado de la casa",
"element": "Elemento",
"state": "Estado",
@@ -730,8 +845,54 @@
"price": "Precio de compra",
"worth": "Valor restante",
"sell": "Vender",
"sellConfirm": "¿De verdad quieres vender tu casa?",
"sellSuccess": "La casa ha sido vendida.",
"sellError": "No se pudo vender la casa.",
"buySuccess": "La casa ha sido comprada.",
"buyError": "No se pudo comprar la casa.",
"renovate": "Renovar",
"renovateAll": "Renovar por completo",
"servants": {
"title": "Servicio doméstico",
"description": "Administra el personal, el orden y los costes periódicos de tu casa.",
"count": "Número de sirvientes",
"expectedRange": "Rango esperado",
"monthlyCost": "Coste mensual",
"quality": "Calidad",
"householdOrder": "Orden del hogar",
"payLevel": "Pago",
"payLevels": {
"low": "Bajo",
"normal": "Normal",
"high": "Generoso"
},
"staffingState": {
"label": "Dotación",
"understaffed": "Insuficiente",
"fitting": "Adecuada",
"overstaffed": "Excesiva"
},
"orderState": {
"label": "Estado del orden",
"chaotic": "Caótico",
"strained": "Tenso",
"stable": "Estable",
"excellent": "Excelente"
},
"actions": {
"hire": "Contratar 1 sirviente",
"dismiss": "Despedir 1 sirviente",
"tidy": "Ordenar la casa",
"hireSuccess": "Se ha ampliado el servicio doméstico.",
"hireError": "No se pudo ampliar el servicio doméstico.",
"dismissSuccess": "Se ha despedido a un sirviente.",
"dismissError": "No se pudo despedir al sirviente.",
"payLevelSuccess": "Se ha ajustado el pago del servicio.",
"payLevelError": "No se pudo ajustar el pago.",
"tidySuccess": "La casa ha sido ordenada.",
"tidyError": "No se pudo ordenar la casa."
}
},
"status": {
"roofCondition": "Techo",
"wallCondition": "Paredes",
@@ -752,11 +913,21 @@
"overview": "Resumen",
"advance": "Ascender"
},
"highestPoliticalOffice": "Cargo político más alto",
"highestOfficeAny": "Cargo más alto en total",
"none": "ninguno",
"nextTitle": "Siguiente título posible",
"requirement": {
"money": "Patrimonio mínimo {amount}",
"cost": "Coste: {amount}",
"branches": "Al menos {amount} sucursales"
"branches": "Al menos {amount} sucursales",
"reputation": "Popularidad mínima {amount}",
"house_position": "Casa al menos nivel {amount}",
"house_condition": "Estado de la casa al menos {amount}",
"office_rank_any": "Cargo político o eclesiástico más alto al menos rango {amount}",
"office_rank_political": "Cargo político más alto al menos rango {amount}",
"lover_count_min": "Al menos {amount} amantes o favoritos",
"lover_count_max": "Como máximo {amount} amantes o favoritos"
},
"advance": {
"confirm": "Solicitar ascenso"
@@ -822,6 +993,64 @@
},
"church": {
"title": "Iglesia",
"tabs": {
"baptism": "Bautizos",
"current": "Cargos actuales",
"available": "Cargos disponibles",
"applications": "Solicitudes"
},
"summary": {
"highestCurrentOffice": "Cargo actual más alto",
"availableApplications": "Solicitudes posibles",
"supervisedApplications": "Solicitudes por decidir",
"guidance": "Los cargos eclesiásticos suelen ascender paso a paso. Las solicitudes normalmente las decide el cargo inmediatamente superior; si no hay jugador en ese puesto, más adelante puede decidir un NPC.",
"none": "Todavía sin cargo eclesiástico"
},
"current": {
"office": "Cargo",
"region": "Región",
"holder": "Titular",
"supervisor": "Superior",
"none": "No hay cargos actuales."
},
"available": {
"office": "Cargo",
"region": "Región",
"supervisor": "Superior",
"decision": "Decide",
"decisionType": {
"entry": "Acceso directo",
"player": "Jugador",
"npc": "NPC",
"interim": "Interino"
},
"seats": "Plazas disponibles",
"action": "Acción",
"apply": "Solicitar",
"applySuccess": "Solicitud enviada correctamente.",
"applyError": "Error al enviar la solicitud.",
"errors": {
"characterNotFound": "No se pudo encontrar tu personaje.",
"officeTypeNotFound": "No se encontró el cargo eclesiástico.",
"churchCareerTooLow": "Tu trayectoria eclesiástica todavía no es suficiente para este cargo.",
"noAvailableSeats": "Actualmente no hay plazas libres para este cargo eclesiástico.",
"applicationAlreadyExists": "Ya existe una solicitud abierta para este cargo eclesiástico en esta región."
},
"none": "No hay cargos disponibles."
},
"applications": {
"office": "Cargo",
"region": "Región",
"applicant": "Solicitante",
"date": "Fecha",
"action": "Acción",
"approve": "Aceptar",
"reject": "Rechazar",
"approveSuccess": "Solicitud aceptada.",
"rejectSuccess": "Solicitud rechazada.",
"decideError": "Error al tomar la decisión.",
"none": "No hay solicitudes."
},
"baptism": {
"title": "Bautizos",
"table": {
@@ -867,6 +1096,23 @@
"maxCredit": "Crédito máximo",
"availableCredit": "Crédito disponible"
},
"debtorsPrison": {
"titleWarning": "Mora crediticia",
"titlePrison": "Prisión por deudas",
"descriptionWarning": "Tus créditos están en mora. Si sigues sin pagar, te amenazan medidas forzosas.",
"descriptionPrison": "Estás en la prisión por deudas. Los nuevos créditos están bloqueados y tu patrimonio se liquidará gradualmente.",
"daysOverdue": "Días de retraso",
"creditworthiness": "Solvencia crediticia",
"nextForcedAction": "Siguiente medida forzosa",
"creditBlocked": "No puedes solicitar nuevos créditos mientras estés en la prisión por deudas.",
"creditError": "No se pudo solicitar el crédito.",
"actions": {
"reminder": "Primer aviso",
"final_warning": "Último aviso",
"debtors_prison": "Ingreso en prisión por deudas",
"asset_seizure": "Embargo de bienes"
}
},
"credits": {
"title": "Créditos",
"none": "Actualmente no tienes ningún crédito.",
@@ -1007,10 +1253,26 @@
"type": "Tipo de actividad",
"victim": "Objetivo",
"cost": "Coste",
"status": "Estado",
"additionalInfo": "Información adicional",
"blackmailAmount": "Suma del chantaje",
"discoveries": "Hallazgos",
"visibilityDelta": "Visibilidad",
"reputationDelta": "Reputación",
"victimPlaceholder": "Introduce el nombre de usuario",
"sabotageTarget": "Objetivo del sabotaje",
"corruptGoal": "Objetivo de la corrupción"
"corruptGoal": "Objetivo de la corrupción",
"affairGoal": "Objetivo de la investigación",
"raidRegion": "Región de emboscada",
"raidRegionPlaceholder": "Seleccionar región",
"bandSize": "Tamaño de la banda",
"raidSummary": "Banda ({bandSize}) en {region}",
"attempts": "Intentos",
"successes": "Éxitos",
"lastOutcome": "Último resultado",
"raidResultTitle": "Último asalto",
"lastTargetTransport": "Último transporte objetivo",
"loot": "Botín"
},
"attacks": {
"target": "Atacante",
@@ -1023,7 +1285,9 @@
"assassin": "Atentado",
"sabotage": "Sabotaje",
"corrupt_politician": "Corrupción",
"rob": "Robo"
"rob": "Robo",
"investigate_affair": "Investigar relación",
"raid_transport": "Asaltos a transportes"
},
"targets": {
"house": "Vivienda",
@@ -1032,7 +1296,19 @@
"goals": {
"elect": "Nombramiento",
"taxIncrease": "Subir impuestos",
"taxDecrease": "Bajar impuestos"
"taxDecrease": "Bajar impuestos",
"expose": "Exponer",
"blackmail": "Chantajear"
},
"status": {
"pending": "Pendiente",
"resolved": "Resuelto",
"failed": "Fallido"
},
"raidOutcomes": {
"repelled": "Rechazado",
"partial_success": "Éxito parcial",
"major_success": "Gran éxito"
}
}
}

Some files were not shown because too many files have changed in this diff Show More