Keeps the repo root clean - only README.md visible on landing page. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
430 lines
17 KiB
C#
430 lines
17 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Globalization;
|
|
using System.Threading;
|
|
using System.Text.RegularExpressions;
|
|
|
|
public struct Point : IEquatable<Point>
|
|
{
|
|
public int X { get; }
|
|
public int Y { get; }
|
|
public Point(int x, int y) { X = x; Y = y; }
|
|
public static implicit operator Point((int x, int y) tuple) => new Point(tuple.x, tuple.y);
|
|
|
|
public bool Equals(Point other) => X == other.X && Y == other.Y;
|
|
public override bool Equals(object obj) => obj is Point other && Equals(other);
|
|
public override int GetHashCode() => HashCode.Combine(X, Y);
|
|
public static bool operator ==(Point left, Point right) => left.Equals(right);
|
|
public static bool operator !=(Point left, Point right) => !(left == right);
|
|
public override string ToString() => $"({X},{Y})";
|
|
}
|
|
|
|
public class Tile
|
|
{
|
|
public int X { get; set; }
|
|
public int Y { get; set; }
|
|
public double Z { get; set; }
|
|
public Point XY => new Point(X, Y);
|
|
public Tile(int x, int y, double z = 0.0) { X = x; Y = y; Z = z; }
|
|
public Tile(Point p, double z = 0.0) : this(p.X, p.Y, z) { }
|
|
}
|
|
|
|
public class TrackedFurni
|
|
{
|
|
public long Id { get; set; }
|
|
public string Name { get; set; }
|
|
public Tile Location { get; set; }
|
|
}
|
|
|
|
public class WiredMovement
|
|
{
|
|
public int FromX { get; set; }
|
|
public int FromY { get; set; }
|
|
public int ToX { get; set; }
|
|
public int ToY { get; set; }
|
|
public string FromHeight { get; set; }
|
|
public string ToHeight { get; set; }
|
|
public int Id { get; set; }
|
|
}
|
|
|
|
public class MoveCandidate
|
|
{
|
|
public Point Point { get; set; }
|
|
public double MinSafety { get; set; }
|
|
public double ThreatPressure { get; set; }
|
|
public double ProgressScore { get; set; }
|
|
public double CoherenceScore { get; set; }
|
|
}
|
|
|
|
Log("started");
|
|
|
|
List<long> dangerousFurniIdsToAvoid = new List<long> { 877618098, 31212, 324234234 };
|
|
List<string> dangerousFurniNamesToAvoid = new List<string> { "Light Royal Protector" };
|
|
|
|
HashSet<Point> walkableTiles = null;
|
|
int roomWidth = 0;
|
|
int roomLength = 0;
|
|
bool floorPlanParsedSuccessfully = false;
|
|
DateTime lastFloorPlanParseAttempt = DateTime.MinValue;
|
|
|
|
Dictionary<long, Dictionary<long, TrackedFurni>> AllTrackedFurnisGlobal = new Dictionary<long, Dictionary<long, TrackedFurni>>();
|
|
|
|
Tile _myAvatarActualTargetTile = null;
|
|
DateTime _lastMoveCommandSentTime = DateTime.MinValue;
|
|
Point _lastMoveCommandSentToXY = default(Point);
|
|
Point _lastMoveDirection = default(Point);
|
|
TimeSpan _clientSideAnticipationWindow = TimeSpan.FromMilliseconds(250);
|
|
|
|
// Tracks the bot's position from the previous frame to prevent moving back to it.
|
|
Point _lastKnownActualPosition = default(Point);
|
|
|
|
Point CurrentAnticipatedBotPosition
|
|
{
|
|
get
|
|
{
|
|
if (Self == null) return default(Point);
|
|
if (_myAvatarActualTargetTile != null) return _myAvatarActualTargetTile.XY;
|
|
if (!_lastMoveCommandSentToXY.Equals(default(Point)) && (DateTime.UtcNow - _lastMoveCommandSentTime) < _clientSideAnticipationWindow)
|
|
return _lastMoveCommandSentToXY;
|
|
if (Self.Location != null) return new Point(Self.Location.X, Self.Location.Y);
|
|
return default(Point);
|
|
}
|
|
}
|
|
|
|
void ExecuteMove(int x, int y)
|
|
{
|
|
Point currentPos = CurrentAnticipatedBotPosition;
|
|
if(!currentPos.Equals(default(Point)))
|
|
{
|
|
_lastMoveDirection = new Point(x - currentPos.X, y - currentPos.Y);
|
|
}
|
|
Move(x,y);
|
|
_lastMoveCommandSentToXY = new Point(x,y);
|
|
_lastMoveCommandSentTime = DateTime.UtcNow;
|
|
_myAvatarActualTargetTile = null;
|
|
}
|
|
|
|
void TryParseFloorPlan()
|
|
{
|
|
if ((DateTime.UtcNow - lastFloorPlanParseAttempt).TotalSeconds < 10 && floorPlanParsedSuccessfully) return;
|
|
lastFloorPlanParseAttempt = DateTime.UtcNow;
|
|
bool currentParseSuccess = false;
|
|
dynamic currentFloorPlan = null;
|
|
int tempRoomWidth = 0;
|
|
int tempRoomLength = 0;
|
|
HashSet<Point> tempWalkableTiles = null;
|
|
try { currentFloorPlan = FloorPlan; }
|
|
catch (Exception ex) { Log($"Error accessing FloorPlan: {ex.Message}"); floorPlanParsedSuccessfully = false; return; }
|
|
if (currentFloorPlan == null) { Log("FloorPlan is null."); floorPlanParsedSuccessfully = false; return; }
|
|
try
|
|
{
|
|
tempRoomWidth = currentFloorPlan.Width;
|
|
tempRoomLength = currentFloorPlan.Length;
|
|
if (tempRoomWidth <= 0 || tempRoomLength <= 0) { Log($"Invalid dimensions: W={tempRoomWidth}, L={tempRoomLength}"); floorPlanParsedSuccessfully = false; return; }
|
|
|
|
tempWalkableTiles = new HashSet<Point>();
|
|
IReadOnlyList<int> tilesData = null; string heightmapString = null;
|
|
object tilesProperty = null; try { tilesProperty = currentFloorPlan.Tiles; } catch { }
|
|
object heightmapProperty = null; try { heightmapProperty = currentFloorPlan.Heightmap; } catch { }
|
|
|
|
if (tilesProperty is IReadOnlyList<int> intTiles) tilesData = intTiles;
|
|
else if (heightmapProperty is string hmString) {
|
|
heightmapString = hmString.Replace("\r", "").Replace("\n", "");
|
|
if (heightmapString.Length != tempRoomWidth * tempRoomLength) { Log("Heightmap length mismatch."); floorPlanParsedSuccessfully = false; return; }
|
|
} else { Log("No recognizable Tiles/Heightmap."); floorPlanParsedSuccessfully = false; return; }
|
|
|
|
for (int y = 0; y < tempRoomLength; y++) {
|
|
for (int x = 0; x < tempRoomWidth; x++) {
|
|
bool isTileWalkable = false;
|
|
if (tilesData != null) {
|
|
int tileIndex = y * tempRoomWidth + x;
|
|
if (tileIndex < tilesData.Count) isTileWalkable = tilesData[tileIndex] >= 0 && tilesData[tileIndex] < 250;
|
|
} else if (heightmapString != null) {
|
|
isTileWalkable = heightmapString[y * tempRoomWidth + x] != 'x';
|
|
}
|
|
|
|
if(isTileWalkable)
|
|
{
|
|
tempWalkableTiles.Add(new Point(x,y));
|
|
}
|
|
}
|
|
}
|
|
currentParseSuccess = true;
|
|
}
|
|
catch (Exception ex) { Log($"Error parsing FloorPlan: {ex.Message}"); currentParseSuccess = false; }
|
|
if(currentParseSuccess) {
|
|
walkableTiles = tempWalkableTiles;
|
|
roomWidth = tempRoomWidth; roomLength = tempRoomLength;
|
|
floorPlanParsedSuccessfully = true; Log($"FloorPlan parsed: {walkableTiles.Count} walkable tiles in a {roomWidth}x{roomLength} area.");
|
|
} else {
|
|
walkableTiles = null; floorPlanParsedSuccessfully = false;
|
|
}
|
|
}
|
|
|
|
void OnBotEnteredNewRoom()
|
|
{
|
|
Log("Entered new room. Wiping memory.");
|
|
_myAvatarActualTargetTile = null;
|
|
_lastMoveCommandSentToXY = default(Point);
|
|
_lastMoveDirection = default(Point);
|
|
_lastKnownActualPosition = default(Point);
|
|
long currentRoomId = RoomId;
|
|
if (!AllTrackedFurnisGlobal.ContainsKey(currentRoomId)) AllTrackedFurnisGlobal[currentRoomId] = new Dictionary<long, TrackedFurni>();
|
|
AllTrackedFurnisGlobal[currentRoomId].Clear();
|
|
if (FloorItems != null) {
|
|
foreach (var item in FloorItems) {
|
|
if (item == null || item.Location == null) continue;
|
|
try {
|
|
if (!AllTrackedFurnisGlobal[currentRoomId].ContainsKey(item.Id)) {
|
|
AllTrackedFurnisGlobal[currentRoomId].Add(item.Id, new TrackedFurni {
|
|
Id = item.Id, Name = item.GetName(), Location = new Tile(item.Location.X, item.Location.Y, item.Location.Z)
|
|
});
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
TryParseFloorPlan();
|
|
}
|
|
|
|
void InterceptWiredMovements(dynamic e)
|
|
{
|
|
long currentRoomId = RoomId;
|
|
if (!AllTrackedFurnisGlobal.ContainsKey(currentRoomId)) AllTrackedFurnisGlobal[currentRoomId] = new Dictionary<long, TrackedFurni>();
|
|
var packet = e.Packet;
|
|
int count = packet.ReadInt();
|
|
for (int i = 0; i < count; i++) {
|
|
packet.ReadInt();
|
|
var movement = new WiredMovement { FromX = packet.ReadInt(), FromY = packet.ReadInt(), ToX = packet.ReadInt(), ToY = packet.ReadInt(), FromHeight = packet.ReadString(), ToHeight = packet.ReadString(), Id = packet.ReadInt() };
|
|
packet.ReadInt(); packet.ReadInt();
|
|
long furniLongId = movement.Id;
|
|
if (double.TryParse(movement.ToHeight, NumberStyles.Any, CultureInfo.InvariantCulture, out double z)) {
|
|
var newLocation = new Tile(movement.ToX, movement.ToY, z);
|
|
if (AllTrackedFurnisGlobal[currentRoomId].TryGetValue(furniLongId, out TrackedFurni trackedFurni)) {
|
|
trackedFurni.Location = newLocation;
|
|
} else {
|
|
AllTrackedFurnisGlobal[currentRoomId][furniLongId] = new TrackedFurni { Id = furniLongId, Location = newLocation };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Regex mvRegex = new Regex(@"/mv (\d+),(\d+),([\d\.]+)/");
|
|
|
|
void InterceptUserUpdate(dynamic e)
|
|
{
|
|
if(Self == null) return;
|
|
var packet = e.Packet;
|
|
int numUpdates = packet.ReadInt();
|
|
for(int i=0; i<numUpdates; i++) {
|
|
int entityIndex = packet.ReadInt();
|
|
int x = packet.ReadInt(); int y = packet.ReadInt(); string zStr = packet.ReadString();
|
|
int headRot = packet.ReadInt(); int bodyRot = packet.ReadInt(); string action = packet.ReadString();
|
|
if (entityIndex == Self.Index) {
|
|
Match match = mvRegex.Match(action);
|
|
if (match.Success) {
|
|
_myAvatarActualTargetTile = new Tile(int.Parse(match.Groups[1].Value), int.Parse(match.Groups[2].Value), double.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture));
|
|
_lastMoveCommandSentToXY = default(Point);
|
|
} else if (action.EndsWith("//") && !action.Contains("/mv")) {
|
|
_myAvatarActualTargetTile = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private double CalculateDistanceSq(Point p1, Point p2)
|
|
{
|
|
return Math.Pow(p1.X - p2.X, 2) + Math.Pow(p1.Y - p2.Y, 2);
|
|
}
|
|
|
|
bool IsTileEffectivelyWalkable(int x, int y)
|
|
{
|
|
if (!floorPlanParsedSuccessfully || walkableTiles == null) return false;
|
|
return walkableTiles.Contains(new Point(x,y));
|
|
}
|
|
|
|
Point FindSafestUltimateDestination(ICollection<Point> dangerZone)
|
|
{
|
|
if (!floorPlanParsedSuccessfully || walkableTiles == null) return default(Point);
|
|
if (!dangerZone.Any()) {
|
|
try { return walkableTiles.OrderBy(t => t.X).ThenBy(t=> t.Y).Skip(walkableTiles.Count/2).First(); } catch { return default(Point); }
|
|
}
|
|
|
|
var distanceMap = new Dictionary<Point, int>();
|
|
var queue = new Queue<Point>(dangerZone.Count);
|
|
|
|
foreach (var dangerPos in dangerZone)
|
|
{
|
|
if (IsTileEffectivelyWalkable(dangerPos.X, dangerPos.Y))
|
|
{
|
|
if (!distanceMap.ContainsKey(dangerPos))
|
|
{
|
|
distanceMap[dangerPos] = 0;
|
|
queue.Enqueue(dangerPos);
|
|
}
|
|
}
|
|
}
|
|
|
|
Point[] dirs = { (0, 1), (0, -1), (1, 0), (-1, 0), (1, 1), (1, -1), (-1, 1), (-1, -1) };
|
|
while(queue.Count > 0) {
|
|
Point p = queue.Dequeue();
|
|
int currentDist = distanceMap[p];
|
|
foreach(var dir in dirs) {
|
|
Point next = new Point(p.X + dir.X, p.Y + dir.Y);
|
|
if (IsTileEffectivelyWalkable(next.X, next.Y) && !distanceMap.ContainsKey(next)) {
|
|
distanceMap[next] = currentDist + 1;
|
|
queue.Enqueue(next);
|
|
}
|
|
}
|
|
}
|
|
|
|
Point bestTile = default(Point);
|
|
int maxDist = -1;
|
|
var safestUnreachableTile = walkableTiles.FirstOrDefault(t => !distanceMap.ContainsKey(t));
|
|
if (!safestUnreachableTile.Equals(default(Point)))
|
|
{
|
|
return safestUnreachableTile;
|
|
}
|
|
|
|
foreach(var tile in walkableTiles)
|
|
{
|
|
if(distanceMap.TryGetValue(tile, out int dist))
|
|
{
|
|
if(dist > maxDist)
|
|
{
|
|
maxDist = dist;
|
|
bestTile = tile;
|
|
}
|
|
}
|
|
}
|
|
return bestTile;
|
|
}
|
|
|
|
// --- LOGIC REVERTED and MINIMALLY FIXED ---
|
|
Point FindBestImmediateStep(Point currentPos, Point lastPos, ICollection<Point> dangerZone, Point ultimateGoal)
|
|
{
|
|
var candidates = new List<MoveCandidate>();
|
|
double initialDistToGoalSq = ultimateGoal.Equals(default(Point)) ? 0 : CalculateDistanceSq(currentPos, ultimateGoal);
|
|
|
|
for (int dx = -1; dx <= 1; dx++) {
|
|
for (int dy = -1; dy <= 1; dy++) {
|
|
Point candidatePoint = new Point(currentPos.X + dx, currentPos.Y + dy);
|
|
|
|
// Explicitly ignore the tile the bot was just on
|
|
if (!lastPos.Equals(default(Point)) && candidatePoint.Equals(lastPos)) continue;
|
|
|
|
if (!IsTileEffectivelyWalkable(candidatePoint.X, candidatePoint.Y)) continue;
|
|
|
|
double minSafetyDistSq = double.MaxValue;
|
|
double threatPressure = 0;
|
|
|
|
if (dangerZone.Any()) {
|
|
foreach(var danger in dangerZone) {
|
|
double distSq = CalculateDistanceSq(danger, candidatePoint);
|
|
if (distSq < minSafetyDistSq) minSafetyDistSq = distSq;
|
|
threatPressure += 1.0 / (distSq + 0.1);
|
|
}
|
|
}
|
|
|
|
double progressScore = ultimateGoal.Equals(default(Point)) ? 0 : initialDistToGoalSq - CalculateDistanceSq(candidatePoint, ultimateGoal);
|
|
double coherenceScore = (dx == _lastMoveDirection.X && dy == _lastMoveDirection.Y) ? 1.0 : 0.0;
|
|
|
|
if(dx == 0 && dy == 0) coherenceScore = -99;
|
|
|
|
candidates.Add(new MoveCandidate {
|
|
Point = candidatePoint,
|
|
MinSafety = minSafetyDistSq,
|
|
ThreatPressure = threatPressure,
|
|
ProgressScore = progressScore,
|
|
CoherenceScore = coherenceScore
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!candidates.Any()) return currentPos;
|
|
|
|
// This is the original logic, with the one key fix.
|
|
var bestMove = candidates
|
|
// *** THE ONLY CHANGE IS HERE ***
|
|
// We round the safety score. This creates "buckets" of tiles that are similarly safe.
|
|
// Within a bucket, ProgressScore will be used as a tie-breaker.
|
|
// This stops the bot from moving backward for a tiny, irrelevant gain in safety.
|
|
.OrderByDescending(c => Math.Round(c.MinSafety))
|
|
.ThenBy(c => c.ThreatPressure)
|
|
.ThenByDescending(c => c.ProgressScore)
|
|
.ThenByDescending(c => c.CoherenceScore)
|
|
.First();
|
|
|
|
return bestMove.Point;
|
|
}
|
|
|
|
|
|
OnEnteredRoom(e => OnBotEnteredNewRoom());
|
|
OnIntercept(In["WiredMovements"], e => InterceptWiredMovements(e));
|
|
OnIntercept(In["UserUpdate"], e => InterceptUserUpdate(e));
|
|
|
|
Point[] threatMoveDirs = { (0, 1), (0, -1), (1, 0), (-1, 0) };
|
|
|
|
while(Run)
|
|
{
|
|
try
|
|
{
|
|
if (!Run) break;
|
|
Point currentSelfLocationXY = CurrentAnticipatedBotPosition;
|
|
if (currentSelfLocationXY.Equals(default(Point))) { Delay(30); continue; }
|
|
|
|
if (!floorPlanParsedSuccessfully) TryParseFloorPlan();
|
|
if (!floorPlanParsedSuccessfully) { Delay(50); continue; }
|
|
|
|
var currentThreats = new HashSet<Point>();
|
|
long currentRoomId = RoomId;
|
|
|
|
if (AllTrackedFurnisGlobal.TryGetValue(currentRoomId, out var currentRoomTrackedItems)) {
|
|
foreach(var item in currentRoomTrackedItems.Values) {
|
|
if (item?.Location != null && (dangerousFurniIdsToAvoid.Contains(item.Id) || (item.Name != null && dangerousFurniNamesToAvoid.Contains(item.Name))))
|
|
currentThreats.Add(item.Location.XY);
|
|
}
|
|
}
|
|
|
|
foreach(string dangerousName in dangerousFurniNamesToAvoid) {
|
|
try {
|
|
var itemsByName = FloorItems.Named(dangerousName);
|
|
if (itemsByName != null) {
|
|
foreach(var item in itemsByName) {
|
|
if (item?.Location != null) currentThreats.Add(new Point(item.Location.X, item.Location.Y));
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
Point lastPositionThisTick = _lastKnownActualPosition;
|
|
_lastKnownActualPosition = currentSelfLocationXY;
|
|
|
|
if (currentThreats.Any())
|
|
{
|
|
var predictiveDangerZone = new HashSet<Point>(currentThreats);
|
|
foreach (var threatPos in currentThreats) {
|
|
foreach (var dir in threatMoveDirs) {
|
|
predictiveDangerZone.Add(new Point(threatPos.X + dir.X, threatPos.Y + dir.Y));
|
|
}
|
|
}
|
|
|
|
Point ultimateGoal = FindSafestUltimateDestination(predictiveDangerZone);
|
|
Point nextStep = FindBestImmediateStep(currentSelfLocationXY, lastPositionThisTick, predictiveDangerZone, ultimateGoal);
|
|
|
|
if (!nextStep.Equals(currentSelfLocationXY)) {
|
|
Log($"Target: {ultimateGoal}. Best step: {nextStep}");
|
|
ExecuteMove(nextStep.X, nextStep.Y);
|
|
} else {
|
|
_lastMoveDirection = default(Point);
|
|
}
|
|
} else {
|
|
_lastMoveDirection = default(Point);
|
|
}
|
|
}
|
|
catch (Exception ex) { Log($"LOOP ERROR: {ex.GetType().Name} - {ex.Message}"); }
|
|
if (!Run) break;
|
|
Delay(30);
|
|
}
|
|
|
|
Log("closed"); |