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

@@ -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;

View File

@@ -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)
);
}

View File

@@ -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: {

View File

@@ -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',

View 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;

View File

@@ -17,6 +17,18 @@ Vehicle.init(
type: DataTypes.INTEGER,
allowNull: false,
},
condition: {
// current condition of the vehicle (0100)
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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);

View File

@@ -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');

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) {

View File

@@ -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,
});
}
}
};