const ROUTES_SHARP_ANGLE = 135; const ROUTES_VERY_SHARP_ANGLE = 115; const MIN_PASSABLE_SEA_TEMP = -4; const ROUTE_TYPE_MODIFIERS = { "-1": 1, // coastline "-2": 1.8, // sea "-3": 4, // open sea "-4": 6, // ocean default: 8 // far ocean }; // Route tier modifiers for different route types (lower = preferred) const ROUTE_TIER_MODIFIERS = { majorSea: { cost: 0.3, priority: "immediate" }, // Major maritime trade routes royal: { cost: 0.4, priority: "immediate" }, // Capital-to-capital roads market: { cost: 1.0, priority: "background" }, // Regional trade roads local: { cost: 1.5, priority: "background" }, // Village-to-market roads footpath: { cost: 2.0, priority: "background" }, // Hamlet paths regional: { cost: 1.2, priority: "background" } // Regional sea routes }; window.Routes = (function () { function generate(lockedRoutes = []) { TIME && console.time("generateRoutes"); const {capitalsByFeature, burgsByFeature, portsByFeature, primaryByFeature, plazaByFeature, unconnectedBurgsByFeature} = sortBurgsByFeature(pack.burgs); const connections = new Map(); lockedRoutes.forEach(route => addConnections(route.points.map(p => p[2]))); // PHASE 1: IMMEDIATE PROCESSING (blocking - critical routes for trade and diplomacy) TIME && console.time("generateCriticalRoutes"); const majorSeaRoutes = generateMajorSeaRoutes(); // Tier 1: Long-distance maritime trade const royalRoads = generateRoyalRoads(); // Tier 2: Capital-to-capital connections TIME && console.timeEnd("generateCriticalRoutes"); // Create initial routes with critical paths only pack.routes = createRoutesData(lockedRoutes); pack.cells.routes = buildLinks(pack.routes); // PHASE 2: BACKGROUND PROCESSING (non-blocking - local and regional routes) setTimeout(() => { TIME && console.time("generateRegionalRoutes"); const marketRoads = generateMarketRoads(); // Tier 3: Regional trade networks (was mainRoads) const localRoads = generateLocalRoads(); // Tier 4: Village-to-market connections (was secondaryRoads) const footpaths = generateFootpaths(); // Tier 5: Hamlet networks (was trails) const regionalSeaRoutes = generateRegionalSeaRoutes(); // Regional sea connections TIME && console.timeEnd("generateRegionalRoutes"); // Append regional routes to existing critical routes appendRoutesToPack(marketRoads, localRoads, footpaths, regionalSeaRoutes); }, 100); TIME && console.timeEnd("generateRoutes"); function sortBurgsByFeature(burgs) { const burgsByFeature = {}; const capitalsByFeature = {}; const portsByFeature = {}; const primaryByFeature = {}; // capitals + large ports const plazaByFeature = {}; // plaza burgs (excluding primary centers) const unconnectedBurgsByFeature = {}; // burgs not connected by main roads or secondary roads const addBurg = (object, feature, burg) => { if (!object[feature]) object[feature] = []; object[feature].push(burg); }; for (const burg of burgs) { if (burg.i && !burg.removed) { const {feature, capital, port} = burg; addBurg(burgsByFeature, feature, burg); if (capital) addBurg(capitalsByFeature, feature, burg); if (port) addBurg(portsByFeature, port, burg); // Primary centers: capitals and large ports if (capital || burg.isLargePort) { addBurg(primaryByFeature, feature, burg); } // Plaza burgs: those with plazas but not primary centers if ((burg.plaza || burg.isRegionalCenter || burg.guaranteedPlaza) && !capital && !burg.isLargePort) { addBurg(plazaByFeature, feature, burg); } // Unconnected burgs: those not already connected by main roads or secondary roads if (!capital && !burg.isLargePort && !(burg.plaza || burg.isRegionalCenter || burg.guaranteedPlaza)) { addBurg(unconnectedBurgsByFeature, feature, burg); } } } return {burgsByFeature, capitalsByFeature, portsByFeature, primaryByFeature, plazaByFeature, unconnectedBurgsByFeature}; } // Tier 1: Major Sea Routes - Connect capitals and major ports across ALL water bodies // Simulates long-distance maritime trade like Hanseatic League routes function generateMajorSeaRoutes() { TIME && console.time("generateMajorSeaRoutes"); const majorSeaRoutes = []; // Get all significant ports for major trade routes const allMajorPorts = []; pack.burgs.forEach(b => { if (b.i && !b.removed && b.port) { // Include more ports in major routes: capitals, large ports, and wealthy market towns if (b.capital || b.isLargePort || (b.population >= 5 && b.plaza) || // Major market towns (5000+ pop with plaza) (b.population >= 10)) { // Large cities regardless of status allMajorPorts.push(b); } } }); if (allMajorPorts.length < 2) { TIME && console.timeEnd("generateMajorSeaRoutes"); return majorSeaRoutes; } // Sort ports by importance (capitals first, then by population) allMajorPorts.sort((a, b) => { if (a.capital && !b.capital) return -1; if (!a.capital && b.capital) return 1; return b.population - a.population; }); // Create a more comprehensive trade network // Primary hubs: ALL capital ports and top large ports const capitalPorts = allMajorPorts.filter(p => p.capital); const largePorts = allMajorPorts.filter(p => !p.capital && (p.isLargePort || p.population >= 10)); const mediumPorts = allMajorPorts.filter(p => !p.capital && !p.isLargePort && p.population < 10); // Use all capitals and top large ports as primary hubs const hubs = [...capitalPorts, ...largePorts.slice(0, Math.max(10, Math.floor(largePorts.length * 0.5)))]; const secondaryHubs = [...largePorts.slice(Math.max(10, Math.floor(largePorts.length * 0.5))), ...mediumPorts.slice(0, 20)]; // Connect primary hubs strategically (not all-to-all to avoid too many routes) // Connect capitals to each other for (let i = 0; i < capitalPorts.length; i++) { for (let j = i + 1; j < capitalPorts.length; j++) { const start = capitalPorts[i].cell; const exit = capitalPorts[j].cell; const distance = Math.sqrt((capitalPorts[i].x - capitalPorts[j].x) ** 2 + (capitalPorts[i].y - capitalPorts[j].y) ** 2); // Connect if reasonably distant (long-distance trade) or same cultural sphere if (distance > 50 || capitalPorts[i].culture === capitalPorts[j].culture) { const segments = findPathSegments({isWater: true, connections, start, exit, routeType: "majorSea"}); for (const segment of segments) { addConnections(segment); majorSeaRoutes.push({feature: -1, cells: segment, type: "majorSea"}); } } } } // Connect large ports to nearest 2-3 capitals for trade network largePorts.slice(0, 15).forEach(port => { const nearestCapitals = capitalPorts .map(cap => ({ cap, distance: Math.sqrt((port.x - cap.x) ** 2 + (port.y - cap.y) ** 2) })) .sort((a, b) => a.distance - b.distance) .slice(0, Math.min(3, capitalPorts.length)); // Connect to up to 3 nearest capitals nearestCapitals.forEach(({cap}) => { const segments = findPathSegments({ isWater: true, connections, start: port.cell, exit: cap.cell, routeType: "majorSea" }); for (const segment of segments) { addConnections(segment); majorSeaRoutes.push({feature: -1, cells: segment, type: "majorSea"}); } }); }); // Connect secondary hubs to nearest primary hub secondaryHubs.forEach(port => { let nearestHub = null; let minDistance = Infinity; hubs.forEach(hub => { const distance = Math.sqrt((port.x - hub.x) ** 2 + (port.y - hub.y) ** 2); if (distance < minDistance) { minDistance = distance; nearestHub = hub; } }); if (nearestHub) { const segments = findPathSegments({ isWater: true, connections, start: port.cell, exit: nearestHub.cell, routeType: "majorSea" }); for (const segment of segments) { addConnections(segment); majorSeaRoutes.push({feature: -1, cells: segment, type: "majorSea"}); } } }); TIME && console.timeEnd("generateMajorSeaRoutes"); return majorSeaRoutes; } // Tier 2: Royal Roads - Connect all state capitals for diplomatic and military movement function generateRoyalRoads() { TIME && console.time("generateRoyalRoads"); const royalRoads = []; // Get all state capitals const capitals = []; pack.states.forEach(state => { if (state.i && !state.removed && state.capital) { const capital = pack.burgs[state.capital]; if (capital && !capital.removed) { capitals.push(capital); } } }); if (capitals.length < 2) { TIME && console.timeEnd("generateRoyalRoads"); return royalRoads; } // Create a minimum spanning tree of capitals using Kruskal's algorithm // This ensures all capitals are connected with minimal total distance const edges = []; for (let i = 0; i < capitals.length; i++) { for (let j = i + 1; j < capitals.length; j++) { const distance = Math.sqrt( (capitals[i].x - capitals[j].x) ** 2 + (capitals[i].y - capitals[j].y) ** 2 ); edges.push({ from: i, to: j, distance, fromCell: capitals[i].cell, toCell: capitals[j].cell }); } } // Sort edges by distance edges.sort((a, b) => a.distance - b.distance); // Use union-find to build minimum spanning tree const parent = Array.from({length: capitals.length}, (_, i) => i); const find = (x) => { if (parent[x] !== x) parent[x] = find(parent[x]); return parent[x]; }; const union = (x, y) => { const px = find(x); const py = find(y); if (px !== py) { parent[px] = py; return true; } return false; }; // Build the tree for (const edge of edges) { if (union(edge.from, edge.to)) { const segments = findPathSegments({ isWater: false, connections, start: edge.fromCell, exit: edge.toCell, routeType: "royal" }); for (const segment of segments) { addConnections(segment); royalRoads.push({ feature: pack.cells.f[edge.fromCell], cells: segment, type: "royal" }); } } } TIME && console.timeEnd("generateRoyalRoads"); return royalRoads; } // Tier 3: Market Roads - Regional trade networks (enhanced main roads) function generateMarketRoads() { TIME && console.time("generateMarketRoads"); const marketRoads = []; // Get all market towns (from new settlement hierarchy) const marketTowns = pack.burgs.filter(b => b.i && !b.removed && (b.settlementType === "marketTown" || b.plaza === 1) ); // Group market towns by feature/region const marketsByFeature = {}; marketTowns.forEach(town => { const feature = town.feature; if (!marketsByFeature[feature]) marketsByFeature[feature] = []; marketsByFeature[feature].push(town); }); // Connect market towns within regions (15-30 km spacing as per research) for (const [feature, towns] of Object.entries(marketsByFeature)) { if (towns.length < 2) continue; // Use Delaunay triangulation for regional connections const points = towns.map(t => [t.x, t.y]); const edges = calculateUrquhartEdges(points); edges.forEach(([fromId, toId]) => { const fromTown = towns[fromId]; const toTown = towns[toId]; // Check distance is within daily travel range (15-30 km) const distance = Math.sqrt((fromTown.x - toTown.x) ** 2 + (fromTown.y - toTown.y) ** 2); const mapScale = Math.sqrt(graphWidth * graphHeight / 1000000); const kmDistance = distance / mapScale; // Only connect if within reasonable market day travel distance if (kmDistance <= 35) { const segments = findPathSegments({ isWater: false, connections, start: fromTown.cell, exit: toTown.cell, routeType: "market" }); for (const segment of segments) { addConnections(segment); marketRoads.push({ feature: Number(feature), cells: segment, type: "market" }); } } }); } // Also use existing main roads logic for primary centers const mainRoads = generateMainRoads(); marketRoads.push(...mainRoads); TIME && console.timeEnd("generateMarketRoads"); return marketRoads; } // Tier 4: Local Roads - Village to nearest market town connections function generateLocalRoads() { TIME && console.time("generateLocalRoads"); const localRoads = []; // Get villages from settlement hierarchy const villages = pack.burgs.filter(b => b.i && !b.removed && ( b.settlementType === "largeVillage" || b.settlementType === "smallVillage" ) ); // Get market towns and regional centers const marketCenters = pack.burgs.filter(b => b.i && !b.removed && ( b.settlementType === "marketTown" || b.plaza === 1 || b.isRegionalCenter || b.capital ) ); // Connect each village to nearest market center villages.forEach(village => { let nearestMarket = null; let minDistance = Infinity; marketCenters.forEach(market => { const distance = Math.sqrt( (village.x - market.x) ** 2 + (village.y - market.y) ** 2 ); // Prefer markets in same state/culture let culturalModifier = 1; if (village.state === market.state) culturalModifier = 0.8; if (village.culture === market.culture) culturalModifier *= 0.9; const adjustedDistance = distance * culturalModifier; if (adjustedDistance < minDistance) { minDistance = adjustedDistance; nearestMarket = market; } }); if (nearestMarket) { const segments = findPathSegments({ isWater: false, connections, start: village.cell, exit: nearestMarket.cell, routeType: "local" }); for (const segment of segments) { addConnections(segment); localRoads.push({ feature: village.feature, cells: segment, type: "local" }); } } }); // Also include existing secondary roads const secondaryRoads = generateSecondaryRoads(); localRoads.push(...secondaryRoads); TIME && console.timeEnd("generateLocalRoads"); return localRoads; } // Tier 5: Footpaths - Hamlet to village networks function generateFootpaths() { TIME && console.time("generateFootpaths"); const footpaths = []; // Get hamlets from settlement hierarchy const hamlets = pack.burgs.filter(b => b.i && !b.removed && b.settlementType === "hamlet" ); // Get villages and larger settlements const largerSettlements = pack.burgs.filter(b => b.i && !b.removed && ( b.settlementType === "smallVillage" || b.settlementType === "largeVillage" || b.settlementType === "marketTown" || b.plaza === 1 ) ); // Connect each hamlet to nearest village (3-6 km as per research) hamlets.forEach(hamlet => { let nearestVillage = null; let minDistance = Infinity; largerSettlements.forEach(village => { const distance = Math.sqrt( (hamlet.x - village.x) ** 2 + (hamlet.y - village.y) ** 2 ); // Strong preference for same culture/state let modifier = 1; if (hamlet.state === village.state) modifier = 0.7; if (hamlet.culture === village.culture) modifier *= 0.8; const adjustedDistance = distance * modifier; // Only connect to nearby settlements (6 km max range) const mapScale = Math.sqrt(graphWidth * graphHeight / 1000000); const kmDistance = distance / mapScale; if (kmDistance <= 8 && adjustedDistance < minDistance) { minDistance = adjustedDistance; nearestVillage = village; } }); if (nearestVillage) { const segments = findPathSegments({ isWater: false, connections, start: hamlet.cell, exit: nearestVillage.cell, routeType: "footpath" }); for (const segment of segments) { addConnections(segment); footpaths.push({ feature: hamlet.feature, cells: segment, type: "footpath" }); } } }); // Also include existing trails for backward compatibility const trails = generateTrails(); footpaths.push(...trails); TIME && console.timeEnd("generateFootpaths"); return footpaths; } // Regional sea routes (within water bodies) function generateRegionalSeaRoutes() { TIME && console.time("generateRegionalSeaRoutes"); const regionalSeaRoutes = []; // Filter ports to only include significant ones (500+ population or special status) // Small fishing villages don't participate in trade routes const significantPortsByFeature = {}; for (const [featureId, featurePorts] of Object.entries(portsByFeature)) { const significantPorts = featurePorts.filter(burg => burg.population >= 0.5 || // 500+ population (in thousands) burg.capital || // Capital cities burg.isLargePort || // Designated large ports burg.plaza || // Market towns with plazas burg.isRegionalCenter // Regional centers ); if (significantPorts.length >= 2) { significantPortsByFeature[featureId] = significantPorts; } } // Connect significant ports within each water body for (const [featureId, featurePorts] of Object.entries(significantPortsByFeature)) { const points = featurePorts.map(burg => [burg.x, burg.y]); const urquhartEdges = calculateUrquhartEdges(points); urquhartEdges.forEach(([fromId, toId]) => { const start = featurePorts[fromId].cell; const exit = featurePorts[toId].cell; const segments = findPathSegments({isWater: true, connections, start, exit, routeType: "regional"}); for (const segment of segments) { addConnections(segment); regionalSeaRoutes.push({feature: Number(featureId), cells: segment, type: "regional"}); } }); } TIME && console.timeEnd("generateRegionalSeaRoutes"); return regionalSeaRoutes; } function generateMainRoads() { TIME && console.time("generateMainRoads"); const mainRoads = []; for (const [key, featurePrimary] of Object.entries(primaryByFeature)) { const points = featurePrimary.map(burg => [burg.x, burg.y]); const urquhartEdges = calculateUrquhartEdges(points); urquhartEdges.forEach(([fromId, toId]) => { const start = featurePrimary[fromId].cell; const exit = featurePrimary[toId].cell; const segments = findPathSegments({isWater: false, connections, start, exit}); for (const segment of segments) { addConnections(segment); mainRoads.push({feature: Number(key), cells: segment}); } }); } TIME && console.timeEnd("generateMainRoads"); return mainRoads; } function generateSecondaryRoads() { TIME && console.time("generateSecondaryRoads"); const secondaryRoads = []; for (const [key, featurePlazas] of Object.entries(plazaByFeature)) { // Skip if no plaza burgs in this feature if (featurePlazas.length === 0) continue; const featurePrimary = primaryByFeature[key] || []; // Combine plaza burgs with primary centers for connection network const allConnectableBurgs = [...featurePlazas, ...featurePrimary]; // If we have primary centers in this feature, connect plazas to them if (featurePrimary.length > 0 && featurePlazas.length > 0) { // Connect each plaza to the nearest primary center for (const plazaBurg of featurePlazas) { let nearestPrimary = null; let minDistance = Infinity; for (const primaryBurg of featurePrimary) { const distance = Math.sqrt( (plazaBurg.x - primaryBurg.x) ** 2 + (plazaBurg.y - primaryBurg.y) ** 2 ); if (distance < minDistance) { minDistance = distance; nearestPrimary = primaryBurg; } } if (nearestPrimary) { const segments = findPathSegments({ isWater: false, connections, start: plazaBurg.cell, exit: nearestPrimary.cell }); for (const segment of segments) { addConnections(segment); secondaryRoads.push({feature: Number(key), cells: segment}); } } } } // Connect plaza burgs to each other if there are multiple if (featurePlazas.length >= 2) { const points = featurePlazas.map(burg => [burg.x, burg.y]); const urquhartEdges = calculateUrquhartEdges(points); urquhartEdges.forEach(([fromId, toId]) => { const start = featurePlazas[fromId].cell; const exit = featurePlazas[toId].cell; const segments = findPathSegments({isWater: false, connections, start, exit}); for (const segment of segments) { addConnections(segment); secondaryRoads.push({feature: Number(key), cells: segment}); } }); } } TIME && console.timeEnd("generateSecondaryRoads"); return secondaryRoads; } function generateTrails() { TIME && console.time("generateTrails"); const trails = []; for (const [key, unconnectedBurgs] of Object.entries(unconnectedBurgsByFeature)) { // Skip if no unconnected burgs in this feature if (unconnectedBurgs.length === 0) continue; // Get all connected burgs in this feature (primary + plaza) const connectedBurgs = [...(primaryByFeature[key] || []), ...(plazaByFeature[key] || [])]; // Connect unconnected burgs to the network for (const unconnectedBurg of unconnectedBurgs) { if (connectedBurgs.length > 0) { // Find the best connection point (could be a burg or a point on an existing route) const bestConnection = findBestConnectionPoint(unconnectedBurg, connectedBurgs, connections); if (bestConnection) { const segments = findPathSegments({ isWater: false, connections, start: unconnectedBurg.cell, exit: bestConnection.cell }); for (const segment of segments) { addConnections(segment); trails.push({feature: Number(key), cells: segment}); } } } else if (unconnectedBurgs.length >= 2) { // If no connected burgs exist, create minimal trail network between unconnected burgs const points = unconnectedBurgs.map(burg => [burg.x, burg.y]); const urquhartEdges = calculateUrquhartEdges(points); urquhartEdges.forEach(([fromId, toId]) => { const start = unconnectedBurgs[fromId].cell; const exit = unconnectedBurgs[toId].cell; const segments = findPathSegments({isWater: false, connections, start, exit}); for (const segment of segments) { addConnections(segment); trails.push({feature: Number(key), cells: segment}); } }); break; // Only do this once per feature } } } TIME && console.timeEnd("generateTrails"); return trails; } // Helper function to find the best connection point for a trail function findBestConnectionPoint(unconnectedBurg, connectedBurgs, connections) { let bestConnection = null; let minDistance = Infinity; // First, try connecting to the nearest connected burg for (const connectedBurg of connectedBurgs) { const distance = Math.sqrt( (unconnectedBurg.x - connectedBurg.x) ** 2 + (unconnectedBurg.y - connectedBurg.y) ** 2 ); if (distance < minDistance) { minDistance = distance; bestConnection = connectedBurg; } } return bestConnection; } function generateSeaRoutes() { TIME && console.time("generateSeaRoutes"); const seaRoutes = []; for (const [featureId, featurePorts] of Object.entries(portsByFeature)) { const points = featurePorts.map(burg => [burg.x, burg.y]); const urquhartEdges = calculateUrquhartEdges(points); urquhartEdges.forEach(([fromId, toId]) => { const start = featurePorts[fromId].cell; const exit = featurePorts[toId].cell; const segments = findPathSegments({isWater: true, connections, start, exit}); for (const segment of segments) { addConnections(segment); seaRoutes.push({feature: Number(featureId), cells: segment}); } }); } TIME && console.timeEnd("generateSeaRoutes"); return seaRoutes; } function addConnections(segment) { for (let i = 0; i < segment.length; i++) { const cellId = segment[i]; const nextCellId = segment[i + 1]; if (nextCellId) { connections.set(`${cellId}-${nextCellId}`, true); connections.set(`${nextCellId}-${cellId}`, true); } } } function findPathSegments({isWater, connections, start, exit, routeType}) { const getCost = createCostEvaluator({isWater, connections, routeType}); const pathCells = findPath(start, current => current === exit, getCost); if (!pathCells) return []; const segments = getRouteSegments(pathCells, connections); return segments; } function createRoutesData(routes) { const pointsArray = preparePointsArray(); // Process critical routes (Tier 1 & 2) - these run immediately for (const {feature, cells, merged, type} of mergeRoutes(majorSeaRoutes)) { if (merged) continue; const points = getPoints("searoutes", cells, pointsArray); routes.push({i: routes.length, group: "searoutes", feature, points, type: type || "majorSea"}); } for (const {feature, cells, merged, type} of mergeRoutes(royalRoads)) { if (merged) continue; const points = getPoints("roads", cells, pointsArray); routes.push({i: routes.length, group: "roads", feature, points, type: type || "royal"}); } return routes; } // Function to append background-generated routes to pack function appendRoutesToPack(marketRoads, localRoads, footpaths, regionalSeaRoutes) { const pointsArray = preparePointsArray(); const routes = pack.routes; // Tier 3: Market Roads for (const {feature, cells, merged} of mergeRoutes(marketRoads)) { if (merged) continue; const points = getPoints("roads", cells, pointsArray); const routeId = getNextId(); routes.push({i: routeId, group: "roads", feature, points, type: "market"}); // Update cell routes for (let i = 0; i < cells.length - 1; i++) { addRouteConnection(cells[i], cells[i + 1], routeId); } } // Tier 4: Local Roads for (const {feature, cells, merged} of mergeRoutes(localRoads)) { if (merged) continue; const points = getPoints("secondary", cells, pointsArray); const routeId = getNextId(); routes.push({i: routeId, group: "secondary", feature, points, type: "local"}); for (let i = 0; i < cells.length - 1; i++) { addRouteConnection(cells[i], cells[i + 1], routeId); } } // Tier 5: Footpaths for (const {feature, cells, merged} of mergeRoutes(footpaths)) { if (merged) continue; const points = getPoints("trails", cells, pointsArray); const routeId = getNextId(); routes.push({i: routeId, group: "trails", feature, points, type: "footpath"}); for (let i = 0; i < cells.length - 1; i++) { addRouteConnection(cells[i], cells[i + 1], routeId); } } // Regional Sea Routes for (const {feature, cells, merged} of mergeRoutes(regionalSeaRoutes)) { if (merged) continue; const points = getPoints("searoutes", cells, pointsArray); const routeId = getNextId(); routes.push({i: routeId, group: "searoutes", feature, points, type: "regional"}); for (let i = 0; i < cells.length - 1; i++) { addRouteConnection(cells[i], cells[i + 1], routeId); } } // Rebuild route links after adding new routes pack.cells.routes = buildLinks(pack.routes); } function addRouteConnection(from, to, routeId) { const routes = pack.cells.routes || {}; if (!routes[from]) routes[from] = {}; routes[from][to] = routeId; if (!routes[to]) routes[to] = {}; routes[to][from] = routeId; pack.cells.routes = routes; } // merge routes so that the last cell of one route is the first cell of the next route function mergeRoutes(routes) { let routesMerged = 0; for (let i = 0; i < routes.length; i++) { const thisRoute = routes[i]; if (thisRoute.merged) continue; for (let j = i + 1; j < routes.length; j++) { const nextRoute = routes[j]; if (nextRoute.merged) continue; if (nextRoute.cells.at(0) === thisRoute.cells.at(-1)) { routesMerged++; thisRoute.cells = thisRoute.cells.concat(nextRoute.cells.slice(1)); nextRoute.merged = true; } } } return routesMerged > 1 ? mergeRoutes(routes) : routes; } } function createCostEvaluator({isWater, connections, routeType = "market"}) { return isWater ? getWaterPathCost : getLandPathCost; function getLandPathCost(current, next) { if (pack.cells.h[next] < 20) return Infinity; // ignore water cells const habitability = biomesData.habitability[pack.cells.biome[next]]; if (!habitability) return Infinity; // inhabitable cells are not passable (e.g. glacier) const distanceCost = dist2(pack.cells.p[current], pack.cells.p[next]); const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1]; const heightModifier = 1 + Math.max(pack.cells.h[next] - 25, 25) / 25; // [1, 3]; const connectionModifier = connections.has(`${current}-${next}`) ? 0.5 : 1; const burgModifier = pack.cells.burg[next] ? 1 : 3; // Medieval travel constraints const riverCrossingPenalty = pack.cells.r[next] && !pack.cells.burg[next] ? 1.5 : 1; // Bridges rare except at settlements const borderPenalty = getBorderPenalty(current, next, routeType); // Political boundaries affect some routes // Apply route tier modifier const tierModifier = ROUTE_TIER_MODIFIERS[routeType]?.cost || 1; const pathCost = distanceCost * habitabilityModifier * heightModifier * connectionModifier * burgModifier * riverCrossingPenalty * borderPenalty * tierModifier; return pathCost; } function getWaterPathCost(current, next) { if (pack.cells.h[next] >= 20) return Infinity; // ignore land cells if (grid.cells.temp[pack.cells.g[next]] < MIN_PASSABLE_SEA_TEMP) return Infinity; // ignore too cold cells const distanceCost = dist2(pack.cells.p[current], pack.cells.p[next]); const typeModifier = ROUTE_TYPE_MODIFIERS[pack.cells.t[next]] || ROUTE_TYPE_MODIFIERS.default; const connectionModifier = connections.has(`${current}-${next}`) ? 0.5 : 1; // Apply route tier modifier for sea routes const tierModifier = ROUTE_TIER_MODIFIERS[routeType]?.cost || 1; const pathCost = distanceCost * typeModifier * connectionModifier * tierModifier; return pathCost; } function getBorderPenalty(current, next, routeType) { // Royal roads and major sea routes ignore borders (diplomatic/trade importance) if (routeType === "royal" || routeType === "majorSea") return 1; // Check if crossing state border const currentState = pack.cells.state[current]; const nextState = pack.cells.state[next]; if (currentState === nextState) return 1; // Higher penalty for local routes crossing borders if (routeType === "footpath") return 3; if (routeType === "local") return 2; return 1.5; // Market roads have moderate border penalty } } function buildLinks(routes) { const links = {}; for (const {points, i: routeId} of routes) { const cells = points.map(p => p[2]); for (let i = 0; i < cells.length - 1; i++) { const cellId = cells[i]; const nextCellId = cells[i + 1]; if (cellId !== nextCellId) { if (!links[cellId]) links[cellId] = {}; links[cellId][nextCellId] = routeId; if (!links[nextCellId]) links[nextCellId] = {}; links[nextCellId][cellId] = routeId; } } } return links; } function preparePointsArray() { const {cells, burgs} = pack; return cells.p.map(([x, y], cellId) => { const burgId = cells.burg[cellId]; if (burgId) return [burgs[burgId].x, burgs[burgId].y]; return [x, y]; }); } function getPoints(group, cells, points) { const data = cells.map(cellId => [...points[cellId], cellId]); // resolve sharp angles if (group !== "searoutes") { for (let i = 1; i < cells.length - 1; i++) { const cellId = cells[i]; if (pack.cells.burg[cellId]) continue; const [prevX, prevY] = data[i - 1]; const [currX, currY] = data[i]; const [nextX, nextY] = data[i + 1]; const dAx = prevX - currX; const dAy = prevY - currY; const dBx = nextX - currX; const dBy = nextY - currY; const angle = Math.abs((Math.atan2(dAx * dBy - dAy * dBx, dAx * dBx + dAy * dBy) * 180) / Math.PI); if (angle < ROUTES_SHARP_ANGLE) { const middleX = (prevX + nextX) / 2; const middleY = (prevY + nextY) / 2; let newX, newY; if (angle < ROUTES_VERY_SHARP_ANGLE) { newX = rn((currX + middleX * 2) / 3, 2); newY = rn((currY + middleY * 2) / 3, 2); } else { newX = rn((currX + middleX) / 2, 2); newY = rn((currY + middleY) / 2, 2); } if (findCell(newX, newY) === cellId) { data[i] = [newX, newY, cellId]; points[cellId] = [data[i][0], data[i][1]]; // change cell coordinate for all routes } } } } return data; // [[x, y, cell], [x, y, cell]]; } function getRouteSegments(pathCells, connections) { const segments = []; let segment = []; for (let i = 0; i < pathCells.length; i++) { const cellId = pathCells[i]; const nextCellId = pathCells[i + 1]; const isConnected = connections.has(`${cellId}-${nextCellId}`) || connections.has(`${nextCellId}-${cellId}`); if (isConnected) { if (segment.length) { // segment stepped into existing segment segment.push(pathCells[i]); segments.push(segment); segment = []; } continue; } segment.push(pathCells[i]); } if (segment.length > 1) segments.push(segment); return segments; } // Urquhart graph is obtained by removing the longest edge from each triangle in the Delaunay triangulation // this gives us an aproximation of a desired road network, i.e. connections between burgs // code from https://observablehq.com/@mbostock/urquhart-graph function calculateUrquhartEdges(points) { const score = (p0, p1) => dist2(points[p0], points[p1]); const {halfedges, triangles} = Delaunator.from(points); const n = triangles.length; const removed = new Uint8Array(n); const edges = []; for (let e = 0; e < n; e += 3) { const p0 = triangles[e], p1 = triangles[e + 1], p2 = triangles[e + 2]; const p01 = score(p0, p1), p12 = score(p1, p2), p20 = score(p2, p0); removed[ p20 > p01 && p20 > p12 ? Math.max(e + 2, halfedges[e + 2]) : p12 > p01 && p12 > p20 ? Math.max(e + 1, halfedges[e + 1]) : Math.max(e, halfedges[e]) ] = 1; } for (let e = 0; e < n; ++e) { if (e > halfedges[e] && !removed[e]) { const t0 = triangles[e]; const t1 = triangles[e % 3 === 2 ? e - 2 : e + 1]; edges.push([t0, t1]); } } return edges; } // connect cell with routes system by land function connect(cellId) { const getCost = createCostEvaluator({isWater: false, connections: new Map()}); const pathCells = findPath(cellId, isConnected, getCost); if (!pathCells) return; const pointsArray = preparePointsArray(); const points = getPoints("trails", pathCells, pointsArray); const feature = pack.cells.f[cellId]; const routeId = getNextId(); const newRoute = {i: routeId, group: "trails", feature, points}; pack.routes.push(newRoute); for (let i = 0; i < pathCells.length; i++) { const cellId = pathCells[i]; const nextCellId = pathCells[i + 1]; if (nextCellId) addConnection(cellId, nextCellId, routeId); } return newRoute; function addConnection(from, to, routeId) { const routes = pack.cells.routes; if (!routes[from]) routes[from] = {}; routes[from][to] = routeId; if (!routes[to]) routes[to] = {}; routes[to][from] = routeId; } } // utility functions function isConnected(cellId) { const routes = pack.cells.routes; return routes[cellId] && Object.keys(routes[cellId]).length > 0; } function areConnected(from, to) { const routeId = pack.cells.routes[from]?.[to]; return routeId !== undefined; } function getRoute(from, to) { const routeId = pack.cells.routes[from]?.[to]; if (routeId === undefined) return null; const route = pack.routes.find(route => route.i === routeId); if (!route) return null; return route; } function hasRoad(cellId) { const connections = pack.cells.routes[cellId]; if (!connections) return false; return Object.values(connections).some(routeId => { const route = pack.routes.find(route => route.i === routeId); if (!route) return false; return route.group === "roads"; }); } function hasSecondaryRoad(cellId) { const connections = pack.cells.routes[cellId]; if (!connections) return false; return Object.values(connections).some(routeId => { const route = pack.routes.find(route => route.i === routeId); if (!route) return false; return route.group === "secondary"; }); } function isCrossroad(cellId) { const connections = pack.cells.routes[cellId]; if (!connections) return false; if (Object.keys(connections).length > 3) return true; const majorRoadConnections = Object.values(connections).filter(routeId => { const route = pack.routes.find(route => route.i === routeId); return route?.group === "roads" || route?.group === "secondary"; }); return majorRoadConnections.length > 2; } // name generator data const models = { roads: {burg_suffix: 3, prefix_suffix: 6, the_descriptor_prefix_suffix: 2, the_descriptor_burg_suffix: 1}, secondary: {burg_suffix: 5, prefix_suffix: 4, the_descriptor_prefix_suffix: 1, the_descriptor_burg_suffix: 2}, trails: {burg_suffix: 8, prefix_suffix: 1, the_descriptor_burg_suffix: 1}, searoutes: {burg_suffix: 4, prefix_suffix: 2, the_descriptor_prefix_suffix: 1} }; const prefixes = [ "King", "Queen", "Military", "Old", "New", "Ancient", "Royal", "Imperial", "Great", "Grand", "High", "Silver", "Dragon", "Shadow", "Star", "Mystic", "Whisper", "Eagle", "Golden", "Crystal", "Enchanted", "Frost", "Moon", "Sun", "Thunder", "Phoenix", "Sapphire", "Celestial", "Wandering", "Echo", "Twilight", "Crimson", "Serpent", "Iron", "Forest", "Flower", "Whispering", "Eternal", "Frozen", "Rain", "Luminous", "Stardust", "Arcane", "Glimmering", "Jade", "Ember", "Azure", "Gilded", "Divine", "Shadowed", "Cursed", "Moonlit", "Sable", "Everlasting", "Amber", "Nightshade", "Wraith", "Scarlet", "Platinum", "Whirlwind", "Obsidian", "Ethereal", "Ghost", "Spike", "Dusk", "Raven", "Spectral", "Burning", "Verdant", "Copper", "Velvet", "Falcon", "Enigma", "Glowing", "Silvered", "Molten", "Radiant", "Astral", "Wild", "Flame", "Amethyst", "Aurora", "Shadowy", "Solar", "Lunar", "Whisperwind", "Fading", "Titan", "Dawn", "Crystalline", "Jeweled", "Sylvan", "Twisted", "Ebon", "Thorn", "Cerulean", "Halcyon", "Infernal", "Storm", "Eldritch", "Sapphire", "Crimson", "Tranquil", "Paved" ]; const descriptors = [ "Great", "Shrouded", "Sacred", "Fabled", "Frosty", "Winding", "Echoing", "Serpentine", "Breezy", "Misty", "Rustic", "Silent", "Cobbled", "Cracked", "Shaky", "Obscure" ]; const suffixes = { roads: {road: 7, route: 3, way: 2, highway: 1}, secondary: {road: 4, route: 2, way: 3, avenue: 1, boulevard: 1}, trails: {trail: 4, path: 1, track: 1, pass: 1}, searoutes: {"sea route": 5, lane: 2, passage: 1, seaway: 1} }; function generateName({group, points}) { if (points.length < 4) return "Unnamed route segment"; const model = rw(models[group]); const suffix = rw(suffixes[group]); const burgName = getBurgName(); if (model === "burg_suffix" && burgName) return `${burgName} ${suffix}`; if (model === "prefix_suffix") return `${ra(prefixes)} ${suffix}`; if (model === "the_descriptor_prefix_suffix") return `The ${ra(descriptors)} ${ra(prefixes)} ${suffix}`; if (model === "the_descriptor_burg_suffix" && burgName) return `The ${ra(descriptors)} ${burgName} ${suffix}`; return "Unnamed route"; function getBurgName() { const priority = [points.at(-1), points.at(0), points.slice(1, -1).reverse()]; for (const [_x, _y, cellId] of priority) { const burgId = pack.cells.burg[cellId]; if (burgId) return getAdjective(pack.burgs[burgId].name); } return null; } } const ROUTE_CURVES = { roads: d3.curveCatmullRom.alpha(0.1), secondary: d3.curveCatmullRom.alpha(0.1), trails: d3.curveCatmullRom.alpha(0.1), searoutes: d3.curveCatmullRom.alpha(0.5), default: d3.curveCatmullRom.alpha(0.1) }; function getPath({group, points}) { const lineGen = d3.line(); lineGen.curve(ROUTE_CURVES[group] || ROUTE_CURVES.default); const path = round(lineGen(points.map(p => [p[0], p[1]])), 1); return path; } function getLength(routeId) { const path = routes.select("#route" + routeId).node(); if (!path) { // Fallback: calculate length from route points if DOM element not available const route = pack.routes.find(r => r.i === routeId); if (route && route.points) { let length = 0; for (let i = 0; i < route.points.length - 1; i++) { const [x1, y1] = route.points[i]; const [x2, y2] = route.points[i + 1]; length += Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); } return length; } return 0; } return path.getTotalLength(); } function getNextId() { return pack.routes.length ? Math.max(...pack.routes.map(r => r.i)) + 1 : 0; } function remove(route) { const routes = pack.cells.routes; for (const point of route.points) { const from = point[2]; if (!routes[from]) continue; for (const [to, routeId] of Object.entries(routes[from])) { if (routeId === route.i) { delete routes[from][to]; delete routes[to][from]; } } } pack.routes = pack.routes.filter(r => r.i !== route.i); viewbox.select("#route" + route.i).remove(); } return { generate, buildLinks, connect, isConnected, areConnected, getRoute, hasRoad, hasSecondaryRoad, isCrossroad, generateName, getPath, getLength, getNextId, remove }; })();