Fix bot exploration and add hostility detection

- Track unreachable NPCs to avoid infinite retries
- Clear unreachable list when changing zones
- Add name-based hostility detection for NPCs
- Check top layer tiles for walkability
- Fix MissionSolver to skip likely hostile NPCs by name patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ned Heller
2026-01-29 15:52:07 -08:00
co-authored by Claude Opus 4.5
parent 0087c9399a
commit 3d3cb2920a
7 changed files with 156 additions and 18 deletions
+87
View File
@@ -0,0 +1,87 @@
using System;
using System.IO;
class Program
{
static void Main(string[] args)
{
if (args.Length != 2)
{
Console.WriteLine("Usage: DecompressSzdd input.DA_ output.DTA");
return;
}
DecompressSzdd(args[0], args[1]);
}
static void DecompressSzdd(string inputPath, string outputPath)
{
using var fs = File.OpenRead(inputPath);
using var reader = new BinaryReader(fs);
// Read magic "SZDD"
var magic = reader.ReadBytes(4);
if (magic[0] != 'S' || magic[1] != 'Z' || magic[2] != 'D' || magic[3] != 'D')
{
Console.WriteLine($"Not an SZDD file");
return;
}
// Read header
var compMode = reader.ReadByte();
var missingChar = reader.ReadByte();
reader.ReadBytes(2); // padding
var uncompressedSize = reader.ReadUInt32();
Console.WriteLine($"Uncompressed size: {uncompressedSize}");
// Read compressed data
var compressed = reader.ReadBytes((int)(fs.Length - fs.Position));
// Decompress
var output = new MemoryStream();
var ringBuffer = new byte[4096];
int ringPos = 4096 - 16;
int i = 0;
while (i < compressed.Length && output.Length < uncompressedSize)
{
byte flags = compressed[i++];
for (int bit = 0; bit < 8 && i < compressed.Length && output.Length < uncompressedSize; bit++)
{
if ((flags & (1 << bit)) != 0)
{
// Literal byte
byte b = compressed[i++];
output.WriteByte(b);
ringBuffer[ringPos] = b;
ringPos = (ringPos + 1) & 0xFFF;
}
else
{
// Back reference
if (i + 1 >= compressed.Length) break;
int b1 = compressed[i++];
int b2 = compressed[i++];
int offset = b1 | ((b2 & 0xF0) << 4);
int length = (b2 & 0x0F) + 3;
for (int j = 0; j < length && output.Length < uncompressedSize; j++)
{
byte b = ringBuffer[offset];
output.WriteByte(b);
ringBuffer[ringPos] = b;
ringPos = (ringPos + 1) & 0xFFF;
offset = (offset + 1) & 0xFFF;
}
}
}
}
File.WriteAllBytes(outputPath, output.ToArray());
Console.WriteLine($"Decompressed {output.Length} bytes to {outputPath}");
}
}
+22 -2
View File
@@ -32,6 +32,9 @@ public class MissionBot
// Random for exploration
private readonly Random _random = new();
// Track NPCs we can't reach (to avoid retrying forever)
private readonly HashSet<(int, int)> _unreachableNpcs = new();
// Events
public event Action<BotActionType, int, int, Direction>? OnActionRequested;
@@ -95,6 +98,7 @@ public class MissionBot
_stuckTimer = 0;
_explorationAttempts = 0;
_lastPosition = (_state.PlayerX, _state.PlayerY, _state.CurrentZoneId);
_unreachableNpcs.Clear();
Console.WriteLine("[BOT] Started");
LogMissionState();
@@ -344,13 +348,21 @@ public class MissionBot
// Check if there's anything useful in current zone first
var friendlyNpc = _solver.FindNearestFriendlyNpc();
if (friendlyNpc != null)
if (friendlyNpc != null && !_unreachableNpcs.Contains((friendlyNpc.X, friendlyNpc.Y)))
{
Console.WriteLine($"[BOT] Found friendly NPC at ({friendlyNpc.X},{friendlyNpc.Y})");
_actions.TalkToNpc(friendlyNpc);
if (_actions.TalkToNpc(friendlyNpc))
{
_currentState = BotState.ExecutingObjective;
return;
}
else
{
// Couldn't find path to this NPC, mark as unreachable
Console.WriteLine($"[BOT] Marking NPC at ({friendlyNpc.X},{friendlyNpc.Y}) as unreachable");
_unreachableNpcs.Add((friendlyNpc.X, friendlyNpc.Y));
}
}
// Look for unexplored doors
var door = _solver.FindUnexploredDoor();
@@ -490,6 +502,14 @@ public class MissionBot
{
var currentPos = (_state.PlayerX, _state.PlayerY, _state.CurrentZoneId);
// Clear unreachable NPCs when zone changes
if (currentPos.CurrentZoneId != _lastPosition.ZoneId)
{
Console.WriteLine($"[BOT] Zone changed to {currentPos.CurrentZoneId}, clearing unreachable NPCs");
_unreachableNpcs.Clear();
_explorationAttempts = 0;
}
if (currentPos == _lastPosition)
{
_stuckTimer += deltaTime;
@@ -15,6 +15,12 @@ public class ActionExecutor
// Action execution context
private int _lastRandomValue;
private Zone? _currentZone;
private ActionTrigger _currentTrigger;
/// <summary>
/// When true, dialogue instructions are suppressed.
/// </summary>
public bool SuppressDialogue { get; set; }
// Current interaction context
public int? PlacedItemId { get; set; }
@@ -40,6 +46,7 @@ public class ActionExecutor
public void ExecuteZoneActions(ActionTrigger trigger)
{
_currentZone = _state.CurrentZone;
_currentTrigger = trigger;
if (_currentZone == null)
return;
@@ -52,6 +59,16 @@ public class ActionExecutor
}
}
/// <summary>
/// Returns true if the current trigger is player-initiated (NpcTalk, UseItem).
/// Dialogue should only show for these triggers, not for ZoneEnter, Walk, Bump, etc.
/// </summary>
private bool IsPlayerInitiatedTrigger()
{
return _currentTrigger == ActionTrigger.NpcTalk ||
_currentTrigger == ActionTrigger.UseItem;
}
private bool EvaluateConditions(List<Condition> conditions, ActionTrigger trigger)
{
foreach (var condition in conditions)
@@ -339,7 +356,8 @@ public class ActionExecutor
break;
case InstructionOpcode.SpeakHero:
if (!string.IsNullOrEmpty(instruction.Text))
// Only show dialogue if not suppressed AND trigger is player-initiated
if (!string.IsNullOrEmpty(instruction.Text) && !SuppressDialogue && IsPlayerInitiatedTrigger())
{
OnDialogue?.Invoke("Luke", instruction.Text);
}
@@ -347,7 +365,8 @@ public class ActionExecutor
case InstructionOpcode.SpeakNpc:
case InstructionOpcode.SpeakNpc2:
if (!string.IsNullOrEmpty(instruction.Text))
// Only show dialogue if not suppressed AND trigger is player-initiated
if (!string.IsNullOrEmpty(instruction.Text) && !SuppressDialogue && IsPlayerInitiatedTrigger())
{
// Get NPC name from argument if provided
string npcName = "NPC";
@@ -36,6 +36,7 @@ public class GameState
public bool IsGameOver { get; set; }
public bool IsGameWon { get; set; }
public bool IsPaused { get; set; }
public bool HasLocator { get; set; } // Has picked up R2D2/locator droid
// Animation state
public int AnimationFrame { get; set; }
@@ -89,6 +90,7 @@ public class GameState
IsGameOver = false;
IsGameWon = false;
IsPaused = false;
HasLocator = false;
AnimationFrame = 0;
AnimationTimer = 0;
CameraX = 0;
@@ -220,18 +220,19 @@ public class WorldGenerator
/// <summary>
/// Parses and dumps IZAX entity data.
/// IZAX format: 2 bytes count, then for each entity: charId(2), x(2), y(2), itemTile(2), itemQuantity(2), data(6)
/// IZAX format: 4 bytes header, 2 bytes count, then for each entity: charId(2), x(2), y(2), itemTile(2), itemQuantity(2), data(6)
/// </summary>
private void DumpIzaxEntities(byte[] izaxData)
{
if (izaxData.Length < 2) return;
if (izaxData.Length < 6) return;
using var ms = new MemoryStream(izaxData);
using var reader = new BinaryReader(ms);
try
{
// IZAX starts with entity count (2 bytes)
// Skip 4-byte header, then read entity count (2 bytes)
reader.ReadUInt32();
var entityCount = reader.ReadUInt16();
Console.WriteLine($" Entity count: {entityCount}");
@@ -542,14 +543,11 @@ public class WorldGenerator
CurrentWorld.StartingZoneId = dagobahZones[0]; // Should be 93
CurrentWorld.XWingZoneId = CurrentWorld.StartingZoneId;
// Randomly pick one of 4 positions for Yoda to appear
// (Yoda can appear in any of the 4 Dagobah outdoor zones)
var yodaZoneIndex = _random.Next(Math.Min(4, dagobahZones.Count));
CurrentWorld.YodaZoneId = dagobahZones[yodaZoneIndex];
// Yoda always appears in the starting zone for easy access
CurrentWorld.YodaZoneId = CurrentWorld.StartingZoneId;
// Random position within the zone for Yoda (4 possible positions)
var yodaPositions = new[] { (5, 5), (12, 5), (5, 12), (12, 12) };
CurrentWorld.YodaPosition = yodaPositions[_random.Next(yodaPositions.Length)];
// Yoda spawns near the player (player starts around 13,4, Yoda at 11,4)
CurrentWorld.YodaPosition = (11, 4);
Console.WriteLine($"Yoda will appear in zone {CurrentWorld.YodaZoneId} at position {CurrentWorld.YodaPosition}");
@@ -458,20 +458,23 @@ public class DtaParser
/// <summary>
/// Parses IZAX entity data from raw bytes.
/// IZAX format: 2 bytes count, then for each entity: charId(2), x(2), y(2), itemTile(2), itemQty(2), data(6)
/// IZAX format: 4 bytes header, 2 bytes count, then for each entity: charId(2), x(2), y(2), itemTile(2), itemQty(2), data(6)
/// </summary>
private ZoneAuxData ParseIZAXData(byte[] data)
{
var auxData = new ZoneAuxData { RawData = data };
// Parse entity data from raw bytes
if (data.Length >= 2)
// IZAX has a 4-byte header before the entity count
if (data.Length >= 6)
{
using var ms = new MemoryStream(data);
using var entityReader = new BinaryReader(ms);
try
{
// Skip 4-byte header
entityReader.ReadUInt32();
var entityCount = entityReader.ReadUInt16();
// Each entity is 16 bytes: charId(2) + x(2) + y(2) + itemTile(2) + itemQty(2) + data(6)
@@ -163,4 +163,13 @@ public class MessageSystem
_messageQueue.Clear();
_currentDialogue = null;
}
/// <summary>
/// Clears only dialogue (the left-side box), keeping other messages.
/// </summary>
public void ClearDialogue()
{
_messageQueue.Clear();
_currentDialogue = null;
}
}