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:
@@ -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}]" : "")}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 <n> [--data-dir <dir>]
|
||||
///
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
@@ -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 <n> --chunk cx,cy --out <file.png> [--data-dir <dir>]
|
||||
///
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
@@ -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 <path> [--sheet <out.png>]
|
||||
/// tile-analyze --files <f1.png> <f2.png> ... [--sheet <out.png>]
|
||||
///
|
||||
/// 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 (0–4)</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 30–70%.
|
||||
///
|
||||
/// • <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 > 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 > 0, opaque < 95%, or shadow > 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 (0–4) 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 (0–100) 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.");
|
||||
}
|
||||
}
|
||||
@@ -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 <n> --tile X,Y [--radius N] [--data-dir <dir>]
|
||||
///
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
@@ -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 <n> --out <file.png> [--data-dir <dir>]
|
||||
///
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user