using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Generation;
namespace Theriapolis.Tools.Commands;
///
/// Phase 6 M0 — settlement-render exports a stamped settlement to PNG.
/// Lets us visually QA building layouts before they're playtested in-game.
///
/// Usage:
/// dotnet run --project Theriapolis.Tools -- settlement-render \
/// --seed 12345 --settlement millhaven --out millhaven.png
///
/// --settlement: anchor name ("millhaven", "thornfield", ...) or numeric
/// settlement id. If omitted, render the first Tier-1 anchor.
/// --pad N: include N extra chunks around the settlement window.
/// --data-dir: Content/Data root. Defaults to ./Content/Data.
///
public static class SettlementRender
{
public static int Run(string[] args)
{
ulong seed = 12345UL;
string settlement = "";
int pad = 1;
string outPath = "settlement.png";
string dataDir = ResolveDataDir();
for (int i = 0; i < args.Length; i++)
{
switch (args[i].ToLowerInvariant())
{
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 "--settlement":
if (i + 1 < args.Length) settlement = args[++i];
break;
case "--pad":
if (i + 1 < args.Length) pad = int.Parse(args[++i]);
break;
case "--out":
if (i + 1 < args.Length) outPath = args[++i];
break;
case "--data-dir":
if (i + 1 < args.Length) dataDir = args[++i];
break;
}
}
if (!Directory.Exists(dataDir))
{
Console.Error.WriteLine($"Data directory not found: {dataDir}");
return 1;
}
Console.WriteLine($"[settlement-render] seed=0x{seed:X} settlement='{settlement}' pad={pad}");
var ctx = new WorldGenContext(seed, dataDir) { Log = msg => Console.WriteLine(msg) };
WorldGenerator.RunAll(ctx);
var content = new ContentResolver(new ContentLoader(dataDir));
var s = ResolveSettlement(ctx.World, settlement);
if (s is null)
{
Console.Error.WriteLine(string.IsNullOrEmpty(settlement)
? "No Tier-1 anchor settlement found in this world."
: $"Settlement '{settlement}' not found.");
return 1;
}
Console.WriteLine($"[settlement-render] resolved -> id={s.Id} name='{s.Name}' tier={s.Tier} tile=({s.TileX},{s.TileY}) anchor={s.Anchor}");
// Compute chunk window covering the settlement plus padding.
int cxPx = (int)s.WorldPixelX;
int cyPx = (int)s.WorldPixelY;
int radiusPx = s.Tier switch { 1 => 32, 2 => 26, 3 => 20, 4 => 16, _ => 12 };
int minTx = cxPx - radiusPx;
int minTy = cyPx - radiusPx;
int maxTx = cxPx + radiusPx;
int maxTy = cyPx + radiusPx;
int minCx = (int)Math.Floor(minTx / (double)C.TACTICAL_CHUNK_SIZE) - pad;
int minCy = (int)Math.Floor(minTy / (double)C.TACTICAL_CHUNK_SIZE) - pad;
int maxCx = (int)Math.Floor(maxTx / (double)C.TACTICAL_CHUNK_SIZE) + pad;
int maxCy = (int)Math.Floor(maxTy / (double)C.TACTICAL_CHUNK_SIZE) + pad;
int gridW = maxCx - minCx + 1;
int gridH = maxCy - minCy + 1;
int sideX = gridW * C.TACTICAL_CHUNK_SIZE;
int sideY = gridH * C.TACTICAL_CHUNK_SIZE;
Console.WriteLine($"[settlement-render] chunk window {minCx}..{maxCx} x {minCy}..{maxCy} ({sideX}x{sideY} px)");
using var img = new Image(sideX, sideY);
for (int gy = 0; gy < gridH; gy++)
for (int gx = 0; gx < gridW; gx++)
{
var cc = new ChunkCoord(minCx + gx, minCy + gy);
var chunk = TacticalChunkGen.Generate(seed, cc, ctx.World, content.Settlements);
int ox = gx * C.TACTICAL_CHUNK_SIZE;
int oy = gy * C.TACTICAL_CHUNK_SIZE;
BlitChunk(img, chunk, ox, oy);
}
// Building summary line.
SettlementStamper_EnsureBuildingsResolved(ctx.World, s, content);
Console.WriteLine($"[settlement-render] {s.Buildings.Count} buildings stamped");
foreach (var b in s.Buildings)
Console.WriteLine($" [{b.Id:00}] {b.TemplateId,-16} ({b.MinX,4},{b.MinY,4})..({b.MaxX,4},{b.MaxY,4}) doors={b.Doors.Length} residents={b.Residents.Length}");
img.SaveAsPng(outPath);
Console.WriteLine($"[settlement-render] wrote {outPath}");
return 0;
}
private static Settlement? ResolveSettlement(WorldState world, string raw)
{
if (string.IsNullOrEmpty(raw))
return world.Settlements.FirstOrDefault(s => s.Tier == 1 && !s.IsPoi)
?? world.Settlements.FirstOrDefault(s => s.Tier <= 2 && !s.IsPoi);
if (int.TryParse(raw, out int id))
return world.Settlements.FirstOrDefault(s => s.Id == id);
// Match anchor name (case-insensitive) first, then settlement.Name.
return world.Settlements.FirstOrDefault(
s => s.Anchor is { } a && string.Equals(a.ToString(), raw, StringComparison.OrdinalIgnoreCase))
?? world.Settlements.FirstOrDefault(
s => string.Equals(s.Name, raw, StringComparison.OrdinalIgnoreCase));
}
///
/// Forces the settlement's list to
/// resolve so the dump line at the end of can describe
/// what got stamped — useful even before we render the chunks (e.g. if a
/// chunk-window calculation goes wrong, the buildings list still tells
/// the user what *would* have stamped).
///
private static void SettlementStamper_EnsureBuildingsResolved(WorldState world, Settlement s, ContentResolver content)
{
if (s.BuildingsResolved) return;
Theriapolis.Core.World.Settlements.SettlementStamper.EnsureBuildingsResolved(world.WorldSeed, s, content.Settlements);
}
private static void BlitChunk(Image img, TacticalChunk chunk, int ox, int oy)
{
for (int ly = 0; ly < C.TACTICAL_CHUNK_SIZE; ly++)
for (int lx = 0; lx < C.TACTICAL_CHUNK_SIZE; lx++)
{
ref var t = ref chunk.Tiles[lx, ly];
img[ox + lx, oy + ly] = ColorFor(t);
}
}
private static Rgba32 ColorFor(TacticalTile t)
{
// Decoration overrides for visual identification.
if (t.Deco == TacticalDeco.Door) return new Rgba32(255, 200, 80); // bright yellow doors
if (t.Deco == TacticalDeco.Counter) return new Rgba32(180, 130, 80);
if (t.Deco == TacticalDeco.Bed) return new Rgba32(160, 100, 140);
if (t.Deco == TacticalDeco.Hearth) return new Rgba32(220, 90, 40);
if (t.Deco == TacticalDeco.Sign) return new Rgba32(220, 220, 100);
if (t.Deco == TacticalDeco.Tree) return new Rgba32(20, 80, 30);
if (t.Deco == TacticalDeco.Bush) return new Rgba32(70, 110, 50);
if (t.Deco == TacticalDeco.Boulder) return new Rgba32(110, 100, 90);
if (t.Deco == TacticalDeco.Rock) return new Rgba32(140, 130, 110);
if (t.Deco == TacticalDeco.Flower) return new Rgba32(220, 180, 210);
return t.Surface switch
{
TacticalSurface.DeepWater => new Rgba32(20, 60, 130),
TacticalSurface.ShallowWater => new Rgba32(60, 120, 180),
TacticalSurface.Marsh => new Rgba32(70, 100, 80),
TacticalSurface.Mud => new Rgba32(100, 80, 60),
TacticalSurface.Sand => new Rgba32(220, 200, 150),
TacticalSurface.Snow => new Rgba32(230, 235, 240),
TacticalSurface.Rock => new Rgba32(120, 115, 110),
TacticalSurface.Cobble => new Rgba32(170, 150, 120),
TacticalSurface.Gravel => new Rgba32(150, 140, 110),
TacticalSurface.TroddenDirt => new Rgba32(140, 110, 80),
TacticalSurface.Wall => new Rgba32(60, 55, 50),
TacticalSurface.Floor => new Rgba32(220, 200, 165),
TacticalSurface.Dirt => new Rgba32(120, 95, 60),
TacticalSurface.TallGrass => new Rgba32(80, 140, 60),
TacticalSurface.Grass => new Rgba32(110, 160, 70),
_ => new Rgba32(255, 0, 255),
};
}
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;
}
}