Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01849c8ffe | ||
|
|
2e6eb53918 | ||
|
|
09141d9e55 | ||
|
|
95c9e7c036 | ||
|
|
850a59a0b5 | ||
|
|
90385f2ee0 | ||
|
|
eb8f9c1d19 | ||
|
|
3ac4ea04d5 | ||
|
|
6be816fe48 | ||
|
|
d50d3c4016 | ||
|
|
8af726c65a | ||
|
|
44991743d2 | ||
|
|
b61a533eac | ||
|
|
de52b6f26d | ||
|
|
43dd1a3b7f | ||
|
|
22f1803e7d | ||
|
|
42e894d4e4 | ||
|
|
9b88a98a20 | ||
|
|
f2343098d2 | ||
|
|
57ab85fe10 | ||
|
|
ce36315b58 | ||
|
|
80d8caee88 | ||
|
|
b3607849d2 | ||
|
|
d901257be1 | ||
|
|
d7c59df225 | ||
|
|
f7e0d97174 | ||
|
|
2055c11fd9 | ||
|
|
f98352088e | ||
|
|
63d9aab66a | ||
|
|
5f9e0a5a49 | ||
|
|
9af974d2f2 | ||
|
|
c0f9fc8970 | ||
|
|
876ee2ab49 | ||
|
|
2977b152a2 | ||
|
|
c7d33525ff | ||
|
|
1774d7df88 | ||
|
|
2c58ef37c4 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@ node_modules
|
||||
node_modules/*
|
||||
**/package-lock.json
|
||||
backend/.env
|
||||
backend/.env.local
|
||||
backend/images
|
||||
backend/images/*
|
||||
backend/node_modules
|
||||
|
||||
@@ -7,6 +7,16 @@ import fs from 'fs';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const quietEnv = process.env.QUIET_ENV_LOGS === '1';
|
||||
const dotenvQuiet = quietEnv || process.env.DOTENV_CONFIG_QUIET === '1';
|
||||
|
||||
function log(...args) {
|
||||
if (!quietEnv) console.log(...args);
|
||||
}
|
||||
function warn(...args) {
|
||||
console.warn(...args);
|
||||
}
|
||||
|
||||
// Versuche zuerst Produktions-.env, dann lokale .env
|
||||
const productionEnvPath = '/opt/yourpart/backend/.env';
|
||||
const localEnvPath = path.resolve(__dirname, '../.env');
|
||||
@@ -19,54 +29,68 @@ if (fs.existsSync(productionEnvPath)) {
|
||||
fs.accessSync(productionEnvPath, fs.constants.R_OK);
|
||||
envPath = productionEnvPath;
|
||||
usingProduction = true;
|
||||
console.log('[env] Produktions-.env gefunden und lesbar:', productionEnvPath);
|
||||
log('[env] Produktions-.env gefunden und lesbar:', productionEnvPath);
|
||||
} catch (err) {
|
||||
console.warn('[env] Produktions-.env vorhanden, aber nicht lesbar - verwende lokale .env stattdessen:', productionEnvPath);
|
||||
console.warn('[env] Fehler:', err && err.message);
|
||||
if (!quietEnv) {
|
||||
warn('[env] Produktions-.env vorhanden, aber nicht lesbar - verwende lokale .env stattdessen:', productionEnvPath);
|
||||
warn('[env] Fehler:', err && err.message);
|
||||
}
|
||||
envPath = localEnvPath;
|
||||
}
|
||||
} else {
|
||||
console.log('[env] Produktions-.env nicht gefunden, lade lokale .env:', localEnvPath);
|
||||
log('[env] Produktions-.env nicht gefunden, lade lokale .env:', localEnvPath);
|
||||
}
|
||||
|
||||
// Lade .env-Datei (robust gegen Fehler)
|
||||
console.log('[env] Versuche .env zu laden von:', envPath);
|
||||
console.log('[env] Datei existiert:', fs.existsSync(envPath));
|
||||
log('[env] Versuche .env zu laden von:', envPath);
|
||||
log('[env] Datei existiert:', fs.existsSync(envPath));
|
||||
let result;
|
||||
try {
|
||||
result = dotenv.config({ path: envPath });
|
||||
result = dotenv.config({ path: envPath, quiet: dotenvQuiet });
|
||||
if (result.error) {
|
||||
console.warn('[env] Konnte .env nicht laden:', result.error.message);
|
||||
console.warn('[env] Fehler-Details:', result.error);
|
||||
warn('[env] Konnte .env nicht laden:', result.error.message);
|
||||
warn('[env] Fehler-Details:', result.error);
|
||||
} else {
|
||||
console.log('[env] .env erfolgreich geladen von:', envPath, usingProduction ? '(production)' : '(local)');
|
||||
console.log('[env] Geladene Variablen:', Object.keys(result.parsed || {}));
|
||||
log('[env] .env erfolgreich geladen von:', envPath, usingProduction ? '(production)' : '(local)');
|
||||
log('[env] Geladene Variablen:', Object.keys(result.parsed || {}));
|
||||
}
|
||||
} catch (err) {
|
||||
// Sollte nicht passieren, aber falls dotenv intern eine Exception wirft (z.B. EACCES), fange sie ab
|
||||
console.warn('[env] Unerwarteter Fehler beim Laden der .env:', err && err.message);
|
||||
console.warn('[env] Stack:', err && err.stack);
|
||||
warn('[env] Unerwarteter Fehler beim Laden der .env:', err && err.message);
|
||||
warn('[env] Stack:', err && err.stack);
|
||||
if (envPath !== localEnvPath && fs.existsSync(localEnvPath)) {
|
||||
console.log('[env] Versuche stattdessen lokale .env:', localEnvPath);
|
||||
log('[env] Versuche stattdessen lokale .env:', localEnvPath);
|
||||
try {
|
||||
result = dotenv.config({ path: localEnvPath });
|
||||
result = dotenv.config({ path: localEnvPath, quiet: dotenvQuiet });
|
||||
if (!result.error) {
|
||||
console.log('[env] Lokale .env erfolgreich geladen von:', localEnvPath);
|
||||
log('[env] Lokale .env erfolgreich geladen von:', localEnvPath);
|
||||
}
|
||||
} catch (err2) {
|
||||
console.warn('[env] Konnte lokale .env auch nicht laden:', err2 && err2.message);
|
||||
warn('[env] Konnte lokale .env auch nicht laden:', err2 && err2.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: Zeige Redis-Konfiguration
|
||||
console.log('[env] Redis-Konfiguration:');
|
||||
console.log('[env] REDIS_HOST:', process.env.REDIS_HOST);
|
||||
console.log('[env] REDIS_PORT:', process.env.REDIS_PORT);
|
||||
console.log('[env] REDIS_PASSWORD:', process.env.REDIS_PASSWORD ? '***gesetzt***' : 'NICHT GESETZT');
|
||||
console.log('[env] REDIS_URL:', process.env.REDIS_URL);
|
||||
// Lokale Überschreibungen (nicht committen): z. B. SSH-Tunnel DB_HOST=127.0.0.1 DB_PORT=60000
|
||||
const localOverridePath = path.resolve(__dirname, '../.env.local');
|
||||
if (fs.existsSync(localOverridePath)) {
|
||||
const overrideResult = dotenv.config({ path: localOverridePath, override: true, quiet: dotenvQuiet });
|
||||
if (!overrideResult.error) {
|
||||
log('[env] .env.local geladen (überschreibt Werte, z. B. SSH-Tunnel)');
|
||||
} else {
|
||||
warn('[env] .env.local vorhanden, aber Laden fehlgeschlagen:', overrideResult.error?.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!quietEnv) {
|
||||
console.log('[env] Redis-Konfiguration:');
|
||||
console.log('[env] REDIS_HOST:', process.env.REDIS_HOST);
|
||||
console.log('[env] REDIS_PORT:', process.env.REDIS_PORT);
|
||||
console.log('[env] REDIS_PASSWORD:', process.env.REDIS_PASSWORD ? '***gesetzt***' : 'NICHT GESETZT');
|
||||
console.log('[env] REDIS_URL:', process.env.REDIS_URL);
|
||||
}
|
||||
|
||||
if (!process.env.SECRET_KEY) {
|
||||
console.warn('[env] SECRET_KEY nicht gesetzt in .env');
|
||||
warn('[env] SECRET_KEY nicht gesetzt in .env');
|
||||
}
|
||||
export {};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -214,6 +214,10 @@ const menuStructure = {
|
||||
visible: ["all"],
|
||||
path: "/settings/account"
|
||||
},
|
||||
languageAssistant: {
|
||||
visible: ["all"],
|
||||
path: "/settings/language-assistant"
|
||||
},
|
||||
personal: {
|
||||
visible: ["all"],
|
||||
path: "/settings/personal"
|
||||
|
||||
@@ -185,6 +185,38 @@ class SettingsController {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
async getLlmSettings(req, res) {
|
||||
try {
|
||||
const hashedUserId = req.headers.userid;
|
||||
const data = await settingsService.getLlmSettings(hashedUserId);
|
||||
res.status(200).json(data);
|
||||
} catch (error) {
|
||||
console.error('Error retrieving LLM settings:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
async saveLlmSettings(req, res) {
|
||||
const schema = Joi.object({
|
||||
baseUrl: Joi.string().allow('').optional(),
|
||||
model: Joi.string().allow('').optional(),
|
||||
enabled: Joi.boolean().optional(),
|
||||
apiKey: Joi.string().allow('').optional(),
|
||||
clearKey: Joi.boolean().optional()
|
||||
});
|
||||
const { error, value } = schema.validate(req.body || {});
|
||||
if (error) {
|
||||
return res.status(400).json({ error: error.details[0].message });
|
||||
}
|
||||
try {
|
||||
await settingsService.saveLlmSettings(req.headers.userid, value);
|
||||
res.status(200).json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Error saving LLM settings:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SettingsController;
|
||||
|
||||
@@ -55,6 +55,7 @@ class VocabController {
|
||||
this.getGrammarExerciseProgress = this._wrapWithUser((userId, req) => this.service.getGrammarExerciseProgress(userId, req.params.lessonId));
|
||||
this.updateGrammarExercise = this._wrapWithUser((userId, req) => this.service.updateGrammarExercise(userId, req.params.exerciseId, req.body));
|
||||
this.deleteGrammarExercise = this._wrapWithUser((userId, req) => this.service.deleteGrammarExercise(userId, req.params.exerciseId));
|
||||
this.sendLessonAssistantMessage = this._wrapWithUser((userId, req) => this.service.sendLessonAssistantMessage(userId, req.params.lessonId, req.body), { successStatus: 201 });
|
||||
}
|
||||
|
||||
_wrapWithUser(fn, { successStatus = 200 } = {}) {
|
||||
@@ -77,4 +78,3 @@ class VocabController {
|
||||
|
||||
export default VocabController;
|
||||
|
||||
|
||||
|
||||
21
backend/env.example
Normal file
21
backend/env.example
Normal file
@@ -0,0 +1,21 @@
|
||||
# Kopie nach `backend/.env` — nicht committen.
|
||||
#
|
||||
# Produktion / direkter DB-Host steht typischerweise in `.env`.
|
||||
# Für Entwicklung mit SSH-Tunnel: Datei `backend/.env.local` anlegen (wird nach `.env`
|
||||
# geladen und überschreibt). So bleibt `.env` mit echtem Host, Tunnel nur lokal.
|
||||
#
|
||||
# Beispiel backend/.env.local:
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=60000
|
||||
# # DB_SSL=0
|
||||
# (Tunnel z. B.: ssh -L 60000:127.0.0.1:5432 user@server)
|
||||
#
|
||||
DB_NAME=
|
||||
DB_USER=
|
||||
DB_PASS=
|
||||
# DB_HOST=
|
||||
# DB_PORT=5432
|
||||
# DB_SSL=0
|
||||
#
|
||||
# Optional (Defaults siehe utils/sequelize.js)
|
||||
# DB_CONNECT_TIMEOUT_MS=30000
|
||||
5
backend/env.local.example
Normal file
5
backend/env.local.example
Normal file
@@ -0,0 +1,5 @@
|
||||
# Kopie nach backend/.env.local (liegt neben .env, wird nicht committet).
|
||||
# Überschreibt nur bei dir lokal z. B. SSH-Tunnel — .env kann weiter DB_HOST=tsschulz.de haben.
|
||||
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=60000
|
||||
@@ -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;
|
||||
`);
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
`);
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
};
|
||||
83
backend/migrations/20260323010000-expand-debtors-prism.cjs
Normal file
83
backend/migrations/20260323010000-expand-debtors-prism.cjs
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
/* eslint-disable */
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
await queryInterface.sequelize.query(`
|
||||
ALTER TABLE community.vocab_course_lesson
|
||||
ADD COLUMN IF NOT EXISTS learning_goals JSONB,
|
||||
ADD COLUMN IF NOT EXISTS core_patterns JSONB,
|
||||
ADD COLUMN IF NOT EXISTS grammar_focus JSONB,
|
||||
ADD COLUMN IF NOT EXISTS speaking_prompts JSONB,
|
||||
ADD COLUMN IF NOT EXISTS practical_tasks JSONB;
|
||||
`);
|
||||
|
||||
await queryInterface.sequelize.query(`
|
||||
INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES
|
||||
('dialog_completion', 'Dialogergänzung'),
|
||||
('situational_response', 'Situative Antwort'),
|
||||
('pattern_drill', 'Muster-Drill'),
|
||||
('reading_aloud', 'Lautlese-Übung'),
|
||||
('speaking_from_memory', 'Freies Sprechen')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
`);
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.sequelize.query(`
|
||||
ALTER TABLE community.vocab_course_lesson
|
||||
DROP COLUMN IF EXISTS practical_tasks,
|
||||
DROP COLUMN IF EXISTS speaking_prompts,
|
||||
DROP COLUMN IF EXISTS grammar_focus,
|
||||
DROP COLUMN IF EXISTS core_patterns,
|
||||
DROP COLUMN IF EXISTS learning_goals;
|
||||
`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
await queryInterface.sequelize.query(`
|
||||
ALTER TABLE community.user_param
|
||||
ALTER COLUMN value TYPE TEXT;
|
||||
`);
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.sequelize.query(`
|
||||
ALTER TABLE community.user_param
|
||||
ALTER COLUMN value TYPE VARCHAR(255);
|
||||
`);
|
||||
}
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,44 @@ import { DataTypes } from 'sequelize';
|
||||
import { encrypt, decrypt } from '../../utils/encryption.js';
|
||||
import crypto from 'crypto';
|
||||
|
||||
function encodeEncryptedValueToBlob(value) {
|
||||
const encrypted = encrypt(value);
|
||||
return Buffer.from(encrypted, 'utf8');
|
||||
}
|
||||
|
||||
function decodeEncryptedBlob(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const encryptedUtf8 = value.toString('utf8');
|
||||
const decryptedUtf8 = decrypt(encryptedUtf8);
|
||||
if (decryptedUtf8) {
|
||||
return decryptedUtf8;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Email utf8 decryption failed, trying legacy hex format:', error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const encryptedHex = value.toString('hex');
|
||||
const decryptedHex = decrypt(encryptedHex);
|
||||
if (decryptedHex) {
|
||||
return decryptedHex;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Email legacy hex decryption failed:', error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
return value.toString('utf8');
|
||||
} catch (error) {
|
||||
console.warn('Email could not be read as plain text:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const User = sequelize.define('user', {
|
||||
email: {
|
||||
type: DataTypes.BLOB,
|
||||
@@ -10,35 +48,12 @@ const User = sequelize.define('user', {
|
||||
unique: true,
|
||||
set(value) {
|
||||
if (value) {
|
||||
const encrypted = encrypt(value);
|
||||
// Konvertiere Hex-String zu Buffer für die Speicherung
|
||||
const buffer = Buffer.from(encrypted, 'hex');
|
||||
this.setDataValue('email', buffer);
|
||||
this.setDataValue('email', encodeEncryptedValueToBlob(value));
|
||||
}
|
||||
},
|
||||
get() {
|
||||
const encrypted = this.getDataValue('email');
|
||||
if (encrypted) {
|
||||
try {
|
||||
// Konvertiere Buffer zu String für die Entschlüsselung
|
||||
const encryptedString = encrypted.toString('hex');
|
||||
const decrypted = decrypt(encryptedString);
|
||||
if (decrypted) {
|
||||
return decrypted;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Email decryption failed, treating as plain text:', error.message);
|
||||
}
|
||||
|
||||
// Fallback: Versuche es als Klartext zu lesen
|
||||
try {
|
||||
return encrypted.toString('utf8');
|
||||
} catch (error) {
|
||||
console.warn('Email could not be read as plain text:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return decodeEncryptedBlob(encrypted);
|
||||
}
|
||||
},
|
||||
salt: {
|
||||
|
||||
@@ -14,7 +14,7 @@ const UserParam = sequelize.define('user_param', {
|
||||
allowNull: false
|
||||
},
|
||||
value: {
|
||||
type: DataTypes.STRING,
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
set(value) {
|
||||
if (value) {
|
||||
|
||||
@@ -58,6 +58,31 @@ VocabCourseLesson.init({
|
||||
allowNull: true,
|
||||
field: 'cultural_notes'
|
||||
},
|
||||
learningGoals: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
field: 'learning_goals'
|
||||
},
|
||||
corePatterns: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
field: 'core_patterns'
|
||||
},
|
||||
grammarFocus: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
field: 'grammar_focus'
|
||||
},
|
||||
speakingPrompts: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
field: 'speaking_prompts'
|
||||
},
|
||||
practicalTasks: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
field: 'practical_tasks'
|
||||
},
|
||||
targetMinutes: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
141
backend/models/falukant/data/relationship_state.js
Normal file
141
backend/models/falukant/data/relationship_state.js
Normal 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;
|
||||
@@ -22,8 +22,9 @@ TownProductWorth.init({
|
||||
timestamps: false,
|
||||
underscored: true,
|
||||
hooks: {
|
||||
// Neu: 55–85 %; ältere Einträge können 40–60 % sein (Preislogik im Service deckelt nach unten ab).
|
||||
beforeCreate: (worthPercent) => {
|
||||
worthPercent.worthPercent = Math.floor(Math.random() * 20) + 40;
|
||||
worthPercent.worthPercent = Math.floor(Math.random() * 31) + 55;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Underground.init({
|
||||
allowNull: false},
|
||||
victimId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false},
|
||||
allowNull: true},
|
||||
parameters: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,13 +9,13 @@ const ContactMessage = sequelize.define('contact_message', {
|
||||
set(value) {
|
||||
if (value) {
|
||||
const encryptedValue = encrypt(value);
|
||||
this.setDataValue('email', encryptedValue.toString('hex'));
|
||||
this.setDataValue('email', encryptedValue);
|
||||
}
|
||||
},
|
||||
get() {
|
||||
const value = this.getDataValue('email');
|
||||
if (value) {
|
||||
return decrypt(Buffer.from(value, 'hex'));
|
||||
return decrypt(value);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -25,13 +25,13 @@ const ContactMessage = sequelize.define('contact_message', {
|
||||
set(value) {
|
||||
if (value) {
|
||||
const encryptedValue = encrypt(value);
|
||||
this.setDataValue('message', encryptedValue.toString('hex'));
|
||||
this.setDataValue('message', encryptedValue);
|
||||
}
|
||||
},
|
||||
get() {
|
||||
const value = this.getDataValue('message');
|
||||
if (value) {
|
||||
return decrypt(Buffer.from(value, 'hex'));
|
||||
return decrypt(value);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -41,13 +41,13 @@ const ContactMessage = sequelize.define('contact_message', {
|
||||
set(value) {
|
||||
if (value) {
|
||||
const encryptedValue = encrypt(value);
|
||||
this.setDataValue('name', encryptedValue.toString('hex'));
|
||||
this.setDataValue('name', encryptedValue);
|
||||
}
|
||||
},
|
||||
get() {
|
||||
const value = this.getDataValue('name');
|
||||
if (value) {
|
||||
return decrypt(Buffer.from(value, 'hex'));
|
||||
return decrypt(value);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -67,13 +67,13 @@ const ContactMessage = sequelize.define('contact_message', {
|
||||
set(value) {
|
||||
if (value) {
|
||||
const encryptedValue = encrypt(value);
|
||||
this.setDataValue('answer', encryptedValue.toString('hex'));
|
||||
this.setDataValue('answer', encryptedValue);
|
||||
}
|
||||
},
|
||||
get() {
|
||||
const value = this.getDataValue('answer');
|
||||
if (value) {
|
||||
return decrypt(Buffer.from(value, 'hex'));
|
||||
return decrypt(value);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,38 +12,40 @@
|
||||
"sync-tables": "node sync-tables-only.js",
|
||||
"check-connections": "node check-connections.js",
|
||||
"cleanup-connections": "node cleanup-connections.js",
|
||||
"diag:town-worth": "QUIET_ENV_LOGS=1 DOTENV_CONFIG_QUIET=1 node scripts/falukant-town-product-worth-stats.mjs",
|
||||
"diag:moneyflow": "QUIET_ENV_LOGS=1 DOTENV_CONFIG_QUIET=1 node scripts/falukant-moneyflow-report.mjs",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -19,5 +19,7 @@ router.post('/setinterest', authenticate, settingsController.addUserInterest.bin
|
||||
router.get('/removeinterest/:id', authenticate, settingsController.removeInterest.bind(settingsController));
|
||||
router.get('/visibilities', authenticate, settingsController.getVisibilities.bind(settingsController));
|
||||
router.post('/update-visibility', authenticate, settingsController.updateVisibility.bind(settingsController));
|
||||
router.get('/llm', authenticate, settingsController.getLlmSettings.bind(settingsController));
|
||||
router.post('/llm', authenticate, settingsController.saveLlmSettings.bind(settingsController));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -48,6 +48,7 @@ router.put('/lessons/:lessonId/progress', vocabController.updateLessonProgress);
|
||||
|
||||
// Grammar Exercises
|
||||
router.get('/grammar/exercise-types', vocabController.getExerciseTypes);
|
||||
router.post('/lessons/:lessonId/assistant', vocabController.sendLessonAssistantMessage);
|
||||
router.post('/lessons/:lessonId/grammar-exercises', vocabController.createGrammarExercise);
|
||||
router.get('/lessons/:lessonId/grammar-exercises', vocabController.getGrammarExercisesForLesson);
|
||||
router.get('/lessons/:lessonId/grammar-exercises/progress', vocabController.getGrammarExerciseProgress);
|
||||
@@ -58,4 +59,3 @@ router.delete('/grammar-exercises/:exerciseId', vocabController.deleteGrammarExe
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,19 @@ const LESSONS_TO_ADD = [
|
||||
title: 'Woche 1 - Wiederholung',
|
||||
description: 'Wiederhole alle Inhalte der ersten Woche',
|
||||
culturalNotes: 'Wiederholung ist der Schlüssel zum Erfolg!',
|
||||
learningGoals: [
|
||||
'Die Kernmuster der ersten Woche ohne Hilfe wiederholen.',
|
||||
'Zwischen Begrüßung, Familie und Fürsorge schneller wechseln.',
|
||||
'Eine kurze Alltagssequenz frei sprechen.'
|
||||
],
|
||||
corePatterns: ['Kumusta ka?', 'Palangga taka.', 'Nikaon na ka?', 'Wala ko kasabot.'],
|
||||
speakingPrompts: [
|
||||
{
|
||||
title: 'Freie Wiederholung',
|
||||
prompt: 'Begrüße jemanden, drücke Zuneigung aus und frage fürsorglich nach dem Essen.',
|
||||
cue: 'Kumusta ka? Palangga taka. Nikaon na ka?'
|
||||
}
|
||||
],
|
||||
targetMinutes: 30,
|
||||
targetScorePercent: 80,
|
||||
requiresReview: false
|
||||
@@ -31,6 +44,12 @@ const LESSONS_TO_ADD = [
|
||||
title: 'Woche 1 - Vokabeltest',
|
||||
description: 'Teste dein Wissen aus Woche 1',
|
||||
culturalNotes: null,
|
||||
learningGoals: [
|
||||
'Die wichtigsten Wörter der ersten Woche schnell abrufen.',
|
||||
'Bedeutung und Gebrauch zentraler Wörter unterscheiden.',
|
||||
'Von einzelnen Wörtern zu kurzen Sätzen übergehen.'
|
||||
],
|
||||
corePatterns: ['Kumusta', 'Salamat', 'Lami', 'Mingaw ko nimo'],
|
||||
targetMinutes: 15,
|
||||
targetScorePercent: 80,
|
||||
requiresReview: true
|
||||
@@ -89,6 +108,9 @@ async function addBisayaWeek1Lessons() {
|
||||
dayNumber: lessonData.dayNumber,
|
||||
lessonType: lessonData.lessonType,
|
||||
culturalNotes: lessonData.culturalNotes,
|
||||
learningGoals: lessonData.learningGoals || [],
|
||||
corePatterns: lessonData.corePatterns || [],
|
||||
speakingPrompts: lessonData.speakingPrompts || [],
|
||||
targetMinutes: lessonData.targetMinutes,
|
||||
targetScorePercent: lessonData.targetScorePercent,
|
||||
requiresReview: lessonData.requiresReview
|
||||
|
||||
207
backend/scripts/apply-bisaya-course-refresh.js
Normal file
207
backend/scripts/apply-bisaya-course-refresh.js
Normal file
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Spielt die überarbeiteten Bisaya-Kursinhalte ein und setzt den Lernfortschritt zurück.
|
||||
*
|
||||
* Verwendung:
|
||||
* node backend/scripts/apply-bisaya-course-refresh.js
|
||||
*/
|
||||
|
||||
import { sequelize } from '../utils/sequelize.js';
|
||||
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
|
||||
import VocabCourseProgress from '../models/community/vocab_course_progress.js';
|
||||
import VocabGrammarExerciseProgress from '../models/community/vocab_grammar_exercise_progress.js';
|
||||
import { Op } from 'sequelize';
|
||||
|
||||
const LESSON_DIDACTICS = {
|
||||
'Begrüßungen & Höflichkeit': {
|
||||
learningGoals: [
|
||||
'Einfache Begrüßungen verstehen und selbst verwenden.',
|
||||
'Tageszeitbezogene Grüße und einfache Verabschiedungen unterscheiden.',
|
||||
'Höfliche Reaktionen wie Danke und Bitte passend einsetzen.',
|
||||
'Ein kurzes Begrüßungs-Mini-Gespräch laut üben.'
|
||||
],
|
||||
corePatterns: ['Kumusta ka?', 'Maayong buntag.', 'Maayong adlaw.', 'Amping.', 'Babay.', 'Maayo ko.', 'Salamat.', 'Palihug.'],
|
||||
grammarFocus: [
|
||||
{ title: 'Kurzantworten mit ko', text: 'Mit "ko" sprichst du über dich selbst: "Maayo ko."', example: 'Maayo ko. = Mir geht es gut.' },
|
||||
{ title: 'Maayong + Tageszeit', text: 'Mit "Maayong" kannst du Grüße für verschiedene Tageszeiten bilden.', example: 'Maayong buntag. / Maayong gabii.' }
|
||||
],
|
||||
speakingPrompts: [
|
||||
{ title: 'Mini-Gespräch', prompt: 'Begrüße eine Person, frage nach dem Befinden und reagiere höflich.', cue: 'Kumusta ka? Maayo ko. Salamat.' },
|
||||
{ title: 'Verabschiedung', prompt: 'Verabschiede dich kurz und wünsche, dass die andere Person auf sich aufpasst.', cue: 'Babay. Amping.' }
|
||||
],
|
||||
practicalTasks: [{ title: 'Alltag', text: 'Sprich die Begrüßung dreimal laut und variiere die Antwort.' }]
|
||||
},
|
||||
'Familienwörter': {
|
||||
learningGoals: [
|
||||
'Die wichtigsten Familienbezeichnungen sicher erkennen.',
|
||||
'Familienmitglieder mit respektvollen Wörtern ansprechen.',
|
||||
'Kurze Sätze über die eigene Familie bilden.'
|
||||
],
|
||||
corePatterns: ['Si Nanay', 'Si Tatay', 'Kuya nako', 'Ate nako'],
|
||||
grammarFocus: [
|
||||
{ title: 'Respekt in Familienanreden', text: 'Kuya und Ate werden nicht nur in der Familie, sondern auch respektvoll für ältere Personen benutzt.', example: 'Kuya, palihug.' }
|
||||
],
|
||||
speakingPrompts: [
|
||||
{ title: 'Meine Familie', prompt: 'Stelle zwei Familienmitglieder mit einem kurzen Satz vor.', cue: 'Si Nanay. Si Kuya.' }
|
||||
],
|
||||
practicalTasks: [{ title: 'Familienpraxis', text: 'Nenne laut fünf Familienwörter und bilde danach zwei Mini-Sätze.' }]
|
||||
},
|
||||
'Essen & Fürsorge': {
|
||||
learningGoals: [
|
||||
'Fürsorgliche Fragen rund ums Essen verstehen.',
|
||||
'Einladungen zum Essen passend beantworten.',
|
||||
'Kurze Essens-Dialoge laut üben.'
|
||||
],
|
||||
corePatterns: ['Nikaon na ka?', 'Kaon ta.', 'Gusto ka mokaon?', 'Lami kaayo.'],
|
||||
grammarFocus: [
|
||||
{ title: 'na als Zustandsmarker', text: '"na" markiert oft etwas, das bereits eingetreten ist oder jetzt gilt.', example: 'Nikaon na ka?' }
|
||||
],
|
||||
speakingPrompts: [
|
||||
{ title: 'Fürsorge-Dialog', prompt: 'Frage, ob jemand schon gegessen hat, und biete Essen oder Wasser an.', cue: 'Nikaon na ka? Gusto ka mokaon?' }
|
||||
],
|
||||
practicalTasks: [{ title: 'Rollenspiel', text: 'Spiele ein kurzes Gespräch zwischen Gastgeber und Gast beim Essen.' }]
|
||||
},
|
||||
'Zeitformen - Grundlagen': {
|
||||
learningGoals: [
|
||||
'Ni- und Mo- als einfache Zeitmarker unterscheiden.',
|
||||
'Kurze Sätze in Vergangenheit und Zukunft bilden.',
|
||||
'Das Muster laut mit mehreren Verben wiederholen.'
|
||||
],
|
||||
corePatterns: ['Ni-kaon ko.', 'Mo-kaon ko.', 'Ni-adto ko.', 'Mo-adto ko.'],
|
||||
grammarFocus: [
|
||||
{ title: 'Zeitpräfixe', text: 'Ni- verweist auf Vergangenes, Mo- auf Zukünftiges oder Bevorstehendes.', example: 'Ni-kaon ko. / Mo-kaon ko.' }
|
||||
],
|
||||
speakingPrompts: [
|
||||
{ title: 'Vorher und nachher', prompt: 'Sage einen Satz über etwas, das du getan hast, und einen Satz über etwas, das du tun wirst.', cue: 'Ni-kaon ko. Mo-adto ko.' }
|
||||
],
|
||||
practicalTasks: [{ title: 'Mustertraining', text: 'Nimm ein Verb und sprich es einmal mit Ni- und einmal mit Mo-.' }]
|
||||
},
|
||||
'Woche 1 - Wiederholung': {
|
||||
learningGoals: [
|
||||
'Die Kernmuster der ersten Woche ohne Hilfe wiederholen.',
|
||||
'Zwischen Begrüßung, Familie und Fürsorge schneller wechseln.',
|
||||
'Eine kurze Alltagssequenz frei sprechen.'
|
||||
],
|
||||
corePatterns: ['Kumusta ka?', 'Palangga taka.', 'Nikaon na ka?', 'Wala ko kasabot.'],
|
||||
speakingPrompts: [
|
||||
{ title: 'Freie Wiederholung', prompt: 'Begrüße jemanden, drücke Zuneigung aus und frage fürsorglich nach dem Essen.', cue: 'Kumusta ka? Palangga taka. Nikaon na ka?' }
|
||||
]
|
||||
},
|
||||
'Woche 1 - Vokabeltest': {
|
||||
learningGoals: [
|
||||
'Die wichtigsten Wörter der ersten Woche schnell abrufen.',
|
||||
'Bedeutung und Gebrauch zentraler Wörter unterscheiden.',
|
||||
'Von einzelnen Wörtern zu kurzen Sätzen übergehen.'
|
||||
],
|
||||
corePatterns: ['Kumusta', 'Salamat', 'Lami', 'Mingaw ko nimo']
|
||||
}
|
||||
};
|
||||
|
||||
async function resetBisayaProgress(courseIds) {
|
||||
if (courseIds.length === 0) return { lessonProgress: 0, exerciseProgress: 0 };
|
||||
|
||||
const lessonIds = await VocabCourseLesson.findAll({
|
||||
where: {
|
||||
courseId: {
|
||||
[Op.in]: courseIds
|
||||
}
|
||||
},
|
||||
attributes: ['id']
|
||||
});
|
||||
|
||||
const numericLessonIds = lessonIds.map((row) => row.id);
|
||||
const exerciseIds = numericLessonIds.length > 0
|
||||
? await VocabGrammarExercise.findAll({
|
||||
where: {
|
||||
lessonId: {
|
||||
[Op.in]: numericLessonIds
|
||||
}
|
||||
},
|
||||
attributes: ['id']
|
||||
})
|
||||
: [];
|
||||
|
||||
const deletedLessonProgress = await VocabCourseProgress.destroy({
|
||||
where: { courseId: { [Op.in]: courseIds } }
|
||||
});
|
||||
|
||||
let deletedExerciseProgress = 0;
|
||||
if (exerciseIds.length > 0) {
|
||||
deletedExerciseProgress = await VocabGrammarExerciseProgress.destroy({
|
||||
where: { exerciseId: { [Op.in]: exerciseIds.map((row) => row.id) } }
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
lessonProgress: deletedLessonProgress,
|
||||
exerciseProgress: deletedExerciseProgress
|
||||
};
|
||||
}
|
||||
|
||||
async function applyBisayaCourseRefresh() {
|
||||
await sequelize.authenticate();
|
||||
|
||||
const [bisayaLanguage] = await sequelize.query(
|
||||
`SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`,
|
||||
{ type: sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
if (!bisayaLanguage) {
|
||||
console.error('❌ Bisaya-Sprache nicht gefunden.');
|
||||
return;
|
||||
}
|
||||
|
||||
const courses = await sequelize.query(
|
||||
`SELECT id, title FROM community.vocab_course WHERE language_id = :languageId ORDER BY id ASC`,
|
||||
{
|
||||
replacements: { languageId: bisayaLanguage.id },
|
||||
type: sequelize.QueryTypes.SELECT
|
||||
}
|
||||
);
|
||||
|
||||
const courseIds = courses.map((course) => course.id);
|
||||
const resetStats = await resetBisayaProgress(courseIds);
|
||||
|
||||
let updatedLessons = 0;
|
||||
for (const course of courses) {
|
||||
const lessons = await VocabCourseLesson.findAll({
|
||||
where: { courseId: course.id }
|
||||
});
|
||||
|
||||
for (const lesson of lessons) {
|
||||
const didactics = LESSON_DIDACTICS[lesson.title];
|
||||
if (!didactics) continue;
|
||||
|
||||
await lesson.update({
|
||||
learningGoals: didactics.learningGoals || [],
|
||||
corePatterns: didactics.corePatterns || [],
|
||||
grammarFocus: didactics.grammarFocus || [],
|
||||
speakingPrompts: didactics.speakingPrompts || [],
|
||||
practicalTasks: didactics.practicalTasks || []
|
||||
});
|
||||
updatedLessons++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Bisaya-Kursupdate vorbereitet.');
|
||||
console.log(` Kurse: ${courses.length}`);
|
||||
console.log(` Didaktisch aktualisierte Lektionen: ${updatedLessons}`);
|
||||
console.log(` Gelöschte Lektionsfortschritte: ${resetStats.lessonProgress}`);
|
||||
console.log(` Gelöschte Übungsfortschritte: ${resetStats.exerciseProgress}`);
|
||||
console.log('');
|
||||
console.log('Nächste Schritte:');
|
||||
console.log('1. create-bisaya-course-content.js ausführen, um die neuen Übungen einzuspielen');
|
||||
console.log('2. optional update-week1-bisaya-exercises.js ausführen, falls Woche 1 separat gepflegt wird');
|
||||
}
|
||||
|
||||
applyBisayaCourseRefresh()
|
||||
.then(() => {
|
||||
sequelize.close();
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('❌ Fehler:', error);
|
||||
sequelize.close();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -14,6 +14,13 @@ import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js'
|
||||
import VocabCourse from '../models/community/vocab_course.js';
|
||||
import User from '../models/community/user.js';
|
||||
|
||||
function withTypeName(exerciseTypeName, exercise) {
|
||||
return {
|
||||
...exercise,
|
||||
exerciseTypeName
|
||||
};
|
||||
}
|
||||
|
||||
// Bisaya-spezifische Übungen basierend auf Lektionsthemen
|
||||
const BISAYA_EXERCISES = {
|
||||
// Lektion 1: Begrüßungen & Höflichkeit
|
||||
@@ -62,6 +69,109 @@ const BISAYA_EXERCISES = {
|
||||
correctAnswer: 0
|
||||
},
|
||||
explanation: '"Salamat" bedeutet "Danke" auf Bisaya.'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 2,
|
||||
title: 'Tagesgruß erkennen',
|
||||
instruction: 'Wähle den passenden Gruß für den Morgen.',
|
||||
questionData: {
|
||||
type: 'multiple_choice',
|
||||
question: 'Wie sagt man "Guten Morgen" auf Bisaya?',
|
||||
options: ['Maayong buntag', 'Maayong gabii', 'Amping', 'Babay']
|
||||
},
|
||||
answerData: {
|
||||
type: 'multiple_choice',
|
||||
correctAnswer: 0
|
||||
},
|
||||
explanation: '"Maayong buntag" ist die übliche Form für "Guten Morgen".'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 2,
|
||||
title: 'Verabschiedung erkennen',
|
||||
instruction: 'Wähle die passende Verabschiedung aus.',
|
||||
questionData: {
|
||||
type: 'multiple_choice',
|
||||
question: 'Was bedeutet "Amping"?',
|
||||
options: ['Pass auf dich auf', 'Guten Morgen', 'Danke', 'Bitte']
|
||||
},
|
||||
answerData: {
|
||||
type: 'multiple_choice',
|
||||
correctAnswer: 0
|
||||
},
|
||||
explanation: '"Amping" benutzt man beim Abschied im Sinn von "Pass auf dich auf".'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 2,
|
||||
title: 'Abschiedsform erkennen',
|
||||
instruction: 'Wähle die richtige Bedeutung.',
|
||||
questionData: {
|
||||
type: 'multiple_choice',
|
||||
question: 'Was bedeutet "Babay"?',
|
||||
options: ['Tschüss / Auf Wiedersehen', 'Wie geht es dir?', 'Guten Tag', 'Ich bin müde']
|
||||
},
|
||||
answerData: {
|
||||
type: 'multiple_choice',
|
||||
correctAnswer: 0
|
||||
},
|
||||
explanation: '"Babay" ist eine einfache alltägliche Verabschiedung.'
|
||||
},
|
||||
withTypeName('dialog_completion', {
|
||||
title: 'Begrüßungsdialog ergänzen',
|
||||
instruction: 'Ergänze die passende Antwort im Mini-Dialog.',
|
||||
questionData: {
|
||||
type: 'dialog_completion',
|
||||
question: 'Welche Antwort passt auf die Begrüßung?',
|
||||
dialog: ['A: Kumusta ka?', 'B: ...']
|
||||
},
|
||||
answerData: {
|
||||
modelAnswer: 'Maayo ko, salamat.',
|
||||
correct: ['Maayo ko, salamat.', 'Maayo ko. Salamat.']
|
||||
},
|
||||
explanation: 'Eine typische kurze Antwort ist "Maayo ko, salamat."'
|
||||
}),
|
||||
withTypeName('dialog_completion', {
|
||||
title: 'Verabschiedungsdialog ergänzen',
|
||||
instruction: 'Ergänze die passende Verabschiedung.',
|
||||
questionData: {
|
||||
type: 'dialog_completion',
|
||||
question: 'Wie endet der kurze Dialog natürlich?',
|
||||
dialog: ['A: Sige, mauna ko.', 'B: ...']
|
||||
},
|
||||
answerData: {
|
||||
modelAnswer: 'Babay, amping.',
|
||||
correct: ['Babay, amping.', 'Amping.', 'Babay. Amping.']
|
||||
},
|
||||
explanation: '"Babay" und "Amping" sind typische kurze Abschiedsformen.'
|
||||
}),
|
||||
{
|
||||
exerciseTypeId: 8,
|
||||
title: 'Begrüßung frei sprechen',
|
||||
instruction: 'Sprich eine kurze Begrüßung mit Frage und Antwort frei nach.',
|
||||
questionData: {
|
||||
type: 'speaking_from_memory',
|
||||
question: 'Begrüße eine Person und antworte kurz auf "Kumusta ka?".',
|
||||
expectedText: 'Kumusta ka? Maayo ko, salamat.',
|
||||
keywords: ['kumusta', 'maayo', 'salamat']
|
||||
},
|
||||
answerData: {
|
||||
type: 'speaking_from_memory'
|
||||
},
|
||||
explanation: 'Wichtig sind hier die Schlüsselwörter für Begrüßung, Antwort und Höflichkeit.'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 8,
|
||||
title: 'Gruß und Abschied laut sprechen',
|
||||
instruction: 'Sprich einen Tagesgruß und eine kurze Verabschiedung laut.',
|
||||
questionData: {
|
||||
type: 'speaking_from_memory',
|
||||
question: 'Sprich: "Guten Morgen" und verabschiede dich danach kurz.',
|
||||
expectedText: 'Maayong buntag. Babay, amping.',
|
||||
keywords: ['maayong', 'buntag', 'babay', 'amping']
|
||||
},
|
||||
answerData: {
|
||||
type: 'speaking_from_memory'
|
||||
},
|
||||
explanation: 'Die Übung verbindet Begrüßung und Verabschiedung in einem kurzen Alltagspfad.'
|
||||
}
|
||||
],
|
||||
|
||||
@@ -188,7 +298,92 @@ const BISAYA_EXERCISES = {
|
||||
alternatives: ['Mama', 'Nanay', 'Inahan']
|
||||
},
|
||||
explanation: '"Nanay" oder "Mama" bedeutet "Mutter" auf Bisaya.'
|
||||
}
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 3,
|
||||
title: 'Familiensatz bauen',
|
||||
instruction: 'Bilde aus den Wörtern einen kurzen Satz.',
|
||||
questionData: {
|
||||
type: 'sentence_building',
|
||||
question: 'Baue einen Satz: "Das ist meine Mutter."',
|
||||
tokens: ['Si', 'Nanay', 'nako', 'ni']
|
||||
},
|
||||
answerData: {
|
||||
correct: ['Si Nanay nako ni.', 'Si Nanay ni nako.']
|
||||
},
|
||||
explanation: 'Mit "Si Nanay nako ni." stellst du deine Mutter kurz vor.'
|
||||
},
|
||||
withTypeName('situational_response', {
|
||||
title: 'Familie vorstellen',
|
||||
instruction: 'Antworte kurz auf die Situation.',
|
||||
questionData: {
|
||||
type: 'situational_response',
|
||||
question: 'Jemand fragt dich nach deiner Familie. Stelle kurz Mutter und älteren Bruder vor.',
|
||||
keywords: ['nanay', 'kuya']
|
||||
},
|
||||
answerData: {
|
||||
modelAnswer: 'Si Nanay ug si Kuya.',
|
||||
keywords: ['nanay', 'kuya']
|
||||
},
|
||||
explanation: 'Für diese Aufgabe reichen kurze, klare Familiennennungen.'
|
||||
})
|
||||
],
|
||||
|
||||
'Essen & Fürsorge': [
|
||||
{
|
||||
exerciseTypeId: 2,
|
||||
title: 'Fürsorgefrage verstehen',
|
||||
instruction: 'Wähle die richtige Bedeutung.',
|
||||
questionData: {
|
||||
type: 'multiple_choice',
|
||||
question: 'Was bedeutet "Nikaon na ka?"?',
|
||||
options: ['Hast du schon gegessen?', 'Bist du müde?', 'Kommst du nach Hause?', 'Möchtest du Wasser?']
|
||||
},
|
||||
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||
explanation: '"Nikaon na ka?" ist eine sehr fürsorgliche Alltagsfrage.'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 1,
|
||||
title: 'Essensdialog ergänzen',
|
||||
instruction: 'Fülle die Lücken mit den passenden Wörtern.',
|
||||
questionData: {
|
||||
type: 'gap_fill',
|
||||
text: 'Nikaon {gap} ka? {gap} ta!',
|
||||
gaps: 2
|
||||
},
|
||||
answerData: {
|
||||
answers: ['na', 'Kaon']
|
||||
},
|
||||
explanation: '"na" markiert hier den bereits eingetretenen Zustand; "Kaon ta!" heißt "Lass uns essen!".'
|
||||
},
|
||||
withTypeName('dialog_completion', {
|
||||
title: 'Einladung zum Essen ergänzen',
|
||||
instruction: 'Ergänze die passende Antwort.',
|
||||
questionData: {
|
||||
type: 'dialog_completion',
|
||||
question: 'Welche Antwort passt auf die Einladung?',
|
||||
dialog: ['A: Kaon ta!', 'B: ...']
|
||||
},
|
||||
answerData: {
|
||||
modelAnswer: 'Oo, gusto ko.',
|
||||
correct: ['Oo, gusto ko.', 'Oo, mokaon ko.']
|
||||
},
|
||||
explanation: 'Eine natürliche kurze Reaktion ist "Oo, gusto ko."'
|
||||
}),
|
||||
withTypeName('situational_response', {
|
||||
title: 'Fürsorglich reagieren',
|
||||
instruction: 'Reagiere passend auf die Situation.',
|
||||
questionData: {
|
||||
type: 'situational_response',
|
||||
question: 'Jemand sieht hungrig aus. Frage fürsorglich nach und biete Essen an.',
|
||||
keywords: ['nikaon', 'kaon']
|
||||
},
|
||||
answerData: {
|
||||
modelAnswer: 'Nikaon na ka? Kaon ta.',
|
||||
keywords: ['nikaon', 'kaon']
|
||||
},
|
||||
explanation: 'Die Übung trainiert einen sehr typischen fürsorglichen Mini-Dialog.'
|
||||
})
|
||||
],
|
||||
|
||||
// Lektion: Haus & Familie (Balay, Kwarto, Kusina, Pamilya)
|
||||
@@ -424,6 +619,34 @@ const BISAYA_EXERCISES = {
|
||||
answers: ['Ni', 'Mo']
|
||||
},
|
||||
explanation: 'Ni- für Vergangenheit, Mo- für Zukunft.'
|
||||
},
|
||||
withTypeName('pattern_drill', {
|
||||
title: 'Zeitmuster anwenden',
|
||||
instruction: 'Bilde mit demselben Muster einen Zukunftssatz.',
|
||||
questionData: {
|
||||
type: 'pattern_drill',
|
||||
question: 'Verwende das Muster für "gehen".',
|
||||
pattern: 'Mo- + Verb + ko'
|
||||
},
|
||||
answerData: {
|
||||
modelAnswer: 'Mo-adto ko.',
|
||||
correct: ['Mo-adto ko.', 'Moadto ko.']
|
||||
},
|
||||
explanation: 'Mit "Mo-" kannst du ein einfaches Zukunftsmuster bilden.'
|
||||
}),
|
||||
{
|
||||
exerciseTypeId: 3,
|
||||
title: 'Vergangenheit und Zukunft bauen',
|
||||
instruction: 'Schreibe beide Formen nacheinander auf.',
|
||||
questionData: {
|
||||
type: 'sentence_building',
|
||||
question: 'Formuliere: "Ich habe gegessen. Ich werde essen."',
|
||||
tokens: ['Ni-kaon', 'ko', 'Mo-kaon', 'ko']
|
||||
},
|
||||
answerData: {
|
||||
correct: ['Ni-kaon ko. Mo-kaon ko.', 'Nikaon ko. Mokaon ko.']
|
||||
},
|
||||
explanation: 'Die Übung trainiert den direkten Wechsel zwischen den beiden Zeitmarkern.'
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1103,7 +1326,35 @@ const BISAYA_EXERCISES = {
|
||||
},
|
||||
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||
explanation: '"Wala ko kasabot" bedeutet "Ich verstehe nicht".'
|
||||
}
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 3,
|
||||
title: 'Woche 1: Minisatz bauen',
|
||||
instruction: 'Schreibe eine kurze Sequenz aus Begrüßung und Fürsorge.',
|
||||
questionData: {
|
||||
type: 'sentence_building',
|
||||
question: 'Baue: "Wie geht es dir? Hast du schon gegessen?"',
|
||||
tokens: ['Kumusta', 'ka', 'Nikaon', 'na', 'ka']
|
||||
},
|
||||
answerData: {
|
||||
correct: ['Kumusta ka? Nikaon na ka?', 'Kumusta ka. Nikaon na ka?']
|
||||
},
|
||||
explanation: 'Hier kombinierst du zwei wichtige Muster aus Woche 1.'
|
||||
},
|
||||
withTypeName('dialog_completion', {
|
||||
title: 'Woche 1: Dialog ergänzen',
|
||||
instruction: 'Ergänze die passende liebevolle Reaktion.',
|
||||
questionData: {
|
||||
type: 'dialog_completion',
|
||||
question: 'Welche Antwort passt?',
|
||||
dialog: ['A: Mingaw ko nimo.', 'B: ...']
|
||||
},
|
||||
answerData: {
|
||||
modelAnswer: 'Palangga taka.',
|
||||
correct: ['Palangga taka.']
|
||||
},
|
||||
explanation: 'Die Kombination klingt im Familienkontext warm und natürlich.'
|
||||
})
|
||||
],
|
||||
|
||||
// Woche 1 - Vokabeltest (Lektion 10)
|
||||
@@ -1167,10 +1418,53 @@ const BISAYA_EXERCISES = {
|
||||
},
|
||||
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||
explanation: '"Mingaw ko nimo" bedeutet "Ich vermisse dich".'
|
||||
}
|
||||
},
|
||||
withTypeName('situational_response', {
|
||||
title: 'Woche 1: Situative Kurzantwort',
|
||||
instruction: 'Reagiere passend auf die Situation.',
|
||||
questionData: {
|
||||
type: 'situational_response',
|
||||
question: 'Jemand fragt: "Kumusta ka?" Antworte kurz und höflich.',
|
||||
keywords: ['maayo', 'salamat']
|
||||
},
|
||||
answerData: {
|
||||
modelAnswer: 'Maayo ko, salamat.',
|
||||
keywords: ['maayo', 'salamat']
|
||||
},
|
||||
explanation: 'Eine kurze höfliche Antwort reicht hier völlig aus.'
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
async function resolveExerciseTypeId(exercise) {
|
||||
if (exercise.exerciseTypeId) {
|
||||
return exercise.exerciseTypeId;
|
||||
}
|
||||
|
||||
const trimmedName =
|
||||
exercise.exerciseTypeName != null && exercise.exerciseTypeName !== ''
|
||||
? String(exercise.exerciseTypeName).trim()
|
||||
: '';
|
||||
|
||||
if (!trimmedName) {
|
||||
throw new Error(`Kein exerciseTypeId oder exerciseTypeName für Übung "${exercise.title || 'unbenannt'}" definiert`);
|
||||
}
|
||||
|
||||
const [type] = await sequelize.query(
|
||||
`SELECT id FROM community.vocab_grammar_exercise_type WHERE name = :name LIMIT 1`,
|
||||
{
|
||||
replacements: { name: trimmedName },
|
||||
type: sequelize.QueryTypes.SELECT
|
||||
}
|
||||
);
|
||||
|
||||
if (!type) {
|
||||
throw new Error(`Übungstyp "${trimmedName}" nicht gefunden`);
|
||||
}
|
||||
|
||||
return Number(type.id);
|
||||
}
|
||||
|
||||
async function findOrCreateSystemUser() {
|
||||
let systemUser = await User.findOne({
|
||||
where: {
|
||||
@@ -1270,10 +1564,14 @@ async function createBisayaCourseContent() {
|
||||
const replacePlaceholders = [
|
||||
'Woche 1 - Wiederholung',
|
||||
'Woche 1 - Vokabeltest',
|
||||
'Begrüßungen & Höflichkeit',
|
||||
'Familienwörter',
|
||||
'Essen & Fürsorge',
|
||||
'Alltagsgespräche - Teil 1',
|
||||
'Alltagsgespräche - Teil 2',
|
||||
'Haus & Familie',
|
||||
'Ort & Richtung'
|
||||
'Ort & Richtung',
|
||||
'Zeitformen - Grundlagen'
|
||||
].includes(lesson.title);
|
||||
const existingCount = await VocabGrammarExercise.count({
|
||||
where: { lessonId: lesson.id }
|
||||
@@ -1292,9 +1590,10 @@ async function createBisayaCourseContent() {
|
||||
// Erstelle Übungen
|
||||
let exerciseNumber = 1;
|
||||
for (const exerciseData of exercises) {
|
||||
const exerciseTypeId = await resolveExerciseTypeId(exerciseData);
|
||||
await VocabGrammarExercise.create({
|
||||
lessonId: lesson.id,
|
||||
exerciseTypeId: exerciseData.exerciseTypeId,
|
||||
exerciseTypeId,
|
||||
exerciseNumber: exerciseNumber++,
|
||||
title: exerciseData.title,
|
||||
instruction: exerciseData.instruction,
|
||||
|
||||
@@ -12,6 +12,189 @@ import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||
import User from '../models/community/user.js';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const LESSON_DIDACTICS = {
|
||||
'Begrüßungen & Höflichkeit': {
|
||||
learningGoals: [
|
||||
'Einfache Begrüßungen verstehen und selbst verwenden.',
|
||||
'Tageszeitbezogene Grüße und einfache Verabschiedungen unterscheiden.',
|
||||
'Höfliche Reaktionen wie Danke und Bitte passend einsetzen.',
|
||||
'Ein kurzes Begrüßungs-Mini-Gespräch laut üben.'
|
||||
],
|
||||
corePatterns: [
|
||||
'Kumusta ka?',
|
||||
'Maayong buntag.',
|
||||
'Maayong adlaw.',
|
||||
'Amping.',
|
||||
'Babay.',
|
||||
'Maayo ko.',
|
||||
'Salamat.',
|
||||
'Palihug.'
|
||||
],
|
||||
grammarFocus: [
|
||||
{
|
||||
title: 'Kurzantworten mit ko',
|
||||
text: 'Mit "ko" sprichst du über dich selbst: "Maayo ko."',
|
||||
example: 'Maayo ko. = Mir geht es gut.'
|
||||
},
|
||||
{
|
||||
title: 'Maayong + Tageszeit',
|
||||
text: 'Mit "Maayong" kannst du Grüße für verschiedene Tageszeiten bilden.',
|
||||
example: 'Maayong buntag. / Maayong gabii.'
|
||||
}
|
||||
],
|
||||
speakingPrompts: [
|
||||
{
|
||||
title: 'Mini-Gespräch',
|
||||
prompt: 'Begrüße eine Person, frage nach dem Befinden und reagiere höflich.',
|
||||
cue: 'Kumusta ka? Maayo ko. Salamat.'
|
||||
},
|
||||
{
|
||||
title: 'Verabschiedung',
|
||||
prompt: 'Verabschiede dich kurz und wünsche, dass die andere Person auf sich aufpasst.',
|
||||
cue: 'Babay. Amping.'
|
||||
}
|
||||
],
|
||||
practicalTasks: [
|
||||
{
|
||||
title: 'Alltag',
|
||||
text: 'Sprich die Begrüßung dreimal laut und variiere die Antwort.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'Familienwörter': {
|
||||
learningGoals: [
|
||||
'Die wichtigsten Familienbezeichnungen sicher erkennen.',
|
||||
'Familienmitglieder mit respektvollen Wörtern ansprechen.',
|
||||
'Kurze Sätze über die eigene Familie bilden.'
|
||||
],
|
||||
corePatterns: [
|
||||
'Si Nanay',
|
||||
'Si Tatay',
|
||||
'Kuya nako',
|
||||
'Ate nako'
|
||||
],
|
||||
grammarFocus: [
|
||||
{
|
||||
title: 'Respekt in Familienanreden',
|
||||
text: 'Kuya und Ate werden nicht nur in der Familie, sondern auch respektvoll für ältere Personen benutzt.',
|
||||
example: 'Kuya, palihug.'
|
||||
}
|
||||
],
|
||||
speakingPrompts: [
|
||||
{
|
||||
title: 'Meine Familie',
|
||||
prompt: 'Stelle zwei Familienmitglieder mit einem kurzen Satz vor.',
|
||||
cue: 'Si Nanay. Si Kuya.'
|
||||
}
|
||||
],
|
||||
practicalTasks: [
|
||||
{
|
||||
title: 'Familienpraxis',
|
||||
text: 'Nenne laut fünf Familienwörter und bilde danach zwei Mini-Sätze.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'Essen & Fürsorge': {
|
||||
learningGoals: [
|
||||
'Fürsorgliche Fragen rund ums Essen verstehen.',
|
||||
'Einladungen zum Essen passend beantworten.',
|
||||
'Kurze Essens-Dialoge laut üben.'
|
||||
],
|
||||
corePatterns: [
|
||||
'Nikaon na ka?',
|
||||
'Kaon ta.',
|
||||
'Gusto ka mokaon?',
|
||||
'Lami kaayo.'
|
||||
],
|
||||
grammarFocus: [
|
||||
{
|
||||
title: 'na als Zustandsmarker',
|
||||
text: '"na" markiert oft etwas, das bereits eingetreten ist oder jetzt gilt.',
|
||||
example: 'Nikaon na ka?'
|
||||
}
|
||||
],
|
||||
speakingPrompts: [
|
||||
{
|
||||
title: 'Fürsorge-Dialog',
|
||||
prompt: 'Frage, ob jemand schon gegessen hat, und biete Essen oder Wasser an.',
|
||||
cue: 'Nikaon na ka? Gusto ka mokaon?'
|
||||
}
|
||||
],
|
||||
practicalTasks: [
|
||||
{
|
||||
title: 'Rollenspiel',
|
||||
text: 'Spiele ein kurzes Gespräch zwischen Gastgeber und Gast beim Essen.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'Zeitformen - Grundlagen': {
|
||||
learningGoals: [
|
||||
'Ni- und Mo- als einfache Zeitmarker unterscheiden.',
|
||||
'Kurze Sätze in Vergangenheit und Zukunft bilden.',
|
||||
'Das Muster laut mit mehreren Verben wiederholen.'
|
||||
],
|
||||
corePatterns: [
|
||||
'Ni-kaon ko.',
|
||||
'Mo-kaon ko.',
|
||||
'Ni-adto ko.',
|
||||
'Mo-adto ko.'
|
||||
],
|
||||
grammarFocus: [
|
||||
{
|
||||
title: 'Zeitpräfixe',
|
||||
text: 'Ni- verweist auf Vergangenes, Mo- auf Zukünftiges oder Bevorstehendes.',
|
||||
example: 'Ni-kaon ko. / Mo-kaon ko.'
|
||||
}
|
||||
],
|
||||
speakingPrompts: [
|
||||
{
|
||||
title: 'Vorher und nachher',
|
||||
prompt: 'Sage einen Satz über etwas, das du getan hast, und einen Satz über etwas, das du tun wirst.',
|
||||
cue: 'Ni-kaon ko. Mo-adto ko.'
|
||||
}
|
||||
],
|
||||
practicalTasks: [
|
||||
{
|
||||
title: 'Mustertraining',
|
||||
text: 'Nimm ein Verb und sprich es einmal mit Ni- und einmal mit Mo-.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'Woche 1 - Wiederholung': {
|
||||
learningGoals: [
|
||||
'Die Kernmuster der ersten Woche ohne Hilfe wiederholen.',
|
||||
'Zwischen Begrüßung, Familie und Fürsorge schneller wechseln.',
|
||||
'Eine kurze Alltagssequenz frei sprechen.'
|
||||
],
|
||||
corePatterns: [
|
||||
'Kumusta ka?',
|
||||
'Palangga taka.',
|
||||
'Nikaon na ka?',
|
||||
'Wala ko kasabot.'
|
||||
],
|
||||
speakingPrompts: [
|
||||
{
|
||||
title: 'Freie Wiederholung',
|
||||
prompt: 'Begrüße jemanden, drücke Zuneigung aus und frage fürsorglich nach dem Essen.',
|
||||
cue: 'Kumusta ka? Palangga taka. Nikaon na ka?'
|
||||
}
|
||||
]
|
||||
},
|
||||
'Woche 1 - Vokabeltest': {
|
||||
learningGoals: [
|
||||
'Die wichtigsten Wörter der ersten Woche schnell abrufen.',
|
||||
'Bedeutung und Gebrauch zentraler Wörter unterscheiden.',
|
||||
'Von einzelnen Wörtern zu kurzen Sätzen übergehen.'
|
||||
],
|
||||
corePatterns: [
|
||||
'Kumusta',
|
||||
'Salamat',
|
||||
'Lami',
|
||||
'Mingaw ko nimo'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const LESSONS = [
|
||||
// WOCHE 1: Grundlagen & Aussprache
|
||||
{ week: 1, day: 1, num: 1, type: 'conversation', title: 'Begrüßungen & Höflichkeit',
|
||||
@@ -262,6 +445,11 @@ async function createBisayaCourse(languageId, ownerHashedId) {
|
||||
dayNumber: lessonData.day,
|
||||
lessonType: lessonData.type,
|
||||
culturalNotes: lessonData.cultural,
|
||||
learningGoals: LESSON_DIDACTICS[lessonData.title]?.learningGoals || [],
|
||||
corePatterns: LESSON_DIDACTICS[lessonData.title]?.corePatterns || [],
|
||||
grammarFocus: LESSON_DIDACTICS[lessonData.title]?.grammarFocus || [],
|
||||
speakingPrompts: LESSON_DIDACTICS[lessonData.title]?.speakingPrompts || [],
|
||||
practicalTasks: LESSON_DIDACTICS[lessonData.title]?.practicalTasks || [],
|
||||
targetMinutes: lessonData.targetMin,
|
||||
targetScorePercent: lessonData.targetScore,
|
||||
requiresReview: lessonData.review
|
||||
|
||||
142
backend/scripts/falukant-moneyflow-report.mjs
Normal file
142
backend/scripts/falukant-moneyflow-report.mjs
Normal file
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Aggregiert falukant_log.moneyflow nach activity (reale Buchungen aus dem Spiel).
|
||||
*
|
||||
* cd backend && npm run diag:moneyflow
|
||||
*
|
||||
* Umgebung:
|
||||
* DIAG_DAYS=30 — Fenster in Tagen (1–3650, Standard 30)
|
||||
* DIAG_USER_ID=123 — optional: nur dieser falukant_user
|
||||
* DIAG_QUERY_TIMEOUT_MS, DIAG_AUTH_TIMEOUT_MS — wie diag:town-worth
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const sqlByActivity = join(__dirname, '../sql/diagnostics/falukant_moneyflow_by_activity.sql');
|
||||
const sqlTotals = join(__dirname, '../sql/diagnostics/falukant_moneyflow_window_totals.sql');
|
||||
|
||||
const QUERY_TIMEOUT_MS = Number.parseInt(process.env.DIAG_QUERY_TIMEOUT_MS || '60000', 10);
|
||||
const AUTH_TIMEOUT_MS = Number.parseInt(process.env.DIAG_AUTH_TIMEOUT_MS || '25000', 10);
|
||||
|
||||
process.env.QUIET_ENV_LOGS = process.env.QUIET_ENV_LOGS || '1';
|
||||
process.env.DOTENV_CONFIG_QUIET = process.env.DOTENV_CONFIG_QUIET || '1';
|
||||
if (!process.env.DB_CONNECT_TIMEOUT_MS) {
|
||||
process.env.DB_CONNECT_TIMEOUT_MS = '15000';
|
||||
}
|
||||
|
||||
await import('../config/loadEnv.js');
|
||||
const { sequelize } = await import('../utils/sequelize.js');
|
||||
|
||||
function withTimeout(promise, ms, onTimeoutError) {
|
||||
let timerId;
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timerId = setTimeout(() => reject(new Error(onTimeoutError)), ms);
|
||||
});
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
clearTimeout(timerId);
|
||||
});
|
||||
}
|
||||
|
||||
function raceQuery(sql) {
|
||||
return withTimeout(
|
||||
sequelize.query(sql),
|
||||
QUERY_TIMEOUT_MS,
|
||||
`Abfrage-Timeout nach ${QUERY_TIMEOUT_MS} ms (DIAG_QUERY_TIMEOUT_MS)`
|
||||
);
|
||||
}
|
||||
|
||||
function raceAuth() {
|
||||
return withTimeout(
|
||||
sequelize.authenticate(),
|
||||
AUTH_TIMEOUT_MS,
|
||||
`authenticate() Timeout nach ${AUTH_TIMEOUT_MS} ms (DIAG_AUTH_TIMEOUT_MS).`
|
||||
);
|
||||
}
|
||||
|
||||
function printConnectionHints() {
|
||||
const port = process.env.DB_PORT || '5432';
|
||||
const host = process.env.DB_HOST || '?';
|
||||
const local = host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
||||
console.error('');
|
||||
console.error('[diag] Mögliche Ursachen:');
|
||||
if (local) {
|
||||
console.error(' • SSH-Tunnel: Läuft z. B. ssh -L ' + port + ':127.0.0.1:5432 …? Dann DB_HOST=127.0.0.1 DB_PORT=' + port + ' (DB_SSL meist aus).');
|
||||
console.error(' • Falscher lokaler Forward-Port in .env (DB_PORT).');
|
||||
} else {
|
||||
console.error(' • PostgreSQL erwartet TLS: in .env DB_SSL=1 setzen (ggf. DB_SSL_REJECT_UNAUTHORIZED=0 bei selbstsigniert).');
|
||||
console.error(' • Falscher Port: DB_PORT=' + port + ' prüfen.');
|
||||
console.error(' • Server-Firewall: deine IP muss für Port', port, 'auf', host, 'freigeschaltet sein.');
|
||||
}
|
||||
console.error(' • Test: nc -zv', host, port);
|
||||
console.error('');
|
||||
}
|
||||
|
||||
function parseDays() {
|
||||
const raw = process.env.DIAG_DAYS;
|
||||
const n = raw === undefined || raw === '' ? 30 : Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n < 1 || n > 3650) {
|
||||
throw new Error('DIAG_DAYS muss eine Ganzzahl zwischen 1 und 3650 sein');
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function parseOptionalUserFilter() {
|
||||
const raw = process.env.DIAG_USER_ID;
|
||||
if (raw === undefined || raw === '') {
|
||||
return '';
|
||||
}
|
||||
const uid = Number.parseInt(raw, 10);
|
||||
if (!Number.isInteger(uid) || uid < 1) {
|
||||
throw new Error('DIAG_USER_ID muss eine positive Ganzzahl sein (falukant_user.id)');
|
||||
}
|
||||
return ` AND m.falukant_user_id = ${uid}`;
|
||||
}
|
||||
|
||||
function applyPlaceholders(sql, days, userFilter) {
|
||||
return sql
|
||||
.replace(/__DIAG_DAYS__/g, String(days))
|
||||
.replace(/__DIAG_USER_FILTER__/g, userFilter);
|
||||
}
|
||||
|
||||
try {
|
||||
const days = parseDays();
|
||||
const userFilter = parseOptionalUserFilter();
|
||||
const host = process.env.DB_HOST || '(unbekannt)';
|
||||
const t0 = Date.now();
|
||||
|
||||
console.log('');
|
||||
console.log('[diag] moneyflow — Fenster:', days, 'Tage', userFilter ? `(User ${process.env.DIAG_USER_ID})` : '(alle Nutzer)');
|
||||
console.log('[diag] PostgreSQL: authenticate() … (Host:', host + ', Port:', process.env.DB_PORT || '5432', ', DB_SSL:', process.env.DB_SSL === '1' ? '1' : '0', ')');
|
||||
console.log('');
|
||||
|
||||
await raceAuth();
|
||||
console.log('[diag] authenticate() ok nach', Date.now() - t0, 'ms');
|
||||
|
||||
await sequelize.query("SET statement_timeout = '60s'");
|
||||
|
||||
const sql1 = applyPlaceholders(readFileSync(sqlByActivity, 'utf8'), days, userFilter);
|
||||
const sql2 = applyPlaceholders(readFileSync(sqlTotals, 'utf8'), days, userFilter);
|
||||
|
||||
const t1 = Date.now();
|
||||
const [totalsRows] = await raceQuery(sql2);
|
||||
console.log('[diag] Fenster-Totals nach', Date.now() - t1, 'ms');
|
||||
console.table(totalsRows);
|
||||
|
||||
const t2 = Date.now();
|
||||
const [byActivity] = await raceQuery(sql1);
|
||||
console.log('[diag] Nach activity nach', Date.now() - t2, 'ms (gesamt', Date.now() - t0, 'ms)');
|
||||
console.log('');
|
||||
console.table(byActivity);
|
||||
|
||||
await sequelize.close();
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error(err.message || err);
|
||||
printConnectionHints();
|
||||
try {
|
||||
await sequelize.close();
|
||||
} catch (_) {}
|
||||
process.exit(1);
|
||||
}
|
||||
103
backend/scripts/falukant-town-product-worth-stats.mjs
Normal file
103
backend/scripts/falukant-town-product-worth-stats.mjs
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Liest backend/sql/diagnostics/falukant_town_product_worth_stats.sql und gibt eine Tabelle aus.
|
||||
*
|
||||
* cd backend && npm run diag:town-worth
|
||||
*
|
||||
* SSH-Tunnel: DB_HOST=127.0.0.1, DB_PORT=<lokaler Forward, z. B. 60000> — siehe backend/env.example
|
||||
* Hängt die Verbindung: Tunnel läuft? Sonst TLS (DB_SSL=1), falscher Port, Firewall.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const sqlPath = join(__dirname, '../sql/diagnostics/falukant_town_product_worth_stats.sql');
|
||||
|
||||
const QUERY_TIMEOUT_MS = Number.parseInt(process.env.DIAG_QUERY_TIMEOUT_MS || '60000', 10);
|
||||
const AUTH_TIMEOUT_MS = Number.parseInt(process.env.DIAG_AUTH_TIMEOUT_MS || '25000', 10);
|
||||
|
||||
process.env.QUIET_ENV_LOGS = process.env.QUIET_ENV_LOGS || '1';
|
||||
process.env.DOTENV_CONFIG_QUIET = process.env.DOTENV_CONFIG_QUIET || '1';
|
||||
if (!process.env.DB_CONNECT_TIMEOUT_MS) {
|
||||
process.env.DB_CONNECT_TIMEOUT_MS = '15000';
|
||||
}
|
||||
|
||||
await import('../config/loadEnv.js');
|
||||
const { sequelize } = await import('../utils/sequelize.js');
|
||||
|
||||
/** Promise.race + Timeout, aber Timer wird bei Erfolg cleared — sonst blockiert setTimeout(60s) den Prozess. */
|
||||
function withTimeout(promise, ms, onTimeoutError) {
|
||||
let timerId;
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timerId = setTimeout(() => reject(new Error(onTimeoutError)), ms);
|
||||
});
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
clearTimeout(timerId);
|
||||
});
|
||||
}
|
||||
|
||||
function raceQuery(sql) {
|
||||
return withTimeout(
|
||||
sequelize.query(sql),
|
||||
QUERY_TIMEOUT_MS,
|
||||
`Abfrage-Timeout nach ${QUERY_TIMEOUT_MS} ms (DIAG_QUERY_TIMEOUT_MS)`
|
||||
);
|
||||
}
|
||||
|
||||
function raceAuth() {
|
||||
return withTimeout(
|
||||
sequelize.authenticate(),
|
||||
AUTH_TIMEOUT_MS,
|
||||
`authenticate() Timeout nach ${AUTH_TIMEOUT_MS} ms — TCP/TLS zu PostgreSQL kommt nicht zustande (DIAG_AUTH_TIMEOUT_MS).`
|
||||
);
|
||||
}
|
||||
|
||||
function printConnectionHints() {
|
||||
const port = process.env.DB_PORT || '5432';
|
||||
const host = process.env.DB_HOST || '?';
|
||||
const local = host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
||||
console.error('');
|
||||
console.error('[diag] Mögliche Ursachen:');
|
||||
if (local) {
|
||||
console.error(' • SSH-Tunnel: Läuft z. B. ssh -L ' + port + ':127.0.0.1:5432 …? Dann DB_HOST=127.0.0.1 DB_PORT=' + port + ' (DB_SSL meist aus).');
|
||||
console.error(' • Falscher lokaler Forward-Port in .env (DB_PORT).');
|
||||
} else {
|
||||
console.error(' • PostgreSQL erwartet TLS: in .env DB_SSL=1 setzen (ggf. DB_SSL_REJECT_UNAUTHORIZED=0 bei selbstsigniert).');
|
||||
console.error(' • Falscher Port: DB_PORT=' + port + ' prüfen.');
|
||||
console.error(' • Server-Firewall: deine IP muss für Port', port, 'auf', host, 'freigeschaltet sein.');
|
||||
}
|
||||
console.error(' • Test: nc -zv', host, port);
|
||||
console.error('');
|
||||
}
|
||||
|
||||
try {
|
||||
const sql = readFileSync(sqlPath, 'utf8');
|
||||
const host = process.env.DB_HOST || '(unbekannt)';
|
||||
const t0 = Date.now();
|
||||
|
||||
console.log('');
|
||||
console.log('[diag] PostgreSQL: authenticate() … (Host:', host + ', Port:', process.env.DB_PORT || '5432', ', DB_SSL:', process.env.DB_SSL === '1' ? '1' : '0', ')');
|
||||
console.log('');
|
||||
|
||||
await raceAuth();
|
||||
console.log('[diag] authenticate() ok nach', Date.now() - t0, 'ms');
|
||||
|
||||
await sequelize.query("SET statement_timeout = '30s'");
|
||||
|
||||
const t1 = Date.now();
|
||||
const [rows] = await raceQuery(sql);
|
||||
console.log('[diag] SELECT ok nach', Date.now() - t1, 'ms (gesamt', Date.now() - t0, 'ms)');
|
||||
console.log('');
|
||||
|
||||
console.table(rows);
|
||||
await sequelize.close();
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error(err.message || err);
|
||||
printConnectionHints();
|
||||
try {
|
||||
await sequelize.close();
|
||||
} catch (_) {}
|
||||
process.exit(1);
|
||||
}
|
||||
157
backend/scripts/update-bisaya-didactics.js
Normal file
157
backend/scripts/update-bisaya-didactics.js
Normal file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Pflegt didaktische Felder in bestehenden Bisaya-Kursen nach.
|
||||
*
|
||||
* Verwendung:
|
||||
* node backend/scripts/update-bisaya-didactics.js
|
||||
*/
|
||||
|
||||
import { sequelize } from '../utils/sequelize.js';
|
||||
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||
|
||||
const LESSON_DIDACTICS = {
|
||||
'Begrüßungen & Höflichkeit': {
|
||||
learningGoals: [
|
||||
'Einfache Begrüßungen verstehen und selbst verwenden.',
|
||||
'Tageszeitbezogene Grüße und einfache Verabschiedungen unterscheiden.',
|
||||
'Höfliche Reaktionen wie Danke und Bitte passend einsetzen.',
|
||||
'Ein kurzes Begrüßungs-Mini-Gespräch laut üben.'
|
||||
],
|
||||
corePatterns: ['Kumusta ka?', 'Maayong buntag.', 'Maayong adlaw.', 'Amping.', 'Babay.', 'Maayo ko.', 'Salamat.', 'Palihug.'],
|
||||
grammarFocus: [
|
||||
{ title: 'Kurzantworten mit ko', text: 'Mit "ko" sprichst du über dich selbst: "Maayo ko."', example: 'Maayo ko. = Mir geht es gut.' },
|
||||
{ title: 'Maayong + Tageszeit', text: 'Mit "Maayong" kannst du Grüße für verschiedene Tageszeiten bilden.', example: 'Maayong buntag. / Maayong gabii.' }
|
||||
],
|
||||
speakingPrompts: [
|
||||
{ title: 'Mini-Gespräch', prompt: 'Begrüße eine Person, frage nach dem Befinden und reagiere höflich.', cue: 'Kumusta ka? Maayo ko. Salamat.' },
|
||||
{ title: 'Verabschiedung', prompt: 'Verabschiede dich kurz und wünsche, dass die andere Person auf sich aufpasst.', cue: 'Babay. Amping.' }
|
||||
],
|
||||
practicalTasks: [
|
||||
{ title: 'Alltag', text: 'Sprich die Begrüßung dreimal laut und variiere die Antwort.' }
|
||||
]
|
||||
},
|
||||
'Familienwörter': {
|
||||
learningGoals: [
|
||||
'Die wichtigsten Familienbezeichnungen sicher erkennen.',
|
||||
'Familienmitglieder mit respektvollen Wörtern ansprechen.',
|
||||
'Kurze Sätze über die eigene Familie bilden.'
|
||||
],
|
||||
corePatterns: ['Si Nanay', 'Si Tatay', 'Kuya nako', 'Ate nako'],
|
||||
grammarFocus: [
|
||||
{ title: 'Respekt in Familienanreden', text: 'Kuya und Ate werden nicht nur in der Familie, sondern auch respektvoll für ältere Personen benutzt.', example: 'Kuya, palihug.' }
|
||||
],
|
||||
speakingPrompts: [
|
||||
{ title: 'Meine Familie', prompt: 'Stelle zwei Familienmitglieder mit einem kurzen Satz vor.', cue: 'Si Nanay. Si Kuya.' }
|
||||
],
|
||||
practicalTasks: [
|
||||
{ title: 'Familienpraxis', text: 'Nenne laut fünf Familienwörter und bilde danach zwei Mini-Sätze.' }
|
||||
]
|
||||
},
|
||||
'Essen & Fürsorge': {
|
||||
learningGoals: [
|
||||
'Fürsorgliche Fragen rund ums Essen verstehen.',
|
||||
'Einladungen zum Essen passend beantworten.',
|
||||
'Kurze Essens-Dialoge laut üben.'
|
||||
],
|
||||
corePatterns: ['Nikaon na ka?', 'Kaon ta.', 'Gusto ka mokaon?', 'Lami kaayo.'],
|
||||
grammarFocus: [
|
||||
{ title: 'na als Zustandsmarker', text: '"na" markiert oft etwas, das bereits eingetreten ist oder jetzt gilt.', example: 'Nikaon na ka?' }
|
||||
],
|
||||
speakingPrompts: [
|
||||
{ title: 'Fürsorge-Dialog', prompt: 'Frage, ob jemand schon gegessen hat, und biete Essen oder Wasser an.', cue: 'Nikaon na ka? Gusto ka mokaon?' }
|
||||
],
|
||||
practicalTasks: [
|
||||
{ title: 'Rollenspiel', text: 'Spiele ein kurzes Gespräch zwischen Gastgeber und Gast beim Essen.' }
|
||||
]
|
||||
},
|
||||
'Zeitformen - Grundlagen': {
|
||||
learningGoals: [
|
||||
'Ni- und Mo- als einfache Zeitmarker unterscheiden.',
|
||||
'Kurze Sätze in Vergangenheit und Zukunft bilden.',
|
||||
'Das Muster laut mit mehreren Verben wiederholen.'
|
||||
],
|
||||
corePatterns: ['Ni-kaon ko.', 'Mo-kaon ko.', 'Ni-adto ko.', 'Mo-adto ko.'],
|
||||
grammarFocus: [
|
||||
{ title: 'Zeitpräfixe', text: 'Ni- verweist auf Vergangenes, Mo- auf Zukünftiges oder Bevorstehendes.', example: 'Ni-kaon ko. / Mo-kaon ko.' }
|
||||
],
|
||||
speakingPrompts: [
|
||||
{ title: 'Vorher und nachher', prompt: 'Sage einen Satz über etwas, das du getan hast, und einen Satz über etwas, das du tun wirst.', cue: 'Ni-kaon ko. Mo-adto ko.' }
|
||||
],
|
||||
practicalTasks: [
|
||||
{ title: 'Mustertraining', text: 'Nimm ein Verb und sprich es einmal mit Ni- und einmal mit Mo-.' }
|
||||
]
|
||||
},
|
||||
'Woche 1 - Wiederholung': {
|
||||
learningGoals: [
|
||||
'Die Kernmuster der ersten Woche ohne Hilfe wiederholen.',
|
||||
'Zwischen Begrüßung, Familie und Fürsorge schneller wechseln.',
|
||||
'Eine kurze Alltagssequenz frei sprechen.'
|
||||
],
|
||||
corePatterns: ['Kumusta ka?', 'Palangga taka.', 'Nikaon na ka?', 'Wala ko kasabot.'],
|
||||
speakingPrompts: [
|
||||
{ title: 'Freie Wiederholung', prompt: 'Begrüße jemanden, drücke Zuneigung aus und frage fürsorglich nach dem Essen.', cue: 'Kumusta ka? Palangga taka. Nikaon na ka?' }
|
||||
]
|
||||
},
|
||||
'Woche 1 - Vokabeltest': {
|
||||
learningGoals: [
|
||||
'Die wichtigsten Wörter der ersten Woche schnell abrufen.',
|
||||
'Bedeutung und Gebrauch zentraler Wörter unterscheiden.',
|
||||
'Von einzelnen Wörtern zu kurzen Sätzen übergehen.'
|
||||
],
|
||||
corePatterns: ['Kumusta', 'Salamat', 'Lami', 'Mingaw ko nimo']
|
||||
}
|
||||
};
|
||||
|
||||
async function updateBisayaDidactics() {
|
||||
await sequelize.authenticate();
|
||||
const [bisayaLanguage] = await sequelize.query(
|
||||
`SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`,
|
||||
{ type: sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
if (!bisayaLanguage) {
|
||||
console.error('❌ Bisaya-Sprache nicht gefunden.');
|
||||
return;
|
||||
}
|
||||
|
||||
const lessons = await sequelize.query(
|
||||
`SELECT l.id
|
||||
FROM community.vocab_course_lesson l
|
||||
JOIN community.vocab_course c ON c.id = l.course_id
|
||||
WHERE c.language_id = :languageId`,
|
||||
{
|
||||
replacements: { languageId: bisayaLanguage.id },
|
||||
type: sequelize.QueryTypes.SELECT
|
||||
}
|
||||
);
|
||||
|
||||
let updated = 0;
|
||||
for (const row of lessons) {
|
||||
const lesson = await VocabCourseLesson.findByPk(row.id);
|
||||
const didactics = LESSON_DIDACTICS[lesson.title];
|
||||
if (!didactics) continue;
|
||||
|
||||
await lesson.update({
|
||||
learningGoals: didactics.learningGoals || [],
|
||||
corePatterns: didactics.corePatterns || [],
|
||||
grammarFocus: didactics.grammarFocus || [],
|
||||
speakingPrompts: didactics.speakingPrompts || [],
|
||||
practicalTasks: didactics.practicalTasks || []
|
||||
});
|
||||
updated++;
|
||||
console.log(`✅ Didaktik aktualisiert: Lektion ${lesson.lessonNumber} - ${lesson.title}`);
|
||||
}
|
||||
|
||||
console.log(`\n🎉 Fertig. ${updated} Lektion(en) aktualisiert.`);
|
||||
}
|
||||
|
||||
updateBisayaDidactics()
|
||||
.then(() => {
|
||||
sequelize.close();
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('❌ Fehler:', error);
|
||||
sequelize.close();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -14,6 +14,13 @@ import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
|
||||
import User from '../models/community/user.js';
|
||||
|
||||
function withTypeName(exerciseTypeName, exercise) {
|
||||
return {
|
||||
...exercise,
|
||||
exerciseTypeName
|
||||
};
|
||||
}
|
||||
|
||||
const LESSON_TITLES = ['Woche 1 - Wiederholung', 'Woche 1 - Vokabeltest'];
|
||||
|
||||
const BISAYA_EXERCISES = {
|
||||
@@ -22,17 +29,45 @@ const BISAYA_EXERCISES = {
|
||||
{ exerciseTypeId: 2, title: 'Wiederholung: Wie sagt man "Mutter" auf Bisaya?', instruction: 'Wähle die richtige Übersetzung.', questionData: { type: 'multiple_choice', question: 'Wie sagt man "Mutter" auf Bisaya?', options: ['Nanay', 'Tatay', 'Kuya', 'Ate'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Nanay" bedeutet "Mutter" auf Bisaya.' },
|
||||
{ exerciseTypeId: 2, title: 'Wiederholung: Was bedeutet "Palangga taka"?', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Palangga taka"?', options: ['Ich hab dich lieb', 'Danke', 'Guten Tag', 'Auf Wiedersehen'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Palangga taka" bedeutet "Ich hab dich lieb" - wärmer als "I love you" im Familienkontext.' },
|
||||
{ exerciseTypeId: 2, title: 'Wiederholung: Was fragt man mit "Nikaon ka?"?', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Nikaon ka?"?', options: ['Hast du schon gegessen?', 'Wie geht es dir?', 'Danke', 'Bitte'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Nikaon ka?" bedeutet "Hast du schon gegessen?" - typisch fürsorglich auf den Philippinen.' },
|
||||
{ exerciseTypeId: 2, title: 'Wiederholung: Wie sagt man "Ich verstehe nicht"?', instruction: 'Wähle die richtige Übersetzung.', questionData: { type: 'multiple_choice', question: 'Wie sagt man "Ich verstehe nicht" auf Bisaya?', options: ['Wala ko kasabot', 'Salamat', 'Maayo', 'Palihug'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Wala ko kasabot" bedeutet "Ich verstehe nicht".' }
|
||||
{ exerciseTypeId: 2, title: 'Wiederholung: Wie sagt man "Ich verstehe nicht"?', instruction: 'Wähle die richtige Übersetzung.', questionData: { type: 'multiple_choice', question: 'Wie sagt man "Ich verstehe nicht" auf Bisaya?', options: ['Wala ko kasabot', 'Salamat', 'Maayo', 'Palihug'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Wala ko kasabot" bedeutet "Ich verstehe nicht".' },
|
||||
{ exerciseTypeId: 3, title: 'Woche 1: Minisatz bauen', instruction: 'Schreibe eine kurze Sequenz aus Begrüßung und Fürsorge.', questionData: { type: 'sentence_building', question: 'Baue: "Wie geht es dir? Hast du schon gegessen?"', tokens: ['Kumusta', 'ka', 'Nikaon', 'na', 'ka'] }, answerData: { correct: ['Kumusta ka? Nikaon na ka?', 'Kumusta ka. Nikaon na ka?'] }, explanation: 'Hier kombinierst du zwei wichtige Muster aus Woche 1.' },
|
||||
withTypeName('dialog_completion', { title: 'Woche 1: Dialog ergänzen', instruction: 'Ergänze die passende liebevolle Reaktion.', questionData: { type: 'dialog_completion', question: 'Welche Antwort passt?', dialog: ['A: Mingaw ko nimo.', 'B: ...'] }, answerData: { modelAnswer: 'Palangga taka.', correct: ['Palangga taka.'] }, explanation: 'Die Kombination klingt im Familienkontext warm und natürlich.' })
|
||||
],
|
||||
'Woche 1 - Vokabeltest': [
|
||||
{ exerciseTypeId: 2, title: 'Vokabeltest: Kumusta', instruction: 'Was bedeutet "Kumusta"?', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Kumusta"?', options: ['Wie geht es dir?', 'Danke', 'Bitte', 'Auf Wiedersehen'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Kumusta" kommt von spanisch "¿Cómo está?" - "Wie geht es dir?"' },
|
||||
{ exerciseTypeId: 2, title: 'Vokabeltest: Lola', instruction: 'Wähle die richtige Übersetzung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Lola"?', options: ['Großmutter', 'Großvater', 'Mutter', 'Vater'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Lola" = Großmutter, "Lolo" = Großvater.' },
|
||||
{ exerciseTypeId: 2, title: 'Vokabeltest: Salamat', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Salamat"?', options: ['Danke', 'Bitte', 'Entschuldigung', 'Gern geschehen'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Salamat" bedeutet "Danke".' },
|
||||
{ exerciseTypeId: 2, title: 'Vokabeltest: Lami', instruction: 'Was bedeutet "Lami"?', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Lami"?', options: ['Lecker', 'Viel', 'Gut', 'Schnell'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Lami" bedeutet "lecker" oder "schmackhaft" - wichtig beim Essen!' },
|
||||
{ exerciseTypeId: 2, title: 'Vokabeltest: Mingaw ko nimo', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Mingaw ko nimo"?', options: ['Ich vermisse dich', 'Ich freue mich', 'Ich mag dich', 'Ich liebe dich'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Mingaw ko nimo" bedeutet "Ich vermisse dich".' }
|
||||
{ exerciseTypeId: 2, title: 'Vokabeltest: Mingaw ko nimo', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Mingaw ko nimo"?', options: ['Ich vermisse dich', 'Ich freue mich', 'Ich mag dich', 'Ich liebe dich'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Mingaw ko nimo" bedeutet "Ich vermisse dich".' },
|
||||
withTypeName('situational_response', { title: 'Woche 1: Situative Kurzantwort', instruction: 'Reagiere passend auf die Situation.', questionData: { type: 'situational_response', question: 'Jemand fragt: "Kumusta ka?" Antworte kurz und höflich.', keywords: ['maayo', 'salamat'] }, answerData: { modelAnswer: 'Maayo ko, salamat.', keywords: ['maayo', 'salamat'] }, explanation: 'Eine kurze höfliche Antwort reicht hier völlig aus.' })
|
||||
]
|
||||
};
|
||||
|
||||
async function resolveExerciseTypeId(exercise) {
|
||||
if (exercise.exerciseTypeId) {
|
||||
return exercise.exerciseTypeId;
|
||||
}
|
||||
|
||||
const name = exercise.exerciseTypeName;
|
||||
if (name === undefined || name === null || String(name).trim() === '') {
|
||||
throw new Error(`Kein exerciseTypeId oder exerciseTypeName für Übung "${exercise.title || 'unbenannt'}"`);
|
||||
}
|
||||
|
||||
const [type] = await sequelize.query(
|
||||
`SELECT id FROM community.vocab_grammar_exercise_type WHERE name = :name LIMIT 1`,
|
||||
{
|
||||
replacements: { name: String(name).trim() },
|
||||
type: sequelize.QueryTypes.SELECT
|
||||
}
|
||||
);
|
||||
|
||||
if (!type) {
|
||||
throw new Error(`Übungstyp "${String(name).trim()}" nicht gefunden`);
|
||||
}
|
||||
|
||||
return Number(type.id);
|
||||
}
|
||||
|
||||
async function updateWeek1BisayaExercises() {
|
||||
await sequelize.authenticate();
|
||||
console.log('Datenbankverbindung erfolgreich hergestellt.\n');
|
||||
@@ -93,9 +128,10 @@ async function updateWeek1BisayaExercises() {
|
||||
|
||||
let exerciseNumber = 1;
|
||||
for (const ex of exercises) {
|
||||
const exerciseTypeId = await resolveExerciseTypeId(ex);
|
||||
await VocabGrammarExercise.create({
|
||||
lessonId: lesson.id,
|
||||
exerciseTypeId: ex.exerciseTypeId,
|
||||
exerciseTypeId,
|
||||
exerciseNumber: exerciseNumber++,
|
||||
title: ex.title,
|
||||
instruction: ex.instruction,
|
||||
|
||||
@@ -14,6 +14,14 @@ import Friendship from '../models/community/friendship.js';
|
||||
|
||||
const saltRounds = 10;
|
||||
|
||||
const buildEncryptedEmailCandidates = (email) => {
|
||||
const encrypted = encrypt(email);
|
||||
return [
|
||||
Buffer.from(encrypted, 'utf8'),
|
||||
Buffer.from(encrypted, 'hex')
|
||||
];
|
||||
};
|
||||
|
||||
const getFriends = async (userId) => {
|
||||
console.log('getFriends', userId);
|
||||
try {
|
||||
@@ -54,13 +62,13 @@ const getFriends = async (userId) => {
|
||||
};
|
||||
|
||||
export const registerUser = async ({ email, username, password, language }) => {
|
||||
const encryptedEmail = encrypt(email);
|
||||
const encryptedEmailCandidates = buildEncryptedEmailCandidates(email);
|
||||
const query = `
|
||||
SELECT id FROM community.user
|
||||
WHERE email = :encryptedEmail
|
||||
WHERE email = ANY(:encryptedEmails)
|
||||
`;
|
||||
const existingUser = await sequelize.query(query, {
|
||||
replacements: { encryptedEmail },
|
||||
replacements: { encryptedEmails: encryptedEmailCandidates },
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
});
|
||||
if (existingUser.length > 0) {
|
||||
@@ -170,7 +178,14 @@ export const logoutUser = async (hashedUserId) => {
|
||||
};
|
||||
|
||||
export const handleForgotPassword = async ({ email }) => {
|
||||
const user = await User.findOne({ where: { email } });
|
||||
const encryptedEmailCandidates = buildEncryptedEmailCandidates(email);
|
||||
const user = await User.findOne({
|
||||
where: {
|
||||
email: {
|
||||
[Op.in]: encryptedEmailCandidates
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!user) {
|
||||
throw new Error('Email not found');
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,18 @@ import InterestTranslation from '../models/type/interest_translation.js';
|
||||
import { Op } from 'sequelize';
|
||||
import UserParamVisibilityType from '../models/type/user_param_visibility.js';
|
||||
import UserParamVisibility from '../models/community/user_param_visibility.js';
|
||||
import { generateIv } from '../utils/encryption.js';
|
||||
import { encrypt } from '../utils/encryption.js';
|
||||
import { sequelize } from '../utils/sequelize.js';
|
||||
|
||||
/** Wie UserParam.value-Setter: bei Verschlüsselungsfehler leeren String speichern, nicht crashen. */
|
||||
function encryptUserParamValue(plain) {
|
||||
try {
|
||||
return encrypt(plain);
|
||||
} catch (error) {
|
||||
console.error('Error encrypting user_param value:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsService extends BaseService{
|
||||
async getUserParams(userId, paramDescriptions) {
|
||||
@@ -381,6 +392,137 @@ class SettingsService extends BaseService{
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM-/Sprachassistent: Werte in community.user_param, Typen in type.user_param,
|
||||
* Gruppe type.settings.name = languageAssistant. API-Key separat (llm_api_key), Metadaten als JSON in llm_settings.
|
||||
* Kein Klartext-Key an den Client.
|
||||
*/
|
||||
async getLlmSettings(hashedUserId) {
|
||||
const user = await this.getUserByHashedId(hashedUserId);
|
||||
const settingsType = await UserParamType.findOne({ where: { description: 'llm_settings' } });
|
||||
const apiKeyType = await UserParamType.findOne({ where: { description: 'llm_api_key' } });
|
||||
if (!settingsType || !apiKeyType) {
|
||||
return {
|
||||
enabled: true,
|
||||
baseUrl: '',
|
||||
model: 'gpt-4o-mini',
|
||||
hasKey: false,
|
||||
keyLast4: null
|
||||
};
|
||||
}
|
||||
|
||||
const settingsRow = await UserParam.findOne({
|
||||
where: { userId: user.id, paramTypeId: settingsType.id }
|
||||
});
|
||||
const keyRow = await UserParam.findOne({
|
||||
where: { userId: user.id, paramTypeId: apiKeyType.id }
|
||||
});
|
||||
|
||||
let parsed = {};
|
||||
if (settingsRow?.value) {
|
||||
try {
|
||||
parsed = JSON.parse(settingsRow.value);
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
}
|
||||
|
||||
const hasStoredKey = Boolean(keyRow && keyRow.getDataValue('value') && String(keyRow.getDataValue('value')).trim());
|
||||
const hasReadableKey = Boolean(keyRow && keyRow.value && String(keyRow.value).trim());
|
||||
|
||||
return {
|
||||
enabled: parsed.enabled !== false,
|
||||
baseUrl: parsed.baseUrl || '',
|
||||
model: parsed.model || 'gpt-4o-mini',
|
||||
hasKey: hasStoredKey,
|
||||
keyLast4: parsed.keyLast4 || null,
|
||||
keyStatus: hasStoredKey ? (hasReadableKey ? 'stored' : 'invalid') : 'missing'
|
||||
};
|
||||
}
|
||||
|
||||
async saveLlmSettings(hashedUserId, payload) {
|
||||
const user = await this.getUserByHashedId(hashedUserId);
|
||||
const settingsType = await UserParamType.findOne({ where: { description: 'llm_settings' } });
|
||||
const apiKeyType = await UserParamType.findOne({ where: { description: 'llm_api_key' } });
|
||||
if (!settingsType || !apiKeyType) {
|
||||
throw new Error(
|
||||
'LLM-Einstellungstypen fehlen (languageAssistant / llm_settings / llm_api_key). initializeSettings & initializeTypes ausführen.'
|
||||
);
|
||||
}
|
||||
|
||||
const settingsRow = await UserParam.findOne({
|
||||
where: { userId: user.id, paramTypeId: settingsType.id }
|
||||
});
|
||||
let parsed = {};
|
||||
if (settingsRow?.value) {
|
||||
try {
|
||||
parsed = JSON.parse(settingsRow.value);
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
}
|
||||
|
||||
const { apiKey, clearKey, baseUrl, model, enabled } = payload;
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
if (clearKey) {
|
||||
const keyRow = await UserParam.findOne({
|
||||
where: { userId: user.id, paramTypeId: apiKeyType.id },
|
||||
transaction
|
||||
});
|
||||
if (keyRow) {
|
||||
await keyRow.destroy({ transaction });
|
||||
}
|
||||
delete parsed.keyLast4;
|
||||
} else if (apiKey !== undefined && String(apiKey).trim() !== '') {
|
||||
const plain = String(apiKey).trim();
|
||||
parsed.keyLast4 = plain.length >= 4 ? plain.slice(-4) : plain;
|
||||
const encKey = encryptUserParamValue(plain);
|
||||
const [keyRow] = await UserParam.findOrCreate({
|
||||
where: { userId: user.id, paramTypeId: apiKeyType.id },
|
||||
defaults: {
|
||||
userId: user.id,
|
||||
paramTypeId: apiKeyType.id,
|
||||
// Platzhalter: Setter verschlüsselt; wird sofort durch encKey überschrieben.
|
||||
value: ' '
|
||||
},
|
||||
transaction
|
||||
});
|
||||
keyRow.setDataValue('value', encKey);
|
||||
await keyRow.save({ fields: ['value'], transaction });
|
||||
}
|
||||
|
||||
if (baseUrl !== undefined) {
|
||||
parsed.baseUrl = String(baseUrl).trim();
|
||||
}
|
||||
if (model !== undefined) {
|
||||
parsed.model = String(model).trim() || 'gpt-4o-mini';
|
||||
}
|
||||
if (enabled !== undefined) {
|
||||
parsed.enabled = Boolean(enabled);
|
||||
}
|
||||
if (!parsed.model) {
|
||||
parsed.model = 'gpt-4o-mini';
|
||||
}
|
||||
|
||||
const jsonStr = JSON.stringify(parsed);
|
||||
const encMeta = encryptUserParamValue(jsonStr);
|
||||
const [metaRow] = await UserParam.findOrCreate({
|
||||
where: { userId: user.id, paramTypeId: settingsType.id },
|
||||
defaults: {
|
||||
userId: user.id,
|
||||
paramTypeId: settingsType.id,
|
||||
value: ' '
|
||||
},
|
||||
transaction
|
||||
});
|
||||
metaRow.setDataValue('value', encMeta);
|
||||
await metaRow.save({ fields: ['value'], transaction });
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
export default new SettingsService();
|
||||
|
||||
@@ -7,6 +7,8 @@ import VocabCourseProgress from '../models/community/vocab_course_progress.js';
|
||||
import VocabGrammarExerciseType from '../models/community/vocab_grammar_exercise_type.js';
|
||||
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
|
||||
import VocabGrammarExerciseProgress from '../models/community/vocab_grammar_exercise_progress.js';
|
||||
import UserParamType from '../models/type/user_param.js';
|
||||
import UserParam from '../models/community/user_param.js';
|
||||
import { sequelize } from '../utils/sequelize.js';
|
||||
import { notifyUser } from '../utils/socket.js';
|
||||
import { Op } from 'sequelize';
|
||||
@@ -22,6 +24,117 @@ export default class VocabService {
|
||||
return user;
|
||||
}
|
||||
|
||||
async _getUserLlmConfig(userId) {
|
||||
const [settingsType, apiKeyType] = await Promise.all([
|
||||
UserParamType.findOne({ where: { description: 'llm_settings' } }),
|
||||
UserParamType.findOne({ where: { description: 'llm_api_key' } })
|
||||
]);
|
||||
|
||||
if (!settingsType || !apiKeyType) {
|
||||
return {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
model: 'gpt-4o-mini',
|
||||
hasKey: false,
|
||||
apiKey: null,
|
||||
configured: false
|
||||
};
|
||||
}
|
||||
|
||||
const [settingsRow, keyRow] = await Promise.all([
|
||||
UserParam.findOne({ where: { userId, paramTypeId: settingsType.id } }),
|
||||
UserParam.findOne({ where: { userId, paramTypeId: apiKeyType.id } })
|
||||
]);
|
||||
|
||||
let parsed = {};
|
||||
if (settingsRow?.value) {
|
||||
try {
|
||||
parsed = JSON.parse(settingsRow.value);
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
}
|
||||
|
||||
const decryptedKey = keyRow?.value ? String(keyRow.value).trim() : null;
|
||||
const hasKey = Boolean(decryptedKey && String(decryptedKey).trim());
|
||||
const enabled = parsed.enabled !== false;
|
||||
const baseUrl = String(parsed.baseUrl || '').trim();
|
||||
|
||||
return {
|
||||
enabled,
|
||||
baseUrl,
|
||||
model: String(parsed.model || 'gpt-4o-mini').trim() || 'gpt-4o-mini',
|
||||
hasKey,
|
||||
apiKey: hasKey ? decryptedKey : null,
|
||||
configured: enabled && (hasKey || Boolean(baseUrl))
|
||||
};
|
||||
}
|
||||
|
||||
_sanitizeAssistantHistory(history) {
|
||||
if (!Array.isArray(history)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return history
|
||||
.slice(-8)
|
||||
.map((entry) => ({
|
||||
role: entry?.role === 'assistant' ? 'assistant' : 'user',
|
||||
content: String(entry?.content || '').trim()
|
||||
}))
|
||||
.filter((entry) => entry.content);
|
||||
}
|
||||
|
||||
_buildLessonAssistantSystemPrompt(lesson, mode = 'practice') {
|
||||
const didactics = lesson?.didactics || {};
|
||||
const learningGoals = Array.isArray(didactics.learningGoals) ? didactics.learningGoals : [];
|
||||
const corePatterns = Array.isArray(didactics.corePatterns) ? didactics.corePatterns : [];
|
||||
const speakingPrompts = Array.isArray(didactics.speakingPrompts) ? didactics.speakingPrompts : [];
|
||||
const practicalTasks = Array.isArray(didactics.practicalTasks) ? didactics.practicalTasks : [];
|
||||
|
||||
const modeDirectives = {
|
||||
explain: 'Erkläre knapp und klar die Grammatik, Muster und typische Fehler dieser Lektion. Nutze kurze Beispiele.',
|
||||
practice: 'Führe den Nutzer aktiv durch kurze Sprachpraxis. Stelle Rückfragen, gib kleine Aufgaben und fordere zu eigenen Antworten auf.',
|
||||
correct: 'Korrigiere Eingaben freundlich, konkret und knapp. Zeige eine bessere Formulierung und erkläre den wichtigsten Fehler.'
|
||||
};
|
||||
|
||||
return [
|
||||
'Du bist ein didaktischer Sprachassistent innerhalb eines Sprachkurses.',
|
||||
'Antworte auf Deutsch, aber verwende die Zielsprache der Lektion aktiv in Beispielen und Mini-Dialogen.',
|
||||
modeDirectives[mode] || modeDirectives.practice,
|
||||
'Halte Antworten kompakt, praxisnah und auf diese Lektion fokussiert.',
|
||||
`Kurs: ${lesson?.course?.title || 'Unbekannter Kurs'}`,
|
||||
`Lektion: ${lesson?.title || 'Unbekannte Lektion'}`,
|
||||
lesson?.description ? `Beschreibung: ${lesson.description}` : '',
|
||||
learningGoals.length ? `Lernziele: ${learningGoals.join(' | ')}` : '',
|
||||
corePatterns.length ? `Kernmuster: ${corePatterns.join(' | ')}` : '',
|
||||
speakingPrompts.length
|
||||
? `Sprechaufträge: ${speakingPrompts.map((item) => item.prompt || item.title || '').filter(Boolean).join(' | ')}`
|
||||
: '',
|
||||
practicalTasks.length
|
||||
? `Praxisaufgaben: ${practicalTasks.map((item) => item.text || item.title || '').filter(Boolean).join(' | ')}`
|
||||
: '',
|
||||
'Wenn der Nutzer eine Formulierung versucht, korrigiere sie präzise und gib eine verbesserte Version.'
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
_extractAssistantContent(responseData) {
|
||||
const rawContent = responseData?.choices?.[0]?.message?.content;
|
||||
if (typeof rawContent === 'string') {
|
||||
return rawContent.trim();
|
||||
}
|
||||
if (Array.isArray(rawContent)) {
|
||||
return rawContent
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return item;
|
||||
if (item?.type === 'text') return item.text || '';
|
||||
return '';
|
||||
})
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
_normalizeLexeme(text) {
|
||||
return String(text || '')
|
||||
.trim()
|
||||
@@ -29,6 +142,126 @@ export default class VocabService {
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
_normalizeTextAnswer(text) {
|
||||
return String(text || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[.,!?;:¿¡"]/g, '')
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
_normalizeStringList(value) {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((entry) => String(entry || '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(/\r?\n|;/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
_normalizeStructuredList(value, keys = ['title', 'text']) {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((entry) => {
|
||||
if (typeof entry === 'string') {
|
||||
return { title: '', text: entry.trim() };
|
||||
}
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
const normalized = {};
|
||||
keys.forEach((key) => {
|
||||
if (entry[key] !== undefined && entry[key] !== null) {
|
||||
normalized[key] = String(entry[key]).trim();
|
||||
}
|
||||
});
|
||||
return Object.keys(normalized).length > 0 ? normalized : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
_buildLessonDidactics(plainLesson) {
|
||||
const grammarExercises = Array.isArray(plainLesson.grammarExercises) ? plainLesson.grammarExercises : [];
|
||||
const grammarExplanations = [];
|
||||
const patterns = [];
|
||||
const speakingPrompts = [];
|
||||
|
||||
grammarExercises.forEach((exercise) => {
|
||||
const questionData = typeof exercise.questionData === 'string'
|
||||
? JSON.parse(exercise.questionData)
|
||||
: (exercise.questionData || {});
|
||||
|
||||
if (exercise.explanation) {
|
||||
grammarExplanations.push({
|
||||
title: exercise.title || '',
|
||||
text: exercise.explanation
|
||||
});
|
||||
}
|
||||
|
||||
const patternCandidates = [
|
||||
questionData.pattern,
|
||||
questionData.exampleSentence,
|
||||
questionData.modelAnswer,
|
||||
questionData.promptSentence
|
||||
].filter(Boolean);
|
||||
|
||||
patternCandidates.forEach((candidate) => {
|
||||
patterns.push(String(candidate).trim());
|
||||
});
|
||||
|
||||
if (questionData.type === 'reading_aloud' || questionData.type === 'speaking_from_memory') {
|
||||
speakingPrompts.push({
|
||||
title: exercise.title || '',
|
||||
prompt: questionData.question || questionData.text || '',
|
||||
cue: questionData.expectedText || '',
|
||||
keywords: Array.isArray(questionData.keywords) ? questionData.keywords : []
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const uniqueGrammarExplanations = grammarExplanations.filter((item, index, list) => {
|
||||
const signature = `${item.title}::${item.text}`;
|
||||
return list.findIndex((entry) => `${entry.title}::${entry.text}` === signature) === index;
|
||||
});
|
||||
|
||||
const uniquePatterns = [...new Set(patterns.map((item) => String(item || '').trim()).filter(Boolean))];
|
||||
|
||||
const learningGoals = this._normalizeStringList(plainLesson.learningGoals);
|
||||
const corePatterns = this._normalizeStringList(plainLesson.corePatterns);
|
||||
const grammarFocus = this._normalizeStructuredList(plainLesson.grammarFocus, ['title', 'text', 'example']);
|
||||
const explicitSpeakingPrompts = this._normalizeStructuredList(plainLesson.speakingPrompts, ['title', 'prompt', 'cue']);
|
||||
const practicalTasks = this._normalizeStructuredList(plainLesson.practicalTasks, ['title', 'text']);
|
||||
|
||||
return {
|
||||
learningGoals: learningGoals.length > 0
|
||||
? learningGoals
|
||||
: [
|
||||
'Die Schlüsselausdrücke der Lektion verstehen und wiedererkennen.',
|
||||
'Ein bis zwei Satzmuster aktiv anwenden.',
|
||||
'Kurze Sätze oder Mini-Dialoge zum Thema selbst bilden.'
|
||||
],
|
||||
corePatterns: corePatterns.length > 0 ? corePatterns : uniquePatterns.slice(0, 5),
|
||||
grammarFocus: grammarFocus.length > 0 ? grammarFocus : uniqueGrammarExplanations.slice(0, 4),
|
||||
speakingPrompts: explicitSpeakingPrompts.length > 0 ? explicitSpeakingPrompts : speakingPrompts.slice(0, 4),
|
||||
practicalTasks: practicalTasks.length > 0
|
||||
? practicalTasks
|
||||
: [
|
||||
{
|
||||
title: 'Mini-Anwendung',
|
||||
text: 'Formuliere zwei bis drei eigene Sätze oder einen kurzen Dialog mit dem Muster dieser Lektion.'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
async _getLanguageAccess(userId, languageId) {
|
||||
const id = Number.parseInt(languageId, 10);
|
||||
if (!Number.isFinite(id)) {
|
||||
@@ -895,18 +1128,111 @@ export default class VocabService {
|
||||
plainLesson.reviewVocabExercises = plainLesson.previousLessonExercises || [];
|
||||
}
|
||||
|
||||
console.log(`[getLesson] Lektion ${lessonId} geladen:`, {
|
||||
id: plainLesson.id,
|
||||
title: plainLesson.title,
|
||||
lessonType: plainLesson.lessonType,
|
||||
exerciseCount: plainLesson.grammarExercises ? plainLesson.grammarExercises.length : 0,
|
||||
reviewLessonsCount: plainLesson.reviewLessons ? plainLesson.reviewLessons.length : 0,
|
||||
reviewVocabExercisesCount: plainLesson.reviewVocabExercises ? plainLesson.reviewVocabExercises.length : 0,
|
||||
previousLessonExercisesCount: plainLesson.previousLessonExercises ? plainLesson.previousLessonExercises.length : 0
|
||||
});
|
||||
plainLesson.didactics = this._buildLessonDidactics(plainLesson);
|
||||
return plainLesson;
|
||||
}
|
||||
|
||||
async sendLessonAssistantMessage(hashedUserId, lessonId, payload = {}) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const lesson = await this.getLesson(hashedUserId, lessonId);
|
||||
const config = await this._getUserLlmConfig(user.id);
|
||||
|
||||
if (!config.enabled) {
|
||||
const err = new Error('Der Sprachassistent ist in deinen Einstellungen derzeit deaktiviert.');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!config.configured) {
|
||||
const err = new Error('Der Sprachassistent ist noch nicht eingerichtet. Bitte hinterlege zuerst Modell und API-Zugang in den Einstellungen.');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const message = String(payload?.message || '').trim();
|
||||
if (!message) {
|
||||
const err = new Error('Bitte gib eine Nachricht für den Sprachassistenten ein.');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const mode = ['explain', 'practice', 'correct'].includes(payload?.mode) ? payload.mode : 'practice';
|
||||
const history = this._sanitizeAssistantHistory(payload?.history);
|
||||
const baseUrl = config.baseUrl || 'https://api.openai.com/v1';
|
||||
const endpoint = `${baseUrl.replace(/\/$/, '')}/chat/completions`;
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (config.apiKey) {
|
||||
headers.Authorization = `Bearer ${config.apiKey}`;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 30000);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: config.model,
|
||||
temperature: 0.7,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: this._buildLessonAssistantSystemPrompt(lesson, mode)
|
||||
},
|
||||
...history,
|
||||
{
|
||||
role: 'user',
|
||||
content: message
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
const err = new Error(
|
||||
error?.name === 'AbortError'
|
||||
? 'Der Sprachassistent hat zu lange für eine Antwort gebraucht.'
|
||||
: 'Der Sprachassistent konnte nicht erreicht werden.'
|
||||
);
|
||||
err.status = 502;
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
let responseData = null;
|
||||
try {
|
||||
responseData = await response.json();
|
||||
} catch {
|
||||
responseData = null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const messageFromApi = responseData?.error?.message || responseData?.message || 'Der Sprachassistent hat die Anfrage abgelehnt.';
|
||||
const err = new Error(messageFromApi);
|
||||
err.status = response.status || 502;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const reply = this._extractAssistantContent(responseData);
|
||||
if (!reply) {
|
||||
const err = new Error('Der Sprachassistent hat keine verwertbare Antwort geliefert.');
|
||||
err.status = 502;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
reply,
|
||||
model: responseData?.model || config.model,
|
||||
mode
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt alle Lektionen, die in einer Wiederholungslektion wiederholt werden sollen
|
||||
*/
|
||||
@@ -975,7 +1301,7 @@ export default class VocabService {
|
||||
return exercises.map(e => e.get({ plain: true }));
|
||||
}
|
||||
|
||||
async addLessonToCourse(hashedUserId, courseId, { chapterId, lessonNumber, title, description, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, targetMinutes, targetScorePercent, requiresReview }) {
|
||||
async addLessonToCourse(hashedUserId, courseId, { chapterId, lessonNumber, title, description, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, targetMinutes, targetScorePercent, requiresReview }) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const course = await VocabCourse.findByPk(courseId);
|
||||
|
||||
@@ -1019,6 +1345,11 @@ export default class VocabService {
|
||||
lessonType: lessonType || 'vocab',
|
||||
audioUrl: audioUrl || null,
|
||||
culturalNotes: culturalNotes || null,
|
||||
learningGoals: this._normalizeStringList(learningGoals),
|
||||
corePatterns: this._normalizeStringList(corePatterns),
|
||||
grammarFocus: this._normalizeStructuredList(grammarFocus, ['title', 'text', 'example']),
|
||||
speakingPrompts: this._normalizeStructuredList(speakingPrompts, ['title', 'prompt', 'cue']),
|
||||
practicalTasks: this._normalizeStructuredList(practicalTasks, ['title', 'text']),
|
||||
targetMinutes: targetMinutes ? Number(targetMinutes) : null,
|
||||
targetScorePercent: targetScorePercent ? Number(targetScorePercent) : 80,
|
||||
requiresReview: requiresReview !== undefined ? Boolean(requiresReview) : false
|
||||
@@ -1027,7 +1358,7 @@ export default class VocabService {
|
||||
return lesson.get({ plain: true });
|
||||
}
|
||||
|
||||
async updateLesson(hashedUserId, lessonId, { title, description, lessonNumber, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, targetMinutes, targetScorePercent, requiresReview }) {
|
||||
async updateLesson(hashedUserId, lessonId, { title, description, lessonNumber, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, targetMinutes, targetScorePercent, requiresReview }) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
||||
include: [{ model: VocabCourse, as: 'course' }]
|
||||
@@ -1054,6 +1385,11 @@ export default class VocabService {
|
||||
if (lessonType !== undefined) updates.lessonType = lessonType;
|
||||
if (audioUrl !== undefined) updates.audioUrl = audioUrl;
|
||||
if (culturalNotes !== undefined) updates.culturalNotes = culturalNotes;
|
||||
if (learningGoals !== undefined) updates.learningGoals = this._normalizeStringList(learningGoals);
|
||||
if (corePatterns !== undefined) updates.corePatterns = this._normalizeStringList(corePatterns);
|
||||
if (grammarFocus !== undefined) updates.grammarFocus = this._normalizeStructuredList(grammarFocus, ['title', 'text', 'example']);
|
||||
if (speakingPrompts !== undefined) updates.speakingPrompts = this._normalizeStructuredList(speakingPrompts, ['title', 'prompt', 'cue']);
|
||||
if (practicalTasks !== undefined) updates.practicalTasks = this._normalizeStructuredList(practicalTasks, ['title', 'text']);
|
||||
if (targetMinutes !== undefined) updates.targetMinutes = targetMinutes ? Number(targetMinutes) : null;
|
||||
if (targetScorePercent !== undefined) updates.targetScorePercent = Number(targetScorePercent);
|
||||
if (requiresReview !== undefined) updates.requiresReview = Boolean(requiresReview);
|
||||
@@ -1450,6 +1786,15 @@ export default class VocabService {
|
||||
correctAnswer = questionData.expectedText || questionData.text || '';
|
||||
alternatives = questionData.keywords || [];
|
||||
}
|
||||
else if (questionData.type === 'sentence_building' || questionData.type === 'dialog_completion' || questionData.type === 'situational_response' || questionData.type === 'pattern_drill') {
|
||||
const rawCorrect = answerData.correct ?? answerData.correctAnswer ?? answerData.answers ?? answerData.modelAnswer;
|
||||
if (Array.isArray(rawCorrect)) {
|
||||
correctAnswer = rawCorrect.join(' / ');
|
||||
} else {
|
||||
correctAnswer = rawCorrect || questionData.modelAnswer || '';
|
||||
}
|
||||
alternatives = answerData.alternatives || questionData.keywords || [];
|
||||
}
|
||||
// Fallback: Versuche correct oder correctAnswer
|
||||
else {
|
||||
correctAnswer = Array.isArray(answerData.correct)
|
||||
@@ -1531,10 +1876,9 @@ export default class VocabService {
|
||||
// Für Reading Aloud: userAnswer ist der erkannte Text (String)
|
||||
// Vergleiche mit dem erwarteten Text aus questionData.text
|
||||
if (parsedQuestionData.type === 'reading_aloud' || parsedQuestionData.type === 'speaking_from_memory') {
|
||||
const normalize = (str) => String(str || '').trim().toLowerCase().replace(/[.,!?;:]/g, '');
|
||||
const expectedText = parsedQuestionData.text || parsedQuestionData.expectedText || '';
|
||||
const normalizedExpected = normalize(expectedText);
|
||||
const normalizedUser = normalize(userAnswer);
|
||||
const normalizedExpected = this._normalizeTextAnswer(expectedText);
|
||||
const normalizedUser = this._normalizeTextAnswer(userAnswer);
|
||||
|
||||
// Für reading_aloud: Exakter Vergleich oder Levenshtein-Distanz
|
||||
if (parsedQuestionData.type === 'reading_aloud') {
|
||||
@@ -1550,16 +1894,33 @@ export default class VocabService {
|
||||
return normalizedUser === normalizedExpected;
|
||||
}
|
||||
// Prüfe ob alle Schlüsselwörter vorhanden sind
|
||||
return keywords.every(keyword => normalizedUser.includes(normalize(keyword)));
|
||||
return keywords.every(keyword => normalizedUser.includes(this._normalizeTextAnswer(keyword)));
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedQuestionData.type === 'sentence_building' || parsedQuestionData.type === 'dialog_completion' || parsedQuestionData.type === 'situational_response' || parsedQuestionData.type === 'pattern_drill') {
|
||||
const candidateAnswers = parsedAnswerData.correct ?? parsedAnswerData.correctAnswer ?? parsedAnswerData.answers ?? parsedAnswerData.modelAnswer ?? [];
|
||||
const normalizedUser = this._normalizeTextAnswer(userAnswer);
|
||||
const answers = Array.isArray(candidateAnswers) ? candidateAnswers : [candidateAnswers];
|
||||
|
||||
if (parsedQuestionData.type === 'situational_response') {
|
||||
const keywords = parsedQuestionData.keywords || parsedAnswerData.keywords || [];
|
||||
if (keywords.length > 0) {
|
||||
return keywords.every((keyword) => normalizedUser.includes(this._normalizeTextAnswer(keyword)));
|
||||
}
|
||||
}
|
||||
|
||||
return answers
|
||||
.map((answer) => this._normalizeTextAnswer(answer))
|
||||
.filter(Boolean)
|
||||
.some((answer) => answer === normalizedUser);
|
||||
}
|
||||
|
||||
// Für andere Typen: einfacher String-Vergleich (kann später erweitert werden)
|
||||
const normalize = (str) => String(str || '').trim().toLowerCase();
|
||||
const correctAnswers = parsedAnswerData.correct || parsedAnswerData.correctAnswer || [];
|
||||
const correctAnswersArray = Array.isArray(correctAnswers) ? correctAnswers : [correctAnswers];
|
||||
const normalizedUserAnswer = normalize(userAnswer);
|
||||
return correctAnswersArray.some(correct => normalize(correct) === normalizedUserAnswer);
|
||||
const normalizedUserAnswer = this._normalizeTextAnswer(userAnswer);
|
||||
return correctAnswersArray.some(correct => this._normalizeTextAnswer(correct) === normalizedUserAnswer);
|
||||
}
|
||||
|
||||
async getGrammarExerciseProgress(hashedUserId, lessonId) {
|
||||
@@ -1638,5 +1999,3 @@ export default class VocabService {
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
5
backend/sql/add_household_tension_to_user_house.sql
Normal file
5
backend/sql/add_household_tension_to_user_house.sql
Normal 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;
|
||||
24
backend/sql/add_language_assistant_user_params.sql
Normal file
24
backend/sql/add_language_assistant_user_params.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Sprachassistent / LLM: Einstellungen über type.settings + type.user_param + community.user_param
|
||||
-- (keine Spalten mehr an community.user).
|
||||
--
|
||||
-- Falls du vorher add_user_llm_columns.sql ausgeführt hast: Spalten an user wieder entfernen.
|
||||
ALTER TABLE community."user" DROP COLUMN IF EXISTS llm_api_key_encrypted;
|
||||
ALTER TABLE community."user" DROP COLUMN IF EXISTS llm_settings;
|
||||
|
||||
-- Gruppe „languageAssistant“
|
||||
INSERT INTO type.settings (name)
|
||||
SELECT 'languageAssistant'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM type.settings WHERE name = 'languageAssistant');
|
||||
|
||||
-- Param-Typen (description eindeutig)
|
||||
INSERT INTO type.user_param (description, datatype, settings_id, order_id, immutable, min_age, gender, unit)
|
||||
SELECT 'llm_settings', 'string', s.id, 900, false, NULL, NULL, NULL
|
||||
FROM type.settings s
|
||||
WHERE s.name = 'languageAssistant'
|
||||
AND NOT EXISTS (SELECT 1 FROM type.user_param p WHERE p.description = 'llm_settings');
|
||||
|
||||
INSERT INTO type.user_param (description, datatype, settings_id, order_id, immutable, min_age, gender, unit)
|
||||
SELECT 'llm_api_key', 'string', s.id, 901, false, NULL, NULL, NULL
|
||||
FROM type.settings s
|
||||
WHERE s.name = 'languageAssistant'
|
||||
AND NOT EXISTS (SELECT 1 FROM type.user_param p WHERE p.description = 'llm_api_key');
|
||||
88
backend/sql/add_relationship_state_and_child_legitimacy.sql
Normal file
88
backend/sql/add_relationship_state_and_child_legitimacy.sql
Normal 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;
|
||||
7
backend/sql/add_servants_to_user_house.sql
Normal file
7
backend/sql/add_servants_to_user_house.sql
Normal 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;
|
||||
@@ -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;
|
||||
5
backend/sql/add_underground_investigate_affair_type.sql
Normal file
5
backend/sql/add_underground_investigate_affair_type.sql
Normal 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;
|
||||
5
backend/sql/add_underground_raid_transport_type.sql
Normal file
5
backend/sql/add_underground_raid_transport_type.sql
Normal 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;
|
||||
14
backend/sql/add_vocab_lesson_didactics.sql
Normal file
14
backend/sql/add_vocab_lesson_didactics.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
ALTER TABLE community.vocab_course_lesson
|
||||
ADD COLUMN IF NOT EXISTS learning_goals JSONB,
|
||||
ADD COLUMN IF NOT EXISTS core_patterns JSONB,
|
||||
ADD COLUMN IF NOT EXISTS grammar_focus JSONB,
|
||||
ADD COLUMN IF NOT EXISTS speaking_prompts JSONB,
|
||||
ADD COLUMN IF NOT EXISTS practical_tasks JSONB;
|
||||
|
||||
INSERT INTO community.vocab_grammar_exercise_type (name, description, created_at) VALUES
|
||||
('dialog_completion', 'Dialogergänzung', NOW()),
|
||||
('situational_response', 'Situative Antwort', NOW()),
|
||||
('pattern_drill', 'Muster-Drill', NOW()),
|
||||
('reading_aloud', 'Lautlese-Übung', NOW()),
|
||||
('speaking_from_memory', 'Freies Sprechen', NOW())
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
50
backend/sql/backfill_relationship_state.sql
Normal file
50
backend/sql/backfill_relationship_state.sql
Normal 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;
|
||||
38
backend/sql/balance_carrot_product.sql
Normal file
38
backend/sql/balance_carrot_product.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- Karotte: Debug-Tempo und Preis an gleiche Basis wie andere Kat.-1-Waren (siehe initializeFalukantPredefines.js).
|
||||
-- Sicher für alle Installationen: nur production_time ohne optionale Spalten.
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE falukant_type.product
|
||||
SET production_time = 2
|
||||
WHERE label_tr = 'carrot';
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Optional (wenn Migration mit original_sell_cost läuft): in derselben Session ausführen
|
||||
/*
|
||||
UPDATE falukant_type.product
|
||||
SET original_sell_cost = 6
|
||||
WHERE label_tr = 'carrot';
|
||||
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region
|
||||
UNION ALL
|
||||
SELECT a.start_id, r.id, r.parent_id, r.tax_percent
|
||||
FROM falukant_data.region r
|
||||
JOIN ancestors a ON r.id = a.parent_id
|
||||
), totals AS (
|
||||
SELECT start_id, COALESCE(SUM(tax_percent), 0) AS total FROM ancestors GROUP BY start_id
|
||||
), mm AS (
|
||||
SELECT COALESCE(MAX(total), 0) AS max_total FROM totals
|
||||
)
|
||||
UPDATE falukant_type.product p
|
||||
SET sell_cost = CEIL(p.original_sell_cost * (
|
||||
CASE WHEN (1 - mm.max_total / 100) <= 0 THEN 1 ELSE (1 / (1 - mm.max_total / 100)) END
|
||||
))
|
||||
FROM mm
|
||||
WHERE p.label_tr = 'carrot' AND p.original_sell_cost IS NOT NULL;
|
||||
*/
|
||||
|
||||
-- Ohne original_sell_cost: grob sell_cost = 6 (wie Milch/Brot; ggf. anpassen)
|
||||
-- UPDATE falukant_type.product SET sell_cost = 6 WHERE label_tr = 'carrot';
|
||||
@@ -44,6 +44,11 @@ CREATE TABLE IF NOT EXISTS community.vocab_course_lesson (
|
||||
lesson_type TEXT DEFAULT 'vocab',
|
||||
audio_url TEXT,
|
||||
cultural_notes TEXT,
|
||||
learning_goals JSONB,
|
||||
core_patterns JSONB,
|
||||
grammar_focus JSONB,
|
||||
speaking_prompts JSONB,
|
||||
practical_tasks JSONB,
|
||||
target_minutes INTEGER,
|
||||
target_score_percent INTEGER DEFAULT 80,
|
||||
requires_review BOOLEAN DEFAULT false,
|
||||
@@ -213,13 +218,18 @@ CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_exercise_idx
|
||||
-- ============================================
|
||||
|
||||
-- Standard-Übungstypen für Grammatik
|
||||
INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES
|
||||
('gap_fill', 'Lückentext-Übung'),
|
||||
('multiple_choice', 'Multiple-Choice-Fragen'),
|
||||
('sentence_building', 'Satzbau-Übung'),
|
||||
('transformation', 'Satzumformung'),
|
||||
('conjugation', 'Konjugations-Übung'),
|
||||
('declension', 'Deklinations-Übung')
|
||||
INSERT INTO community.vocab_grammar_exercise_type (name, description, created_at) VALUES
|
||||
('gap_fill', 'Lückentext-Übung', NOW()),
|
||||
('multiple_choice', 'Multiple-Choice-Fragen', NOW()),
|
||||
('sentence_building', 'Satzbau-Übung', NOW()),
|
||||
('transformation', 'Satzumformung', NOW()),
|
||||
('conjugation', 'Konjugations-Übung', NOW()),
|
||||
('declension', 'Deklinations-Übung', NOW()),
|
||||
('dialog_completion', 'Dialogergänzung', NOW()),
|
||||
('situational_response', 'Situative Antwort', NOW()),
|
||||
('pattern_drill', 'Muster-Drill', NOW()),
|
||||
('reading_aloud', 'Lautlese-Übung', NOW()),
|
||||
('speaking_from_memory', 'Freies Sprechen', NOW())
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- ============================================
|
||||
@@ -230,6 +240,16 @@ COMMENT ON COLUMN community.vocab_course_lesson.lesson_type IS
|
||||
'Type: vocab, grammar, conversation, culture, review';
|
||||
COMMENT ON COLUMN community.vocab_course_lesson.target_minutes IS
|
||||
'Zielzeit in Minuten für diese Lektion';
|
||||
COMMENT ON COLUMN community.vocab_course_lesson.learning_goals IS
|
||||
'Lernziele der Lektion als JSON-Array';
|
||||
COMMENT ON COLUMN community.vocab_course_lesson.core_patterns IS
|
||||
'Kernmuster und Beispielsätze als JSON-Array';
|
||||
COMMENT ON COLUMN community.vocab_course_lesson.grammar_focus IS
|
||||
'Grammatik-Impulse als JSON-Array von Objekten';
|
||||
COMMENT ON COLUMN community.vocab_course_lesson.speaking_prompts IS
|
||||
'Sprechaufträge als JSON-Array von Objekten';
|
||||
COMMENT ON COLUMN community.vocab_course_lesson.practical_tasks IS
|
||||
'Praxisaufgaben als JSON-Array von Objekten';
|
||||
COMMENT ON COLUMN community.vocab_course_lesson.target_score_percent IS
|
||||
'Mindestpunktzahl in Prozent zum Abschluss (z.B. 80)';
|
||||
COMMENT ON COLUMN community.vocab_course_lesson.requires_review IS
|
||||
|
||||
31
backend/sql/diagnostics/falukant_moneyflow_by_activity.sql
Normal file
31
backend/sql/diagnostics/falukant_moneyflow_by_activity.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- Falukant: Geldbewegungen aus falukant_log.moneyflow (jede Zeile = ein Aufruf von
|
||||
-- falukant_data.update_money → activity-String wie im Backend übergeben).
|
||||
--
|
||||
-- Typische activity-Werte (siehe falukantService.js → updateFalukantUserMoney):
|
||||
-- Wirtschaft: Production cost; Product sale (net); Sell all products (net);
|
||||
-- Steueranteil Region (Verkauf); Sales tax (…); Buy/Sell storage (type: …)
|
||||
-- Transport/Fahrzeuge: transport; build_vehicles; buy_vehicles; repair_vehicle; repair_all_vehicles
|
||||
-- Filiale: create_branch
|
||||
-- Haus: housebuy; servants_hired; household_order; renovation_*; renovation_all
|
||||
-- Soziales: marriage_gift; Marriage cost; Gift cost; partyOrder; Baptism; Reputation action: …
|
||||
-- Bildung: learnAll; learnItem:<productId>
|
||||
-- Sonst: new nobility title; health.<aktivität>; credit taken (Kredit = positiver change_value)
|
||||
--
|
||||
-- Platzhalter (ersetzt durch scripts/falukant-moneyflow-report.mjs):
|
||||
-- __DIAG_DAYS__ → positive Ganzzahl (Tage zurück)
|
||||
-- __DIAG_USER_FILTER__ → leer ODER " AND m.falukant_user_id = <id>"
|
||||
--
|
||||
-- Manuell in psql: __DIAG_DAYS__ durch z. B. 30 ersetzen, __DIAG_USER_FILTER__ leer lassen.
|
||||
|
||||
SELECT
|
||||
m.activity,
|
||||
COUNT(*)::bigint AS n,
|
||||
ROUND(SUM(m.change_value)::numeric, 2) AS sum_change,
|
||||
ROUND(SUM(CASE WHEN m.change_value < 0 THEN -m.change_value ELSE 0 END)::numeric, 2) AS total_outflow,
|
||||
ROUND(SUM(CASE WHEN m.change_value > 0 THEN m.change_value ELSE 0 END)::numeric, 2) AS total_inflow,
|
||||
ROUND(AVG(m.change_value)::numeric, 4) AS avg_change
|
||||
FROM falukant_log.moneyflow m
|
||||
WHERE m."time" >= NOW() - (INTERVAL '1 day' * __DIAG_DAYS__)
|
||||
__DIAG_USER_FILTER__
|
||||
GROUP BY m.activity
|
||||
ORDER BY sum_change ASC NULLS LAST;
|
||||
14
backend/sql/diagnostics/falukant_moneyflow_window_totals.sql
Normal file
14
backend/sql/diagnostics/falukant_moneyflow_window_totals.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Kurzüberblick: Zeilen, Nutzer, Summen Ein-/Ausgang im gleichen Fenster wie
|
||||
-- falukant_moneyflow_by_activity.sql (Platzhalter identisch).
|
||||
|
||||
SELECT
|
||||
COUNT(*)::bigint AS row_count,
|
||||
COUNT(DISTINCT m.falukant_user_id)::bigint AS distinct_falukant_users,
|
||||
ROUND(SUM(m.change_value)::numeric, 2) AS net_sum_all,
|
||||
ROUND(SUM(CASE WHEN m.change_value < 0 THEN m.change_value ELSE 0 END)::numeric, 2) AS sum_negative_only,
|
||||
ROUND(SUM(CASE WHEN m.change_value > 0 THEN m.change_value ELSE 0 END)::numeric, 2) AS sum_positive_only,
|
||||
MIN(m."time") AS first_ts,
|
||||
MAX(m."time") AS last_ts
|
||||
FROM falukant_log.moneyflow m
|
||||
WHERE m."time" >= NOW() - (INTERVAL '1 day' * __DIAG_DAYS__)
|
||||
__DIAG_USER_FILTER__;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Übersicht: regionaler Warenwert je Produkt (Nachfrage / „worth“).
|
||||
-- Niedrige Mittelwerte erklären schwache Verkaufspreise; siehe auch falukant_data.town_product_worth Hooks im Backend-Model.
|
||||
|
||||
SELECT
|
||||
p.id,
|
||||
p.label_tr,
|
||||
p.category,
|
||||
p.sell_cost::numeric AS sell_cost,
|
||||
ROUND(AVG(tpw.worth_percent)::numeric, 2) AS avg_worth_pct,
|
||||
ROUND(MIN(tpw.worth_percent)::numeric, 2) AS min_worth_pct,
|
||||
ROUND(MAX(tpw.worth_percent)::numeric, 2) AS max_worth_pct,
|
||||
COUNT(tpw.region_id) AS region_rows
|
||||
FROM falukant_type.product p
|
||||
LEFT JOIN falukant_data.town_product_worth tpw ON tpw.product_id = p.id
|
||||
GROUP BY p.id, p.label_tr, p.category, p.sell_cost
|
||||
ORDER BY p.category, p.label_tr;
|
||||
32
backend/sql/expand_debtors_prism.sql
Normal file
32
backend/sql/expand_debtors_prism.sql
Normal 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;
|
||||
2
backend/sql/expand_user_param_value_to_text.sql
Normal file
2
backend/sql/expand_user_param_value_to_text.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE community.user_param
|
||||
ALTER COLUMN value TYPE TEXT;
|
||||
@@ -19,6 +19,11 @@ ADD COLUMN IF NOT EXISTS day_number INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS lesson_type TEXT DEFAULT 'vocab',
|
||||
ADD COLUMN IF NOT EXISTS audio_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS cultural_notes TEXT,
|
||||
ADD COLUMN IF NOT EXISTS learning_goals JSONB,
|
||||
ADD COLUMN IF NOT EXISTS core_patterns JSONB,
|
||||
ADD COLUMN IF NOT EXISTS grammar_focus JSONB,
|
||||
ADD COLUMN IF NOT EXISTS speaking_prompts JSONB,
|
||||
ADD COLUMN IF NOT EXISTS practical_tasks JSONB,
|
||||
ADD COLUMN IF NOT EXISTS target_minutes INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS target_score_percent INTEGER DEFAULT 80,
|
||||
ADD COLUMN IF NOT EXISTS requires_review BOOLEAN DEFAULT false;
|
||||
@@ -105,13 +110,18 @@ CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_exercise_idx
|
||||
-- ============================================
|
||||
-- 5. Standard-Daten einfügen
|
||||
-- ============================================
|
||||
INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES
|
||||
('gap_fill', 'Lückentext-Übung'),
|
||||
('multiple_choice', 'Multiple-Choice-Fragen'),
|
||||
('sentence_building', 'Satzbau-Übung'),
|
||||
('transformation', 'Satzumformung'),
|
||||
('conjugation', 'Konjugations-Übung'),
|
||||
('declension', 'Deklinations-Übung')
|
||||
INSERT INTO community.vocab_grammar_exercise_type (name, description, created_at) VALUES
|
||||
('gap_fill', 'Lückentext-Übung', NOW()),
|
||||
('multiple_choice', 'Multiple-Choice-Fragen', NOW()),
|
||||
('sentence_building', 'Satzbau-Übung', NOW()),
|
||||
('transformation', 'Satzumformung', NOW()),
|
||||
('conjugation', 'Konjugations-Übung', NOW()),
|
||||
('declension', 'Deklinations-Übung', NOW()),
|
||||
('dialog_completion', 'Dialogergänzung', NOW()),
|
||||
('situational_response', 'Situative Antwort', NOW()),
|
||||
('pattern_drill', 'Muster-Drill', NOW()),
|
||||
('reading_aloud', 'Lautlese-Übung', NOW()),
|
||||
('speaking_from_memory', 'Freies Sprechen', NOW())
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- ============================================
|
||||
@@ -121,6 +131,16 @@ COMMENT ON COLUMN community.vocab_course_lesson.lesson_type IS
|
||||
'Type: vocab, grammar, conversation, culture, review';
|
||||
COMMENT ON COLUMN community.vocab_course_lesson.target_minutes IS
|
||||
'Zielzeit in Minuten für diese Lektion';
|
||||
COMMENT ON COLUMN community.vocab_course_lesson.learning_goals IS
|
||||
'Lernziele der Lektion als JSON-Array';
|
||||
COMMENT ON COLUMN community.vocab_course_lesson.core_patterns IS
|
||||
'Kernmuster und Beispielsätze als JSON-Array';
|
||||
COMMENT ON COLUMN community.vocab_course_lesson.grammar_focus IS
|
||||
'Grammatik-Impulse als JSON-Array von Objekten';
|
||||
COMMENT ON COLUMN community.vocab_course_lesson.speaking_prompts IS
|
||||
'Sprechaufträge als JSON-Array von Objekten';
|
||||
COMMENT ON COLUMN community.vocab_course_lesson.practical_tasks IS
|
||||
'Praxisaufgaben als JSON-Array von Objekten';
|
||||
COMMENT ON COLUMN community.vocab_course_lesson.target_score_percent IS
|
||||
'Mindestpunktzahl in Prozent zum Abschluss (z.B. 80)';
|
||||
COMMENT ON COLUMN community.vocab_course_lesson.requires_review IS
|
||||
|
||||
132
backend/sql/update_nobility_requirements_extended.sql
Normal file
132
backend/sql/update_nobility_requirements_extended.sql
Normal 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;
|
||||
@@ -14,19 +14,32 @@ export const generateIv = () => {
|
||||
|
||||
export const encrypt = (text) => {
|
||||
const cipher = crypto.createCipheriv(algorithm, key, null);
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
let encrypted = cipher.update(text, 'utf8', 'base64');
|
||||
encrypted += cipher.final('base64');
|
||||
return encrypted;
|
||||
};
|
||||
|
||||
export const decrypt = (text) => {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const input = String(text);
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(algorithm, key, null);
|
||||
let decrypted = decipher.update(text, 'hex', 'utf8');
|
||||
let decrypted = decipher.update(input, 'base64', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return null;
|
||||
} catch (base64Error) {
|
||||
try {
|
||||
// Rueckwaertskompatibel fuer bereits gespeicherte Hex-Werte.
|
||||
const decipher = crypto.createDecipheriv(algorithm, key, null);
|
||||
let decrypted = decipher.update(input, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
} catch (hexError) {
|
||||
console.log(hexError);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -250,7 +250,8 @@ async function initializeFalukantProducts() {
|
||||
const baseProducts = [
|
||||
{ labelTr: 'wheat', category: 1, productionTime: 2, sellCost: 7 },
|
||||
{ labelTr: 'grain', category: 1, productionTime: 2, sellCost: 7 },
|
||||
{ labelTr: 'carrot', category: 1, productionTime: 1, sellCost: 5},
|
||||
// Wie Weizen/Getreide (kein Debug-Tempo mehr); Verkaufspreis wie Milch/Brot (Kat. 1, schnelle Ware)
|
||||
{ labelTr: 'carrot', category: 1, productionTime: 2, sellCost: 6 },
|
||||
{ labelTr: 'fish', category: 1, productionTime: 2, sellCost: 7 },
|
||||
{ labelTr: 'meat', category: 1, productionTime: 2, sellCost: 7 },
|
||||
{ labelTr: 'leather', category: 1, productionTime: 2, sellCost: 7 },
|
||||
@@ -297,24 +298,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 +326,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 +335,7 @@ async function initializeFalukantTitleRequirements() {
|
||||
}
|
||||
}
|
||||
|
||||
await TitleRequirement.destroy({ where: {} });
|
||||
await TitleRequirement.bulkCreate(requirementsToInsert, { ignoreDuplicates: true });
|
||||
}
|
||||
|
||||
@@ -359,4 +354,4 @@ async function initializeFalukantTownProductWorth() {
|
||||
from \"falukant_type\".\"product\" ftp, \"falukant_data\".\"region\" fdr';
|
||||
sequelize.query(deleteQuery);
|
||||
sequelize.query(createQuery);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,6 +659,14 @@ const undergroundTypes = [
|
||||
"tr": "rob",
|
||||
"cost": 500
|
||||
},
|
||||
{
|
||||
"tr": "investigate_affair",
|
||||
"cost": 7000
|
||||
},
|
||||
{
|
||||
"tr": "raid_transport",
|
||||
"cost": 9000
|
||||
},
|
||||
];
|
||||
|
||||
{
|
||||
|
||||
@@ -17,6 +17,10 @@ const initializeSettings = async () => {
|
||||
where: { name: 'flirt' },
|
||||
defaults: { name: 'flirt' }
|
||||
});
|
||||
await SettingsType.findOrCreate({
|
||||
where: { name: 'languageAssistant' },
|
||||
defaults: { name: 'languageAssistant' }
|
||||
});
|
||||
};
|
||||
|
||||
export default initializeSettings;
|
||||
@@ -46,6 +46,8 @@ const initializeTypes = async () => {
|
||||
willChildren: { type: 'bool', setting: 'flirt', minAge: 14 },
|
||||
smokes: { type: 'singleselect', setting: 'flirt', minAge: 14},
|
||||
drinks: { type: 'singleselect', setting: 'flirt', minAge: 14 },
|
||||
llm_settings: { type: 'string', setting: 'languageAssistant' },
|
||||
llm_api_key: { type: 'string', setting: 'languageAssistant' },
|
||||
};
|
||||
let orderId = 1;
|
||||
for (const key of Object.keys(userParams)) {
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { Sequelize, DataTypes } from 'sequelize';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
dotenv.config();
|
||||
const _dotenvQuiet = process.env.QUIET_ENV_LOGS === '1' || process.env.DOTENV_CONFIG_QUIET === '1';
|
||||
dotenv.config({ quiet: _dotenvQuiet });
|
||||
|
||||
// backend/.env.local — Tunnel/Entwicklung (override), auch wenn loadEnv.js nicht importiert wurde
|
||||
const _sequelizeDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const _envLocalPath = path.join(_sequelizeDir, '../.env.local');
|
||||
if (fs.existsSync(_envLocalPath)) {
|
||||
dotenv.config({ path: _envLocalPath, override: true, quiet: _dotenvQuiet });
|
||||
}
|
||||
|
||||
// Optionales Performance-Logging (aktivierbar per ENV)
|
||||
// - SQL_BENCHMARK=1: Sequelize liefert Query-Timing (ms) an logger
|
||||
@@ -27,12 +38,20 @@ const dbName = process.env.DB_NAME;
|
||||
const dbUser = process.env.DB_USER;
|
||||
const dbPass = process.env.DB_PASS || ''; // Fallback auf leeren String
|
||||
const dbHost = process.env.DB_HOST;
|
||||
const dbPort = Number.parseInt(process.env.DB_PORT || '5432', 10);
|
||||
|
||||
console.log('Database configuration:');
|
||||
console.log('DB_NAME:', dbName);
|
||||
console.log('DB_USER:', dbUser);
|
||||
console.log('DB_PASS:', dbPass ? '[SET]' : '[NOT SET]');
|
||||
console.log('DB_HOST:', dbHost);
|
||||
const useSsl = process.env.DB_SSL === '1' || process.env.PGSSLMODE === 'require';
|
||||
const connectTimeoutMs = Number.parseInt(process.env.DB_CONNECT_TIMEOUT_MS || '30000', 10);
|
||||
|
||||
if (process.env.QUIET_ENV_LOGS !== '1') {
|
||||
console.log('Database configuration:');
|
||||
console.log('DB_NAME:', dbName);
|
||||
console.log('DB_USER:', dbUser);
|
||||
console.log('DB_PASS:', dbPass ? '[SET]' : '[NOT SET]');
|
||||
console.log('DB_HOST:', dbHost);
|
||||
console.log('DB_PORT:', dbPort);
|
||||
console.log('DB_SSL:', useSsl ? 'on' : 'off');
|
||||
}
|
||||
|
||||
if (!dbName || !dbUser || !dbHost) {
|
||||
throw new Error('Missing required database environment variables: DB_NAME, DB_USER, or DB_HOST');
|
||||
@@ -44,8 +63,22 @@ const poolAcquire = Number.parseInt(process.env.DB_POOL_ACQUIRE || '30000', 10);
|
||||
const poolIdle = Number.parseInt(process.env.DB_POOL_IDLE || '10000', 10);
|
||||
const poolEvict = Number.parseInt(process.env.DB_POOL_EVICT || '1000', 10);
|
||||
|
||||
const dialectOptions = {
|
||||
connectTimeout: connectTimeoutMs,
|
||||
...(useSsl
|
||||
? {
|
||||
// node-pg: true oder { rejectUnauthorized: false } bei selbstsigniertem Zertifikat
|
||||
ssl:
|
||||
process.env.DB_SSL_REJECT_UNAUTHORIZED === '0'
|
||||
? { rejectUnauthorized: false }
|
||||
: true
|
||||
}
|
||||
: {})
|
||||
};
|
||||
|
||||
const sequelize = new Sequelize(dbName, dbUser, dbPass, {
|
||||
host: dbHost,
|
||||
port: dbPort,
|
||||
dialect: 'postgres',
|
||||
define: {
|
||||
timestamps: false,
|
||||
@@ -61,9 +94,7 @@ const sequelize = new Sequelize(dbName, dbUser, dbPass, {
|
||||
evict: poolEvict, // Intervall (ms) zum Prüfen auf idle Verbindungen
|
||||
handleDisconnects: true // Automatisches Reconnect bei Verbindungsverlust
|
||||
},
|
||||
dialectOptions: {
|
||||
connectTimeout: 30000 // Timeout für Verbindungsaufbau (30 Sekunden)
|
||||
},
|
||||
dialectOptions,
|
||||
retry: {
|
||||
max: 3, // Maximale Anzahl von Wiederholungsversuchen
|
||||
match: [
|
||||
|
||||
328
docs/FALUKANT_CHURCH_ADVANCEMENT_DAEMON_SPEC.md
Normal file
328
docs/FALUKANT_CHURCH_ADVANCEMENT_DAEMON_SPEC.md
Normal 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
|
||||
|
||||
403
docs/FALUKANT_DEBTORS_PRISON_CONCEPT.md
Normal file
403
docs/FALUKANT_DEBTORS_PRISON_CONCEPT.md
Normal 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.
|
||||
|
||||
447
docs/FALUKANT_DEBTORS_PRISON_DAEMON_SPEC.md
Normal file
447
docs/FALUKANT_DEBTORS_PRISON_DAEMON_SPEC.md
Normal 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.
|
||||
342
docs/FALUKANT_LOVERS_CONCEPT.md
Normal file
342
docs/FALUKANT_LOVERS_CONCEPT.md
Normal 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?
|
||||
263
docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md
Normal file
263
docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md
Normal 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
|
||||
775
docs/FALUKANT_LOVERS_DAEMON_SPEC.md
Normal file
775
docs/FALUKANT_LOVERS_DAEMON_SPEC.md
Normal 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.
|
||||
478
docs/FALUKANT_LOVERS_IMPLEMENTATION_BACKLOG.md
Normal file
478
docs/FALUKANT_LOVERS_IMPLEMENTATION_BACKLOG.md
Normal 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.
|
||||
503
docs/FALUKANT_LOVERS_TECHNICAL_CONCEPT.md
Normal file
503
docs/FALUKANT_LOVERS_TECHNICAL_CONCEPT.md
Normal 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
|
||||
476
docs/FALUKANT_LOVERS_UNDERGROUND_DAEMON_SPEC.md
Normal file
476
docs/FALUKANT_LOVERS_UNDERGROUND_DAEMON_SPEC.md
Normal 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
|
||||
569
docs/FALUKANT_MARRIAGE_HOUSEHOLD_CONTROL_SPEC.md
Normal file
569
docs/FALUKANT_MARRIAGE_HOUSEHOLD_CONTROL_SPEC.md
Normal 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.
|
||||
137
docs/FALUKANT_MARRIAGE_HOUSEHOLD_DAEMON_HANDOFF.md
Normal file
137
docs/FALUKANT_MARRIAGE_HOUSEHOLD_DAEMON_HANDOFF.md
Normal 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
|
||||
431
docs/FALUKANT_NOBILITY_ADVANCEMENT_SPEC.md
Normal file
431
docs/FALUKANT_NOBILITY_ADVANCEMENT_SPEC.md
Normal 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
|
||||
487
docs/FALUKANT_PRODUCTION_CERTIFICATE_SPEC.md
Normal file
487
docs/FALUKANT_PRODUCTION_CERTIFICATE_SPEC.md
Normal 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
|
||||
336
docs/FALUKANT_SERVANTS_CONCEPT.md
Normal file
336
docs/FALUKANT_SERVANTS_CONCEPT.md
Normal 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.
|
||||
636
docs/FALUKANT_SERVANTS_IMPLEMENTATION_SPEC.md
Normal file
636
docs/FALUKANT_SERVANTS_IMPLEMENTATION_SPEC.md
Normal 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.
|
||||
412
docs/FALUKANT_TRANSPORT_RAIDS_SPEC.md
Normal file
412
docs/FALUKANT_TRANSPORT_RAIDS_SPEC.md
Normal 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
|
||||
316
docs/FALUKANT_UI_WEBSOCKET.md
Normal file
316
docs/FALUKANT_UI_WEBSOCKET.md
Normal 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 |
|
||||
180
docs/FALUKANT_UNDERGROUND_AFFAIR_DAEMON_HANDOFF.md
Normal file
180
docs/FALUKANT_UNDERGROUND_AFFAIR_DAEMON_HANDOFF.md
Normal 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
|
||||
67
docs/FALUKANT_UNDERGROUND_AFFAIR_PLAN.md
Normal file
67
docs/FALUKANT_UNDERGROUND_AFFAIR_PLAN.md
Normal 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.
|
||||
178
docs/UMLAUT_NORMALIZATION_PLAN.md
Normal file
178
docs/UMLAUT_NORMALIZATION_PLAN.md
Normal 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
|
||||
@@ -343,6 +343,36 @@ Aktueller Stand:
|
||||
- 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
|
||||
@@ -378,6 +408,13 @@ Aktueller Stand:
|
||||
- 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:
|
||||
@@ -386,6 +423,7 @@ Das Bedienbarkeitsprojekt gilt als abgeschlossen, wenn:
|
||||
- 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
|
||||
@@ -395,7 +433,8 @@ Das Bedienbarkeitsprojekt gilt als abgeschlossen, wenn:
|
||||
3. Formulare und Abschlusslogik
|
||||
4. Falukant, Vokabeltrainer, Admin, Minigames
|
||||
5. Mobile Endabnahme
|
||||
6. Vereinfachungsnachlauf ueber Feedback, Scrolllogik und tabellenlastige Restbereiche
|
||||
|
||||
## Naechster konkreter Schritt
|
||||
|
||||
Der erste sinnvolle Umsetzungsschritt ist nicht sofort Code, sondern ein kurzer UX-Audit-Durchgang ueber die wichtigsten Aufgabenfluesse. Daraus entsteht ein priorisierter Problemkatalog, auf dessen Basis die Bedienbarkeitsarbeit strukturiert umgesetzt wird.
|
||||
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.
|
||||
|
||||
647
docs/VOCAB_TRAINER_DIDACTIC_CONCEPT.md
Normal file
647
docs/VOCAB_TRAINER_DIDACTIC_CONCEPT.md
Normal file
@@ -0,0 +1,647 @@
|
||||
# Vokabeltrainer: Didaktisches Konzept mit Praxisfokus
|
||||
|
||||
## 1. Zielbild
|
||||
|
||||
Der aktuelle Vokabeltrainer ist als Inhaltscontainer brauchbar, aber als Sprachlernsystem noch zu stark auf einzelne Wörter und simple Abfragen reduziert.
|
||||
|
||||
Das Ziel ist kein reiner Karteikasten, sondern ein lernbarer Sprachkurs mit:
|
||||
|
||||
- aktivem Verstehen
|
||||
- aktiver Produktion
|
||||
- Sprechaufforderungen
|
||||
- klarer Grammatikführung
|
||||
- kurzen, wiederholbaren Übungsblöcken
|
||||
- alltagsnahen Dialogen
|
||||
|
||||
Der Lernende soll nicht nur Wörter erkennen, sondern Sätze bilden, typische Muster wiederverwenden und Sprache mündlich vorbereiten können.
|
||||
|
||||
## 2. Kernprobleme des aktuellen Stands
|
||||
|
||||
### 2.1 Zu viel Vokabelabfrage, zu wenig Sprachhandlung
|
||||
|
||||
Der aktuelle Aufbau wirkt, als ob Lernen vor allem bedeutet:
|
||||
|
||||
- Wort sehen
|
||||
- Übersetzung wählen
|
||||
- nächste Vokabel
|
||||
|
||||
Das reicht für passives Wiedererkennen, aber nicht für aktives Sprechen.
|
||||
|
||||
### 2.2 Grammatik ist nicht als Lernhilfe integriert
|
||||
|
||||
Grammatik taucht bisher eher als eigener Inhaltspunkt auf statt als direkt nutzbare Erklärung.
|
||||
|
||||
Fehlt:
|
||||
|
||||
- kurze Regel
|
||||
- 2 bis 4 gute Beispiele
|
||||
- Mini-Transformation
|
||||
- direkte Anwendung im Satz
|
||||
|
||||
### 2.3 Sprechen ist nicht ernsthaft eingebaut
|
||||
|
||||
Für eine Sprache wie Bisaya reicht stilles Lesen nicht.
|
||||
|
||||
Es braucht:
|
||||
|
||||
- laut nachsprechen
|
||||
- Satzmuster wiederholen
|
||||
- kurze Antwortaufgaben
|
||||
- Mini-Dialoge
|
||||
- bewusste Aussprachehinweise
|
||||
|
||||
### 2.4 Übungen sind zu monoton
|
||||
|
||||
Wenn fast alles Multiple Choice oder reine Vokabelabfrage ist, entsteht:
|
||||
|
||||
- wenig Transfer
|
||||
- wenig aktive Erinnerung
|
||||
- wenig Satzgefühl
|
||||
- zu geringe Motivation
|
||||
|
||||
## 3. Didaktische Leitprinzipien
|
||||
|
||||
### 3.1 Vom Gebrauch aus denken
|
||||
|
||||
Jede Lektion soll zuerst die Frage beantworten:
|
||||
|
||||
- Was soll der Lernende danach konkret sagen können?
|
||||
|
||||
Nicht:
|
||||
|
||||
- Welche Wörter kommen vor?
|
||||
|
||||
Sondern:
|
||||
|
||||
- Welche Alltagssituation wird beherrschbar?
|
||||
|
||||
### 3.2 Erst Muster, dann Ausbau
|
||||
|
||||
Menschen lernen Sprache im Alltag oft über wiederkehrende Muster:
|
||||
|
||||
- Begrüßen
|
||||
- Fragen
|
||||
- Antworten
|
||||
- Bitten
|
||||
- Reagieren
|
||||
|
||||
Darum soll jede Lektion 1 bis 3 tragende Satzmuster haben.
|
||||
|
||||
### 3.3 Kleine Grammatik, sofort angewandt
|
||||
|
||||
Grammatik soll kurz, klar und funktional sein:
|
||||
|
||||
- Regel in einem Satz
|
||||
- 2 gute Beispiele
|
||||
- 1 Gegenbeispiel oder Stolperstein
|
||||
- dann direkte Übung
|
||||
|
||||
### 3.4 Aktive Produktion in jeder Lektion
|
||||
|
||||
Jede Lektion braucht mindestens eine Aufgabe, in der der Lernende selbst produziert:
|
||||
|
||||
- Wort einsetzen
|
||||
- Satz umformen
|
||||
- Antwort formulieren
|
||||
- Dialog ergänzen
|
||||
- laut sprechen
|
||||
|
||||
### 3.5 Wiederholung als Spiralmodell
|
||||
|
||||
Wiederholung soll nicht nur „nochmal dieselben Wörter“ heißen.
|
||||
|
||||
Wiederholung muss variieren:
|
||||
|
||||
- zuerst Erkennen
|
||||
- dann Erinnern
|
||||
- dann Anwenden
|
||||
- dann freies Reagieren
|
||||
|
||||
## 4. Neuer Aufbau einer Lektion
|
||||
|
||||
Jede Lektion sollte einem festen, didaktisch sinnvollen Raster folgen.
|
||||
|
||||
### 4.1 Abschnitt A: Einstieg
|
||||
|
||||
Ziel:
|
||||
|
||||
- Thema aktivieren
|
||||
- Nutzwert klären
|
||||
- Leitmuster setzen
|
||||
|
||||
Inhalt:
|
||||
|
||||
- 1 kurzer Alltagskontext
|
||||
- 2 bis 4 Leitsätze
|
||||
- Audio oder Sprechhinweis
|
||||
|
||||
Beispiel:
|
||||
|
||||
- „Heute lernst du, wie du auf Bisaya nach dem Befinden fragst und fürsorglich reagierst.“
|
||||
|
||||
### 4.2 Abschnitt B: Kernvokabular
|
||||
|
||||
Nicht zu groß.
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- 8 bis 15 neue Einheiten
|
||||
- nicht nur Einzelwörter
|
||||
- auch feste Wendungen und Halbsätze
|
||||
|
||||
Beispiel:
|
||||
|
||||
- nicht nur `kaon = essen`
|
||||
- sondern auch `Nikaon ka? = Hast du schon gegessen?`
|
||||
|
||||
### 4.3 Abschnitt C: Grammatik-Impuls
|
||||
|
||||
Kurz und direkt.
|
||||
|
||||
Empfehlung pro Lektion:
|
||||
|
||||
- genau 1 Hauptregel
|
||||
- optional 1 Nebenhinweis
|
||||
|
||||
Format:
|
||||
|
||||
1. Was ist das Muster?
|
||||
2. Wie bildet man es?
|
||||
3. Zwei gute Beispiele
|
||||
4. Ein typischer Fehler
|
||||
|
||||
### 4.4 Abschnitt D: Gesteuerte Übung
|
||||
|
||||
Hier wird Sicherheit aufgebaut.
|
||||
|
||||
Übungstypen:
|
||||
|
||||
- Zuordnen
|
||||
- Lückentext
|
||||
- einfache Umformung
|
||||
- Satzbausteine ordnen
|
||||
|
||||
### 4.5 Abschnitt E: Aktive Sprachproduktion
|
||||
|
||||
Pflichtblock.
|
||||
|
||||
Übungstypen:
|
||||
|
||||
- antworte mit einem kurzen Satz
|
||||
- ergänze einen Dialog
|
||||
- formuliere eine passende Reaktion
|
||||
- sprich die drei Beispielsätze laut
|
||||
|
||||
### 4.6 Abschnitt F: Mini-Sprechauftrag
|
||||
|
||||
Auch ohne automatische Spracherkennung wertvoll.
|
||||
|
||||
Der Kurs fordert aktiv auf:
|
||||
|
||||
- „Sprich den Satz dreimal laut.“
|
||||
- „Beantworte die Frage frei.“
|
||||
- „Lies den Dialog mit verteilten Rollen.“
|
||||
|
||||
Optional später:
|
||||
|
||||
- Aufnahmefunktion
|
||||
- Selbstbewertung
|
||||
- Tutor-/Partnerabgleich
|
||||
|
||||
### 4.7 Abschnitt G: Lernabschluss
|
||||
|
||||
Zum Ende jeder Lektion:
|
||||
|
||||
- Was kannst du jetzt sagen?
|
||||
- 3 Wiederholungssätze
|
||||
- 1 Mini-Selbsttest
|
||||
|
||||
## 5. Verbesserte Übungstypen
|
||||
|
||||
Die Vokabelübungen sollten nicht ersetzt, sondern erweitert werden.
|
||||
|
||||
### 5.1 Grundtypen
|
||||
|
||||
- `recognition`
|
||||
- richtige Übersetzung erkennen
|
||||
- `recall`
|
||||
- Wort oder Wendung aktiv erinnern
|
||||
- `spelling`
|
||||
- Schreibform festigen
|
||||
- `listening_prompt`
|
||||
- Audio hören, Bedeutung erfassen
|
||||
- `speaking_prompt`
|
||||
- Satz laut sprechen
|
||||
|
||||
### 5.2 Satz- und Strukturtypen
|
||||
|
||||
- `sentence_building`
|
||||
- Bausteine in richtige Reihenfolge bringen
|
||||
- `gap_fill`
|
||||
- fehlendes Wort oder Morphem einsetzen
|
||||
- `transformation`
|
||||
- Aussage in Frage oder Antwort umformen
|
||||
- `response_choice`
|
||||
- passende Reaktion auswählen
|
||||
- `dialog_completion`
|
||||
- fehlende Dialogzeile ergänzen
|
||||
|
||||
### 5.3 Freiere Übungstypen
|
||||
|
||||
- `micro_translation`
|
||||
- kurzen alltagsnahen Satz übersetzen
|
||||
- `situational_response`
|
||||
- Was würdest du hier sagen?
|
||||
- `shadowing`
|
||||
- Satz nachsprechen und Rhythmus übernehmen
|
||||
- `pattern_drill`
|
||||
- dieselbe Struktur mit wechselnden Inhalten
|
||||
|
||||
## 6. Grammatik-Konzept
|
||||
|
||||
Grammatik soll in drei Ebenen organisiert werden.
|
||||
|
||||
### 6.1 Ebene 1: Sofort nutzbare Muster
|
||||
|
||||
Beispiele:
|
||||
|
||||
- Fragen nach dem Befinden
|
||||
- Besitz ausdrücken
|
||||
- einfache Zeitbezüge
|
||||
- höfliche Bitte
|
||||
|
||||
### 6.2 Ebene 2: Häufige Strukturbausteine
|
||||
|
||||
Beispiele:
|
||||
|
||||
- Personalpronomen
|
||||
- Fragepartikel
|
||||
- häufige Verbmuster
|
||||
- Negation
|
||||
|
||||
### 6.3 Ebene 3: Vertiefung
|
||||
|
||||
Erst später:
|
||||
|
||||
- Variation
|
||||
- Register
|
||||
- Kontraste
|
||||
- Sonderfälle
|
||||
|
||||
Wichtig:
|
||||
|
||||
- Anfänger sollen nicht mit Vollständigkeit überlastet werden
|
||||
- lieber funktional korrekt als theoretisch vollständig
|
||||
|
||||
## 7. Sprechen systematisch einbauen
|
||||
|
||||
Auch ohne automatische Bewertung kann Sprechen systematisch geübt werden.
|
||||
|
||||
### 7.1 Pflicht-Sprechmomente
|
||||
|
||||
Jede Lektion soll enthalten:
|
||||
|
||||
- 3 bis 5 Sätze zum Nachsprechen
|
||||
- 1 kurze freie Antwort
|
||||
- 1 Mini-Dialog
|
||||
|
||||
### 7.2 Sichtbare Sprechmarker
|
||||
|
||||
UI-Idee:
|
||||
|
||||
- eigener Block `Sprich jetzt`
|
||||
- Mikrofon-Symbol auch ohne Aufnahmefunktion
|
||||
- Timer oder Wiederholungszähler
|
||||
|
||||
### 7.3 Aussprachehilfen
|
||||
|
||||
Gerade bei Bisaya wichtig:
|
||||
|
||||
- einfache Lautumschrift
|
||||
- Betonungshinweis
|
||||
- keine überladene Phonetik, sondern pragmatische Hilfen
|
||||
|
||||
## 8. Fortschrittsmodell
|
||||
|
||||
Nicht nur Prozent und „bestanden“.
|
||||
|
||||
Sinnvoll sind getrennte Fortschrittsspuren:
|
||||
|
||||
- Wortschatz
|
||||
- Satzmuster
|
||||
- Grammatikmuster
|
||||
- Hörverstehen
|
||||
- Sprechpraxis
|
||||
|
||||
So kann ein Lernender sehen:
|
||||
|
||||
- Ich erkenne Wörter schon gut
|
||||
- aber ich antworte noch zu unsicher
|
||||
|
||||
## 9. Beispielkurs: Bisaya
|
||||
|
||||
Bisaya eignet sich besonders gut für einen praxisnahen Kurs, weil Alltag, Familie und Fürsorge sprachlich sehr stark über feste Muster laufen.
|
||||
|
||||
### 9.1 Leitidee
|
||||
|
||||
Nicht:
|
||||
|
||||
- erst viele isolierte Wörter
|
||||
|
||||
Sondern:
|
||||
|
||||
- von Anfang an nützliche, warme Alltagskommunikation
|
||||
|
||||
### 9.2 Frühe Kernbereiche
|
||||
|
||||
Empfohlene Startthemen:
|
||||
|
||||
- Begrüßung und Befinden
|
||||
- Fürsorge und Nachfragen
|
||||
- Familie
|
||||
- Essen und Trinken
|
||||
- Bitten und Höflichkeit
|
||||
- Wege, Orte, Alltag
|
||||
|
||||
### 9.3 Beispiel: Lektion „Wie geht es dir?“
|
||||
|
||||
#### Lernziel
|
||||
|
||||
Der Lernende soll:
|
||||
|
||||
- fragen können, wie es jemandem geht
|
||||
- kurz antworten können
|
||||
- fürsorglich reagieren können
|
||||
|
||||
#### Kernmuster
|
||||
|
||||
- `Kumusta ka?`
|
||||
- `Maayo ra.`
|
||||
- `Nikaon ka?`
|
||||
- `Salamat.`
|
||||
|
||||
#### Grammatik-Impuls
|
||||
|
||||
Fokus:
|
||||
|
||||
- kurze Standardantworten ohne unnötige Theorie
|
||||
- `ka` als direkte Anrede im einfachen Muster
|
||||
|
||||
#### Übungen
|
||||
|
||||
- Multiple Choice: Was bedeutet `Kumusta ka?`
|
||||
- Satzbau: `ka / Kumusta`
|
||||
- Dialogergänzung:
|
||||
- A: `Kumusta ka?`
|
||||
- B: `_____`
|
||||
- Sprechauftrag:
|
||||
- „Sprich `Kumusta ka?` dreimal laut.“
|
||||
- „Beantworte frei: `Maayo ra` oder `Okay ra`.“
|
||||
|
||||
### 9.4 Beispiel: Lektion „Fürsorge im Familienalltag“
|
||||
|
||||
#### Ziel
|
||||
|
||||
Typische fürsorgliche Fragen und Reaktionen lernen.
|
||||
|
||||
#### Kernmuster
|
||||
|
||||
- `Nikaon ka?`
|
||||
- `Palihug kaon.`
|
||||
- `Kapoy ka?`
|
||||
- `Pahuway sa.`
|
||||
|
||||
#### Grammatik
|
||||
|
||||
- Imperativ und fürsorgliche Aufforderung in einfacher Form
|
||||
|
||||
#### Praktische Übung
|
||||
|
||||
- Reaktion passend auswählen
|
||||
- kurze Fürsorge-Sätze selbst bilden
|
||||
- Mini-Rollenspiel:
|
||||
- Mutter
|
||||
- Kind
|
||||
- Besuch
|
||||
|
||||
### 9.5 Beispiel: Lektion „Familie“
|
||||
|
||||
Nicht nur Vokabelliste:
|
||||
|
||||
- `nanay`, `tatay`, `ate`, `kuya`, `lola`, `lolo`
|
||||
|
||||
Sondern auch Anwendung:
|
||||
|
||||
- `Asa si Nanay?`
|
||||
- `Si Kuya naa sa balay.`
|
||||
- `Mingaw ko nimo.`
|
||||
|
||||
## 10. Struktur für einen besseren Bisaya-Kurs
|
||||
|
||||
### Phase 1: Sofort sprechen
|
||||
|
||||
Ziel:
|
||||
|
||||
- die ersten 20 bis 30 hochrelevanten Wendungen sicher nutzen
|
||||
|
||||
### Phase 2: Alltag führen
|
||||
|
||||
Ziel:
|
||||
|
||||
- kurze Familien- und Alltagssituationen bewältigen
|
||||
|
||||
### Phase 3: Sicherer reagieren
|
||||
|
||||
Ziel:
|
||||
|
||||
- verstehen, variieren, Rückfragen stellen
|
||||
|
||||
### Phase 4: Freier Alltag
|
||||
|
||||
Ziel:
|
||||
|
||||
- kleine Dialoge ohne starres Vorbild
|
||||
|
||||
## 10.1 Empfohlene Lektionslängen
|
||||
|
||||
Die aktuellen Lektionen wirken oft zu kurz, nicht nur zeitlich, sondern vor allem in ihrer didaktischen Substanz.
|
||||
|
||||
Sinnvoll ist eine klare Trennung nach Lektionstyp:
|
||||
|
||||
### Mikro-Lektion
|
||||
|
||||
Einsatz:
|
||||
|
||||
- Wiederholung
|
||||
- Auffrischung
|
||||
- kurzer Tagesimpuls
|
||||
|
||||
Empfohlene Dauer:
|
||||
|
||||
- `5 bis 8 Minuten`
|
||||
|
||||
Typischer Inhalt:
|
||||
|
||||
- 1 Leitmuster
|
||||
- 4 bis 6 Wiederholungsaufgaben
|
||||
- 1 kurzer Sprechimpuls
|
||||
|
||||
### Standard-Lektion
|
||||
|
||||
Einsatz:
|
||||
|
||||
- neues Alltagsthema
|
||||
- neues Grammatikmuster
|
||||
- neue Kernvokabeln mit echter Anwendung
|
||||
|
||||
Empfohlene Dauer:
|
||||
|
||||
- `12 bis 20 Minuten`
|
||||
|
||||
Typischer Inhalt:
|
||||
|
||||
- Einstieg
|
||||
- 8 bis 15 neue Einheiten
|
||||
- 1 Grammatik-Impuls
|
||||
- 4 bis 8 Übungen
|
||||
- 1 aktiver Sprachteil
|
||||
- 1 Abschlussblock
|
||||
|
||||
### Praxis-/Review-Lektion
|
||||
|
||||
Einsatz:
|
||||
|
||||
- Wochenabschluss
|
||||
- Wiederholung mehrerer Themen
|
||||
- Mini-Dialoge
|
||||
- Transfer in Alltagssituationen
|
||||
|
||||
Empfohlene Dauer:
|
||||
|
||||
- `15 bis 25 Minuten`
|
||||
|
||||
Typischer Inhalt:
|
||||
|
||||
- Wiederholung mehrerer Muster
|
||||
- Dialogergänzungen
|
||||
- Situationsaufgaben
|
||||
- mehrere Sprechaufträge
|
||||
- kleiner Abschlusstest
|
||||
|
||||
## 10.2 Mindestumfang einer guten Standard-Lektion
|
||||
|
||||
Eine normale Lektion sollte nicht nur aus zwei oder drei Aufgaben bestehen.
|
||||
|
||||
Empfohlener Mindestumfang:
|
||||
|
||||
- 1 klares Lernziel
|
||||
- 2 bis 4 Kernmuster
|
||||
- 8 bis 15 neue Wörter oder Wendungen
|
||||
- 1 kurzer Grammatik-Impuls
|
||||
- mindestens 4 Übungsaufgaben
|
||||
- mindestens 1 Aufgabe mit aktiver Satzproduktion
|
||||
- mindestens 1 Sprechaufforderung
|
||||
- 1 Mini-Abschluss oder Selbsttest
|
||||
|
||||
Damit fühlt sich eine Lektion nicht mehr wie ein Fragment an, sondern wie ein echter Lernschritt.
|
||||
|
||||
## 10.3 Empfehlung für Bisaya
|
||||
|
||||
Für Bisaya sind besonders geeignet:
|
||||
|
||||
- viele Standard-Lektionen im Bereich `12 bis 18 Minuten`
|
||||
- dazwischen kurze Mikro-Wiederholungen
|
||||
- pro Woche eine längere Praxis-/Review-Lektion
|
||||
|
||||
Warum:
|
||||
|
||||
- Bisaya profitiert stark von wiederkehrenden Mustern
|
||||
- Familien- und Alltagssprache lässt sich sehr gut in mittleren, thematisch dichten Lerneinheiten aufbauen
|
||||
- zu kurze Lektionen schneiden genau die Teile ab, die wichtig wären:
|
||||
- Reaktion
|
||||
- Variation
|
||||
- Sprechen
|
||||
- Dialog
|
||||
|
||||
## 11. Umsetzung im System
|
||||
|
||||
### 11.1 Neue didaktische Metadaten für Lektionen
|
||||
|
||||
Sinnvoll wären zusätzliche Felder:
|
||||
|
||||
- `canDoAfterLesson`
|
||||
- `corePatterns`
|
||||
- `grammarFocus`
|
||||
- `speakingPrompts`
|
||||
- `reviewMode`
|
||||
|
||||
### 11.2 Neue Übungstypen
|
||||
|
||||
Mindestens ergänzen:
|
||||
|
||||
- `sentence_building`
|
||||
- `dialog_completion`
|
||||
- `situational_response`
|
||||
- `speaking_prompt`
|
||||
- `pattern_drill`
|
||||
|
||||
### 11.3 Bewertungslogik
|
||||
|
||||
Nicht nur eine Gesamtquote.
|
||||
|
||||
Sinnvoll:
|
||||
|
||||
- Vokabeltreffer
|
||||
- Satzmuster
|
||||
- Grammatik
|
||||
- aktive Produktion
|
||||
|
||||
### 11.4 Wiederholungslogik
|
||||
|
||||
Wiederholen je nach Schwäche:
|
||||
|
||||
- Wortschatz schwach -> Recall/Vokabel
|
||||
- Grammatik schwach -> Transformation
|
||||
- Sprechen schwach -> Sprechblock erneut
|
||||
|
||||
## 12. Priorisierte Verbesserungsschritte
|
||||
|
||||
### Stufe 1: Sofort sinnvoll
|
||||
|
||||
- jede Lektion bekommt Lernziel
|
||||
- jede Lektion bekommt Kernmuster
|
||||
- jede Lektion bekommt kurzen Grammatik-Impuls
|
||||
- jede Lektion bekommt einen Sprechauftrag
|
||||
- Vokabelübungen werden um Satzbau und Dialogergänzung ergänzt
|
||||
|
||||
### Stufe 2: Didaktisch deutlich besser
|
||||
|
||||
- Fortschritt getrennt nach Kompetenzen
|
||||
- bessere Review-Logik
|
||||
- alltagsnahe Mini-Dialoge
|
||||
- Bisaya-Kurse nach Themen statt nur nach Vokabelgruppen schärfen
|
||||
|
||||
### Stufe 3: Später Ausbau
|
||||
|
||||
- Audio
|
||||
- Aufnahmefunktion
|
||||
- automatische Ausspracheunterstützung
|
||||
- freiere Antwortbewertung
|
||||
|
||||
## 13. Fazit
|
||||
|
||||
Der Vokabeltrainer sollte von einem reinen Lernkarten-System zu einem praktischen Sprachlern-System werden.
|
||||
|
||||
Die wichtigste Verschiebung ist:
|
||||
|
||||
- weg von isolierten Vokabeln
|
||||
- hin zu Mustern, Reaktionen, Sprechen und kurzen brauchbaren Dialogen
|
||||
|
||||
Gerade mit Bisaya lässt sich das sehr gut umsetzen, weil die Sprache im Familien- und Alltagskontext stark über konkrete, wiederkehrende Wendungen vermittelt werden kann.
|
||||
|
||||
Der Kurs muss sich deshalb nicht „größer“, sondern „lebendiger“ anfühlen:
|
||||
|
||||
- weniger bloße Abfrage
|
||||
- mehr echte Verwendung
|
||||
- mehr kurze Sprechhandlungen
|
||||
- mehr verständliche Grammatik
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,16 @@
|
||||
.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) {
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapState } from 'vuex';
|
||||
import { showInfo } from '@/utils/feedback.js';
|
||||
|
||||
export default {
|
||||
name: 'AppFooter',
|
||||
@@ -71,10 +72,10 @@ 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.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -175,24 +175,11 @@
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapActions } from 'vuex';
|
||||
import { createApp } from 'vue';
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import { EventBus } from '@/utils/eventBus.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';
|
||||
|
||||
export default {
|
||||
name: 'AppNavigation',
|
||||
components: {
|
||||
RandomChatDialog,
|
||||
MultiChatDialog
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
forumList: [],
|
||||
@@ -292,7 +279,8 @@ export default {
|
||||
this.pinnedSubKey = this.pinnedSubKey === key ? null : key;
|
||||
},
|
||||
|
||||
collapseMenus() {
|
||||
collapseMenus(options = {}) {
|
||||
const { blurActiveElement = true } = options;
|
||||
this.expandedMainKey = null;
|
||||
this.expandedSubKey = null;
|
||||
this.pinnedMainKey = null;
|
||||
@@ -305,11 +293,13 @@ export default {
|
||||
this.suppressHover = false;
|
||||
this.hoverReleaseTimer = null;
|
||||
}, 180);
|
||||
this.$nextTick(() => {
|
||||
if (document.activeElement && typeof document.activeElement.blur === 'function') {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
});
|
||||
if (blurActiveElement) {
|
||||
this.$nextTick(() => {
|
||||
if (document.activeElement && typeof document.activeElement.blur === 'function') {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleDocumentClick(event) {
|
||||
@@ -317,7 +307,7 @@ export default {
|
||||
if (!root || root.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
this.collapseMenus();
|
||||
this.collapseMenus({ blurActiveElement: false });
|
||||
},
|
||||
|
||||
handleDocumentKeydown(event) {
|
||||
@@ -435,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);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -515,7 +516,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../assets/styles.scss';
|
||||
@use '../assets/styles.scss' as *;
|
||||
|
||||
.app-navigation,
|
||||
.nav-primary > ul {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
class="app-section-bar__back"
|
||||
@click="navigateBack"
|
||||
>
|
||||
Zurueck
|
||||
Zurück
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
@@ -25,13 +25,13 @@ const SECTION_LABELS = [
|
||||
{ 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: 'Persoenlich' },
|
||||
{ test: (path) => path.startsWith('/personal'), label: 'Persönlich' },
|
||||
{ test: (path) => path.startsWith('/blogs'), label: 'Blog' }
|
||||
];
|
||||
|
||||
const TITLE_MAP = {
|
||||
Friends: 'Freunde',
|
||||
Guestbook: 'Gaestebuch',
|
||||
Guestbook: 'Gästebuch',
|
||||
'Search users': 'Suche',
|
||||
Gallery: 'Galerie',
|
||||
Forum: 'Forum',
|
||||
@@ -46,7 +46,7 @@ const TITLE_MAP = {
|
||||
VocabCourse: 'Kurs',
|
||||
VocabLesson: 'Lektion',
|
||||
FalukantCreate: 'Charakter erstellen',
|
||||
FalukantOverview: 'Uebersicht',
|
||||
FalukantOverview: 'Übersicht',
|
||||
BranchView: 'Niederlassung',
|
||||
MoneyHistoryView: 'Geldverlauf',
|
||||
FalukantFamily: 'Familie',
|
||||
@@ -60,11 +60,12 @@ const TITLE_MAP = {
|
||||
HealthView: 'Gesundheit',
|
||||
PoliticsView: 'Politik',
|
||||
UndergroundView: 'Untergrund',
|
||||
'Personal settings': 'Persoenliche Daten',
|
||||
'Personal settings': 'Persönliche Daten',
|
||||
'View settings': 'Ansicht',
|
||||
'Sexuality settings': 'Sexualitaet',
|
||||
'Sexuality settings': 'Sexualität',
|
||||
'Flirt settings': 'Flirt',
|
||||
'Account settings': 'Account',
|
||||
'Language assistant settings': 'Sprachassistent',
|
||||
Interests: 'Interessen',
|
||||
AdminInterests: 'Interessenverwaltung',
|
||||
AdminUsers: 'Benutzer',
|
||||
@@ -132,11 +133,19 @@ export default {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -17,23 +17,33 @@ 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 = Promise.all([
|
||||
import('three'),
|
||||
import('three/addons/loaders/GLTFLoader.js'),
|
||||
import('three/addons/loaders/DRACOLoader.js')
|
||||
]).then(([THREE, { GLTFLoader }, { DRACOLoader }]) => ({
|
||||
THREE,
|
||||
GLTFLoader,
|
||||
DRACOLoader
|
||||
}));
|
||||
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',
|
||||
props: {
|
||||
@@ -50,6 +60,10 @@ export default {
|
||||
noBackground: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
lightweight: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -65,7 +79,9 @@ export default {
|
||||
clock: null,
|
||||
baseYPosition: 0,
|
||||
showFallback: false,
|
||||
threeRuntime: null
|
||||
threeRuntime: null,
|
||||
threeLoaders: null,
|
||||
threeModelRuntime: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -149,49 +165,65 @@ export default {
|
||||
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 { THREE } = await this.ensureThreeRuntime();
|
||||
this.clock = markRaw(new THREE.Clock());
|
||||
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.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);
|
||||
|
||||
@@ -200,13 +232,13 @@ export default {
|
||||
},
|
||||
|
||||
async loadBackground() {
|
||||
const { THREE } = await this.ensureThreeRuntime();
|
||||
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) => {
|
||||
@@ -220,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);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -228,7 +260,8 @@ export default {
|
||||
|
||||
async loadModel() {
|
||||
if (!this.scene) return;
|
||||
const { THREE, GLTFLoader, DRACOLoader } = await this.ensureThreeRuntime();
|
||||
const modelRuntime = await this.ensureThreeModelRuntime();
|
||||
const loaders = await this.ensureThreeLoaders();
|
||||
|
||||
// Altes Modell entfernen
|
||||
if (this.model) {
|
||||
@@ -252,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();
|
||||
@@ -293,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;
|
||||
@@ -318,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
|
||||
@@ -331,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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -173,7 +173,7 @@ export default {
|
||||
this.fetchSettings();
|
||||
} catch (err) {
|
||||
console.error('Error updating setting:', err);
|
||||
showApiError(this, err, 'Aenderung konnte nicht gespeichert werden.');
|
||||
showApiError(this, err, 'Änderung konnte nicht gespeichert werden.');
|
||||
}
|
||||
},
|
||||
languagesList() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user