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>
This commit is contained in:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Tools.Commands;
/// <summary>
/// Headless character builder. Useful for verifying CharacterBuilder
/// determinism, dumping a representative stat block, or scripted balance
/// sweeps in CI.
///
/// Usage:
/// dotnet run --project Theriapolis.Tools -- character-roll \
/// --seed 12345 \
/// --clade canidae --species wolf \
/// --class fangsworn --background pack_raised \
/// --name "Grev" \
/// [--data-dir ./Content/Data] \
/// [--roll] (use 4d6-drop-lowest instead of Standard Array)
/// [--ms-override 1000] (fix the rolling seed for reproducibility)
/// [--level N] (Phase 7 M0 — apply N-1 level-ups deterministically;
/// picks the first subclass at L3 and assigns ASI to
/// CON each ASI level for predictable balance testing)
/// </summary>
public static class CharacterRoll
{
public static int Run(string[] args)
{
ulong seed = 12345UL;
string dataDir = "./Content/Data";
string cladeId = "canidae";
string speciesId = "wolf";
string classId = "fangsworn";
string bgId = "pack_raised";
string name = "Wanderer";
bool roll = false;
ulong msOverride = 0;
bool msOverrideSet = false;
int targetLevel = 1;
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--seed": seed = ulong.Parse(args[++i]); break;
case "--data-dir": dataDir = args[++i]; break;
case "--clade": cladeId = args[++i]; break;
case "--species": speciesId= args[++i]; break;
case "--class": classId = args[++i]; break;
case "--background": bgId = args[++i]; break;
case "--name": name = args[++i]; break;
case "--roll": roll = true; break;
case "--ms-override": msOverride = ulong.Parse(args[++i]); msOverrideSet = true; break;
case "--level": targetLevel = int.Parse(args[++i]); break;
}
}
if (targetLevel < 1 || targetLevel > Theriapolis.Core.C.CHARACTER_LEVEL_MAX)
{
Console.Error.WriteLine($"--level must be in [1, {Theriapolis.Core.C.CHARACTER_LEVEL_MAX}]");
return 1;
}
var loader = new ContentLoader(dataDir);
var content = new ContentResolver(loader);
if (!content.Clades.TryGetValue(cladeId, out var clade))
{ Console.Error.WriteLine($"Unknown clade: {cladeId}"); return 1; }
if (!content.Species.TryGetValue(speciesId, out var species))
{ Console.Error.WriteLine($"Unknown species: {speciesId}"); return 1; }
if (!content.Classes.TryGetValue(classId, out var classDef))
{ Console.Error.WriteLine($"Unknown class: {classId}"); return 1; }
if (!content.Backgrounds.TryGetValue(bgId, out var bg))
{ Console.Error.WriteLine($"Unknown background: {bgId}"); return 1; }
// Stats
AbilityScores baseAbilities;
if (roll)
{
ulong msArg = msOverrideSet ? msOverride : (ulong)Environment.TickCount64;
baseAbilities = CharacterBuilder.RollAbilityScores(seed, msArg);
}
else
{
// Standard Array assigned by class priority (matches CharacterCreationScreen default).
int[] values = (int[])AbilityScores.StandardArray.Clone();
Array.Sort(values, (a, b) => b - a);
baseAbilities = AssignByClassPriority(classDef, values);
}
var b = new CharacterBuilder
{
Clade = clade,
Species = species,
ClassDef = classDef,
Background = bg,
BaseAbilities = baseAbilities,
Name = name,
};
// Auto-pick first N skills
int n = classDef.SkillsChoose;
foreach (var raw in classDef.SkillOptions)
{
if (b.ChosenClassSkills.Count >= n) break;
try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { }
}
if (!b.Validate(out var error))
{
Console.Error.WriteLine($"Validation failed: {error}");
return 1;
}
var c = b.Build(content.Items);
// Phase 7 M0 — apply N-1 level-ups deterministically. The seed for
// each level mirrors the Phase 6.5 contract:
// levelUpSeed = worldSeed ^ msOverride ^ RNG_LEVELUP ^ targetLevel
// We default subclass to the class's first declared subclass and
// ASIs to a +2-CON bias for predictable HP scaling — this is balance-
// sweep mode, not playthrough mode. Players in-game pick their own.
ulong msForLevelup = msOverrideSet ? msOverride : 0UL;
for (int lv = 2; lv <= targetLevel; lv++)
{
ulong levelSeed = seed ^ msForLevelup ^ Theriapolis.Core.C.RNG_LEVELUP ^ (ulong)lv;
var result = Theriapolis.Core.Rules.Character.LevelUpFlow.Compute(
c, lv, levelSeed,
takeAverage: true,
subclasses: content.Subclasses);
var choices = new Theriapolis.Core.Rules.Character.LevelUpChoices
{
TakeAverageHp = true,
SubclassId = result.GrantsSubclassChoice && classDef.SubclassIds.Length > 0
? classDef.SubclassIds[0]
: null,
};
if (result.GrantsAsiChoice)
{
choices.AsiAdjustments[Theriapolis.Core.Rules.Stats.AbilityId.CON] = 2;
}
c.ApplyLevelUp(result, choices);
}
PrintCharacter(c, name);
return 0;
}
private static AbilityScores AssignByClassPriority(ClassDef classDef, int[] sortedDescValues)
{
var primary = classDef.PrimaryAbility ?? Array.Empty<string>();
var order = new List<string>();
foreach (var p in primary) order.Add(p.ToUpperInvariant());
foreach (var a in new[] { "CON", "DEX", "STR", "WIS", "INT", "CHA" })
if (!order.Contains(a)) order.Add(a);
var assigned = new Dictionary<string, int>();
for (int i = 0; i < 6; i++) assigned[order[i]] = sortedDescValues[i];
return new AbilityScores(
assigned["STR"], assigned["DEX"], assigned["CON"],
assigned["INT"], assigned["WIS"], assigned["CHA"]);
}
private static void PrintCharacter(Character c, string name)
{
Console.WriteLine();
Console.WriteLine($"=== {name} ===");
Console.WriteLine($"Clade: {c.Clade.Name} ({c.Clade.Kind})");
Console.WriteLine($"Species: {c.Species.Name} size: {c.Species.Size} speed: {c.Species.BaseSpeedFt} ft.");
Console.WriteLine($"Class: {c.ClassDef.Name} hit die: d{c.ClassDef.HitDie} level: {c.Level}");
Console.WriteLine($"Background: {c.Background.Name}");
Console.WriteLine();
Console.WriteLine("Ability scores (post clade + species mods):");
Console.WriteLine($" STR {c.Abilities.STR} DEX {c.Abilities.DEX} CON {c.Abilities.CON}");
Console.WriteLine($" INT {c.Abilities.INT} WIS {c.Abilities.WIS} CHA {c.Abilities.CHA}");
Console.WriteLine();
Console.WriteLine($"HP: {c.CurrentHp}/{c.MaxHp}");
Console.WriteLine($"AC: {Theriapolis.Core.Rules.Stats.DerivedStats.ArmorClass(c)}");
Console.WriteLine($"Speed: {Theriapolis.Core.Rules.Stats.DerivedStats.SpeedFt(c)} ft.");
Console.WriteLine($"Carry: {c.Inventory.TotalWeightLb:F1} / {Theriapolis.Core.Rules.Stats.DerivedStats.CarryCapacityLb(c):F1} lb ({Theriapolis.Core.Rules.Stats.DerivedStats.Encumbrance(c)})");
Console.WriteLine($"Initiative:{(Theriapolis.Core.Rules.Stats.DerivedStats.Initiative(c) >= 0 ? "+" : "")}{Theriapolis.Core.Rules.Stats.DerivedStats.Initiative(c)}");
Console.WriteLine($"Prof: +{c.ProficiencyBonus}");
Console.WriteLine();
Console.WriteLine($"Skill proficiencies ({c.SkillProficiencies.Count}):");
foreach (var s in c.SkillProficiencies.OrderBy(s => s.ToString()))
Console.WriteLine($" - {s} ({s.Ability()})");
Console.WriteLine();
Console.WriteLine($"Inventory ({c.Inventory.Items.Count} stacks, {c.Inventory.TotalWeightLb:F1} lb):");
foreach (var i in c.Inventory.Items)
Console.WriteLine($" - {i.Def.Name}{(i.Qty > 1 ? $" ×{i.Qty}" : "")}{(i.EquippedAt is { } slot ? $" [{slot}]" : "")}");
}
}
+239
View File
@@ -0,0 +1,239 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
namespace Theriapolis.Tools.Commands;
/// <summary>
/// Headless combat scenario runner. Builds two combatants, places them on
/// an empty arena, and runs a simple "close distance, then attack" AI loop
/// until one side falls or the round cap is hit. Prints the full encounter
/// log so test scenarios and balance sweeps can grep the output.
///
/// Usage:
/// dotnet run --project Theriapolis.Tools -- combat-duel \
/// --a brigand_footpad --b wolf --seed 42 [--rounds 20] [--data-dir ./Content/Data]
///
/// Combatant specs:
/// - An NPC template id (e.g. "brigand_footpad", "wolf", "bear_brown")
/// - A character spec "char:CLADE:SPECIES:CLASS:BACKGROUND" (e.g.
/// "char:canidae:wolf:fangsworn:pack_raised") — uses Standard Array
/// stats and the class's starting kit.
/// </summary>
public static class CombatDuel
{
public static int Run(string[] args)
{
ulong seed = 42UL;
string dataDir = "./Content/Data";
string specA = "brigand_footpad";
string specB = "wolf";
int maxRounds = 20;
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--seed": seed = ulong.Parse(args[++i]); break;
case "--data-dir": dataDir = args[++i]; break;
case "--a": specA = args[++i]; break;
case "--b": specB = args[++i]; break;
case "--rounds": maxRounds = int.Parse(args[++i]); break;
}
}
var loader = new ContentLoader(dataDir);
var content = new ContentResolver(loader);
Combatant a, b;
try
{
a = BuildCombatant(specA, content, id: 1, position: new Vec2(0, 0),
allegiance: Allegiance.Player);
b = BuildCombatant(specB, content, id: 2, position: new Vec2(6, 0),
allegiance: Allegiance.Hostile);
}
catch (System.Exception ex)
{
System.Console.Error.WriteLine($"Failed to build combatants: {ex.Message}");
return 1;
}
var enc = new Encounter(seed, encounterId: 1, new[] { a, b });
System.Console.WriteLine($"=== combat-duel: {a.Name} vs {b.Name} ===");
System.Console.WriteLine($"Seed: 0x{seed:X} encounterSeed: 0x{enc.EncounterSeed:X}");
System.Console.WriteLine($"Initial positions: {a.Name} ({a.Position.X},{a.Position.Y}) HP {a.CurrentHp} AC {a.ArmorClass}; " +
$"{b.Name} ({b.Position.X},{b.Position.Y}) HP {b.CurrentHp} AC {b.ArmorClass}");
System.Console.WriteLine();
int turnsTaken = 0;
// Hard safety cap: at most 4 actions per combatant per round.
int maxTurns = maxRounds * (a.AttackOptions.Count + b.AttackOptions.Count) * 4 + 4;
while (!enc.IsOver && enc.RoundNumber <= maxRounds && turnsTaken < maxTurns)
{
turnsTaken++;
DriveTurn(enc);
enc.EndTurn();
}
if (enc.RoundNumber > maxRounds && !enc.IsOver)
System.Console.WriteLine($"--- Round cap reached ({maxRounds} rounds). ---");
System.Console.WriteLine();
System.Console.WriteLine("--- Combat log ---");
foreach (var entry in enc.Log)
System.Console.WriteLine($"R{entry.Round} T{entry.Turn} [{entry.Type}] {entry.Message}");
System.Console.WriteLine();
var aliveA = a.IsAlive && !a.IsDown;
var aliveB = b.IsAlive && !b.IsDown;
System.Console.WriteLine($"Final: {a.Name} HP {a.CurrentHp}/{a.MaxHp} {(aliveA ? "alive" : "down")}; " +
$"{b.Name} HP {b.CurrentHp}/{b.MaxHp} {(aliveB ? "alive" : "down")}");
System.Console.WriteLine($"Total dice rolled: {enc.RollCount}");
return 0;
}
/// <summary>Simple one-attack-per-turn AI: move toward the nearest hostile, attack when in reach.</summary>
private static void DriveTurn(Encounter enc)
{
var actor = enc.CurrentActor;
if (!actor.IsAlive || actor.IsDown) return;
var target = FindHostile(enc, actor);
if (target is null) return;
var attack = actor.AttackOptions[0];
// Movement budget — convert ft. to tiles. 5 ft. = 1 tile (d20 standard).
int tilesAvailable = enc.CurrentTurn.RemainingMovementFt / 5;
while (!ReachAndCover.IsInReach(actor, target, attack) && tilesAvailable > 0)
{
var next = ReachAndCover.StepToward(actor.Position, target.Position);
if (next.X == actor.Position.X && next.Y == actor.Position.Y) break;
actor.Position = next;
enc.AppendLog(CombatLogEntry.Kind.Move,
$"{actor.Name} moves to ({next.X},{next.Y}).");
tilesAvailable--;
}
// Charge the consumed movement back to the turn budget.
int consumed = (enc.CurrentTurn.RemainingMovementFt / 5) - tilesAvailable;
enc.CurrentTurn.ConsumeMovement(consumed * 5);
if (!ReachAndCover.IsInReach(actor, target, attack)) return;
Resolver.AttemptAttack(enc, actor, target, attack);
enc.CurrentTurn.ConsumeAction();
}
private static Combatant? FindHostile(Encounter enc, Combatant actor)
{
// Hostile = different allegiance side. Player + Allied are friends; Hostile vs Player.
Combatant? best = null;
int bestDist = int.MaxValue;
foreach (var c in enc.Participants)
{
if (c.Id == actor.Id) continue;
if (!c.IsAlive || c.IsDown) continue;
if (!IsHostileTo(actor.Allegiance, c.Allegiance)) continue;
int d = ReachAndCover.EdgeToEdgeChebyshev(actor, c);
if (d < bestDist) { best = c; bestDist = d; }
}
return best;
}
private static bool IsHostileTo(Allegiance a, Allegiance b)
{
bool aIsPlayerSide = a == Allegiance.Player || a == Allegiance.Allied;
bool bIsPlayerSide = b == Allegiance.Player || b == Allegiance.Allied;
if (aIsPlayerSide && b == Allegiance.Hostile) return true;
if (bIsPlayerSide && a == Allegiance.Hostile) return true;
return false;
}
private static Combatant BuildCombatant(string spec, ContentResolver content, int id, Vec2 position, Allegiance allegiance)
{
if (spec.StartsWith("char:", System.StringComparison.OrdinalIgnoreCase))
{
var parts = spec.Split(':');
if (parts.Length != 5)
throw new System.ArgumentException($"Character spec '{spec}' must be char:clade:species:class:background");
string cladeId = parts[1];
string speciesId = parts[2];
string classId = parts[3];
string bgId = parts[4];
var character = BuildLevel1Character(content, cladeId, speciesId, classId, bgId);
string name = character.Species.Name + " " + character.ClassDef.Name;
return Combatant.FromCharacter(character, id, name, position, allegiance);
}
// Otherwise treat as an NPC template id.
if (!content.Npcs.Templates.Any(t => string.Equals(t.Id, spec, System.StringComparison.OrdinalIgnoreCase)))
throw new System.ArgumentException($"Unknown NPC template id: '{spec}'");
var def = content.Npcs.Templates.First(t => string.Equals(t.Id, spec, System.StringComparison.OrdinalIgnoreCase));
var combatant = Combatant.FromNpcTemplate(def, id, position);
// Allow caller to override allegiance (e.g. force a brigand to fight a wolf).
// A new Combatant doesn't expose Allegiance setter; build a fresh one with the override.
if (combatant.Allegiance != allegiance)
{
// Cheap rebuild: clone the template's combatant with overridden allegiance.
combatant = CloneWithAllegiance(combatant, allegiance);
}
return combatant;
}
private static Combatant CloneWithAllegiance(Combatant c, Allegiance newAllegiance)
{
// Combatant has no copy-with API; for the duel tool we just rebuild from
// the source template with the overridden allegiance baked in. The template
// is the only path that produces NPC combatants in this command.
var def = c.SourceTemplate!;
// Fake the template's default_allegiance with a mutated copy (records support `with`).
var swapped = def with { DefaultAllegiance = newAllegiance.ToString().ToLowerInvariant() };
return Combatant.FromNpcTemplate(swapped, c.Id, c.Position);
}
private static Character BuildLevel1Character(
ContentResolver content, string cladeId, string speciesId, string classId, string bgId)
{
if (!content.Clades.TryGetValue(cladeId, out var clade))
throw new System.ArgumentException($"Unknown clade: {cladeId}");
if (!content.Species.TryGetValue(speciesId, out var species))
throw new System.ArgumentException($"Unknown species: {speciesId}");
if (!content.Classes.TryGetValue(classId, out var classDef))
throw new System.ArgumentException($"Unknown class: {classId}");
if (!content.Backgrounds.TryGetValue(bgId, out var bg))
throw new System.ArgumentException($"Unknown background: {bgId}");
// Standard array assigned by class priority.
int[] vals = (int[])AbilityScores.StandardArray.Clone();
System.Array.Sort(vals, (a, b) => b - a);
var primary = classDef.PrimaryAbility ?? System.Array.Empty<string>();
var order = new List<string>();
foreach (var p in primary) order.Add(p.ToUpperInvariant());
foreach (var a in new[] { "CON", "DEX", "STR", "WIS", "INT", "CHA" })
if (!order.Contains(a)) order.Add(a);
var assigned = new Dictionary<string, int>();
for (int i = 0; i < 6; i++) assigned[order[i]] = vals[i];
var b = new CharacterBuilder
{
Clade = clade,
Species = species,
ClassDef = classDef,
Background = bg,
BaseAbilities = new AbilityScores(
assigned["STR"], assigned["DEX"], assigned["CON"],
assigned["INT"], assigned["WIS"], assigned["CHA"]),
Name = $"{species.Name} {classDef.Name}",
};
int n = classDef.SkillsChoose;
foreach (var raw in classDef.SkillOptions)
{
if (b.ChosenClassSkills.Count >= n) break;
try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { }
}
return b.Build(content.Items);
}
}
@@ -0,0 +1,155 @@
using Theriapolis.Core.Data;
namespace Theriapolis.Tools.Commands;
/// <summary>
/// Loads every Phase 5 content file via <see cref="ContentLoader"/>, runs
/// per-file referential integrity checks, and exits non-zero on any error.
/// CI runs this so a broken JSON edit fails the build instead of crashing
/// the game at runtime.
///
/// Usage:
/// dotnet run --project Theriapolis.Tools -- content-validate
/// dotnet run --project Theriapolis.Tools -- content-validate --data-dir ./Content/Data
/// </summary>
public static class ContentValidate
{
public static int Run(string[] args)
{
string dataDir = "./Content/Data";
for (int i = 0; i < args.Length; i++)
{
if (args[i] == "--data-dir" && i + 1 < args.Length)
{
dataDir = args[i + 1];
i++;
}
}
Console.WriteLine($"Validating content in: {dataDir}");
var loader = new ContentLoader(dataDir);
int errorCount = 0;
// Phase 1+ content (already validated elsewhere, but check it loads)
var biomes = TryLoad("biomes", () => loader.LoadBiomes(), ref errorCount);
var factions = TryLoad("factions", () => loader.LoadFactions(), ref errorCount);
TryLoad("macro_template", () => loader.LoadMacroTemplate(), ref errorCount);
// Phase 5 content
var clades = TryLoad("clades", () => loader.LoadClades(), ref errorCount);
var species = clades is not null
? TryLoad("species", () => loader.LoadSpecies(clades), ref errorCount)
: null;
var classes = TryLoad("classes", () => loader.LoadClasses(), ref errorCount);
if (classes is not null)
TryLoad("subclasses", () => loader.LoadSubclasses(classes), ref errorCount);
TryLoad("backgrounds", () => loader.LoadBackgrounds(), ref errorCount);
var items = TryLoad("items", () => loader.LoadItems(), ref errorCount);
TryLoad("npc_templates", () => loader.LoadNpcTemplates(items, factions), ref errorCount);
TryLoad("loot_tables", () => loader.LoadLootTables(items), ref errorCount);
// Phase 6 M0 content
var buildings = TryLoad("building_templates", () => loader.LoadBuildingTemplates(), ref errorCount);
var layouts = buildings is not null
? TryLoad("settlement_layouts", () => loader.LoadSettlementLayouts(buildings), ref errorCount)
: null;
// Phase 6 M1 content
var biasProfiles = TryLoad("bias_profiles",
() => loader.LoadBiasProfiles(clades, factions),
ref errorCount);
var residents = TryLoad("resident_templates",
() => loader.LoadResidentTemplates(biasProfiles, clades, species, factions),
ref errorCount);
// Phase 6 M3 content
var dialogues = TryLoad("dialogues",
() => loader.LoadDialogues(items),
ref errorCount);
// Phase 6 M4 content
var quests = TryLoad("quests",
() => loader.LoadQuests(items),
ref errorCount);
// Phase 7 M0 content
var lootTables = items is not null
? TryLoad("loot_tables_for_dungeons", () => loader.LoadLootTables(items), ref errorCount)
: null;
var roomTemplates = TryLoad("room_templates",
() => loader.LoadRoomTemplates(),
ref errorCount);
var dungeonLayouts = TryLoad("dungeon_layouts",
() => loader.LoadDungeonLayouts(roomTemplates, lootTables),
ref errorCount);
// Cross-file referential checks
if (clades is not null && species is not null)
{
CrossCheck(
"every species references a real clade",
() => {
var cladeIds = clades.Select(c => c.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var sp in species)
if (!cladeIds.Contains(sp.CladeId))
throw new InvalidDataException($"species '{sp.Id}' references unknown clade_id '{sp.CladeId}'");
},
ref errorCount);
}
if (errorCount == 0)
{
Console.WriteLine();
Console.WriteLine("All content valid.");
Console.WriteLine($" {biomes?.Length ?? 0} biomes");
Console.WriteLine($" {factions?.Length ?? 0} factions");
Console.WriteLine($" {clades?.Length ?? 0} clades");
Console.WriteLine($" {species?.Length ?? 0} species");
Console.WriteLine($" {classes?.Length ?? 0} classes");
Console.WriteLine($" {items?.Length ?? 0} items");
Console.WriteLine($" {buildings?.Length ?? 0} building templates");
Console.WriteLine($" {layouts?.Length ?? 0} settlement layouts");
Console.WriteLine($" {biasProfiles?.Length ?? 0} bias profiles");
Console.WriteLine($" {residents?.Length ?? 0} resident templates");
Console.WriteLine($" {dialogues?.Length ?? 0} dialogue trees");
Console.WriteLine($" {quests?.Length ?? 0} quest trees");
Console.WriteLine($" {roomTemplates?.Length ?? 0} room templates");
Console.WriteLine($" {dungeonLayouts?.Length ?? 0} dungeon layouts");
return 0;
}
Console.Error.WriteLine();
Console.Error.WriteLine($"Validation failed with {errorCount} error(s).");
return 1;
}
private static T? TryLoad<T>(string label, Func<T> action, ref int errorCount) where T : class
{
try
{
var result = action();
Console.WriteLine($" ✓ {label}");
return result;
}
catch (Exception ex)
{
Console.Error.WriteLine($" ✗ {label}: {ex.Message}");
errorCount++;
return null;
}
}
private static void CrossCheck(string description, Action check, ref int errorCount)
{
try
{
check();
Console.WriteLine($" ✓ {description}");
}
catch (Exception ex)
{
Console.Error.WriteLine($" ✗ {description}: {ex.Message}");
errorCount++;
}
}
}
@@ -0,0 +1,82 @@
using Theriapolis.Core.Data;
namespace Theriapolis.Tools.Commands;
/// <summary>
/// Phase 6 M3 — dialogue-validate. Loads every <c>dialogues/*.json</c>
/// through <see cref="ContentLoader.LoadDialogues"/> (which already
/// validates structure + references) and prints a per-tree summary.
/// CI gate: exits non-zero on any validation error.
///
/// Usage:
/// dotnet run --project Theriapolis.Tools -- dialogue-validate
/// dotnet run --project Theriapolis.Tools -- dialogue-validate --data-dir ./Content/Data
/// </summary>
public static class DialogueValidate
{
public static int Run(string[] args)
{
string dataDir = "./Content/Data";
for (int i = 0; i < args.Length; i++)
{
if (args[i] == "--data-dir" && i + 1 < args.Length)
{
dataDir = args[i + 1];
i++;
}
}
Console.WriteLine($"Validating dialogues in: {dataDir}");
var loader = new ContentLoader(dataDir);
DialogueDef[] defs;
try
{
// Load items for cross-reference (give_item / has_item).
var items = loader.LoadItems();
defs = loader.LoadDialogues(items);
}
catch (Exception ex)
{
Console.Error.WriteLine($" ✗ {ex.Message}");
return 1;
}
Console.WriteLine($" ✓ {defs.Length} dialogue tree(s) loaded.");
foreach (var d in defs.OrderBy(d => d.Id, StringComparer.Ordinal))
{
int totalOptions = d.Nodes.Sum(n => n.Options.Length);
int skillChecks = d.Nodes.Sum(n => n.Options.Count(o => o.SkillCheck is not null));
Console.WriteLine($" [{d.Id}] root='{d.Root}' nodes={d.Nodes.Length} options={totalOptions} skill_checks={skillChecks}");
// Reachability: BFS from root over options.next + next_on_*.
var reachable = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { d.Root };
var queue = new Queue<string>();
queue.Enqueue(d.Root);
var nodesById = d.Nodes.ToDictionary(n => n.Id, StringComparer.OrdinalIgnoreCase);
while (queue.Count > 0)
{
var cur = queue.Dequeue();
if (!nodesById.TryGetValue(cur, out var node)) continue;
foreach (var opt in node.Options)
{
foreach (var n in new[] { opt.Next, opt.NextOnSuccess, opt.NextOnFailure })
{
if (string.IsNullOrEmpty(n) || string.Equals(n, "<end>", StringComparison.OrdinalIgnoreCase)) continue;
if (reachable.Add(n)) queue.Enqueue(n);
}
}
}
int unreachable = d.Nodes.Length - reachable.Count;
if (unreachable > 0)
{
Console.Error.WriteLine($" ✗ {unreachable} unreachable node(s) in '{d.Id}': "
+ string.Join(", ", d.Nodes.Where(n => !reachable.Contains(n.Id)).Select(n => n.Id)));
return 1;
}
}
Console.WriteLine();
Console.WriteLine("All dialogue trees valid.");
return 0;
}
}
+249
View File
@@ -0,0 +1,249 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using Theriapolis.Core.Data;
using Theriapolis.Core.Dungeons;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.World;
namespace Theriapolis.Tools.Commands;
/// <summary>
/// Phase 7 M0 / M1 — render a room template OR a fully-generated dungeon
/// to a PNG. Two modes:
///
/// Template mode (M0):
/// dotnet run --project Theriapolis.Tools -- dungeon-render \
/// --template imperium.entry_grand_hall --out hall.png \
/// [--data-dir ./Content/Data] [--cell 16]
///
/// Pipeline mode (M1):
/// dotnet run --project Theriapolis.Tools -- dungeon-render \
/// --seed 12345 --poi 42 --type ImperiumRuin --out d.png \
/// [--data-dir ./Content/Data] [--cell 8]
///
/// In pipeline mode, rooms are colour-tinted by role (entry blue, boss
/// red, narrative gold, dead-end grey) so designers can visually verify
/// generator output across seeds.
/// </summary>
public static class DungeonRender
{
public static int Run(string[] args)
{
string templateId = "";
string outPath = "room.png";
string dataDir = "./Content/Data";
int cellPx = 16;
ulong? seed = null;
int? poiId = null;
string typeName = "ImperiumRuin";
for (int i = 0; i < args.Length; i++)
{
switch (args[i].ToLowerInvariant())
{
case "--template": if (i + 1 < args.Length) templateId = 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;
case "--cell": if (i + 1 < args.Length) cellPx = int.Parse(args[++i]); break;
case "--seed": if (i + 1 < args.Length) seed = ParseUlong(args[++i]); break;
case "--poi": if (i + 1 < args.Length) poiId = int.Parse(args[++i]); break;
case "--type": if (i + 1 < args.Length) typeName = args[++i]; break;
}
}
// Pipeline mode takes precedence when seed + poi are supplied.
if (seed is not null && poiId is not null)
return RenderPipeline(seed.Value, poiId.Value, typeName, outPath, dataDir, cellPx);
if (string.IsNullOrEmpty(templateId))
{
Console.Error.WriteLine("Either --template <id> OR --seed N --poi N required.");
return 1;
}
var loader = new ContentLoader(dataDir);
var rooms = loader.LoadRoomTemplates();
var def = Array.Find(rooms, r => string.Equals(r.Id, templateId, StringComparison.OrdinalIgnoreCase));
if (def is null)
{
Console.Error.WriteLine($"Room template '{templateId}' not found.");
Console.Error.WriteLine("Available:");
foreach (var r in rooms.OrderBy(r => r.Id, StringComparer.Ordinal))
Console.Error.WriteLine($" {r.Id}");
return 1;
}
int w = def.FootprintWTiles * cellPx;
int h = def.FootprintHTiles * cellPx;
using var img = new Image<Rgba32>(w, h);
for (int ty = 0; ty < def.FootprintHTiles; ty++)
for (int tx = 0; tx < def.FootprintWTiles; tx++)
{
char ch = def.Grid[ty][tx];
Rgba32 fill = ColorForChar(ch);
for (int py = 0; py < cellPx; py++)
for (int px = 0; px < cellPx; px++)
img[tx * cellPx + px, ty * cellPx + py] = fill;
}
// Thin grid lines so the ASCII grid is legible.
var gridColor = new Rgba32(60, 60, 60, 255);
for (int x = 0; x < w; x += cellPx)
for (int y = 0; y < h; y++) img[x, y] = gridColor;
for (int y = 0; y < h; y += cellPx)
for (int x = 0; x < w; x++) img[x, y] = gridColor;
img.SaveAsPng(outPath);
Console.WriteLine($"Wrote {outPath} ({w}×{h}px) for template '{def.Id}' " +
$"({def.FootprintWTiles}×{def.FootprintHTiles} tiles, type={def.Type}, built_by={def.BuiltBy}).");
return 0;
}
private static int RenderPipeline(ulong seed, int poiId, string typeName, string outPath, string dataDir, int cellPx)
{
if (!Enum.TryParse<PoiType>(typeName, ignoreCase: true, out var type) || type == PoiType.None)
{
Console.Error.WriteLine($"--type '{typeName}' invalid. Expected one of: ImperiumRuin, AbandonedMine, CultDen, NaturalCave, OvergrownSettlement.");
return 1;
}
var content = new ContentResolver(new ContentLoader(dataDir));
var d = DungeonGenerator.Generate(seed, poiId, type, content);
int w = d.W * cellPx;
int h = d.H * cellPx;
using var img = new Image<Rgba32>(w, h);
// Pass 1: paint surface + deco from the dungeon's tile array.
for (int ty = 0; ty < d.H; ty++)
for (int tx = 0; tx < d.W; tx++)
{
var tile = d.Tiles[tx, ty];
Rgba32 fill = ColorForTile(tile);
for (int py = 0; py < cellPx; py++)
for (int px = 0; px < cellPx; px++)
img[tx * cellPx + px, ty * cellPx + py] = fill;
}
// Pass 2: tint room interiors by role with a translucent overlay so
// designers can verify role placement at a glance.
foreach (var room in d.Rooms)
{
var tint = ColorForRole(room.Role);
for (int ty = room.AabbY + 1; ty < room.AabbY + room.AabbH - 1; ty++)
for (int tx = room.AabbX + 1; tx < room.AabbX + room.AabbW - 1; tx++)
{
if (tx < 0 || ty < 0 || tx >= d.W || ty >= d.H) continue;
if (!d.Tiles[tx, ty].IsWalkable) continue;
for (int py = 0; py < cellPx; py++)
for (int px = 0; px < cellPx; px++)
{
var existing = img[tx * cellPx + px, ty * cellPx + py];
img[tx * cellPx + px, ty * cellPx + py] = Blend(existing, tint, 0.25f);
}
}
}
// Pass 3: highlight the entrance with a magenta border.
var entrance = new Rgba32(255, 0, 200, 255);
var (ex, ey) = d.EntranceTile;
for (int dx = -1; dx <= 1; dx++)
for (int dy = -1; dy <= 1; dy++)
{
int tx = ex + dx, ty = ey + dy;
if (tx < 0 || ty < 0 || tx >= d.W || ty >= d.H) continue;
if (Math.Abs(dx) + Math.Abs(dy) != 1) continue;
for (int py = 0; py < cellPx; py++)
for (int px = 0; px < cellPx; px++)
{
if ((px == 0 || py == 0 || px == cellPx - 1 || py == cellPx - 1))
img[tx * cellPx + px, ty * cellPx + py] = entrance;
}
}
img.SaveAsPng(outPath);
Console.WriteLine($"Wrote {outPath} ({w}×{h}px) — dungeon poi={poiId} seed=0x{seed:X} type={type}");
Console.WriteLine($" {d.Rooms.Length} rooms, {d.Connections.Length} connections, " +
$"{d.W}×{d.H} tactical tiles, entrance @ ({ex},{ey}).");
foreach (var r in d.Rooms)
Console.WriteLine($" R{r.Id} [{r.Role}] {r.TemplateId} aabb=({r.AabbX},{r.AabbY},{r.AabbW}x{r.AabbH})");
return 0;
}
private static ulong ParseUlong(string raw) =>
raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? Convert.ToUInt64(raw[2..], 16)
: ulong.Parse(raw);
private static Rgba32 ColorForTile(TacticalTile tile)
{
// Surface decides base, deco overrides if present (overlay color).
if (tile.Deco != TacticalDeco.None)
{
return tile.Deco switch
{
TacticalDeco.Stairs => new Rgba32(120, 60, 150, 255),
TacticalDeco.DungeonDoor => new Rgba32(120, 90, 40, 255),
TacticalDeco.Container => new Rgba32(220, 180, 50, 255),
TacticalDeco.Trap => new Rgba32(200, 100, 100, 255),
TacticalDeco.Pillar => new Rgba32(110, 110, 130, 255),
TacticalDeco.Brazier => new Rgba32(220, 120, 50, 255),
TacticalDeco.ImperiumStatue=> new Rgba32(160, 140, 110, 255),
_ => ColorForSurface(tile.Surface),
};
}
return ColorForSurface(tile.Surface);
}
private static Rgba32 ColorForSurface(TacticalSurface s) => s switch
{
TacticalSurface.Wall => new Rgba32(45, 45, 60, 255),
TacticalSurface.DungeonFloor => new Rgba32(180, 165, 130, 255),
TacticalSurface.DungeonRubble => new Rgba32(140, 125, 100, 255),
TacticalSurface.DungeonTile => new Rgba32(100, 130, 200, 255),
TacticalSurface.MineFloor => new Rgba32(120, 100, 80, 255),
TacticalSurface.Cave => new Rgba32(90, 85, 75, 255),
TacticalSurface.None => new Rgba32(0, 0, 0, 255),
_ => new Rgba32(80, 80, 80, 255),
};
private static Rgba32 ColorForRole(RoomRole role) => role switch
{
RoomRole.Entry => new Rgba32(50, 130, 220, 255), // blue
RoomRole.Transit => new Rgba32(0, 0, 0, 0), // no tint
RoomRole.Narrative => new Rgba32(220, 180, 50, 255), // gold
RoomRole.Loot => new Rgba32(180, 220, 80, 255), // green
RoomRole.Boss => new Rgba32(220, 60, 60, 255), // red
RoomRole.DeadEnd => new Rgba32(120, 120, 120, 255), // grey
_ => new Rgba32(0, 0, 0, 0),
};
private static Rgba32 Blend(Rgba32 dst, Rgba32 src, float t)
{
// Linear blend with the tint's alpha + the t factor.
if (src.A == 0) return dst;
float a = (src.A / 255f) * t;
byte r = (byte)Math.Clamp(dst.R + (src.R - dst.R) * a, 0, 255);
byte g = (byte)Math.Clamp(dst.G + (src.G - dst.G) * a, 0, 255);
byte b = (byte)Math.Clamp(dst.B + (src.B - dst.B) * a, 0, 255);
return new Rgba32(r, g, b, (byte)255);
}
private static Rgba32 ColorForChar(char ch) => ch switch
{
'#' => new Rgba32(45, 45, 60, 255), // wall — dark slate
'.' => new Rgba32(180, 165, 130, 255), // floor — warm sand
',' => new Rgba32(140, 125, 100, 255), // rubble
'D' => new Rgba32(120, 90, 40, 255), // door — chestnut
'S' => new Rgba32(120, 60, 150, 255), // stairs — purple
'@' => new Rgba32(200, 60, 60, 255), // encounter slot — red
'C' => new Rgba32(220, 180, 50, 255), // container — gold
'T' => new Rgba32(200, 100, 100, 255), // trap — coral
'P' => new Rgba32(110, 110, 130, 255), // pillar — grey-blue
'B' => new Rgba32(220, 120, 50, 255), // brazier — fire orange
'M' => new Rgba32(100, 130, 200, 255), // mosaic — sky blue
' ' => new Rgba32(0, 0, 0, 0), // unused/transparent
_ => new Rgba32(255, 0, 200, 255), // unknown — magenta marker
};
}
@@ -0,0 +1,81 @@
using Theriapolis.Core.Data;
namespace Theriapolis.Tools.Commands;
/// <summary>
/// Phase 6 M4 — quest-validate. Loads every <c>quests/*.json</c> through
/// <see cref="ContentLoader.LoadQuests"/> (which validates structure +
/// references) and runs reachability analysis from each quest's entry
/// step. CI gate: exits non-zero on any error.
///
/// Usage:
/// dotnet run --project Theriapolis.Tools -- quest-validate
/// dotnet run --project Theriapolis.Tools -- quest-validate --data-dir ./Content/Data
/// </summary>
public static class QuestValidate
{
public static int Run(string[] args)
{
string dataDir = "./Content/Data";
for (int i = 0; i < args.Length; i++)
{
if (args[i] == "--data-dir" && i + 1 < args.Length) { dataDir = args[++i]; }
}
Console.WriteLine($"Validating quests in: {dataDir}");
var loader = new ContentLoader(dataDir);
QuestDef[] quests;
try
{
var items = loader.LoadItems();
quests = loader.LoadQuests(items);
}
catch (Exception ex)
{
Console.Error.WriteLine($" ✗ {ex.Message}");
return 1;
}
Console.WriteLine($" ✓ {quests.Length} quest tree(s) loaded.");
foreach (var q in quests.OrderBy(q => q.Id, StringComparer.Ordinal))
{
int outcomes = q.Steps.Sum(s => s.Outcomes.Length);
int terminals = q.Steps.Count(s => s.CompletesQuest || s.FailsQuest);
Console.WriteLine($" [{q.Id}] '{q.Title}' steps={q.Steps.Length} outcomes={outcomes} terminals={terminals}");
// Reachability from entry step.
var stepsById = q.Steps.ToDictionary(s => s.Id, StringComparer.OrdinalIgnoreCase);
var reached = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { q.EntryStep };
var queue = new Queue<string>();
queue.Enqueue(q.EntryStep);
while (queue.Count > 0)
{
var cur = queue.Dequeue();
if (!stepsById.TryGetValue(cur, out var s)) continue;
foreach (var o in s.Outcomes)
{
if (string.IsNullOrEmpty(o.Next)) continue;
if (string.Equals(o.Next, "<end>", StringComparison.OrdinalIgnoreCase)) continue;
if (reached.Add(o.Next)) queue.Enqueue(o.Next);
}
// start_quest / end_quest effects can transition out, but we
// don't follow them here — quest-graph reachability is
// single-quest by design.
}
int unreachable = q.Steps.Length - reached.Count;
if (unreachable > 0)
{
Console.Error.WriteLine($" ✗ {unreachable} unreachable step(s) in '{q.Id}': "
+ string.Join(", ", q.Steps.Where(s => !reached.Contains(s.Id)).Select(s => s.Id)));
return 1;
}
// Every quest must have at least one terminal path.
if (terminals == 0)
Console.Error.WriteLine($" ⚠ quest '{q.Id}' has no completes_quest / fails_quest step (warning only)");
}
Console.WriteLine();
Console.WriteLine("All quest trees valid.");
return 0;
}
}
@@ -0,0 +1,216 @@
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;
/// <summary>
/// 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.
/// </summary>
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<Rgba32>(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));
}
/// <summary>
/// Forces the settlement's <see cref="Settlement.Buildings"/> list to
/// resolve so the dump line at the end of <see cref="Run"/> 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).
/// </summary>
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<Rgba32> 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;
}
}
@@ -0,0 +1,166 @@
using Theriapolis.Core;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Generation;
namespace Theriapolis.Tools.Commands;
/// <summary>
/// settlement-report --seed &lt;n&gt; [--data-dir &lt;dir&gt;]
///
/// Runs the full pipeline and prints a human-readable settlement report:
/// narrative anchors, tier breakdown, economy distribution, and PoI list.
/// </summary>
public static class SettlementReport
{
public static int Run(string[] args)
{
ulong seed = 12345;
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];
if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
seed = Convert.ToUInt64(raw[2..], 16);
else
seed = ulong.Parse(raw);
}
break;
case "--data-dir":
if (i + 1 < args.Length) dataDir = args[++i];
break;
}
}
Console.WriteLine($"[settlement-report] seed=0x{seed:X} data-dir={dataDir}");
if (!Directory.Exists(dataDir))
{
Console.Error.WriteLine($"Data directory not found: {dataDir}");
return 1;
}
var ctx = new WorldGenContext(seed, dataDir)
{
ProgressCallback = (name, _) => Console.Write($"\r Running {name,-28} "),
Log = _ => { }, // suppress detailed logs
};
WorldGenerator.RunAll(ctx);
Console.WriteLine("\r ");
var world = ctx.World;
var ss = world.Settlements;
if (ss.Count == 0)
{
Console.WriteLine("No settlements generated.");
return 0;
}
// ── Narrative anchors ─────────────────────────────────────────────────
Console.WriteLine("═══════════════════════════════════════════════════════");
Console.WriteLine(" NARRATIVE ANCHORS");
Console.WriteLine("═══════════════════════════════════════════════════════");
foreach (var s in ss.Where(s => s.Anchor.HasValue).OrderBy(s => s.Anchor))
{
Console.WriteLine($" [{s.Anchor}]");
Console.WriteLine($" Name : {s.Name}");
Console.WriteLine($" Tier : {s.Tier}");
Console.WriteLine($" Position : ({s.TileX}, {s.TileY})");
Console.WriteLine($" Economy : {s.Economy}");
Console.WriteLine($" Wealth : {s.WealthLevel:F3}");
Console.WriteLine($" Pop : ~{s.Population}");
Console.WriteLine($" River : {s.IsOnRiver}");
Console.WriteLine($" Rail : {s.HasRailStation}");
Console.WriteLine();
}
// ── Tier breakdown ────────────────────────────────────────────────────
Console.WriteLine("═══════════════════════════════════════════════════════");
Console.WriteLine(" SETTLEMENTS BY TIER");
Console.WriteLine("═══════════════════════════════════════════════════════");
for (int tier = 1; tier <= 4; tier++)
{
var ts = ss.Where(s => s.Tier == tier && !s.IsPoi).ToList();
if (ts.Count == 0) continue;
Console.WriteLine($" Tier {tier} ({ts.Count}):");
foreach (var s in ts.OrderBy(s => s.Name))
{
string anchor = s.Anchor.HasValue ? $" [{s.Anchor}]" : "";
Console.WriteLine($" ({s.TileX,4},{s.TileY,4}) {s.Name,-24}{anchor}");
Console.WriteLine($" Economy={s.Economy,-14} Gov={s.Governance,-16} Wealth={s.WealthLevel:F2}");
}
}
Console.WriteLine();
// ── Economy distribution ──────────────────────────────────────────────
Console.WriteLine("═══════════════════════════════════════════════════════");
Console.WriteLine(" ECONOMY DISTRIBUTION (Tier 1-4)");
Console.WriteLine("═══════════════════════════════════════════════════════");
var econGroups = ss.Where(s => !s.IsPoi)
.GroupBy(s => s.Economy)
.OrderByDescending(g => g.Count());
foreach (var g in econGroups)
Console.WriteLine($" {g.Key,-18}: {g.Count(),3}");
Console.WriteLine();
// ── PoI list ──────────────────────────────────────────────────────────
var pois = ss.Where(s => s.IsPoi).ToList();
if (pois.Count > 0)
{
Console.WriteLine("═══════════════════════════════════════════════════════");
Console.WriteLine($" POINTS OF INTEREST ({pois.Count})");
Console.WriteLine("═══════════════════════════════════════════════════════");
foreach (var p in pois.OrderBy(p => p.PoiType).ThenBy(p => p.TileX))
Console.WriteLine($" ({p.TileX,4},{p.TileY,4}) {p.Name,-24} [{p.PoiType}]");
Console.WriteLine();
}
// ── Linear features ────────────────────────────────────────────────────
Console.WriteLine("═══════════════════════════════════════════════════════");
Console.WriteLine(" LINEAR FEATURES");
Console.WriteLine("═══════════════════════════════════════════════════════");
Console.WriteLine($" Rivers : {world.Rivers.Count}");
Console.WriteLine($" Roads : {world.Roads.Count}");
Console.WriteLine($" Rails : {world.Rails.Count}");
// ── Validation summary ─────────────────────────────────────────────────
if (world.StageHashes.TryGetValue("ValidationPass", out ulong vhash))
{
int violations = (int)(vhash / 1000);
int warnings = (int)(vhash % 1000);
Console.WriteLine();
Console.WriteLine("═══════════════════════════════════════════════════════");
Console.WriteLine(" VALIDATION");
Console.WriteLine("═══════════════════════════════════════════════════════");
Console.WriteLine($" Violations : {violations}");
Console.WriteLine($" Warnings : {warnings}");
}
return 0;
}
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;
}
}
+147
View File
@@ -0,0 +1,147 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Theriapolis.Core;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.World.Generation;
namespace Theriapolis.Tools.Commands;
/// <summary>
/// tactical-dump --seed &lt;n&gt; --chunk cx,cy --out &lt;file.png&gt; [--data-dir &lt;dir&gt;]
///
/// Runs the full pipeline, then generates a single tactical chunk and exports
/// it as a PNG. Used during M2 to eyeball biome ground variants, polyline
/// burn-in, and settlement footprints without running the game.
///
/// Optional --grid 3 — render a 3x3 set of chunks centred on (cx, cy) and stitch
/// them so chunk-boundary continuity is also visible.
/// </summary>
public static class TacticalDump
{
public static int Run(string[] args)
{
ulong seed = 12345;
int cx = 0, cy = 0;
int grid = 1;
string outPath = "tactical.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 "--chunk":
if (i + 1 < args.Length)
{
var parts = args[++i].Split(',');
cx = int.Parse(parts[0]);
cy = int.Parse(parts[1]);
}
break;
case "--grid":
if (i + 1 < args.Length) grid = 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;
}
}
Console.WriteLine($"[tactical-dump] seed=0x{seed:X} chunk=({cx},{cy}) grid={grid} out={outPath}");
if (!Directory.Exists(dataDir))
{
Console.Error.WriteLine($"Data directory not found: {dataDir}");
return 1;
}
var ctx = new WorldGenContext(seed, dataDir)
{
Log = msg => Console.WriteLine(msg),
};
WorldGenerator.RunAll(ctx);
int side = C.TACTICAL_CHUNK_SIZE * grid;
using var img = new Image<Rgba32>(side, side);
for (int gy = 0; gy < grid; gy++)
for (int gx = 0; gx < grid; gx++)
{
int ccx = cx + gx - grid / 2;
int ccy = cy + gy - grid / 2;
var chunk = TacticalChunkGen.Generate(seed, new ChunkCoord(ccx, ccy), ctx.World);
int ox = gx * C.TACTICAL_CHUNK_SIZE;
int oy = gy * C.TACTICAL_CHUNK_SIZE;
BlitChunk(img, chunk, ox, oy);
}
img.SaveAsPng(outPath);
Console.WriteLine($"[tactical-dump] wrote {outPath} ({side}x{side})");
return 0;
}
private static void BlitChunk(Image<Rgba32> 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 surface for visual punch.
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.Wall => new Rgba32(60, 55, 50),
TacticalSurface.Floor => new Rgba32(180, 160, 130),
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;
}
}
+288
View File
@@ -0,0 +1,288 @@
using SixLabors.Fonts;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace Theriapolis.Tools.Commands;
/// <summary>
/// tile-analyze --dir &lt;path&gt; [--sheet &lt;out.png&gt;]
/// tile-analyze --files &lt;f1.png&gt; &lt;f2.png&gt; ... [--sheet &lt;out.png&gt;]
///
/// Reports per-tile diagnostics for a folder of 32×32 PNG tiles
/// (typically a Pixellab tiles_pro download). Used to vet tile quality
/// before saving picks into <c>Content/Gfx/tactical/</c>.
///
/// What it checks:
///
/// • <b>Border edges (04)</b>: how many of the four perimeter rows/cols are
/// ≥80% "dark uniform" pixels (max channel ≤ 95 AND max - min ≤ 25). Catches
/// the hard near-black or dark-grey frames that the regular create_tiles_pro
/// path bakes around every tile. Should be 0 for a clean surface tile.
///
/// • <b>Opaque %</b>: fraction of pixels with α ≥ 128. Catches the failure
/// mode where a "marsh" or other prompt produces transparent decoration
/// sprites instead of edge-to-edge surface tiles. Surface tiles want ~100%;
/// decoration sprites are typically 3070%.
///
/// • <b>Shadow scores (top/bot/lef/rig)</b>: brightness drop on each edge
/// versus the interior average. Positive = edge is darker than interior.
/// The pseudo-3D shading that <c>tile_view: "low top-down"</c> bakes in
/// shows up here as a +50 to +90 drop on the bottom and right edges that
/// the border detector misses (the colors aren't black-uniform, just
/// darker). Anything &gt; 30 is a red flag for tiling: adjacent tiles will
/// show a visible diagonal grid of dark seams.
///
/// Optionally writes a labeled 4×-upscaled contact sheet so you can present
/// the batch to a user for picks. Labels turn ORANGE for any tile that fails
/// any check (borders &gt; 0, opaque &lt; 95%, or shadow &gt; threshold).
///
/// See <c>theriapolis-tile-generation-handoff.md</c> for the full Pixellab
/// MCP workflow this tool slots into.
/// </summary>
public static class TileAnalyze
{
public static int Run(string[] args)
{
string? dir = null;
string? sheetOut = null;
var explicitFiles = new List<string>();
int shadowThreshold = 30;
int upscale = 4;
for (int i = 0; i < args.Length; i++)
{
switch (args[i].ToLowerInvariant())
{
case "--dir":
if (i + 1 < args.Length) dir = args[++i];
break;
case "--sheet":
if (i + 1 < args.Length) sheetOut = args[++i];
break;
case "--shadow-threshold":
if (i + 1 < args.Length) shadowThreshold = int.Parse(args[++i]);
break;
case "--upscale":
if (i + 1 < args.Length) upscale = int.Parse(args[++i]);
break;
case "--files":
while (i + 1 < args.Length && !args[i + 1].StartsWith("--"))
explicitFiles.Add(args[++i]);
break;
}
}
List<string> files;
if (dir is not null)
{
if (!Directory.Exists(dir))
{
Console.Error.WriteLine($"Directory not found: {dir}");
return 1;
}
// Sort numerically when the filename is tile_N.png — common Pixellab layout.
files = Directory.EnumerateFiles(dir, "*.png")
.OrderBy(NumericOrderKey)
.ToList();
}
else if (explicitFiles.Count > 0)
{
files = explicitFiles;
}
else
{
PrintHelp();
return 1;
}
if (files.Count == 0) { Console.Error.WriteLine("No PNGs found."); return 1; }
var stats = new List<TileStat>(files.Count);
Console.WriteLine($"Analyzing {files.Count} tiles (shadow threshold {shadowThreshold})\n");
Console.WriteLine($"{"file",-32} | brd | op% | int | top bot lef rig | verdict");
Console.WriteLine(new string('-', 90));
foreach (var path in files)
{
using var img = Image.Load<Rgba32>(path);
var st = AnalyzeTile(path, img);
stats.Add(st);
int maxShadow = Math.Max(Math.Max(Math.Abs(st.TopDiff), Math.Abs(st.BotDiff)),
Math.Max(Math.Abs(st.LefDiff), Math.Abs(st.RigDiff)));
string verdict = (st.BorderEdges == 0 && st.OpaquePct >= 95 && maxShadow <= shadowThreshold)
? "ok"
: "⚠";
Console.WriteLine(
$"{Path.GetFileNameWithoutExtension(path),-32} | {st.BorderEdges} | {st.OpaquePct,3}% | {st.InteriorBrightness,3} |" +
$" {st.TopDiff,4} {st.BotDiff,4} {st.LefDiff,4} {st.RigDiff,4} | {verdict}");
}
if (sheetOut is not null)
{
BuildContactSheet(files, stats, sheetOut, upscale, shadowThreshold);
Console.WriteLine($"\nContact sheet: {sheetOut}");
}
return 0;
}
// Numeric key so "tile_2" sorts before "tile_10".
private static (int, string) NumericOrderKey(string path)
{
var name = Path.GetFileNameWithoutExtension(path);
int u = name.LastIndexOf('_');
if (u >= 0 && int.TryParse(name[(u + 1)..], out var n)) return (n, name);
return (int.MaxValue, name);
}
// ── Detector logic ────────────────────────────────────────────────────
public readonly record struct TileStat(
int BorderEdges,
int OpaquePct,
int InteriorBrightness,
int TopDiff,
int BotDiff,
int LefDiff,
int RigDiff);
public static TileStat AnalyzeTile(string path, Image<Rgba32> img)
{
int border = CountBorderEdges(img);
int opaque = CountOpaqueFraction(img);
var (interior, top, bot, lef, rig) = ShadowScores(img);
return new TileStat(border, opaque, interior, top, bot, lef, rig);
}
/// <summary>
/// "Border" pixel: alpha ≥ 128 AND max channel ≤ 95 AND (max - min) ≤ 25.
/// Catches the dark-uniform frames Pixellab bakes around tiles.
/// </summary>
private static bool IsBorderPixel(Rgba32 p)
{
if (p.A < 128) return false;
int max = Math.Max(p.R, Math.Max(p.G, p.B));
int min = Math.Min(p.R, Math.Min(p.G, p.B));
return max <= 95 && (max - min) <= 25;
}
/// <summary>Number of edges (04) where ≥80% of perimeter pixels are border-pixels.</summary>
public static int CountBorderEdges(Image<Rgba32> img)
{
int w = img.Width, h = img.Height;
int top = 0, bot = 0, lef = 0, rig = 0;
for (int x = 0; x < w; x++)
{
if (IsBorderPixel(img[x, 0])) top++;
if (IsBorderPixel(img[x, h - 1])) bot++;
}
for (int y = 0; y < h; y++)
{
if (IsBorderPixel(img[0, y])) lef++;
if (IsBorderPixel(img[w - 1, y])) rig++;
}
int edges = 0;
if (top >= w * 0.8) edges++;
if (bot >= w * 0.8) edges++;
if (lef >= h * 0.8) edges++;
if (rig >= h * 0.8) edges++;
return edges;
}
/// <summary>Percent (0100) of pixels with alpha ≥ 128.</summary>
public static int CountOpaqueFraction(Image<Rgba32> img)
{
int total = img.Width * img.Height, opaque = 0;
for (int y = 0; y < img.Height; y++)
for (int x = 0; x < img.Width; x++)
if (img[x, y].A >= 128) opaque++;
return (int)(opaque * 100.0 / total);
}
/// <summary>
/// Edge-vs-interior brightness drop for each side. Positive value = edge
/// is darker than interior (= bad: baked-in shadow gradient).
/// Interior excludes a 4-pixel margin so it's the "true" middle.
/// </summary>
public static (int interior, int topDiff, int botDiff, int lefDiff, int rigDiff) ShadowScores(Image<Rgba32> img)
{
int w = img.Width, h = img.Height;
long ir = 0, ig = 0, ib = 0, ic = 0;
for (int y = 4; y < h - 4; y++)
for (int x = 4; x < w - 4; x++)
{
var p = img[x, y]; if (p.A < 128) continue;
ir += p.R; ig += p.G; ib += p.B; ic++;
}
if (ic == 0) return (0, 0, 0, 0, 0);
int avgR = (int)(ir / ic), avgG = (int)(ig / ic), avgB = (int)(ib / ic);
int interior = (avgR + avgG + avgB) / 3;
int EdgeBrightness(Func<int, (int x, int y)> sel, int len)
{
long er = 0, eg = 0, eb = 0;
for (int i = 0; i < len; i++)
{
var (x, y) = sel(i);
var p = img[x, y];
er += p.R; eg += p.G; eb += p.B;
}
return ((int)(er / len) + (int)(eg / len) + (int)(eb / len)) / 3;
}
int top = EdgeBrightness(i => (i, 0), w);
int bot = EdgeBrightness(i => (i, h - 1), w);
int lef = EdgeBrightness(i => (0, i), h);
int rig = EdgeBrightness(i => (w - 1, i), h);
return (interior, interior - top, interior - bot, interior - lef, interior - rig);
}
// ── Contact sheet ─────────────────────────────────────────────────────
private static void BuildContactSheet(
List<string> files, List<TileStat> stats, string outPath,
int upscale, int shadowThreshold)
{
int n = files.Count;
int cols = (int)Math.Ceiling(Math.Sqrt(n));
int rows = (int)Math.Ceiling((double)n / cols);
int cell = 32, gap = 6, label = 30;
int side = cell * upscale;
int sheetW = side * cols + (cols + 1) * gap;
int sheetH = (side + label) * rows + (rows + 1) * gap;
var family = SystemFonts.Get("Consolas");
var font = family.CreateFont(10f, FontStyle.Regular);
using var sheet = new Image<Rgba32>(sheetW, sheetH, new Rgba32(40, 40, 40));
for (int i = 0; i < n; i++)
{
using var t = Image.Load<Rgba32>(files[i]);
t.Mutate(x => x.Resize(side, side, KnownResamplers.NearestNeighbor));
int cx = (i % cols) * (side + gap) + gap;
int cy = (i / cols) * (side + label + gap) + gap;
sheet.Mutate(x => x.DrawImage(t, new Point(cx, cy), 1f));
var st = stats[i];
int maxShadow = Math.Max(Math.Max(Math.Abs(st.TopDiff), Math.Abs(st.BotDiff)),
Math.Max(Math.Abs(st.LefDiff), Math.Abs(st.RigDiff)));
bool ok = st.BorderEdges == 0 && st.OpaquePct >= 95 && maxShadow <= shadowThreshold;
var col = ok ? Color.LightGreen : Color.Orange;
string idx = Path.GetFileNameWithoutExtension(files[i]);
sheet.Mutate(x => x.DrawText(
$"{idx} b:{st.BorderEdges} op:{st.OpaquePct}%",
font, col, new PointF(cx + 4, cy + side + 2)));
sheet.Mutate(x => x.DrawText(
$"sh t/b/l/r: {st.TopDiff,3} {st.BotDiff,3} {st.LefDiff,3} {st.RigDiff,3}",
font, col, new PointF(cx + 4, cy + side + 14)));
}
sheet.Save(outPath);
}
private static void PrintHelp()
{
Console.WriteLine("Usage:");
Console.WriteLine(" tile-analyze --dir <path> [--sheet <out.png>] [--shadow-threshold N] [--upscale N]");
Console.WriteLine(" tile-analyze --files <a.png> <b.png> ... [--sheet <out.png>]");
Console.WriteLine();
Console.WriteLine("Reports border edges, opaque %, and shadow gradients per tile,");
Console.WriteLine("optionally writing a labeled contact sheet for visual review.");
}
}
+228
View File
@@ -0,0 +1,228 @@
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;
}
}
+353
View File
@@ -0,0 +1,353 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Generation;
using Theriapolis.Core.World.Generation.Stages;
using Theriapolis.Core.World.Polylines;
namespace Theriapolis.Tools.Commands;
/// <summary>
/// worldgen-dump --seed &lt;n&gt; --out &lt;file.png&gt; [--data-dir &lt;dir&gt;]
///
/// Runs the full pipeline headless and exports a PNG of the biome map overlaid
/// with rivers (blue), roads (tan), rail (dark), and settlement icons.
/// </summary>
public static class WorldgenDump
{
public static int Run(string[] args)
{
ulong seed = 12345;
string outPath = "world.png";
string dataDir = ResolveDataDir();
bool showViolations = false;
for (int i = 0; i < args.Length; i++)
{
switch (args[i].ToLowerInvariant())
{
case "--seed":
if (i + 1 < args.Length)
{
string raw = args[++i];
if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
seed = Convert.ToUInt64(raw[2..], 16);
else
seed = ulong.Parse(raw);
}
break;
case "--out":
if (i + 1 < args.Length) outPath = args[++i];
break;
case "--data-dir":
if (i + 1 < args.Length) dataDir = args[++i];
break;
case "--show-violations":
showViolations = true;
break;
}
}
Console.WriteLine($"[worldgen-dump] seed=0x{seed:X} out={outPath} data-dir={dataDir}");
if (!Directory.Exists(dataDir))
{
Console.Error.WriteLine($"Data directory not found: {dataDir}");
return 1;
}
var ctx = new WorldGenContext(seed, dataDir)
{
ProgressCallback = (name, frac) =>
Console.Write($"\r {name,-28} {frac * 100f:F0}% "),
Log = msg => Console.WriteLine(msg),
};
WorldGenerator.RunAll(ctx);
Console.WriteLine();
// Collect biome colours from the loaded BiomeDef array
var colorMap = BuildColorMap(ctx.World.BiomeDefs!);
int W = C.WORLD_WIDTH_TILES;
int H = C.WORLD_HEIGHT_TILES;
using var image = new Image<Rgb24>(W, H);
// ── 1. Biome base layer ───────────────────────────────────────────────
for (int ty = 0; ty < H; ty++)
for (int tx = 0; tx < W; tx++)
{
var biome = ctx.World.Tiles[tx, ty].Biome;
if (!colorMap.TryGetValue(biome, out var px))
px = new Rgb24(255, 0, 255);
image[tx, ty] = px;
}
if (showViolations)
OverlayViolations(image, ctx, W, H);
// ── 2. Roads ─────────────────────────────────────────────────────────
foreach (var road in ctx.World.Roads)
{
var color = road.RoadClassification switch
{
RoadType.Highway => new Rgb24(210, 180, 80),
RoadType.PostRoad => new Rgb24(180, 155, 70),
_ => new Rgb24(150, 130, 90),
};
DrawPolyline(image, road, color, W, H);
}
// ── 3. Rivers ─────────────────────────────────────────────────────────
foreach (var river in ctx.World.Rivers)
{
var color = river.RiverClassification switch
{
RiverClass.MajorRiver => new Rgb24(40, 100, 200),
RiverClass.River => new Rgb24(60, 120, 200),
_ => new Rgb24(100, 150, 220),
};
DrawPolyline(image, river, color, W, H);
}
// ── 4. Rail ───────────────────────────────────────────────────────────
var railColor = new Rgb24(80, 65, 50);
foreach (var rail in ctx.World.Rails)
DrawPolyline(image, rail, railColor, W, H);
// ── 4b. Bridges ──────────────────────────────────────────────────────
var bridgeColor = new Rgb24(160, 140, 100);
foreach (var bridge in ctx.World.Bridges)
{
int bx = (int)(bridge.WorldPixelX / C.WORLD_TILE_PIXELS);
int by = (int)(bridge.WorldPixelY / C.WORLD_TILE_PIXELS);
// Draw a small cross at the bridge location
for (int d = -1; d <= 1; d++)
{
int px = bx + d, py = by;
if ((uint)px < (uint)W && (uint)py < (uint)H) image[px, py] = bridgeColor;
px = bx; py = by + d;
if ((uint)px < (uint)W && (uint)py < (uint)H) image[px, py] = bridgeColor;
}
}
// ── 5. Settlements ────────────────────────────────────────────────────
foreach (var s in ctx.World.Settlements)
{
var (color, radius) = s.Tier switch
{
1 => (new Rgb24(255, 215, 0), 4), // gold, capital
2 => (new Rgb24(230, 230, 230), 3), // white, city
3 => (new Rgb24(150, 200, 255), 2), // blue, town
4 => (new Rgb24(200, 200, 200), 1), // grey, village
_ => (new Rgb24(200, 60, 60), 1), // red, PoI
};
DrawDot(image, s.TileX, s.TileY, color, radius, W, H);
}
// ── 6. Biome coverage stats ────────────────────────────────────────────
PrintBiomeCoverage(ctx, W, H);
// ── 7. Settlement summary ─────────────────────────────────────────────
PrintSettlementSummary(ctx);
image.Save(outPath);
Console.WriteLine($"[worldgen-dump] Saved {outPath} ({W}×{H} px)");
return 0;
}
private static void DrawPolyline(Image<Rgb24> img, Polyline poly, Rgb24 color, int W, int H)
{
var pts = poly.Points;
if (pts.Count < 2) return;
for (int i = 0; i < pts.Count - 1; i++)
{
int x0 = (int)(pts[i].X / C.WORLD_TILE_PIXELS);
int y0 = (int)(pts[i].Y / C.WORLD_TILE_PIXELS);
int x1 = (int)(pts[i + 1].X / C.WORLD_TILE_PIXELS);
int y1 = (int)(pts[i + 1].Y / C.WORLD_TILE_PIXELS);
BresenhamLine(img, x0, y0, x1, y1, color, W, H);
}
}
private static void BresenhamLine(Image<Rgb24> img, int x0, int y0, int x1, int y1, Rgb24 color, int W, int H)
{
int dx = Math.Abs(x1 - x0), dy = Math.Abs(y1 - y0);
int sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
int err = dx - dy;
while (true)
{
if ((uint)x0 < (uint)W && (uint)y0 < (uint)H)
img[x0, y0] = color;
if (x0 == x1 && y0 == y1) break;
int e2 = 2 * err;
if (e2 > -dy) { err -= dy; x0 += sx; }
if (e2 < dx) { err += dx; y0 += sy; }
}
}
private static void DrawDot(Image<Rgb24> img, int cx, int cy, Rgb24 color, int radius, int W, int H)
{
for (int dy = -radius; dy <= radius; dy++)
for (int dx = -radius; dx <= radius; dx++)
{
if (dx * dx + dy * dy > radius * radius) continue;
int nx = cx + dx, ny = cy + dy;
if ((uint)nx < (uint)W && (uint)ny < (uint)H)
img[nx, ny] = color;
}
}
private static void OverlayViolations(Image<Rgb24> image, WorldGenContext ctx, int W, int H)
{
var viols = BorderDistortionGenStage.FindStraightViolations(ctx);
var byLen = viols.OrderByDescending(v => v.len).ToList();
Console.WriteLine($"[worldgen-dump] Total straight-run violations: {viols.Count}");
var buckets = new (int min, int max, int count)[]
{
(6, 7, 0), (8, 9, 0), (10, 11, 0), (12, 14, 0),
(15, 19, 0), (20, 29, 0), (30, 49, 0), (50, 99, 0),
};
foreach (var v in viols)
{
for (int i = 0; i < buckets.Length; i++)
{
if (v.len >= buckets[i].min && v.len <= buckets[i].max)
{
buckets[i].count++;
break;
}
}
}
Console.WriteLine($"[worldgen-dump] Run length distribution:");
foreach (var b in buckets)
Console.WriteLine($" len {b.min,2}-{b.max,2}: {b.count,5}");
Console.WriteLine($"[worldgen-dump] Top 25 longest runs:");
for (int i = 0; i < Math.Min(25, byLen.Count); i++)
{
var v = byLen[i];
string orient = (v.dx, v.dy) switch
{
(1, 0) => "→",
(0, 1) => "↓",
(1, 1) => "↘",
(1,-1) => "↗",
_ => "?",
};
Console.WriteLine($" #{i+1,2} ({v.x,4},{v.y,4}) {orient} len={v.len}");
}
var red = new Rgb24(255, 0, 0);
int overlayCount = Math.Min(50, byLen.Count);
for (int i = 0; i < overlayCount; i++)
{
var v = byLen[i];
int px = v.x, py = v.y;
for (int step = 0; step < v.len; step++)
{
if ((uint)px < (uint)W && (uint)py < (uint)H)
image[px, py] = red;
px += v.dx;
py += v.dy;
}
}
}
private static void PrintBiomeCoverage(WorldGenContext ctx, int W, int H)
{
var biomeCounts = new Dictionary<BiomeId, int>();
int total = W * H;
for (int ty = 0; ty < H; ty++)
for (int tx = 0; tx < W; tx++)
{
var b = ctx.World.Tiles[tx, ty].Biome;
biomeCounts.TryGetValue(b, out int c);
biomeCounts[b] = c + 1;
}
int oceanCount = biomeCounts.GetValueOrDefault(BiomeId.Ocean);
int landTotal = total - oceanCount;
Console.WriteLine($"[worldgen-dump] Biome coverage: ocean={100.0 * oceanCount / total:F1}%, land tiles={landTotal}");
foreach (var kv in biomeCounts.OrderByDescending(kv => kv.Value))
{
if (kv.Key == BiomeId.Ocean) continue;
double pct = landTotal > 0 ? 100.0 * kv.Value / landTotal : 0;
Console.WriteLine($" {kv.Key,-22} {pct,6:F2}% ({kv.Value} tiles)");
}
}
private static void PrintSettlementSummary(WorldGenContext ctx)
{
var ss = ctx.World.Settlements;
if (ss.Count == 0) return;
Console.WriteLine($"[worldgen-dump] Settlements: {ss.Count} total");
for (int tier = 1; tier <= 4; tier++)
{
var ts = ss.Where(s => s.Tier == tier && !s.IsPoi).ToList();
Console.WriteLine($" Tier {tier}: {ts.Count}");
foreach (var s in ts)
Console.WriteLine($" [{s.TileX,4},{s.TileY,4}] {s.Name,-24} {s.Economy,-14} wealth={s.WealthLevel:F2}");
}
int poiCount = ss.Count(s => s.IsPoi);
Console.WriteLine($" PoIs: {poiCount}");
Console.WriteLine($"[worldgen-dump] Rivers: {ctx.World.Rivers.Count}, Roads: {ctx.World.Roads.Count}, Rails: {ctx.World.Rails.Count}, Bridges: {ctx.World.Bridges.Count}");
}
private static Dictionary<BiomeId, Rgb24> BuildColorMap(BiomeDef[] defs)
{
var map = new Dictionary<BiomeId, Rgb24>();
foreach (var def in defs)
{
var biomeId = ParseBiomeId(def.Id);
var (r, g, b) = def.ParsedColor();
map[biomeId] = new Rgb24(r, g, b);
}
return map;
}
private static BiomeId ParseBiomeId(string id) => id.ToLowerInvariant() switch
{
"ocean" => BiomeId.Ocean,
"tundra" => BiomeId.Tundra,
"boreal" => BiomeId.Boreal,
"temperate_deciduous" => BiomeId.TemperateDeciduous,
"temperate_grassland" => BiomeId.TemperateGrassland,
"mountain_alpine" => BiomeId.MountainAlpine,
"mountain_forested" => BiomeId.MountainForested,
"subtropical_forest" => BiomeId.SubtropicalForest,
"wetland" => BiomeId.Wetland,
"coastal" => BiomeId.Coastal,
"river_valley" => BiomeId.RiverValley,
"scrubland" => BiomeId.Scrubland,
"desert_cold" => BiomeId.DesertCold,
"forest_edge" => BiomeId.ForestEdge,
"foothills" => BiomeId.Foothills,
"marsh_edge" => BiomeId.MarshEdge,
"beach" => BiomeId.Beach,
"cliff" => BiomeId.Cliff,
"tidal_flat" => BiomeId.TidalFlat,
"mangrove" => BiomeId.Mangrove,
_ => BiomeId.TemperateGrassland,
};
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;
}
}
+100
View File
@@ -0,0 +1,100 @@
using Theriapolis.Tools.Commands;
if (args.Length == 0)
{
PrintHelp();
return 0;
}
return args[0].ToLowerInvariant() switch
{
"hello" => Hello(),
"worldgen-dump" => WorldgenDump.Run(args[1..]),
"settlement-report" => SettlementReport.Run(args[1..]),
"tile-inspect" => TileInspect.Run(args[1..]),
"tactical-dump" => TacticalDump.Run(args[1..]),
"tile-analyze" => TileAnalyze.Run(args[1..]),
"content-validate" => ContentValidate.Run(args[1..]),
"character-roll" => CharacterRoll.Run(args[1..]),
"combat-duel" => CombatDuel.Run(args[1..]),
"settlement-render" => SettlementRender.Run(args[1..]),
"dialogue-validate" => DialogueValidate.Run(args[1..]),
"quest-validate" => QuestValidate.Run(args[1..]),
"dungeon-render" => DungeonRender.Run(args[1..]),
_ => Unknown(args[0]),
};
static int Hello()
{
var asm = typeof(Theriapolis.Core.C).Assembly;
var ver = asm.GetName().Version?.ToString() ?? "0.0.0.0";
Console.WriteLine($"Theriapolis Tools — Core version {ver}");
return 0;
}
static int Unknown(string cmd)
{
Console.Error.WriteLine($"Unknown command: {cmd}");
PrintHelp();
return 1;
}
static void PrintHelp()
{
Console.WriteLine("Theriapolis Tools");
Console.WriteLine();
Console.WriteLine("Commands:");
Console.WriteLine(" hello Print version.");
Console.WriteLine(" worldgen-dump --seed <n> Run full pipeline and export biome+feature map PNG.");
Console.WriteLine(" --out <file.png>");
Console.WriteLine(" [--data-dir <dir>]");
Console.WriteLine(" [--show-violations]");
Console.WriteLine(" settlement-report --seed <n> Run full pipeline and print settlement report.");
Console.WriteLine(" [--data-dir <dir>]");
Console.WriteLine(" tile-inspect --seed <n> Print polylines/bridges near a reported tile.");
Console.WriteLine(" --tile X,Y (for debugging bug reports from the in-game overlay)");
Console.WriteLine(" [--radius N] (default 3)");
Console.WriteLine(" [--data-dir <dir>]");
Console.WriteLine(" tactical-dump --seed <n> Run full pipeline and export a tactical chunk PNG.");
Console.WriteLine(" --chunk cx,cy");
Console.WriteLine(" [--grid N] (NxN chunks centered on cx,cy; default 1)");
Console.WriteLine(" [--out <file.png>]");
Console.WriteLine(" [--data-dir <dir>]");
Console.WriteLine(" tile-analyze --dir <path> Vet a directory of 32x32 tile PNGs (typically");
Console.WriteLine(" a Pixellab tiles_pro download). Reports per-tile");
Console.WriteLine(" [--sheet <out.png>] border edges, opaque %, and shadow gradient scores.");
Console.WriteLine(" [--shadow-threshold N] Optionally writes a labeled contact sheet.");
Console.WriteLine(" [--upscale N] See theriapolis-tile-generation-handoff.md.");
Console.WriteLine(" content-validate Load every Phase 5 JSON content file and run");
Console.WriteLine(" [--data-dir <dir>] per-file + cross-file referential checks. CI gate.");
Console.WriteLine(" character-roll Build a sample character via CharacterBuilder and");
Console.WriteLine(" [--seed N] dump the resulting stat block. Useful for verifying");
Console.WriteLine(" [--clade ID] determinism, balance sweeps, and content edits.");
Console.WriteLine(" [--species ID] Defaults: canidae / wolf / fangsworn / pack_raised.");
Console.WriteLine(" [--class ID]");
Console.WriteLine(" [--background ID]");
Console.WriteLine(" [--name STR]");
Console.WriteLine(" [--roll] Use 4d6-drop-lowest (otherwise Standard Array).");
Console.WriteLine(" [--ms-override N] Pin the roll seed for reproducibility.");
Console.WriteLine(" combat-duel Run a deterministic scripted duel between two");
Console.WriteLine(" [--seed N] combatants and print the encounter log. Combatants");
Console.WriteLine(" [--a SPEC] may be NPC template ids (\"brigand_footpad\", \"wolf\")");
Console.WriteLine(" [--b SPEC] or character specs (\"char:canidae:wolf:fangsworn:");
Console.WriteLine(" [--rounds N] pack_raised\"). Defaults brigand_footpad vs wolf at");
Console.WriteLine(" seed 42 with a 20-round cap.");
Console.WriteLine(" settlement-render Phase 6 M0 — stamp a settlement and export PNG.");
Console.WriteLine(" [--seed N] Defaults seed=12345, first Tier-1 anchor.");
Console.WriteLine(" [--settlement S] Anchor name (\"millhaven\") or numeric id.");
Console.WriteLine(" [--pad N] Extra chunks of context around the settlement.");
Console.WriteLine(" [--out file.png] Output path.");
Console.WriteLine(" [--data-dir <dir>]");
Console.WriteLine(" dialogue-validate Phase 6 M3 — load every dialogues/*.json and run");
Console.WriteLine(" [--data-dir <dir>] structural + reachability checks. CI gate.");
Console.WriteLine(" quest-validate Phase 6 M4 — load every quests/*.json and run");
Console.WriteLine(" [--data-dir <dir>] structural + reachability checks. CI gate.");
Console.WriteLine(" dungeon-render Phase 7 M0 — render a single room template's");
Console.WriteLine(" --template ID ASCII grid to PNG. M1 will add full procedural");
Console.WriteLine(" --out file.png --seed/--poi assembly mode.");
Console.WriteLine(" [--cell N] (px per tactical tile; default 16)");
Console.WriteLine(" [--data-dir <dir>]");
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Theriapolis.Tools</RootNamespace>
<AssemblyName>Theriapolis.Tools</AssemblyName>
<LangVersion>12</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Theriapolis.Core\Theriapolis.Core.csproj" />
</ItemGroup>
<!-- Copy content data files to output -->
<ItemGroup>
<Content Include="..\Content\Data\**\*"
Link="Data\%(RecursiveDir)%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>