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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user