Skip to content

Commit

Permalink
feat(Ship Map): Add a pathfinding algorithm which takes a ship map an…
Browse files Browse the repository at this point in the history
…d returns the rooms to travel to in order to reach a destination. Closes #264
  • Loading branch information
alexanderson1993 committed Jun 15, 2022
1 parent cc83b62 commit 7fbe026
Show file tree
Hide file tree
Showing 5 changed files with 290 additions and 1 deletion.
2 changes: 1 addition & 1 deletion server/src/classes/Plugins/Ship/Deck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export class DeckEdge {
this.id = params.id;
this.to = params.to || 0;
this.from = params.from || 0;
this.weight = params.weight || 0;
this.weight = params.weight || 1;
this.isOpen = params.isOpen || true;
this.flags = params.flags || [];
}
Expand Down
2 changes: 2 additions & 0 deletions server/src/components/shipMap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import DeckPlugin, {DeckEdge} from "../classes/Plugins/Ship/Deck";
import {ShipMapGraph} from "../utils/shipMapPathfinder";
import {Component} from "./utils";

const roundTo1000 = (num: number) => Math.round(num * 1000) / 1000;
Expand Down Expand Up @@ -39,4 +40,5 @@ export class ShipMapComponent extends Component {
}
decks: DeckPlugin[] = [];
deckEdges: DeckEdge[] = [];
graph: ShipMapGraph | null = null;
}
108 changes: 108 additions & 0 deletions server/src/utils/priorityQueue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// This was taken from https://www.npmjs.com/package/node-dijkstra
/**
* This very basic implementation of a priority queue is used to select the
* next node of the graph to walk to.
*
* The queue is always sorted to have the least expensive node on top.
* Some helper methods are also implemented.
*
* You should **never** modify the queue directly, but only using the methods
* provided by the class.
*/
export class PriorityQueue<T> {
keys: Set<T>;
private queue: {priority: number; key: T}[];
/**
* Creates a new empty priority queue
*/
constructor() {
// The `keys` set is used to greatly improve the speed at which we can
// check the presence of a value in the queue
this.keys = new Set();
this.queue = [];
}

/**
* Sort the queue to have the least expensive node to visit on top
*
* @private
*/
sort() {
this.queue.sort((a, b) => a.priority - b.priority);
}

/**
* Sets a priority for a key in the queue.
* Inserts it in the queue if it does not already exists.
*
* @param {any} key Key to update or insert
* @param {number} value Priority of the key
* @return {number} Size of the queue
*/
set(key: T, value: number): number {
const priority = Number(value);
if (isNaN(priority)) throw new TypeError('"priority" must be a number');

if (!this.keys.has(key)) {
// Insert a new entry if the key is not already in the queue
this.keys.add(key);
this.queue.push({key, priority});
} else {
// Update the priority of an existing key
this.queue.map(element => {
if (element.key === key) {
Object.assign(element, {priority});
}

return element;
});
}

this.sort();
return this.queue.length;
}

/**
* The next method is used to dequeue a key:
* It removes the first element from the queue and returns it
*
* @return {object} First priority queue entry
*/
next() {
const element = this.queue.shift() as {priority: number; key: T};
// Remove the key from the `_keys` set
this.keys.delete(element.key);

return element;
}

/**
* @return {boolean} `true` when the queue is empty
*/
isEmpty() {
return Boolean(this.queue.length === 0);
}

/**
* Check if the queue has a key in it
*
* @param {any} key Key to lookup
* @return {boolean}
*/
has(key: T) {
return this.keys.has(key);
}

/**
* Get the element in the queue with the specified key
*
* @param {any} key Key to lookup
* @return {object}
*/
get(key: T) {
return this.queue.find(element => element.key === key) as {
priority: number;
key: T;
};
}
}
39 changes: 39 additions & 0 deletions server/src/utils/shipMapPathfinder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {createShipMapGraph, calculateShipMapPath} from "./shipMapPathfinder";

