diff --git a/backend/controllers/falukantController.js b/backend/controllers/falukantController.js index b0fb50a..dddc9ac 100644 --- a/backend/controllers/falukantController.js +++ b/backend/controllers/falukantController.js @@ -252,6 +252,7 @@ class FalukantController { 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)); diff --git a/backend/migrations/20260323020000-add-transport-guards-and-raid-underground.cjs b/backend/migrations/20260323020000-add-transport-guards-and-raid-underground.cjs new file mode 100644 index 0000000..f5a297e --- /dev/null +++ b/backend/migrations/20260323020000-add-transport-guards-and-raid-underground.cjs @@ -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; + `); + } +}; diff --git a/backend/models/falukant/data/transport.js b/backend/models/falukant/data/transport.js index 667b323..5687a2b 100644 --- a/backend/models/falukant/data/transport.js +++ b/backend/models/falukant/data/transport.js @@ -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; - diff --git a/backend/models/falukant/data/underground.js b/backend/models/falukant/data/underground.js index eb757e3..7c9303f 100644 --- a/backend/models/falukant/data/underground.js +++ b/backend/models/falukant/data/underground.js @@ -12,7 +12,7 @@ Underground.init({ allowNull: false}, victimId: { type: DataTypes.INTEGER, - allowNull: false}, + allowNull: true}, parameters: { type: DataTypes.JSON, allowNull: true}, diff --git a/backend/routers/falukantRouter.js b/backend/routers/falukantRouter.js index 1c06974..8b028fd 100644 --- a/backend/routers/falukantRouter.js +++ b/backend/routers/falukantRouter.js @@ -112,6 +112,7 @@ 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); diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index 8b7535f..527551a 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -1330,8 +1330,9 @@ class FalukantService extends BaseService { }; } - async createTransport(hashedUserId, { branchId, vehicleTypeId, vehicleIds, productId, quantity, targetBranchId }) { + async createTransport(hashedUserId, { branchId, vehicleTypeId, vehicleIds, productId, quantity, targetBranchId, guardCount: rawGuardCount }) { const user = await getFalukantUserOrFail(hashedUserId); + const guardCount = Math.max(0, Number.parseInt(rawGuardCount ?? 0, 10) || 0); const sourceBranch = await Branch.findOne({ where: { id: branchId, falukantUserId: user.id }, @@ -1509,6 +1510,9 @@ class FalukantService extends BaseService { hardMax = 1; } + transportCost += guardCount * 4; + transportCost = Math.round(transportCost * 100) / 100; + if (user.money < transportCost) { throw new PreconditionError('insufficientFunds'); } @@ -1538,6 +1542,7 @@ class FalukantService extends BaseService { productId: isEmptyTransport ? null : productId, size: isEmptyTransport ? 0 : size, vehicleId: v.id, + guardCount, }, { transaction: tx } ); @@ -1601,6 +1606,7 @@ class FalukantService extends BaseService { id: t.id, size: t.size, vehicleId: t.vehicleId, + guardCount: t.guardCount, })), }; }); @@ -1669,6 +1675,7 @@ class FalukantService extends BaseService { targetRegion: t.targetRegion, product: t.productType, size: t.size, + guardCount: Number(t.guardCount || 0), vehicleId: t.vehicleId, vehicleType: t.vehicle?.type || null, createdAt: t.createdAt, @@ -6529,6 +6536,34 @@ ORDER BY r.id`, return undergroundTypes; } + async getRaidTransportRegions(hashedUserId) { + await getFalukantUserOrFail(hashedUserId); + + const regions = await RegionData.findAll({ + where: { + regionTypeId: { [Op.in]: [4, 5] } + }, + include: [ + { + model: RegionType, + as: 'regionType', + attributes: ['id', 'labelTr'] + } + ], + attributes: ['id', 'name', 'regionTypeId'], + order: [['name', 'ASC']] + }); + + return regions + .filter((region) => region.regionType?.labelTr !== 'town') + .map((region) => ({ + id: region.id, + name: region.name, + regionTypeId: region.regionTypeId, + regionTypeLabel: region.regionType?.labelTr || null + })); + } + async getNotifications(hashedUserId) { const user = await getFalukantUserOrFail(hashedUserId); @@ -6768,7 +6803,7 @@ ORDER BY r.id`, } async createUndergroundActivity(hashedUserId, payload) { - const { typeId, victimUsername, target, goal, politicalTargets } = payload; + const { typeId, victimUsername, target, goal, politicalTargets, regionId, bandSize } = payload; // 1) Performer auflösen const falukantUser = await this.getFalukantUserByHashedId(hashedUserId); @@ -6777,42 +6812,43 @@ ORDER BY r.id`, } const performerChar = falukantUser.character; - // 2) Victim auflösen über Username (inner join) - const victimChar = await FalukantCharacter.findOne({ - include: [ - { - model: FalukantUser, - as: 'user', - required: true, // inner join - attributes: [], - include: [ - { - model: User, - as: 'user', - required: true, // inner join - where: { username: victimUsername }, - attributes: [] - } - ] - } - ] - }); - - if (!victimChar) { - throw new PreconditionError('Victim character not found'); - } - - // 3) Selbstangriff verhindern - if (victimChar.id === performerChar.id) { - throw new PreconditionError('Cannot target yourself'); - } - - // 4) Typ-spezifische Validierung + // 2) Typ-spezifische Validierung const undergroundType = await UndergroundType.findByPk(typeId); if (!undergroundType) { throw new Error('Invalid underground type'); } + let victimChar = null; + if (undergroundType.tr !== 'raid_transport') { + victimChar = await FalukantCharacter.findOne({ + include: [ + { + model: FalukantUser, + as: 'user', + required: true, + attributes: [], + include: [ + { + model: User, + as: 'user', + required: true, + where: { username: victimUsername }, + attributes: [] + } + ] + } + ] + }); + + if (!victimChar) { + throw new PreconditionError('Victim character not found'); + } + + if (victimChar.id === performerChar.id) { + throw new PreconditionError('Cannot target yourself'); + } + } + if (undergroundType.tr === 'sabotage') { if (!target) { throw new PreconditionError('Sabotage target missing'); @@ -6832,23 +6868,67 @@ ORDER BY r.id`, } } - // 5) Eintrag anlegen (optional: in Transaction) - const newEntry = await Underground.create({ - undergroundTypeId: typeId, - performerId: performerChar.id, - victimId: victimChar.id, - result: { + if (undergroundType.tr === 'raid_transport') { + const parsedRegionId = Number.parseInt(regionId, 10); + const parsedBandSize = Math.max(1, Number.parseInt(bandSize, 10) || 0); + + if (!parsedRegionId) { + throw new PreconditionError('Raid region missing'); + } + if (!parsedBandSize) { + throw new PreconditionError('Band size missing'); + } + + const validRegion = await RegionData.findOne({ + where: { + id: parsedRegionId, + regionTypeId: { [Op.in]: [4, 5] } + }, + include: [ + { + model: RegionType, + as: 'regionType', + attributes: ['labelTr'] + } + ], + attributes: ['id', 'name'] + }); + + if (!validRegion || validRegion.regionType?.labelTr === 'town') { + throw new PreconditionError('Invalid raid region'); + } + } + + const defaultResult = undergroundType.tr === 'raid_transport' + ? { + status: 'pending', + outcome: null, + attempts: 0, + successes: 0, + lastTargetTransportId: null, + lastLoot: null, + lastOutcome: null + } + : { status: 'pending', outcome: null, discoveries: null, visibilityDelta: 0, reputationDelta: 0, blackmailAmount: 0 - }, + }; + + const newEntry = await Underground.create({ + undergroundTypeId: typeId, + performerId: performerChar.id, + victimId: victimChar?.id || null, + result: defaultResult, parameters: { target: target || null, goal: goal || null, - politicalTargets: politicalTargets || null + politicalTargets: politicalTargets || null, + regionId: regionId || null, + bandSize: bandSize || null } }); @@ -6883,6 +6963,19 @@ ORDER BY r.id`, order: [['createdAt', 'DESC']] }); + const regionIds = [...new Set( + activities + .map((activity) => Number.parseInt(activity.parameters?.regionId, 10)) + .filter((id) => !Number.isNaN(id) && id > 0) + )]; + const regions = regionIds.length + ? await RegionData.findAll({ + where: { id: regionIds }, + attributes: ['id', 'name'] + }) + : []; + const regionMap = new Map(regions.map((region) => [region.id, region.name])); + return activities.map((activity) => { const result = activity.result || {}; const status = result.status || (result.outcome ? 'resolved' : 'pending'); @@ -6896,11 +6989,16 @@ ORDER BY r.id`, success: result.outcome === 'success', target: activity.parameters?.target || null, goal: activity.parameters?.goal || null, + regionName: regionMap.get(Number.parseInt(activity.parameters?.regionId, 10)) || null, additionalInfo: { discoveries: result.discoveries || null, visibilityDelta: result.visibilityDelta ?? null, reputationDelta: result.reputationDelta ?? null, - blackmailAmount: result.blackmailAmount ?? null + blackmailAmount: result.blackmailAmount ?? null, + bandSize: activity.parameters?.bandSize ?? null, + attempts: result.attempts ?? null, + successes: result.successes ?? null, + lastOutcome: result.lastOutcome ?? null } }; }); diff --git a/backend/sql/add_transport_guards_and_raid_underground.sql b/backend/sql/add_transport_guards_and_raid_underground.sql new file mode 100644 index 0000000..76544fc --- /dev/null +++ b/backend/sql/add_transport_guards_and_raid_underground.sql @@ -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; diff --git a/backend/sql/add_underground_raid_transport_type.sql b/backend/sql/add_underground_raid_transport_type.sql new file mode 100644 index 0000000..99631fa --- /dev/null +++ b/backend/sql/add_underground_raid_transport_type.sql @@ -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; diff --git a/backend/utils/falukant/initializeFalukantTypes.js b/backend/utils/falukant/initializeFalukantTypes.js index 63b1fdd..4088070 100644 --- a/backend/utils/falukant/initializeFalukantTypes.js +++ b/backend/utils/falukant/initializeFalukantTypes.js @@ -663,6 +663,10 @@ const undergroundTypes = [ "tr": "investigate_affair", "cost": 7000 }, + { + "tr": "raid_transport", + "cost": 9000 + }, ]; { diff --git a/docs/FALUKANT_PRODUCTION_CERTIFICATE_SPEC.md b/docs/FALUKANT_PRODUCTION_CERTIFICATE_SPEC.md index 1c1d19d..636c25e 100644 --- a/docs/FALUKANT_PRODUCTION_CERTIFICATE_SPEC.md +++ b/docs/FALUKANT_PRODUCTION_CERTIFICATE_SPEC.md @@ -207,60 +207,68 @@ Der Daemon berechnet: ```text certificateScore = - knowledgePoints * 0.35 + - productionPoints * 0.20 + - officePoints * 0.15 + - nobilityPoints * 0.10 + - reputationPoints * 0.10 + - housePoints * 0.10 + 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 >= 25` -- `completedProductions >= 5` +- `avgKnowledge >= 15` +- `completedProductions >= 4` ### Für Zertifikat 3 -- `avgKnowledge >= 40` -- `completedProductions >= 20` +- `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 >= 1` - -### Für Zertifikat 4 - -- `avgKnowledge >= 55` -- `completedProductions >= 60` -- mindestens zwei Statusfaktoren erfüllt + - oder `housePoints >= 2` ### Für Zertifikat 5 -- `avgKnowledge >= 70` -- `completedProductions >= 150` -- `reputationPoints >= 3` +- `avgKnowledge >= 60` +- `completedProductions >= 110` +- `reputationPoints >= 2` - mindestens zwei der folgenden: - `officePoints >= 2` - - `nobilityPoints >= 2` + - `nobilityPoints >= 1` - `housePoints >= 2` ## 4.6 Ableitung der Zielstufe Vorschlag: -- `targetCertificate = 1`, wenn `certificateScore < 1.2` -- `targetCertificate = 2`, wenn `certificateScore >= 1.2` -- `targetCertificate = 3`, wenn `certificateScore >= 2.1` -- `targetCertificate = 4`, wenn `certificateScore >= 3.0` -- `targetCertificate = 5`, wenn `certificateScore >= 4.0` +- `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. @@ -330,6 +338,13 @@ Für jeden Spieler mit `falukant_user`: - `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 diff --git a/docs/FALUKANT_TRANSPORT_RAIDS_SPEC.md b/docs/FALUKANT_TRANSPORT_RAIDS_SPEC.md new file mode 100644 index 0000000..4eb850a --- /dev/null +++ b/docs/FALUKANT_TRANSPORT_RAIDS_SPEC.md @@ -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 diff --git a/frontend/src/components/falukant/DirectorInfo.vue b/frontend/src/components/falukant/DirectorInfo.vue index 1cc3b87..8c74712 100644 --- a/frontend/src/components/falukant/DirectorInfo.vue +++ b/frontend/src/components/falukant/DirectorInfo.vue @@ -147,6 +147,11 @@ + +
{{ $t('falukant.branch.director.emptyTransport.cost', { cost: emptyTransportForm.costLabel }) }}
@@ -200,6 +205,7 @@ export default { emptyTransportForm: { vehicleTypeId: null, targetBranchId: null, + guardCount: 0, distance: null, durationHours: null, eta: null, @@ -390,8 +396,8 @@ export default { this.emptyTransportForm.routeNames = (data.regions || []).map(r => r.name); } // Kosten für leeren Transport: 0.1 - this.emptyTransportForm.cost = 0.1; - this.emptyTransportForm.costLabel = this.formatMoney(0.1); + this.emptyTransportForm.cost = 0.1 + ((this.emptyTransportForm.guardCount || 0) * 4); + this.emptyTransportForm.costLabel = this.formatMoney(this.emptyTransportForm.cost); } catch (error) { console.error('Error loading transport route:', error); this.emptyTransportForm.distance = null; @@ -426,11 +432,13 @@ export default { productId: null, quantity: 0, targetBranchId: this.emptyTransportForm.targetBranchId, + guardCount: this.emptyTransportForm.guardCount || 0, }); // Formular zurücksetzen this.emptyTransportForm = { vehicleTypeId: null, targetBranchId: null, + guardCount: 0, distance: null, durationHours: null, eta: null, diff --git a/frontend/src/components/falukant/SaleSection.vue b/frontend/src/components/falukant/SaleSection.vue index a5d67ee..79f29a4 100644 --- a/frontend/src/components/falukant/SaleSection.vue +++ b/frontend/src/components/falukant/SaleSection.vue @@ -90,6 +90,17 @@ + +
{{ $t('falukant.branch.sale.transportCost', { cost: transportForm.costLabel }) }}
@@ -138,6 +149,7 @@ {{ $t('falukant.branch.sale.runningEta') }} {{ $t('falukant.branch.sale.runningRemaining') }} {{ $t('falukant.branch.sale.runningVehicleCount') }} + {{ $t('falukant.branch.sale.runningGuards') }} @@ -164,6 +176,7 @@ {{ formatEta({ eta: group.eta }) }} {{ formatRemaining({ eta: group.eta }) }} {{ group.vehicleCount }} + {{ group.totalGuards || 0 }} @@ -189,6 +202,7 @@ vehicleTypeId: null, targetBranchId: null, quantity: 0, + guardCount: 0, maxQuantity: 0, distance: null, durationHours: null, @@ -232,12 +246,14 @@ eta: transport.eta, vehicleCount: 0, totalQuantity: 0, + totalGuards: 0, transports: [], }); } const group = groups.get(key); group.vehicleCount += 1; + group.totalGuards += transport.guardCount || 0; if (transport.product && transport.size > 0) { group.totalQuantity += transport.size || 0; } @@ -414,7 +430,8 @@ } const unitValue = item.product.sellCost || 0; const totalValue = unitValue * qty; - const cost = Math.max(0.1, totalValue * 0.01); + const guardCost = (this.transportForm.guardCount || 0) * 4; + const cost = Math.max(0.1, totalValue * 0.01) + guardCost; this.transportForm.cost = cost; this.transportForm.costLabel = this.formatMoney(cost); }, @@ -500,6 +517,7 @@ productId: source.productId, quantity: this.transportForm.quantity, targetBranchId: this.transportForm.targetBranchId, + guardCount: this.transportForm.guardCount || 0, }); await this.loadInventory(); await this.loadTransports(); diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index 3866b6c..b91b8d5 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -334,6 +334,7 @@ "runningEta": "Ankunft", "runningRemaining": "Restzeit", "runningVehicleCount": "Fahrzeuge", + "runningGuards": "Wachen", "runningDirectionOut": "Ausgehend", "runningDirectionIn": "Eingehend" }, @@ -408,6 +409,8 @@ "transport": { "title": "Transportmittel", "placeholder": "Hier kannst du Transportmittel für deine Region kaufen oder bauen.", + "guardCount": "Wachen", + "guardHint": "Zusatzkosten für Wachen: {cost}", "vehicleType": "Transportmittel", "mode": "Art", "modeBuy": "Kaufen (sofort verfügbar)", @@ -1335,7 +1338,17 @@ "victimPlaceholder": "Benutzername eingeben", "sabotageTarget": "Sabotageziel", "corruptGoal": "Ziel der Korruption", - "affairGoal": "Ziel der Untersuchung" + "affairGoal": "Ziel der Untersuchung", + "raidRegion": "Überfallregion", + "raidRegionPlaceholder": "Region wählen", + "bandSize": "Bandengröße", + "raidSummary": "Bande ({bandSize}) in {region}", + "attempts": "Versuche", + "successes": "Erfolge", + "lastOutcome": "Letztes Ergebnis", + "raidResultTitle": "Letzter Überfall", + "lastTargetTransport": "Letzter Zieltransport", + "loot": "Beute" }, "attacks": { "target": "Angreifer", @@ -1349,7 +1362,8 @@ "sabotage": "Sabotage", "corrupt_politician": "Korruption", "rob": "Raub", - "investigate_affair": "Liebschaft untersuchen" + "investigate_affair": "Liebschaft untersuchen", + "raid_transport": "Überfälle auf Transporte" }, "targets": { "house": "Wohnhaus", @@ -1366,6 +1380,11 @@ "pending": "Ausstehend", "resolved": "Abgeschlossen", "failed": "Gescheitert" + }, + "raidOutcomes": { + "repelled": "Abgewehrt", + "partial_success": "Teilweise erfolgreich", + "major_success": "Großer Erfolg" } } } diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json index df4ac1c..4a80708 100644 --- a/frontend/src/i18n/locales/en/falukant.json +++ b/frontend/src/i18n/locales/en/falukant.json @@ -294,7 +294,26 @@ }, "director": { "income": "Income", - "incomeUpdated": "Salary has been successfully updated." + "incomeUpdated": "Salary has been successfully updated.", + "starttransport": "May start transports", + "emptyTransport": { + "title": "Transport without products", + "description": "Move vehicles from this branch to another to use them better.", + "vehicleType": "Vehicle type", + "selectVehicle": "Select vehicle type", + "targetBranch": "Target branch", + "selectTarget": "Select target branch", + "cost": "Cost: {cost}", + "duration": "Duration: {duration}", + "arrival": "Arrival: {datetime}", + "route": "Route", + "create": "Start transport", + "success": "Transport started successfully.", + "error": "Error starting the transport." + } + }, + "sale": { + "runningGuards": "Guards" }, "production": { "title": "Production", @@ -328,6 +347,10 @@ "raft": "Raft", "sailing_ship": "Sailing ship" }, + "transport": { + "guardCount": "Guards", + "guardHint": "Additional cost for guards: {cost}" + }, "tabs": { "director": "Director", "inventory": "Inventory", @@ -900,7 +923,17 @@ "victimPlaceholder": "Enter username", "sabotageTarget": "Sabotage target", "corruptGoal": "Corruption goal", - "affairGoal": "Investigation goal" + "affairGoal": "Investigation goal", + "raidRegion": "Raid region", + "raidRegionPlaceholder": "Select region", + "bandSize": "Band size", + "raidSummary": "Gang ({bandSize}) in {region}", + "attempts": "Attempts", + "successes": "Successes", + "lastOutcome": "Last outcome", + "raidResultTitle": "Latest raid", + "lastTargetTransport": "Latest target transport", + "loot": "Loot" }, "attacks": { "target": "Attacker", @@ -914,7 +947,8 @@ "sabotage": "Sabotage", "corrupt_politician": "Corruption", "rob": "Robbery", - "investigate_affair": "Investigate affair" + "investigate_affair": "Investigate affair", + "raid_transport": "Raid transports" }, "targets": { "house": "House", @@ -931,6 +965,11 @@ "pending": "Pending", "resolved": "Resolved", "failed": "Failed" + }, + "raidOutcomes": { + "repelled": "Repelled", + "partial_success": "Partial success", + "major_success": "Major success" } } } diff --git a/frontend/src/i18n/locales/es/falukant.json b/frontend/src/i18n/locales/es/falukant.json index 1f69336..f973fea 100644 --- a/frontend/src/i18n/locales/es/falukant.json +++ b/frontend/src/i18n/locales/es/falukant.json @@ -322,6 +322,7 @@ "runningEta": "Llegada", "runningRemaining": "Tiempo restante", "runningVehicleCount": "Vehículos", + "runningGuards": "Guardias", "runningDirectionOut": "Salida", "runningDirectionIn": "Entrada" }, @@ -393,6 +394,8 @@ "transport": { "title": "Transporte", "placeholder": "Aquí puedes comprar o construir medios de transporte para tu región.", + "guardCount": "Guardias", + "guardHint": "Coste adicional por guardias: {cost}", "vehicleType": "Medio de transporte", "mode": "Tipo", "modeBuy": "Comprar (disponible de inmediato)", @@ -1259,7 +1262,17 @@ "victimPlaceholder": "Introduce el nombre de usuario", "sabotageTarget": "Objetivo del sabotaje", "corruptGoal": "Objetivo de la corrupción", - "affairGoal": "Objetivo de la investigación" + "affairGoal": "Objetivo de la investigación", + "raidRegion": "Región de emboscada", + "raidRegionPlaceholder": "Seleccionar región", + "bandSize": "Tamaño de la banda", + "raidSummary": "Banda ({bandSize}) en {region}", + "attempts": "Intentos", + "successes": "Éxitos", + "lastOutcome": "Último resultado", + "raidResultTitle": "Último asalto", + "lastTargetTransport": "Último transporte objetivo", + "loot": "Botín" }, "attacks": { "target": "Atacante", @@ -1273,7 +1286,8 @@ "sabotage": "Sabotaje", "corrupt_politician": "Corrupción", "rob": "Robo", - "investigate_affair": "Investigar relación" + "investigate_affair": "Investigar relación", + "raid_transport": "Asaltos a transportes" }, "targets": { "house": "Vivienda", @@ -1290,6 +1304,11 @@ "pending": "Pendiente", "resolved": "Resuelto", "failed": "Fallido" + }, + "raidOutcomes": { + "repelled": "Rechazado", + "partial_success": "Éxito parcial", + "major_success": "Gran éxito" } } } diff --git a/frontend/src/views/falukant/BranchView.vue b/frontend/src/views/falukant/BranchView.vue index 37f1e3c..114ee64 100644 --- a/frontend/src/views/falukant/BranchView.vue +++ b/frontend/src/views/falukant/BranchView.vue @@ -258,6 +258,13 @@ + +

