Keeps the repo root clean - only README.md visible on landing page. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
519 lines
14 KiB
C#
519 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
|
|
class Tile
|
|
{
|
|
public long Id;
|
|
public int Kind;
|
|
public string Name;
|
|
public int X;
|
|
public int Y;
|
|
public double Z;
|
|
public int State;
|
|
}
|
|
|
|
class Cluster
|
|
{
|
|
public int Kind;
|
|
public List<Tile> Tiles = new List<Tile>();
|
|
public double AvgZ;
|
|
public HashSet<int> StateSet = new HashSet<int>();
|
|
public double CenterX;
|
|
public double CenterY;
|
|
}
|
|
|
|
const int SPAWN_X = 13;
|
|
const int SPAWN_Y = 13;
|
|
const bool WAIT_FOR_SPAWN_START = true;
|
|
const int SPAWN_WAIT_TIMEOUT_MS = 180000;
|
|
|
|
int GetState(dynamic item)
|
|
{
|
|
try { return int.Parse(item.State?.ToString() ?? "0"); }
|
|
catch { return 0; }
|
|
}
|
|
|
|
int GetKind(dynamic item)
|
|
{
|
|
try { return (int)item.Kind; }
|
|
catch { return -1; }
|
|
}
|
|
|
|
string GetNameSafe(dynamic item)
|
|
{
|
|
try
|
|
{
|
|
string n = item.GetName();
|
|
return string.IsNullOrWhiteSpace(n) ? "<unknown>" : n;
|
|
}
|
|
catch { return "<unknown>"; }
|
|
}
|
|
|
|
List<Tile> ReadAllFloorTiles()
|
|
{
|
|
var list = new List<Tile>();
|
|
foreach (var item in FloorItems)
|
|
{
|
|
if (item == null) continue;
|
|
list.Add(new Tile {
|
|
Id = item.Id,
|
|
Kind = GetKind(item),
|
|
Name = GetNameSafe(item),
|
|
X = item.Location.X,
|
|
Y = item.Location.Y,
|
|
Z = item.Location.Z,
|
|
State = GetState(item)
|
|
});
|
|
}
|
|
return list;
|
|
}
|
|
|
|
string PosKey(int x, int y) => x + "," + y;
|
|
|
|
List<Cluster> BuildClusters(List<Tile> tilesOfKind)
|
|
{
|
|
var byPos = tilesOfKind.ToDictionary(t => PosKey(t.X, t.Y), t => t);
|
|
var visited = new HashSet<string>();
|
|
var clusters = new List<Cluster>();
|
|
|
|
int[] d = { -1, 0, 1 };
|
|
foreach (var t in tilesOfKind)
|
|
{
|
|
string start = PosKey(t.X, t.Y);
|
|
if (visited.Contains(start)) continue;
|
|
|
|
var q = new Queue<Tile>();
|
|
var c = new Cluster { Kind = t.Kind };
|
|
q.Enqueue(t);
|
|
visited.Add(start);
|
|
|
|
while (q.Count > 0)
|
|
{
|
|
var cur = q.Dequeue();
|
|
c.Tiles.Add(cur);
|
|
c.StateSet.Add(cur.State);
|
|
|
|
foreach (int dx in d)
|
|
foreach (int dy in d)
|
|
{
|
|
if (dx == 0 && dy == 0) continue;
|
|
string nk = PosKey(cur.X + dx, cur.Y + dy);
|
|
if (visited.Contains(nk)) continue;
|
|
if (!byPos.ContainsKey(nk)) continue;
|
|
visited.Add(nk);
|
|
q.Enqueue(byPos[nk]);
|
|
}
|
|
}
|
|
|
|
c.AvgZ = c.Tiles.Count == 0 ? 0.0 : c.Tiles.Average(x => x.Z);
|
|
c.CenterX = c.Tiles.Count == 0 ? 0.0 : c.Tiles.Average(x => x.X);
|
|
c.CenterY = c.Tiles.Count == 0 ? 0.0 : c.Tiles.Average(x => x.Y);
|
|
clusters.Add(c);
|
|
}
|
|
|
|
return clusters;
|
|
}
|
|
|
|
void LogRoomScan(List<Tile> all)
|
|
{
|
|
Log("=== Full Room Scan ===");
|
|
Log($"FloorItems total: {all.Count}");
|
|
|
|
var byKind = all.GroupBy(x => x.Kind)
|
|
.Select(g => new {
|
|
Kind = g.Key,
|
|
Count = g.Count(),
|
|
Names = g.Select(x => x.Name).Distinct().Take(3).ToArray(),
|
|
MinX = g.Min(x => x.X), MaxX = g.Max(x => x.X),
|
|
MinY = g.Min(x => x.Y), MaxY = g.Max(x => x.Y),
|
|
MinZ = g.Min(x => x.Z), MaxZ = g.Max(x => x.Z),
|
|
States = string.Join(",", g.Select(x => x.State).Distinct().OrderBy(x => x))
|
|
})
|
|
.OrderByDescending(x => x.Count)
|
|
.Take(30)
|
|
.ToList();
|
|
|
|
foreach (var k in byKind)
|
|
{
|
|
Log($"Kind {k.Kind} x{k.Count} | states [{k.States}] | bbox X[{k.MinX}-{k.MaxX}] Y[{k.MinY}-{k.MaxY}] Z[{k.MinZ:F2}-{k.MaxZ:F2}] | name {string.Join(" / ", k.Names)}");
|
|
}
|
|
}
|
|
|
|
double DistD(double x1, double y1, double x2, double y2)
|
|
{
|
|
double dx = x1 - x2;
|
|
double dy = y1 - y2;
|
|
return Math.Sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
bool TryDetectBoards(List<Tile> all, int spawnX, int spawnY, out Cluster top, out Cluster bottom)
|
|
{
|
|
top = null;
|
|
bottom = null;
|
|
|
|
var byKind = all.GroupBy(x => x.Kind).ToList();
|
|
Cluster bestA = null, bestB = null;
|
|
int bestScore = -1;
|
|
|
|
foreach (var g in byKind)
|
|
{
|
|
var clusters = BuildClusters(g.ToList())
|
|
.Where(c => c.Tiles.Count >= 9)
|
|
.OrderByDescending(c => c.Tiles.Count)
|
|
.ToList();
|
|
if (clusters.Count < 2) continue;
|
|
|
|
for (int i = 0; i < clusters.Count; i++)
|
|
{
|
|
for (int j = i + 1; j < clusters.Count; j++)
|
|
{
|
|
var a = clusters[i];
|
|
var b = clusters[j];
|
|
int minCount = Math.Min(a.Tiles.Count, b.Tiles.Count);
|
|
int diff = Math.Abs(a.Tiles.Count - b.Tiles.Count);
|
|
int score = (minCount * 10) - diff;
|
|
if (a.StateSet.Count > 1) score += 5;
|
|
if (b.StateSet.Count > 1) score += 5;
|
|
|
|
if (score > bestScore)
|
|
{
|
|
bestScore = score;
|
|
bestA = a;
|
|
bestB = b;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bestA == null || bestB == null) return false;
|
|
|
|
// Prefer the board whose center is closer to your spawn as bottom/play board.
|
|
double da = DistD(bestA.CenterX, bestA.CenterY, spawnX, spawnY);
|
|
double db = DistD(bestB.CenterX, bestB.CenterY, spawnX, spawnY);
|
|
if (Math.Abs(da - db) >= 2.0)
|
|
{
|
|
if (da <= db) { bottom = bestA; top = bestB; }
|
|
else { bottom = bestB; top = bestA; }
|
|
}
|
|
else
|
|
{
|
|
// Fallback if both are similarly far away.
|
|
if (bestA.AvgZ >= bestB.AvgZ) { top = bestA; bottom = bestB; }
|
|
else { top = bestB; bottom = bestA; }
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool WaitForRoundSpawn(int spawnX, int spawnY, int timeoutMs)
|
|
{
|
|
Log($"Waiting for round start spawn at {spawnX}:{spawnY}...");
|
|
int elapsed = 0;
|
|
while (elapsed < timeoutMs)
|
|
{
|
|
if (Self != null && Self.Location != null)
|
|
{
|
|
if (Self.Location.X == spawnX && Self.Location.Y == spawnY)
|
|
{
|
|
Log("Spawn detected. Round started.");
|
|
Delay(350);
|
|
return true;
|
|
}
|
|
}
|
|
Delay(200);
|
|
elapsed += 200;
|
|
}
|
|
|
|
Log("Spawn wait timeout reached. Continuing anyway.");
|
|
return false;
|
|
}
|
|
|
|
Dictionary<string, Tile> BuildIndexedMap(Cluster c, out int w, out int h)
|
|
{
|
|
var xs = c.Tiles.Select(t => t.X).Distinct().OrderBy(x => x).ToList();
|
|
var ys = c.Tiles.Select(t => t.Y).Distinct().OrderBy(y => y).ToList();
|
|
w = xs.Count;
|
|
h = ys.Count;
|
|
|
|
var xToI = new Dictionary<int, int>();
|
|
var yToI = new Dictionary<int, int>();
|
|
for (int i = 0; i < xs.Count; i++) xToI[xs[i]] = i;
|
|
for (int i = 0; i < ys.Count; i++) yToI[ys[i]] = i;
|
|
|
|
var map = new Dictionary<string, Tile>();
|
|
foreach (var t in c.Tiles)
|
|
{
|
|
int xi = xToI[t.X];
|
|
int yi = yToI[t.Y];
|
|
map[PosKey(xi, yi)] = t;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
bool TryBuildTargetMap(Cluster top, Cluster bottom, out Dictionary<long, int> targetByBottomId, out List<Tile> bottomTilesOrdered)
|
|
{
|
|
targetByBottomId = new Dictionary<long, int>();
|
|
bottomTilesOrdered = new List<Tile>();
|
|
|
|
int bw, bh, tw, th;
|
|
var bMap = BuildIndexedMap(bottom, out bw, out bh);
|
|
var tMap = BuildIndexedMap(top, out tw, out th);
|
|
|
|
if (bw != tw || bh != th)
|
|
{
|
|
Log($"WARN: Different board dimensions: top {tw}x{th}, bottom {bw}x{bh}. Trying overlap map.");
|
|
}
|
|
|
|
int w = Math.Min(bw, tw);
|
|
int h = Math.Min(bh, th);
|
|
if (w <= 0 || h <= 0) return false;
|
|
|
|
var transforms = new List<Func<int, int, (int x, int y)>>();
|
|
transforms.Add((x, y) => (x, y));
|
|
transforms.Add((x, y) => (w - 1 - x, y));
|
|
transforms.Add((x, y) => (x, h - 1 - y));
|
|
transforms.Add((x, y) => (w - 1 - x, h - 1 - y));
|
|
if (w == h)
|
|
{
|
|
transforms.Add((x, y) => (y, x));
|
|
transforms.Add((x, y) => (w - 1 - y, x));
|
|
transforms.Add((x, y) => (y, h - 1 - x));
|
|
transforms.Add((x, y) => (w - 1 - y, h - 1 - x));
|
|
}
|
|
|
|
int bestIdx = 0;
|
|
int bestMatches = -1;
|
|
|
|
for (int ti = 0; ti < transforms.Count; ti++)
|
|
{
|
|
int matches = 0;
|
|
for (int y = 0; y < h; y++)
|
|
{
|
|
for (int x = 0; x < w; x++)
|
|
{
|
|
var bKey = PosKey(x, y);
|
|
if (!bMap.ContainsKey(bKey)) continue;
|
|
var tr = transforms[ti](x, y);
|
|
var tKey = PosKey(tr.x, tr.y);
|
|
if (tMap.ContainsKey(tKey)) matches++;
|
|
}
|
|
}
|
|
if (matches > bestMatches)
|
|
{
|
|
bestMatches = matches;
|
|
bestIdx = ti;
|
|
}
|
|
}
|
|
|
|
var bestTf = transforms[bestIdx];
|
|
Log($"Mapping transform index: {bestIdx}, overlap: {bestMatches}");
|
|
|
|
foreach (var kv in bMap)
|
|
{
|
|
var p = kv.Key.Split(',');
|
|
int x = int.Parse(p[0]);
|
|
int y = int.Parse(p[1]);
|
|
if (x >= w || y >= h) continue;
|
|
|
|
var tr = bestTf(x, y);
|
|
var tKey = PosKey(tr.x, tr.y);
|
|
if (!tMap.ContainsKey(tKey)) continue;
|
|
|
|
var bTile = kv.Value;
|
|
var tTile = tMap[tKey];
|
|
targetByBottomId[bTile.Id] = tTile.State;
|
|
bottomTilesOrdered.Add(bTile);
|
|
}
|
|
|
|
return targetByBottomId.Count > 0;
|
|
}
|
|
|
|
Dictionary<long, int> ReadCurrentStatesById(HashSet<long> ids)
|
|
{
|
|
var map = new Dictionary<long, int>();
|
|
foreach (var item in FloorItems)
|
|
{
|
|
if (item == null) continue;
|
|
long id = item.Id;
|
|
if (!ids.Contains(id)) continue;
|
|
map[id] = GetState(item);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
int CircularNeed(int current, int target, int mod)
|
|
{
|
|
int d = (target - current) % mod;
|
|
if (d < 0) d += mod;
|
|
return d;
|
|
}
|
|
|
|
int CalcObjective(Dictionary<long, int> current, Dictionary<long, int> target, int cycle)
|
|
{
|
|
int s = 0;
|
|
foreach (var kv in target)
|
|
{
|
|
if (!current.ContainsKey(kv.Key)) continue;
|
|
s += CircularNeed(current[kv.Key], kv.Value, cycle);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
bool WaitUntilAt(int tx, int ty, int timeoutMs)
|
|
{
|
|
int elapsed = 0;
|
|
while (elapsed < timeoutMs)
|
|
{
|
|
if (Self != null && Self.Location != null && Self.Location.X == tx && Self.Location.Y == ty)
|
|
return true;
|
|
Delay(120);
|
|
elapsed += 120;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
int Dist(int x1, int y1, int x2, int y2)
|
|
{
|
|
return Math.Abs(x1 - x2) + Math.Abs(y1 - y2);
|
|
}
|
|
|
|
Log("=== Color Pattern Walker Solver ===");
|
|
|
|
if (WAIT_FOR_SPAWN_START)
|
|
WaitForRoundSpawn(SPAWN_X, SPAWN_Y, SPAWN_WAIT_TIMEOUT_MS);
|
|
|
|
var allTiles = ReadAllFloorTiles();
|
|
if (allTiles.Count == 0)
|
|
{
|
|
Log("ERROR: No floor items found.");
|
|
return;
|
|
}
|
|
|
|
LogRoomScan(allTiles);
|
|
|
|
Cluster top, bottom;
|
|
if (!TryDetectBoards(allTiles, SPAWN_X, SPAWN_Y, out top, out bottom))
|
|
{
|
|
Log("ERROR: Could not auto-detect top template board + bottom play board.");
|
|
Log("Tip: run ColorPuzzleScanner first and tell me tile Kind/Name, then I hard-bind it.");
|
|
return;
|
|
}
|
|
|
|
Log($"Detected kind: {top.Kind}");
|
|
Log($"Top tiles: {top.Tiles.Count}, avgZ={top.AvgZ:F2}");
|
|
Log($"Bottom tiles: {bottom.Tiles.Count}, avgZ={bottom.AvgZ:F2}");
|
|
|
|
Dictionary<long, int> targetById;
|
|
List<Tile> bottomTiles;
|
|
if (!TryBuildTargetMap(top, bottom, out targetById, out bottomTiles))
|
|
{
|
|
Log("ERROR: Could not map top pattern to bottom board.");
|
|
return;
|
|
}
|
|
|
|
var targetIds = new HashSet<long>(targetById.Keys);
|
|
var current = ReadCurrentStatesById(targetIds);
|
|
if (current.Count == 0)
|
|
{
|
|
Log("ERROR: Could not read current bottom states.");
|
|
return;
|
|
}
|
|
|
|
int maxStateSeen = 0;
|
|
foreach (var v in targetById.Values) if (v > maxStateSeen) maxStateSeen = v;
|
|
foreach (var v in current.Values) if (v > maxStateSeen) maxStateSeen = v;
|
|
int cycle = maxStateSeen + 1;
|
|
if (cycle < 2 || cycle > 8) cycle = 4;
|
|
|
|
Log($"Mapped tiles: {targetById.Count}");
|
|
Log($"State cycle guessed: {cycle}");
|
|
|
|
int objective = CalcObjective(current, targetById, cycle);
|
|
Log($"Initial distance: {objective}");
|
|
if (objective == 0)
|
|
{
|
|
Log("Already solved.");
|
|
return;
|
|
}
|
|
|
|
if (Self == null || Self.Location == null)
|
|
{
|
|
Log("ERROR: Self position unavailable.");
|
|
return;
|
|
}
|
|
|
|
var rng = new Random();
|
|
int stagnation = 0;
|
|
const int MAX_MOVES = 1200;
|
|
|
|
for (int step = 1; step <= MAX_MOVES; step++)
|
|
{
|
|
var meX = Self?.Location?.X ?? -1;
|
|
var meY = Self?.Location?.Y ?? -1;
|
|
|
|
Tile chosen = null;
|
|
|
|
var needTiles = bottomTiles
|
|
.Where(t => current.ContainsKey(t.Id) && targetById.ContainsKey(t.Id))
|
|
.Select(t => new {
|
|
Tile = t,
|
|
Need = CircularNeed(current[t.Id], targetById[t.Id], cycle),
|
|
D = (meX >= 0 && meY >= 0) ? Dist(meX, meY, t.X, t.Y) : 9999
|
|
})
|
|
.Where(x => x.Need > 0)
|
|
.OrderByDescending(x => x.Need)
|
|
.ThenBy(x => x.D)
|
|
.ToList();
|
|
|
|
if (needTiles.Count == 0)
|
|
{
|
|
Log("Solved (need list empty).");
|
|
break;
|
|
}
|
|
|
|
if (stagnation >= 12)
|
|
{
|
|
chosen = needTiles[rng.Next(needTiles.Count)].Tile;
|
|
}
|
|
else
|
|
{
|
|
chosen = needTiles[0].Tile;
|
|
}
|
|
|
|
Log($"[{step}] Move to ({chosen.X},{chosen.Y}) id={chosen.Id} state={current[chosen.Id]} target={targetById[chosen.Id]}");
|
|
Move(chosen.X, chosen.Y);
|
|
|
|
bool arrived = WaitUntilAt(chosen.X, chosen.Y, 2800);
|
|
if (!arrived)
|
|
{
|
|
Move(chosen.X, chosen.Y);
|
|
WaitUntilAt(chosen.X, chosen.Y, 2000);
|
|
}
|
|
|
|
Delay(220);
|
|
current = ReadCurrentStatesById(targetIds);
|
|
int newObj = CalcObjective(current, targetById, cycle);
|
|
int delta = objective - newObj;
|
|
Log($" distance: {objective} -> {newObj} (delta {delta})");
|
|
|
|
if (newObj <= 0)
|
|
{
|
|
Log("=== Done: bottom now matches top pattern ===");
|
|
return;
|
|
}
|
|
|
|
if (newObj < objective) stagnation = 0;
|
|
else stagnation++;
|
|
|
|
objective = newObj;
|
|
Delay(120);
|
|
}
|
|
|
|
var finalStates = ReadCurrentStatesById(targetIds);
|
|
int finalObj = CalcObjective(finalStates, targetById, cycle);
|
|
if (finalObj == 0)
|
|
Log("=== Done: bottom now matches top pattern ===");
|
|
else
|
|
Log($"Stopped. Remaining distance: {finalObj}. Re-run script to continue.");
|