Add Falukant region and transport management features

- Implemented new endpoints in AdminController for managing Falukant regions, including fetching, updating, and deleting region distances.
- Enhanced the FalukantService with methods for retrieving region distances and handling upsert operations.
- Updated the router to expose new routes for region management and transport creation.
- Introduced a transport management interface in the frontend, allowing users to create and manage transports between branches.
- Added localization for new transport-related terms and improved the vehicle management interface to include transport options.
- Enhanced the database initialization logic to support new region and transport models.
This commit is contained in:
Torsten Schulz (local)
2025-11-26 16:44:27 +01:00
parent 29dd7ec80c
commit 06ea259dc9
27 changed files with 2100 additions and 57 deletions

View File

@@ -59,6 +59,8 @@ import PoliticalOffice from '../models/falukant/data/political_office.js';
import Underground from '../models/falukant/data/underground.js';
import VehicleType from '../models/falukant/type/vehicle.js';
import Vehicle from '../models/falukant/data/vehicle.js';
import Transport from '../models/falukant/data/transport.js';
import RegionDistance from '../models/falukant/data/region_distance.js';
function calcAge(birthdate) {
const b = new Date(birthdate); b.setHours(0, 0);
@@ -93,6 +95,85 @@ function calculateMarriageCost(titleOfNobility, age) {
return baseCost * Math.pow(adjustedTitle, 1.3) - (age - 12) * 20;
}
async function computeShortestRoute(transportMode, sourceRegionId, targetRegionId) {
const src = parseInt(sourceRegionId, 10);
const tgt = parseInt(targetRegionId, 10);
if (Number.isNaN(src) || Number.isNaN(tgt)) {
throw new Error('Invalid region ids for route calculation');
}
if (src === tgt) {
return { distance: 0, path: [src] };
}
const rows = await RegionDistance.findAll({
where: { transportMode },
attributes: ['sourceRegionId', 'targetRegionId', 'distance'],
});
if (!rows.length) {
return null;
}
const adj = new Map();
for (const r of rows) {
const a = r.sourceRegionId;
const b = r.targetRegionId;
const d = r.distance;
if (!adj.has(a)) adj.set(a, []);
if (!adj.has(b)) adj.set(b, []);
adj.get(a).push({ to: b, w: d });
adj.get(b).push({ to: a, w: d });
}
if (!adj.has(src) || !adj.has(tgt)) {
return null;
}
const dist = new Map();
const prev = new Map();
const visited = new Set();
for (const node of adj.keys()) {
dist.set(node, Number.POSITIVE_INFINITY);
}
dist.set(src, 0);
while (true) {
let u = null;
let best = Number.POSITIVE_INFINITY;
for (const [node, d] of dist.entries()) {
if (!visited.has(node) && d < best) {
best = d;
u = node;
}
}
if (u === null || u === tgt) break;
visited.add(u);
const neighbors = adj.get(u) || [];
for (const { to, w } of neighbors) {
if (visited.has(to)) continue;
const alt = dist.get(u) + w;
if (alt < dist.get(to)) {
dist.set(to, alt);
prev.set(to, u);
}
}
}
if (!prev.has(tgt) && tgt !== src) {
return null;
}
const path = [];
let cur = tgt;
while (cur !== undefined) {
path.unshift(cur);
cur = prev.get(cur);
}
return { distance: dist.get(tgt), path };
}
class PreconditionError extends Error {
constructor(label) {
super(label);
@@ -404,6 +485,9 @@ class FalukantService extends BaseService {
const exponentBase = Math.max(existingCount, 1);
const rawCost = branchType.baseCost * Math.pow(exponentBase, 1.2);
const cost = Math.round(rawCost * 100) / 100;
if (user.money < cost) {
throw new PreconditionError('insufficientFunds');
}
await updateFalukantUserMoney(
user.id,
-cost,
@@ -444,6 +528,41 @@ class FalukantService extends BaseService {
return br;
}
async upgradeBranch(hashedUserId, branchId) {
const user = await getFalukantUserOrFail(hashedUserId);
const branch = await Branch.findOne({
where: { id: branchId, falukantUserId: user.id },
include: [{ model: BranchType, as: 'branchType', attributes: ['id', 'labelTr'] }],
});
if (!branch) {
throw new Error('Branch not found');
}
const currentLabel = branch.branchType?.labelTr;
let targetLabel = null;
if (currentLabel === 'production') {
targetLabel = 'fullstack';
} else if (currentLabel === 'store') {
targetLabel = 'fullstack';
} else {
// already fullstack or unknown type
throw new PreconditionError('noUpgradeAvailable');
}
const targetType = await BranchType.findOne({ where: { labelTr: targetLabel } });
if (!targetType) {
throw new Error(`Target branch type '${targetLabel}' not found`);
}
// Für den Moment ohne zusätzliche Kosten kann später erweitert werden
branch.branchTypeId = targetType.id;
await branch.save();
const updated = await this.getBranch(hashedUserId, branch.id);
return updated;
}
async getStock(hashedUserId, branchId) {
const u = await getFalukantUserOrFail(hashedUserId);
const b = await getBranchOrFail(u.id, branchId);
@@ -456,7 +575,372 @@ class FalukantService extends BaseService {
return VehicleType.findAll();
}
async buyVehicles(hashedUserId, { vehicleTypeId, quantity, regionId }) {
async getVehicles(hashedUserId, regionId) {
const user = await getFalukantUserOrFail(hashedUserId);
const where = { falukantUserId: user.id };
if (regionId) {
where.regionId = regionId;
}
const vehicles = await Vehicle.findAll({
where,
include: [
{
model: VehicleType,
as: 'type',
attributes: ['id', 'tr', 'capacity', 'transportMode', 'speed', 'buildTimeMinutes', 'cost'],
},
{
model: Transport,
as: 'transports',
attributes: ['id', 'sourceRegionId', 'targetRegionId'],
required: false,
},
],
order: [['availableFrom', 'ASC'], ['id', 'ASC']],
});
const now = new Date();
const branchRegionId = regionId ? parseInt(regionId, 10) : undefined;
return vehicles.map((v) => {
const plain = v.get({ plain: true });
const effectiveRegionId = branchRegionId ?? plain.regionId;
const hasTransportHere = Array.isArray(plain.transports) && plain.transports.some(
(t) =>
t.sourceRegionId === effectiveRegionId ||
t.targetRegionId === effectiveRegionId
);
let status;
if (hasTransportHere) {
// verknüpft mit Transport in dieser Region = unterwegs
status = 'travelling';
} else if (plain.availableFrom && new Date(plain.availableFrom).getTime() > now.getTime()) {
// kein Transport, aber Verfügbarkeit liegt in der Zukunft = im Bau
status = 'building';
} else {
// kein Transport und Verfügbarkeit erreicht = verfügbar
status = 'available';
}
return {
id: plain.id,
condition: plain.condition,
availableFrom: plain.availableFrom,
status,
type: {
id: plain.type?.id,
tr: plain.type?.tr,
capacity: plain.type?.capacity,
transportMode: plain.type?.transportMode,
speed: plain.type?.speed,
buildTimeMinutes: plain.type?.buildTimeMinutes,
cost: plain.type?.cost,
},
};
});
}
async getTransportRoute(hashedUserId, { sourceRegionId, targetRegionId, vehicleTypeId }) {
const user = await getFalukantUserOrFail(hashedUserId);
if (!user) {
throw new Error('User not found');
}
const type = await VehicleType.findByPk(vehicleTypeId);
if (!type) {
throw new Error('Vehicle type not found');
}
const route = await computeShortestRoute(
type.transportMode,
sourceRegionId,
targetRegionId
);
if (!route) {
return {
mode: type.transportMode,
totalDistance: null,
regions: [],
};
}
const regions = await RegionData.findAll({
where: { id: route.path },
attributes: ['id', 'name'],
});
const regionMap = new Map(regions.map((r) => [r.id, r.name]));
const ordered = route.path.map((id) => ({
id,
name: regionMap.get(id) || String(id),
}));
return {
mode: type.transportMode,
totalDistance: route.distance,
regions: ordered,
};
}
async createTransport(hashedUserId, { branchId, vehicleTypeId, productId, quantity, targetBranchId }) {
const user = await getFalukantUserOrFail(hashedUserId);
const sourceBranch = await Branch.findOne({
where: { id: branchId, falukantUserId: user.id },
include: [{ model: RegionData, as: 'region' }],
});
if (!sourceBranch) {
throw new Error('Branch not found');
}
const targetBranch = await Branch.findOne({
where: { id: targetBranchId, falukantUserId: user.id },
include: [{ model: BranchType, as: 'branchType', attributes: ['labelTr'] }, { model: RegionData, as: 'region' }],
});
if (!targetBranch) {
throw new Error('Target branch not found');
}
if (!['store', 'fullstack'].includes(targetBranch.branchType.labelTr)) {
throw new PreconditionError('invalidTargetBranch');
}
const sourceRegionId = sourceBranch.regionId;
const targetRegionId = targetBranch.regionId;
const now = new Date();
const type = await VehicleType.findByPk(vehicleTypeId);
if (!type) {
throw new Error('Vehicle type not found');
}
const route = await computeShortestRoute(type.transportMode, sourceRegionId, targetRegionId);
if (!route) {
throw new PreconditionError('noRoute');
}
// Freie Fahrzeuge dieses Typs in der Quell-Region
const vehicles = await Vehicle.findAll({
where: {
falukantUserId: user.id,
regionId: sourceRegionId,
vehicleTypeId,
availableFrom: { [Op.lte]: now },
},
include: [
{
model: Transport,
as: 'transports',
required: false,
attributes: ['id'],
},
],
});
const freeVehicles = vehicles.filter((v) => {
const t = v.transports || [];
return t.length === 0;
});
if (!freeVehicles.length) {
throw new PreconditionError('noVehiclesAvailable');
}
const capacityPerVehicle = type.capacity || 0;
if (capacityPerVehicle <= 0) {
throw new Error('Invalid vehicle capacity');
}
const maxByVehicles = capacityPerVehicle * freeVehicles.length;
const stock = await FalukantStock.findOne({ where: { branchId: sourceBranch.id } });
if (!stock) {
throw new Error('Stock not found');
}
const inventory = await Inventory.findAll({
where: { stockId: stock.id },
include: [
{
model: ProductType,
as: 'productType',
required: true,
where: { id: productId },
},
],
});
const available = inventory.reduce((sum, i) => sum + i.quantity, 0);
if (available <= 0) {
throw new PreconditionError('noInventory');
}
const maxByInventory = available;
const hardMax = Math.min(maxByVehicles, maxByInventory);
const requested = Math.max(1, parseInt(quantity, 10) || 0);
if (requested > hardMax) {
throw new PreconditionError('quantityTooHigh');
}
// Transportkosten: 1 % des Warenwerts, mindestens 0,1
const productType = inventory[0]?.productType;
const unitValue = productType?.sellCost || 0;
const totalValue = unitValue * requested;
const transportCost = Math.max(0.1, totalValue * 0.01);
if (user.money < transportCost) {
throw new PreconditionError('insufficientFunds');
}
const result = await sequelize.transaction(async (tx) => {
// Geld für den Transport abziehen
const moneyResult = await updateFalukantUserMoney(
user.id,
-transportCost,
'transport',
user.id
);
if (!moneyResult.success) {
throw new Error('Failed to update money');
}
let remaining = requested;
const transportsCreated = [];
for (const v of freeVehicles) {
if (remaining <= 0) break;
const size = Math.min(remaining, capacityPerVehicle);
const t = await Transport.create(
{
sourceRegionId,
targetRegionId,
productId,
size,
vehicleId: v.id,
},
{ transaction: tx }
);
transportsCreated.push(t);
remaining -= size;
}
if (remaining > 0) {
throw new Error('Not enough vehicle capacity for requested quantity');
}
// Inventar in der Quell-Niederlassung reduzieren
let left = requested;
for (const inv of inventory) {
if (left <= 0) break;
if (inv.quantity <= left) {
left -= inv.quantity;
await inv.destroy({ transaction: tx });
} else {
await inv.update({ quantity: inv.quantity - left }, { transaction: tx });
left = 0;
break;
}
}
if (left > 0) {
throw new Error('Inventory changed during transport creation');
}
notifyUser(user.user.hashedId, 'stock_change', { branchId: sourceBranch.id });
notifyUser(user.user.hashedId, 'falukantBranchUpdate', { branchId: sourceBranch.id });
return {
success: true,
maxQuantity: hardMax,
requested,
totalDistance: route.distance,
totalCost: transportCost,
transports: transportsCreated.map((t) => ({
id: t.id,
size: t.size,
vehicleId: t.vehicleId,
})),
};
});
return result;
}
async getBranchTransports(hashedUserId, branchId) {
const user = await getFalukantUserOrFail(hashedUserId);
const branch = await Branch.findOne({
where: { id: branchId, falukantUserId: user.id },
include: [{ model: RegionData, as: 'region', attributes: ['id', 'name'] }],
});
if (!branch) {
throw new Error('Branch not found');
}
const regionId = branch.regionId;
const transports = await Transport.findAll({
where: {
[Op.or]: [
{ sourceRegionId: regionId },
{ targetRegionId: regionId },
],
},
include: [
{ model: RegionData, as: 'sourceRegion', attributes: ['id', 'name'] },
{ model: RegionData, as: 'targetRegion', attributes: ['id', 'name'] },
{ model: ProductType, as: 'productType', attributes: ['id', 'labelTr'] },
{
model: Vehicle,
as: 'vehicle',
include: [
{
model: VehicleType,
as: 'type',
attributes: ['id', 'tr', 'speed'],
},
],
},
],
order: [['createdAt', 'DESC']],
});
const now = Date.now();
return transports.map((t) => {
const direction = t.sourceRegionId === regionId ? 'outgoing' : 'incoming';
let eta = null;
let durationHours = null;
if (t.vehicle && t.vehicle.type && t.vehicle.type.speed && t.sourceRegionId && t.targetRegionId) {
// Näherungsweise Dauer: wir haben die exakte Distanz hier nicht, deshalb nur anhand der
// bei Erstellung verwendeten Route in der UI berechnet für die Liste reicht createdAt + 1h.
// TODO: Optional: Distanz persistent speichern.
durationHours = 1;
const createdAt = t.createdAt ? new Date(t.createdAt).getTime() : now;
const etaMs = createdAt + durationHours * 60 * 60 * 1000;
eta = new Date(etaMs);
}
return {
id: t.id,
direction,
sourceRegion: t.sourceRegion,
targetRegion: t.targetRegion,
product: t.productType,
size: t.size,
vehicleId: t.vehicleId,
vehicleType: t.vehicle?.type || null,
createdAt: t.createdAt,
eta,
durationHours,
};
});
}
async buyVehicles(hashedUserId, { vehicleTypeId, quantity, regionId, mode = 'buy' }) {
const user = await getFalukantUserOrFail(hashedUserId);
const qty = Math.max(1, parseInt(quantity, 10) || 0);
@@ -465,7 +949,11 @@ class FalukantService extends BaseService {
throw new Error('Vehicle type not found');
}
const totalCost = type.cost * qty;
const baseCost = type.cost;
const unitCost = mode === 'build'
? Math.round(baseCost * 0.75)
: baseCost;
const totalCost = unitCost * qty;
if (user.money < totalCost) {
throw new PreconditionError('insufficientFunds');
}
@@ -481,16 +969,24 @@ class FalukantService extends BaseService {
await updateFalukantUserMoney(
user.id,
-totalCost,
'buy_vehicles',
mode === 'build' ? 'build_vehicles' : 'buy_vehicles',
user.id
);
const records = [];
const now = new Date();
const baseTime = now.getTime();
const buildMs = mode === 'build'
? (type.buildTimeMinutes || 0) * 60 * 1000
: 0;
for (let i = 0; i < qty; i++) {
records.push({
vehicleTypeId: type.id,
falukantUserId: user.id,
regionId: region.id,
availableFrom: new Date(baseTime + buildMs),
condition: 100,
});
}
await Vehicle.bulkCreate(records, { transaction: tx });
@@ -2475,6 +2971,13 @@ class FalukantService extends BaseService {
async checkBranchesRequirement(hashedUserId, requirement) {
const user = await this.getFalukantUserByHashedId(hashedUserId);
if (!user) {
throw new Error('User not found');
}
const branchCount = await Branch.count({
where: { falukantUserId: user.id }
});
return branchCount >= requirement.requirementValue;
}
async getHealth(hashedUserId) {