+ {{ $t('falukant.branch.transport.guardHint', { cost: formatMoney((sendVehicleDialog.guardCount || 0) * 4) }) }} +

+ + @@ -250,7 +322,10 @@ export default { newPoliticalTargets: [], newSabotageTarget: 'house', newCorruptGoal: 'elect', - newAffairGoal: 'expose' + newAffairGoal: 'expose', + raidRegions: [], + newRaidRegionId: null, + newRaidBandSize: 3 }; }, computed: { @@ -261,6 +336,9 @@ export default { }, canCreate() { if (!this.newActivityTypeId) return false; + if (this.selectedType?.tr === 'raid_transport') { + return !!this.newRaidRegionId && !!this.newRaidBandSize; + } const hasUser = this.newVictimUsername.trim().length > 0; const hasPol = this.newPoliticalTargets.length > 0; if (!hasUser && !hasPol) return false; @@ -284,6 +362,7 @@ export default { }, async mounted() { await this.loadUndergroundTypes(); + await this.loadRaidRegions(); if (this.undergroundTypes.length) { this.newActivityTypeId = this.undergroundTypes[0].id; } @@ -343,6 +422,11 @@ export default { if (this.selectedType.tr === 'investigate_affair') { payload.goal = this.newAffairGoal; } + if (this.selectedType.tr === 'raid_transport') { + payload.victimUsername = null; + payload.regionId = this.newRaidRegionId; + payload.bandSize = this.newRaidBandSize; + } try { await apiClient.post( '/api/falukant/underground/activities', @@ -353,6 +437,8 @@ export default { this.newSabotageTarget = 'house'; this.newCorruptGoal = 'elect'; this.newAffairGoal = 'expose'; + this.newRaidRegionId = null; + this.newRaidBandSize = 3; await this.loadActivities(); } catch (err) { console.error('Error creating activity', err); @@ -366,6 +452,11 @@ export default { this.undergroundTypes = data; }, + async loadRaidRegions() { + const { data } = await apiClient.get('/api/falukant/underground/raid-regions'); + this.raidRegions = Array.isArray(data) ? data : []; + }, + async loadActivities() { this.loading.activities = true; try { @@ -407,6 +498,35 @@ export default { }).format(v); }, + hasLootDetails(activity) { + return this.getLootDetails(activity).length > 0; + }, + + getLootDetails(activity) { + const loot = activity?.additionalInfo?.lastLoot; + if (!loot) { + return []; + } + if (Array.isArray(loot)) { + return loot + .map((entry) => { + if (!entry) return null; + if (typeof entry === 'string') return entry; + if (typeof entry === 'object') { + const name = entry.productName || entry.product || entry.label || this.$t('falukant.underground.activities.loot'); + const amount = entry.amount ?? entry.quantity ?? entry.count ?? null; + return amount != null ? `${name}: ${amount}` : String(name); + } + return String(entry); + }) + .filter(Boolean); + } + if (typeof loot === 'object') { + return Object.entries(loot).map(([key, value]) => `${key}: ${value}`); + } + return [String(loot)]; + }, + hasNumericValue(value) { return typeof value === 'number' && !Number.isNaN(value); },