describe("ship map pathfinder", () => {
it("should create a graph", () => {
const edges = [
{from: 1, to: 2},
{from: 1, to: 3},
{from: 2, to: 3},
{from: 2, to: 4},
{from: 3, to: 4},
{from: 3, to: 5},
];
const graph = createShipMapGraph(edges);

expect(graph.size).toBe(5);
expect(graph.get(1)?.size).toBe(2);

expect(calculateShipMapPath(graph, 1, 5)).toMatchInlineSnapshot(`
Array [
1,
3,
5,
]
`);
expect(calculateShipMapPath(graph, 5, 1)).toMatchInlineSnapshot(`
Array [
5,
3,
1,
]
`);
expect(calculateShipMapPath(graph, 3, 2)).toMatchInlineSnapshot(`
Array [
3,
2,
]
`);
});
});
140 changes: 140 additions & 0 deletions server/src/utils/shipMapPathfinder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import type {DeckEdge} from "../classes/Plugins/Ship/Deck";
import {PriorityQueue} from "./priorityQueue";

export type ShipMapGraph = Map<number, Map<number, 1>>;
export function createShipMapGraph(edges: {from: number; to: number}[]) {
const nodes: ShipMapGraph = new Map();
edges.forEach(edge => {
if (!nodes.has(edge.from)) nodes.set(edge.from, new Map());
if (!nodes.has(edge.to)) nodes.set(edge.to, new Map());
nodes.get(edge.from)?.set(edge.to, 1);
nodes.get(edge.to)?.set(edge.from, 1);
});
// Verify that every node has at least one input and one output.
for (let node of nodes.keys()) {
if (nodes.get(node)?.size === 0)
throw new Error("Node has no outgoing edges");
// Check to find another node that links to this one
const otherNode = Array.from(nodes.values()).find(val => val.has(node));
if (!otherNode) throw new Error("Node has no incoming edges");
}
return nodes;
}

// This function was adapted from: https://www.npmjs.com/package/node-dijkstra
export function calculateShipMapPath(
graph: ShipMapGraph,
start: number,
goal: number,
options = {cost: false, avoid: [], trim: false, reverse: false}
) {
// Don't run when we don't have nodes set
if (!graph.size) {
if (options.cost) return {path: null, cost: 0};

return null;
}

const explored = new Set();
const frontier = new PriorityQueue<number>();
const previous = new Map();

let path = [];
let totalCost = 0;

let avoid: number[] = [];
if (options.avoid) avoid = [].concat(options.avoid);

if (avoid.includes(start)) {
throw new Error(`Starting node (${start}) cannot be avoided`);
} else if (avoid.includes(goal)) {
throw new Error(`Ending node (${goal}) cannot be avoided`);
}

// Add the starting point to the frontier, it will be the first node visited
frontier.set(start, 0);

// Run until we have visited every node in the frontier
while (!frontier.isEmpty()) {
// Get the node in the frontier with the lowest cost (`priority`)
const node = frontier.next();

// When the node with the lowest cost in the frontier in our goal node,
// we can compute the path and exit the loop
if (node.key === goal) {
// Set the total cost to the current value
totalCost = node.priority;

let nodeKey = node.key;
while (previous.has(nodeKey)) {
path.push(nodeKey);
nodeKey = previous.get(nodeKey);
}

break;
}

// Add the current node to the explored set
explored.add(node.key);

// Loop all the neighboring nodes
const neighbors = graph.get(node.key) || new Map();
neighbors.forEach((nCost, nNode) => {
// If we already explored the node, or the node is to be avoided, skip it
if (explored.has(nNode) || avoid.includes(nNode)) return null;

// If the neighboring node is not yet in the frontier, we add it with
// the correct cost
if (!frontier.has(nNode)) {
previous.set(nNode, node.key);
return frontier.set(nNode, node.priority + nCost);
}

const frontierPriority = frontier.get(nNode).priority;
const nodeCost = node.priority + nCost;

// Otherwise we only update the cost of this node in the frontier when
// it's below what's currently set
if (nodeCost < frontierPriority) {
previous.set(nNode, node.key);
return frontier.set(nNode, nodeCost);
}

return null;
});
}

// Return null when no path can be found
if (!path.length) {
if (options.cost) return {path: null, cost: 0};

return null;
}

// From now on, keep in mind that `path` is populated in reverse order,
// from destination to origin

// Remove the first value (the goal node) if we want a trimmed result
if (options.trim) {
path.shift();
} else {
// Add the origin waypoint at the end of the array
path = path.concat([start]);
}

// Reverse the path if we don't want it reversed, so the result will be
// from `start` to `goal`
if (!options.reverse) {
path = path.reverse();
}

// Return an object if we also want the cost
if (options.cost) {
return {
path,
cost: totalCost,
};
}

return path;
}

0 comments on commit 7fbe026

Please sign in to comment.