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:
@@ -38,6 +38,11 @@ class AdminController {
|
||||
|
||||
// Statistics
|
||||
this.getUserStatistics = this.getUserStatistics.bind(this);
|
||||
this.getFalukantRegions = this.getFalukantRegions.bind(this);
|
||||
this.updateFalukantRegionMap = this.updateFalukantRegionMap.bind(this);
|
||||
this.getRegionDistances = this.getRegionDistances.bind(this);
|
||||
this.upsertRegionDistance = this.upsertRegionDistance.bind(this);
|
||||
this.deleteRegionDistance = this.deleteRegionDistance.bind(this);
|
||||
}
|
||||
|
||||
async getOpenInterests(req, res) {
|
||||
@@ -315,6 +320,69 @@ class AdminController {
|
||||
}
|
||||
}
|
||||
|
||||
async getFalukantRegions(req, res) {
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
const regions = await AdminService.getFalukantRegions(userId);
|
||||
res.status(200).json(regions);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
const status = error.message === 'noaccess' ? 403 : 500;
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async updateFalukantRegionMap(req, res) {
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
const { id } = req.params;
|
||||
const { map } = req.body || {};
|
||||
const region = await AdminService.updateFalukantRegionMap(userId, id, map);
|
||||
res.status(200).json(region);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
const status = error.message === 'noaccess' ? 403 : (error.message === 'regionNotFound' ? 404 : 500);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getRegionDistances(req, res) {
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
const distances = await AdminService.getRegionDistances(userId);
|
||||
res.status(200).json(distances);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
const status = error.message === 'noaccess' ? 403 : 500;
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async upsertRegionDistance(req, res) {
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
const record = await AdminService.upsertRegionDistance(userId, req.body || {});
|
||||
res.status(200).json(record);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
const status = error.message === 'noaccess' ? 403 : 400;
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRegionDistance(req, res) {
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
const { id } = req.params;
|
||||
const result = await AdminService.deleteRegionDistance(userId, id);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
const status = error.message === 'noaccess' ? 403 : (error.message === 'notfound' ? 404 : 500);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getRoomTypes(req, res) {
|
||||
try {
|
||||
const userId = req.headers.userid;
|
||||
|
||||
@@ -30,6 +30,7 @@ class FalukantController {
|
||||
this.createBranch = this._wrapWithUser((userId, req) => this.service.createBranch(userId, req.body.cityId, req.body.branchTypeId));
|
||||
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.createProduction = this._wrapWithUser((userId, req) => {
|
||||
const { branchId, productId, quantity } = req.body;
|
||||
return this.service.createProduction(userId, branchId, productId, quantity);
|
||||
@@ -186,6 +187,19 @@ class FalukantController {
|
||||
(userId, req) => this.service.buyVehicles(userId, req.body),
|
||||
{ successStatus: 201 }
|
||||
);
|
||||
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 }
|
||||
);
|
||||
this.getTransportRoute = this._wrapWithUser(
|
||||
(userId, req) => this.service.getTransportRoute(userId, req.query)
|
||||
);
|
||||
this.getBranchTransports = this._wrapWithUser(
|
||||
(userId, req) => this.service.getBranchTransports(userId, req.params.branchId)
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ const menuStructure = {
|
||||
interests: {
|
||||
visible: ["mainadmin", "interests"],
|
||||
path: "/admin/interests"
|
||||
},
|
||||
},
|
||||
falukant: {
|
||||
visible: ["mainadmin", "falukant"],
|
||||
children: {
|
||||
@@ -270,6 +270,10 @@ const menuStructure = {
|
||||
visible: ["mainadmin", "falukant"],
|
||||
path: "/admin/falukant/database"
|
||||
},
|
||||
mapEditor: {
|
||||
visible: ["mainadmin", "falukant"],
|
||||
path: "/admin/falukant/map"
|
||||
},
|
||||
}
|
||||
},
|
||||
minigames: {
|
||||
|
||||
@@ -98,6 +98,7 @@ import UndergroundType from './falukant/type/underground.js';
|
||||
import VehicleType from './falukant/type/vehicle.js';
|
||||
import Vehicle from './falukant/data/vehicle.js';
|
||||
import Transport from './falukant/data/transport.js';
|
||||
import RegionDistance from './falukant/data/region_distance.js';
|
||||
import Blog from './community/blog.js';
|
||||
import BlogPost from './community/blog_post.js';
|
||||
import Campaign from './match3/campaign.js';
|
||||
@@ -453,6 +454,24 @@ export default function setupAssociations() {
|
||||
as: 'region',
|
||||
});
|
||||
|
||||
// Region distances
|
||||
RegionData.hasMany(RegionDistance, {
|
||||
foreignKey: 'sourceRegionId',
|
||||
as: 'distancesFrom',
|
||||
});
|
||||
RegionData.hasMany(RegionDistance, {
|
||||
foreignKey: 'targetRegionId',
|
||||
as: 'distancesTo',
|
||||
});
|
||||
RegionDistance.belongsTo(RegionData, {
|
||||
foreignKey: 'sourceRegionId',
|
||||
as: 'sourceRegion',
|
||||
});
|
||||
RegionDistance.belongsTo(RegionData, {
|
||||
foreignKey: 'targetRegionId',
|
||||
as: 'targetRegion',
|
||||
});
|
||||
|
||||
Transport.belongsTo(RegionData, {
|
||||
foreignKey: 'sourceRegionId',
|
||||
as: 'sourceRegion',
|
||||
|
||||
51
backend/models/falukant/data/region_distance.js
Normal file
51
backend/models/falukant/data/region_distance.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Model, DataTypes } from 'sequelize';
|
||||
import { sequelize } from '../../../utils/sequelize.js';
|
||||
import RegionData from './region.js';
|
||||
|
||||
class RegionDistance extends Model {}
|
||||
|
||||
RegionDistance.init(
|
||||
{
|
||||
sourceRegionId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: RegionData,
|
||||
key: 'id',
|
||||
schema: 'falukant_data',
|
||||
},
|
||||
},
|
||||
targetRegionId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: RegionData,
|
||||
key: 'id',
|
||||
schema: 'falukant_data',
|
||||
},
|
||||
},
|
||||
transportMode: {
|
||||
// e.g. 'land', 'water', 'air' – should match VehicleType.transportMode
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
distance: {
|
||||
// distance between regions (e.g. in abstract units, used for travel time etc.)
|
||||
type: DataTypes.DOUBLE,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: 'RegionDistance',
|
||||
tableName: 'region_distance',
|
||||
schema: 'falukant_data',
|
||||
timestamps: false,
|
||||
underscored: true,
|
||||
}
|
||||
);
|
||||
|
||||
export default RegionDistance;
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,18 @@ Vehicle.init(
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
condition: {
|
||||
// current condition of the vehicle (0–100)
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 100,
|
||||
},
|
||||
availableFrom: {
|
||||
// timestamp when the vehicle becomes available for use
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
|
||||
@@ -8,13 +8,19 @@ VehicleType.init(
|
||||
tr: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
unique: true,
|
||||
},
|
||||
cost: {
|
||||
// base purchase cost of the vehicle
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
buildTimeMinutes: {
|
||||
// time to construct the vehicle, in minutes
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
capacity: {
|
||||
// transport capacity (e.g. in units of goods)
|
||||
type: DataTypes.INTEGER,
|
||||
|
||||
@@ -116,6 +116,7 @@ import Underground from './falukant/data/underground.js';
|
||||
import VehicleType from './falukant/type/vehicle.js';
|
||||
import Vehicle from './falukant/data/vehicle.js';
|
||||
import Transport from './falukant/data/transport.js';
|
||||
import RegionDistance from './falukant/data/region_distance.js';
|
||||
|
||||
import Room from './chat/room.js';
|
||||
import ChatUser from './chat/user.js';
|
||||
@@ -210,6 +211,7 @@ const models = {
|
||||
Credit,
|
||||
DebtorsPrism,
|
||||
HealthActivity,
|
||||
RegionDistance,
|
||||
VehicleType,
|
||||
Vehicle,
|
||||
Transport,
|
||||
|
||||
@@ -41,6 +41,11 @@ router.get('/falukant/branches/:falukantUserId', authenticate, adminController.g
|
||||
router.put('/falukant/stock/:stockId', authenticate, adminController.updateFalukantStock);
|
||||
router.post('/falukant/stock', authenticate, adminController.addFalukantStock);
|
||||
router.get('/falukant/stock-types', authenticate, adminController.getFalukantStockTypes);
|
||||
router.get('/falukant/regions', authenticate, adminController.getFalukantRegions);
|
||||
router.put('/falukant/regions/:id/map', authenticate, adminController.updateFalukantRegionMap);
|
||||
router.get('/falukant/region-distances', authenticate, adminController.getRegionDistances);
|
||||
router.post('/falukant/region-distances', authenticate, adminController.upsertRegionDistance);
|
||||
router.delete('/falukant/region-distances/:id', authenticate, adminController.deleteRegionDistance);
|
||||
|
||||
// --- Minigames Admin ---
|
||||
router.get('/minigames/match3/campaigns', authenticate, adminController.getMatch3Campaigns);
|
||||
|
||||
@@ -15,6 +15,7 @@ router.get('/branches/types', falukantController.getBranchTypes);
|
||||
router.get('/branches/:branch', falukantController.getBranch);
|
||||
router.get('/branches', falukantController.getBranches);
|
||||
router.post('/branches', falukantController.createBranch);
|
||||
router.post('/branches/upgrade', falukantController.upgradeBranch);
|
||||
router.get('/productions', falukantController.getAllProductions);
|
||||
router.post('/production', falukantController.createProduction);
|
||||
router.get('/production/:branchId', falukantController.getProduction);
|
||||
@@ -71,6 +72,10 @@ router.post('/politics/open', falukantController.applyForElections);
|
||||
router.get('/cities', falukantController.getRegions);
|
||||
router.get('/vehicles/types', falukantController.getVehicleTypes);
|
||||
router.post('/vehicles', falukantController.buyVehicles);
|
||||
router.get('/vehicles', falukantController.getVehicles);
|
||||
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('/notifications', falukantController.getNotifications);
|
||||
router.get('/notifications/all', falukantController.getAllNotifications);
|
||||
|
||||
@@ -19,7 +19,9 @@ import Branch from "../models/falukant/data/branch.js";
|
||||
import FalukantStock from "../models/falukant/data/stock.js";
|
||||
import FalukantStockType from "../models/falukant/type/stock.js";
|
||||
import RegionData from "../models/falukant/data/region.js";
|
||||
import RegionType from "../models/falukant/type/region.js";
|
||||
import BranchType from "../models/falukant/type/branch.js";
|
||||
import RegionDistance from "../models/falukant/data/region_distance.js";
|
||||
import Room from '../models/chat/room.js';
|
||||
import UserParam from '../models/community/user_param.js';
|
||||
|
||||
@@ -298,6 +300,104 @@ class AdminService {
|
||||
}
|
||||
}
|
||||
|
||||
async getFalukantRegions(userId) {
|
||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||
throw new Error('noaccess');
|
||||
}
|
||||
|
||||
const regions = await RegionData.findAll({
|
||||
attributes: ['id', 'name', 'map'],
|
||||
include: [
|
||||
{
|
||||
model: RegionType,
|
||||
as: 'regionType',
|
||||
where: { labelTr: 'city' },
|
||||
attributes: ['labelTr'],
|
||||
},
|
||||
],
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
|
||||
return regions;
|
||||
}
|
||||
|
||||
async updateFalukantRegionMap(userId, regionId, map) {
|
||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||
throw new Error('noaccess');
|
||||
}
|
||||
|
||||
const region = await RegionData.findByPk(regionId);
|
||||
if (!region) {
|
||||
throw new Error('regionNotFound');
|
||||
}
|
||||
|
||||
region.map = map || {};
|
||||
await region.save();
|
||||
|
||||
return region;
|
||||
}
|
||||
|
||||
async getRegionDistances(userId) {
|
||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||
throw new Error('noaccess');
|
||||
}
|
||||
|
||||
const distances = await RegionDistance.findAll();
|
||||
return distances;
|
||||
}
|
||||
|
||||
async upsertRegionDistance(userId, { sourceRegionId, targetRegionId, transportMode, distance }) {
|
||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||
throw new Error('noaccess');
|
||||
}
|
||||
|
||||
if (!sourceRegionId || !targetRegionId || !transportMode) {
|
||||
throw new Error('missingParameters');
|
||||
}
|
||||
|
||||
const src = await RegionData.findByPk(sourceRegionId);
|
||||
const tgt = await RegionData.findByPk(targetRegionId);
|
||||
if (!src || !tgt) {
|
||||
throw new Error('regionNotFound');
|
||||
}
|
||||
|
||||
const mode = String(transportMode);
|
||||
const dist = Number(distance);
|
||||
if (!Number.isFinite(dist) || dist <= 0) {
|
||||
throw new Error('invalidDistance');
|
||||
}
|
||||
|
||||
const [record] = await RegionDistance.findOrCreate({
|
||||
where: {
|
||||
sourceRegionId: src.id,
|
||||
targetRegionId: tgt.id,
|
||||
transportMode: mode,
|
||||
},
|
||||
defaults: {
|
||||
distance: dist,
|
||||
},
|
||||
});
|
||||
|
||||
if (record.distance !== dist) {
|
||||
record.distance = dist;
|
||||
await record.save();
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
async deleteRegionDistance(userId, id) {
|
||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||
throw new Error('noaccess');
|
||||
}
|
||||
const record = await RegionDistance.findByPk(id);
|
||||
if (!record) {
|
||||
throw new Error('notfound');
|
||||
}
|
||||
await record.destroy();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async updateFalukantStock(userId, stockId, quantity) {
|
||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||
throw new Error('noaccess');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -276,13 +276,14 @@ const learnerTypes = [
|
||||
];
|
||||
|
||||
const vehicleTypes = [
|
||||
{ tr: 'cargo_cart', name: 'Lastkarren', cost: 100, capacity: 20, transportMode: 'land', speed: 1 },
|
||||
{ tr: 'ox_cart', name: 'Ochsenkarren', cost: 200, capacity: 50, transportMode: 'land', speed: 2 },
|
||||
{ tr: 'small_carriage', name: 'kleine Pferdekutsche', cost: 300, capacity: 35, transportMode: 'land', speed: 3 },
|
||||
{ tr: 'large_carriage', name: 'große Pferdekutsche', cost: 1000, capacity: 100, transportMode: 'land', speed: 3 },
|
||||
{ tr: 'four_horse_carriage', name: 'Vierspänner', cost: 5000, capacity: 200, transportMode: 'land', speed: 4 },
|
||||
{ tr: 'raft', name: 'Floß', cost: 100, capacity: 25, transportMode: 'water', speed: 1 },
|
||||
{ tr: 'sailing_ship', name: 'Segelschiff', cost: 500, capacity: 200, transportMode: 'water', speed: 3 },
|
||||
// build times (in minutes): 60, 90, 180, 300, 720, 120, 1440
|
||||
{ tr: 'cargo_cart', name: 'Lastkarren', cost: 100, capacity: 20, transportMode: 'land', speed: 1, buildTimeMinutes: 60 },
|
||||
{ tr: 'ox_cart', name: 'Ochsenkarren', cost: 200, capacity: 50, transportMode: 'land', speed: 2, buildTimeMinutes: 90 },
|
||||
{ tr: 'small_carriage', name: 'kleine Pferdekutsche', cost: 300, capacity: 35, transportMode: 'land', speed: 3, buildTimeMinutes: 180 },
|
||||
{ tr: 'large_carriage', name: 'große Pferdekutsche', cost: 1000, capacity: 100, transportMode: 'land', speed: 3, buildTimeMinutes: 300 },
|
||||
{ tr: 'four_horse_carriage', name: 'Vierspänner', cost: 5000, capacity: 200, transportMode: 'land', speed: 4, buildTimeMinutes: 720 },
|
||||
{ tr: 'raft', name: 'Floß', cost: 100, capacity: 25, transportMode: 'water', speed: 1, buildTimeMinutes: 120 },
|
||||
{ tr: 'sailing_ship', name: 'Segelschiff', cost: 500, capacity: 200, transportMode: 'water', speed: 3, buildTimeMinutes: 1440 },
|
||||
];
|
||||
|
||||
const politicalOfficeBenefitTypes = [
|
||||
@@ -897,16 +898,26 @@ export const initializeLearnerTypes = async () => {
|
||||
|
||||
export const initializeVehicleTypes = async () => {
|
||||
for (const v of vehicleTypes) {
|
||||
await VehicleType.findOrCreate({
|
||||
where: { tr: v.tr },
|
||||
defaults: {
|
||||
const existing = await VehicleType.findOne({ where: { tr: v.tr } });
|
||||
if (!existing) {
|
||||
await VehicleType.create({
|
||||
tr: v.tr,
|
||||
cost: v.cost,
|
||||
capacity: v.capacity,
|
||||
transportMode: v.transportMode,
|
||||
speed: v.speed,
|
||||
},
|
||||
});
|
||||
buildTimeMinutes: v.buildTimeMinutes,
|
||||
});
|
||||
} else {
|
||||
// ensure new fields like cost/buildTime are updated if missing
|
||||
await existing.update({
|
||||
cost: v.cost,
|
||||
capacity: v.capacity,
|
||||
transportMode: v.transportMode,
|
||||
speed: v.speed,
|
||||
buildTimeMinutes: v.buildTimeMinutes,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user