Files
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

229 lines
10 KiB
C#

using Theriapolis.Core;
using Theriapolis.Core.Util;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Generation;
using Theriapolis.Core.World.Polylines;
namespace Theriapolis.Tools.Commands;
/// <summary>
/// tile-inspect --seed &lt;n&gt; --tile X,Y [--radius N] [--data-dir &lt;dir&gt;]
///
/// Runs the full pipeline headless and prints every road / rail / river polyline
/// and every bridge whose geometry passes within <c>radius</c> tiles of (X, Y),
/// along with the raw point sequence. Intended for diagnosing drawing bugs
/// reported from the in-game debug overlay.
/// </summary>
public static class TileInspect
{
public static int Run(string[] args)
{
ulong seed = 12345;
string dataDir = ResolveDataDir();
int tx = -1, ty = -1;
int radius = 3;
bool dumpAll = false;
int stopAtStage = -1;
for (int i = 0; i < args.Length; i++)
{
switch (args[i].ToLowerInvariant())
{
case "--dump-all":
dumpAll = true;
break;
case "--stop-at-stage":
if (i + 1 < args.Length && int.TryParse(args[++i], out int s)) stopAtStage = s;
break;
case "--seed":
if (i + 1 < args.Length)
{
string raw = args[++i];
seed = raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? Convert.ToUInt64(raw[2..], 16)
: ulong.Parse(raw);
}
break;
case "--tile":
if (i + 1 < args.Length)
{
var parts = args[++i].Split(',');
if (parts.Length == 2 && int.TryParse(parts[0], out tx) && int.TryParse(parts[1], out ty)) { }
}
break;
case "--radius":
if (i + 1 < args.Length && int.TryParse(args[++i], out int r)) radius = r;
break;
case "--data-dir":
if (i + 1 < args.Length) dataDir = args[++i];
break;
}
}
if (tx < 0 || ty < 0)
{
Console.Error.WriteLine("tile-inspect: --tile X,Y is required");
return 1;
}
// Optional --crop-png outPath argument — writes a crop of the biome+feature map.
string? cropPath = null;
for (int i = 0; i < args.Length - 1; i++)
if (args[i].Equals("--crop-png", StringComparison.OrdinalIgnoreCase))
cropPath = args[i + 1];
Console.WriteLine($"[tile-inspect] seed={seed} tile=({tx},{ty}) radius={radius}" + (stopAtStage >= 0 ? $" stopAtStage={stopAtStage}" : ""));
var ctx = new WorldGenContext(seed, dataDir);
if (stopAtStage >= 0)
WorldGenerator.RunThrough(ctx, stopAtStage);
else
WorldGenerator.RunAll(ctx);
var world = ctx.World;
int px = C.WORLD_TILE_PIXELS;
float rpx = radius * px;
float rpxSq = rpx * rpx;
Vec2 target = new(tx * px + px * 0.5f, ty * px + px * 0.5f);
// ── Tile state ────────────────────────────────────────────────────────
Console.WriteLine();
Console.WriteLine("== Tile state ==");
for (int dy = -1; dy <= 1; dy++)
for (int dx = -1; dx <= 1; dx++)
{
int nx = tx + dx, ny = ty + dy;
if ((uint)nx >= C.WORLD_WIDTH_TILES || (uint)ny >= C.WORLD_HEIGHT_TILES) continue;
ref var t = ref world.TileAt(nx, ny);
Console.WriteLine($" ({nx,4},{ny,4}) biome={t.Biome,-20} flags={t.Features,-40} riverDir={DirName(t.RiverFlowDir)} railDir={DirName(t.RailDir)}");
}
// ── Polylines passing near the tile ───────────────────────────────────
PrintPolylines("Rivers", world.Rivers, target, rpxSq, px, dumpAll);
PrintPolylines("Roads", world.Roads, target, rpxSq, px, dumpAll);
PrintPolylines("Rails", world.Rails, target, rpxSq, px, dumpAll);
// ── River bounding boxes (to find rivers visible but not indexed near the tile) ──
Console.WriteLine();
Console.WriteLine($"== All {world.Rivers.Count} rivers (bounding box in tiles) ==");
foreach (var r in world.Rivers)
{
if (r.Points.Count < 2) continue;
float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
foreach (var p in r.Points) { if (p.X<minX)minX=p.X; if (p.Y<minY)minY=p.Y; if (p.X>maxX)maxX=p.X; if (p.Y>maxY)maxY=p.Y; }
Console.WriteLine($" [id={r.Id}] class={r.RiverClassification} flow={r.FlowAccumulation} pts={r.Points.Count} tiles x=[{(int)(minX/px),4}..{(int)(maxX/px),4}] y=[{(int)(minY/px),4}..{(int)(maxY/px),4}] first=({r.Points[0].X:F0},{r.Points[0].Y:F0}) last=({r.Points[^1].X:F0},{r.Points[^1].Y:F0})");
}
// ── Tiles flagged HasRiver near target, with source polyline ──────────
Console.WriteLine();
Console.WriteLine($"== HasRiver tiles within {radius} of ({tx},{ty}) ==");
int hits = 0;
for (int yy = Math.Max(0, ty - radius); yy <= Math.Min(C.WORLD_HEIGHT_TILES - 1, ty + radius); yy++)
for (int xx = Math.Max(0, tx - radius); xx <= Math.Min(C.WORLD_WIDTH_TILES - 1, tx + radius); xx++)
{
if ((world.Tiles[xx, yy].Features & FeatureFlags.HasRiver) != 0)
{
Console.WriteLine($" ({xx,4},{yy,4})");
hits++;
}
}
if (hits == 0) Console.WriteLine(" (none)");
// ── Settlements within radius ────────────────────────────────────────
Console.WriteLine();
Console.WriteLine($"== Settlements within {radius} tiles ==");
foreach (var s in world.Settlements)
{
int dt = Math.Max(Math.Abs(s.TileX - tx), Math.Abs(s.TileY - ty));
if (dt <= radius)
Console.WriteLine($" id={s.Id,4} tier={s.Tier} poi={s.IsPoi} tile=({s.TileX,4},{s.TileY,4}) dt={dt}");
}
// ── Bridges near the tile ─────────────────────────────────────────────
Console.WriteLine();
Console.WriteLine($"== Bridges (all {world.Bridges.Count}) ==");
foreach (var b in world.Bridges)
{
int btx = (int)(b.WorldPixelX / px);
int bty = (int)(b.WorldPixelY / px);
int dt = Math.Max(Math.Abs(btx - tx), Math.Abs(bty - ty));
if (dt <= radius + 2)
Console.WriteLine($" roadId={b.RoadId,4} tile=({btx,4},{bty,4}) deck=({b.Start.X:F1},{b.Start.Y:F1})-({b.End.X:F1},{b.End.Y:F1})");
}
return 0;
}
private static void PrintPolylines(string label, List<Polyline> polys, Vec2 target, float rpxSq, int px, bool dumpAll)
{
Console.WriteLine();
Console.WriteLine($"== {label} near tile ==");
for (int pi = 0; pi < polys.Count; pi++)
{
var p = polys[pi];
if (p.Points.Count < 2) continue;
// Find closest segment
float bestSq = float.MaxValue;
int bestIdx = -1;
for (int i = 0; i < p.Points.Count - 1; i++)
{
float dSq = NearestPointOnSegment(target, p.Points[i], p.Points[i + 1], out _);
if (dSq < bestSq) { bestSq = dSq; bestIdx = i; }
}
if (bestSq > rpxSq) continue;
string endpointInfo = p.Type == PolylineType.Road ? $" from={p.FromSettlementId} to={p.ToSettlementId}" : "";
Console.WriteLine($" [id={p.Id}] type={p.Type} class={(p.Type == PolylineType.Road ? p.RoadClassification.ToString() : p.RiverClassification.ToString())} pts={p.Points.Count}{endpointInfo} closestSeg=#{bestIdx} dist={MathF.Sqrt(bestSq) / px:F2}tiles");
int ctxStart = dumpAll ? 0 : Math.Max(0, bestIdx - 2);
int ctxEnd = dumpAll ? p.Points.Count - 1 : Math.Min(p.Points.Count - 1, bestIdx + 3);
for (int i = ctxStart; i <= ctxEnd; i++)
{
int t0x = (int)(p.Points[i].X / px);
int t0y = (int)(p.Points[i].Y / px);
string marker = i == bestIdx ? " <-" : "";
Console.WriteLine($" pt[{i,3}] world=({p.Points[i].X,7:F1},{p.Points[i].Y,7:F1}) tile=({t0x,4},{t0y,4}){marker}");
}
}
}
private static float NearestPointOnSegment(Vec2 p, Vec2 a, Vec2 b, out Vec2 nearest)
{
Vec2 ab = b - a;
float lenSq = ab.LengthSquared;
if (lenSq < 1e-8f) { nearest = a; return Vec2.DistSq(p, a); }
float t = Math.Clamp(Vec2.Dot(p - a, ab) / lenSq, 0f, 1f);
nearest = a + ab * t;
return Vec2.DistSq(p, nearest);
}
private static string DirName(byte d) => d switch
{
Dir.None => "—",
Dir.N => "N",
Dir.NE => "NE",
Dir.E => "E",
Dir.SE => "SE",
Dir.S => "S",
Dir.SW => "SW",
Dir.W => "W",
Dir.NW => "NW",
_ => $"?{d}",
};
private static string ResolveDataDir()
{
string local = Path.Combine(AppContext.BaseDirectory, "Data");
if (Directory.Exists(local)) return local;
string? dir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
for (int i = 0; i < 6; i++)
{
if (dir is null) break;
string candidate = Path.Combine(dir, "Content", "Data");
if (Directory.Exists(candidate)) return candidate;
dir = Path.GetDirectoryName(dir);
}
return local;
}
}