201 lines
6.5 KiB
C#
201 lines
6.5 KiB
C#
using System.Text.Json;
|
|
|
|
namespace Roboto.Data;
|
|
|
|
public record AreaNode(
|
|
string Id,
|
|
string Name,
|
|
int Act,
|
|
int Level,
|
|
int Order,
|
|
bool HasWaypoint,
|
|
bool IsTown,
|
|
List<string> ConnectsTo);
|
|
|
|
/// <summary>
|
|
/// Graph of game areas loaded from data/poe2/areas.json.
|
|
/// Supports progression ordering, adjacency queries, and BFS pathfinding.
|
|
/// </summary>
|
|
public sealed class AreaGraph
|
|
{
|
|
private readonly Dictionary<string, AreaNode> _byId;
|
|
private readonly Dictionary<string, AreaNode> _byName;
|
|
private readonly List<AreaNode> _allByOrder;
|
|
|
|
private AreaGraph(Dictionary<string, AreaNode> byId, Dictionary<string, AreaNode> byName, List<AreaNode> allByOrder)
|
|
{
|
|
_byId = byId;
|
|
_byName = byName;
|
|
_allByOrder = allByOrder;
|
|
}
|
|
|
|
public AreaNode? GetById(string id)
|
|
=> _byId.GetValueOrDefault(id);
|
|
|
|
public AreaNode? GetByName(string name)
|
|
=> _byName.GetValueOrDefault(name);
|
|
|
|
public List<AreaNode> GetConnections(string id)
|
|
{
|
|
if (!_byId.TryGetValue(id, out var node)) return [];
|
|
var result = new List<AreaNode>(node.ConnectsTo.Count);
|
|
foreach (var cid in node.ConnectsTo)
|
|
{
|
|
if (_byId.TryGetValue(cid, out var connected))
|
|
result.Add(connected);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// BFS from currentId to find the lowest-order unvisited reachable area.
|
|
/// Returns the area ID to target next, or null if progression is complete.
|
|
/// </summary>
|
|
public string? FindNextTarget(string currentId, HashSet<string> visited)
|
|
{
|
|
if (!_byId.ContainsKey(currentId)) return null;
|
|
|
|
// BFS to find all reachable areas
|
|
var reachable = new HashSet<string>();
|
|
var queue = new Queue<string>();
|
|
queue.Enqueue(currentId);
|
|
reachable.Add(currentId);
|
|
|
|
while (queue.Count > 0)
|
|
{
|
|
var id = queue.Dequeue();
|
|
if (!_byId.TryGetValue(id, out var node)) continue;
|
|
foreach (var cid in node.ConnectsTo)
|
|
{
|
|
if (reachable.Add(cid))
|
|
queue.Enqueue(cid);
|
|
}
|
|
}
|
|
|
|
// Find the lowest-order unvisited reachable area
|
|
AreaNode? best = null;
|
|
foreach (var rid in reachable)
|
|
{
|
|
if (visited.Contains(rid)) continue;
|
|
if (!_byId.TryGetValue(rid, out var node)) continue;
|
|
if (best is null || CompareOrder(node, best) < 0)
|
|
best = node;
|
|
}
|
|
|
|
return best?.Id;
|
|
}
|
|
|
|
/// <summary>
|
|
/// BFS shortest path from fromId to toId through the area graph.
|
|
/// Returns the sequence of area IDs, or null if unreachable.
|
|
/// </summary>
|
|
public List<string>? FindAreaPath(string fromId, string toId)
|
|
{
|
|
if (fromId == toId) return [fromId];
|
|
if (!_byId.ContainsKey(fromId) || !_byId.ContainsKey(toId)) return null;
|
|
|
|
var prev = new Dictionary<string, string>();
|
|
var queue = new Queue<string>();
|
|
queue.Enqueue(fromId);
|
|
prev[fromId] = fromId;
|
|
|
|
while (queue.Count > 0)
|
|
{
|
|
var id = queue.Dequeue();
|
|
if (id == toId)
|
|
{
|
|
// Reconstruct path
|
|
var path = new List<string>();
|
|
var cur = toId;
|
|
while (cur != fromId)
|
|
{
|
|
path.Add(cur);
|
|
cur = prev[cur];
|
|
}
|
|
path.Add(fromId);
|
|
path.Reverse();
|
|
return path;
|
|
}
|
|
|
|
if (!_byId.TryGetValue(id, out var node)) continue;
|
|
foreach (var cid in node.ConnectsTo)
|
|
{
|
|
if (!prev.ContainsKey(cid))
|
|
{
|
|
prev[cid] = id;
|
|
queue.Enqueue(cid);
|
|
}
|
|
}
|
|
}
|
|
|
|
return null; // unreachable
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compare two nodes by (act, order) for progression ordering.
|
|
/// </summary>
|
|
private static int CompareOrder(AreaNode a, AreaNode b)
|
|
{
|
|
var actCmp = a.Act.CompareTo(b.Act);
|
|
return actCmp != 0 ? actCmp : a.Order.CompareTo(b.Order);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns all area IDs with order less than or equal to the given area's order (same act).
|
|
/// Used to auto-mark earlier areas as visited so progression always moves forward.
|
|
/// </summary>
|
|
public List<string> GetEarlierAreas(string areaId)
|
|
{
|
|
if (!_byId.TryGetValue(areaId, out var current)) return [];
|
|
var result = new List<string>();
|
|
foreach (var node in _allByOrder)
|
|
{
|
|
if (node.Act == current.Act && node.Order < current.Order)
|
|
result.Add(node.Id);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
public static AreaGraph Load()
|
|
{
|
|
var byId = new Dictionary<string, AreaNode>(StringComparer.OrdinalIgnoreCase);
|
|
var byName = new Dictionary<string, AreaNode>(StringComparer.OrdinalIgnoreCase);
|
|
var allByOrder = new List<AreaNode>();
|
|
|
|
try
|
|
{
|
|
var path = Path.Combine("data", "poe2", "areas.json");
|
|
if (!File.Exists(path)) return new AreaGraph(byId, byName, allByOrder);
|
|
|
|
var json = File.ReadAllText(path);
|
|
using var doc = JsonDocument.Parse(json);
|
|
|
|
foreach (var actElement in doc.RootElement.EnumerateArray())
|
|
{
|
|
var act = actElement.GetProperty("act").GetInt32();
|
|
foreach (var area in actElement.GetProperty("areas").EnumerateArray())
|
|
{
|
|
var id = area.GetProperty("id").GetString()!;
|
|
var name = area.GetProperty("name").GetString()!;
|
|
var level = area.GetProperty("level").GetInt32();
|
|
var order = area.GetProperty("order").GetInt32();
|
|
var wp = area.GetProperty("wp").GetBoolean();
|
|
var town = area.TryGetProperty("town", out var townProp) && townProp.GetBoolean();
|
|
|
|
var connects = new List<string>();
|
|
foreach (var c in area.GetProperty("connects").EnumerateArray())
|
|
connects.Add(c.GetString()!);
|
|
|
|
var node = new AreaNode(id, name, act, level, order, wp, town, connects);
|
|
byId[id] = node;
|
|
byName[name] = node;
|
|
allByOrder.Add(node);
|
|
}
|
|
}
|
|
}
|
|
catch { /* non-critical */ }
|
|
|
|
allByOrder.Sort((a, b) => CompareOrder(a, b));
|
|
return new AreaGraph(byId, byName, allByOrder);
|
|
}
|
|
}
|