poe2-bot/src/Roboto.Data/AreaGraph.cs
2026-03-03 12:54:30 -05:00

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