-
-
Notifications
You must be signed in to change notification settings - Fork 14
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(Ship Map): Add a pathfinding algorithm which takes a ship map an…
…d returns the rooms to travel to in order to reach a destination. Closes #264
- Loading branch information
1 parent
cc83b62
commit 7fbe026
Showing
5 changed files
with
290 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
] | ||
`); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |