7de76fa45d
CSVs now write to soak/ instead of the project root. A .gdignore there stops the Godot editor from importing CSV columns as translation files - which had been silently generating 50+ .translation and .import files per run. Only the .gdignore is tracked; the CSVs stay ignored. Root is clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
199 lines
8.8 KiB
C#
199 lines
8.8 KiB
C#
using System.Globalization;
|
|
using System.IO;
|
|
using System.Text;
|
|
using Godot;
|
|
|
|
namespace WorldSim;
|
|
|
|
/// <summary>
|
|
/// Daily stats to CSV (SPEC §11 behavioral validation).
|
|
/// One row per simulated day — the soak test's flight recorder.
|
|
/// Written to the project folder as soak_stats.csv, fresh each run.
|
|
/// </summary>
|
|
public static class StatsLogger
|
|
{
|
|
private static string _path = "";
|
|
private static string _npcPath = "";
|
|
|
|
private const string Header =
|
|
"day,season,wood,stone,clay,food," +
|
|
"harvested,trades," +
|
|
"avg_fatigue,avg_nourish,avg_health,carried," +
|
|
"gather_ticks,build_ticks,trade_ticks,rest_ticks,social_ticks," +
|
|
"shelters_done,ruins,avg_cond,upkeep_stock,burn_per_day,modules,granary_food," +
|
|
"avg_debris,refusals,incomplete_sites,avg_knowledge,deaths";
|
|
|
|
private const string NpcHeader =
|
|
"day,npc,name,soul,town,dominant,gather_ticks,build_ticks,trade_ticks,rest_ticks,social_ticks," +
|
|
"nourish,health,debris,demand,rough_nights,hunger_mem," +
|
|
"age,know_forage,know_wood,know_masonry";
|
|
|
|
private const string GiftHeader =
|
|
"day,giver,giver_soul,receiver,receiver_soul,material,amount";
|
|
|
|
private static string _giftPath = "";
|
|
|
|
public static void Init()
|
|
{
|
|
// Write into soak/ — a plain folder Godot doesn't import as assets
|
|
// (a .gdignore there stops the editor from turning CSV columns into
|
|
// translation files). Keeps generated debug output out of the way.
|
|
string dir = Path.Combine(ProjectSettings.GlobalizePath("res://"), "soak");
|
|
Directory.CreateDirectory(dir);
|
|
_path = Path.Combine(dir, "soak_stats.csv");
|
|
_npcPath = Path.Combine(dir, "soak_npc_days.csv");
|
|
_giftPath = Path.Combine(dir, "soak_gifts.csv");
|
|
File.WriteAllText(_path, Header + "\n");
|
|
File.WriteAllText(_npcPath, NpcHeader + "\n");
|
|
File.WriteAllText(_giftPath, GiftHeader + "\n");
|
|
GD.Print($"[Stats] Logging to {_path}, {_npcPath}, {_giftPath}");
|
|
}
|
|
|
|
/// <summary>Every gift, with names — so we can see who carries the town.</summary>
|
|
public static void LogGift(Npc giver, Npc receiver, MaterialKind material, float amount)
|
|
{
|
|
if (string.IsNullOrEmpty(_giftPath)) return;
|
|
int day = GameManager.Instance?.Day ?? -1;
|
|
File.AppendAllText(_giftPath,
|
|
$"{day},{giver.Name},{giver.Soul.Type},{receiver.Name},{receiver.Soul.Type}," +
|
|
$"{material},{amount.ToString("0.0", CultureInfo.InvariantCulture)}\n");
|
|
}
|
|
|
|
/// <summary>Every refusal too — the gift log is really a contact log.</summary>
|
|
public static void LogRefusal(Npc giver, Npc asker)
|
|
{
|
|
if (string.IsNullOrEmpty(_giftPath)) return;
|
|
int day = GameManager.Instance?.Day ?? -1;
|
|
File.AppendAllText(_giftPath,
|
|
$"{day},{giver.Name},{giver.Soul.Type},{asker.Name},{asker.Soul.Type},Refusal,0.0\n");
|
|
}
|
|
|
|
public static void LogDay(GameManager gm)
|
|
{
|
|
var ci = CultureInfo.InvariantCulture;
|
|
|
|
// World resource totals
|
|
float wood = 0f, stone = 0f, clay = 0f, food = 0f;
|
|
foreach (var n in gm.World.Nodes)
|
|
{
|
|
switch (n.Kind)
|
|
{
|
|
case MaterialKind.Wood: wood += n.Amount; break;
|
|
case MaterialKind.Stone: stone += n.Amount; break;
|
|
case MaterialKind.Clay: clay += n.Amount; break;
|
|
case MaterialKind.Food: food += n.Amount; break;
|
|
}
|
|
}
|
|
|
|
// Villagers — state time from full-day tick counts, not a midnight
|
|
// snapshot (the night rule made snapshots useless).
|
|
float fatigue = 0f, nourish = 0f, health = 0f, carried = 0f;
|
|
int gathering = 0, building = 0, trading = 0, resting = 0, socializing = 0;
|
|
foreach (var npc in gm.Npcs)
|
|
{
|
|
fatigue += npc.Fatigue;
|
|
nourish += npc.Nourishment;
|
|
health += npc.Health;
|
|
carried += npc.TotalCarried();
|
|
gathering += npc.StateTicksToday[(int)NpcState.Gathering];
|
|
building += npc.StateTicksToday[(int)NpcState.Building];
|
|
trading += npc.StateTicksToday[(int)NpcState.Trading];
|
|
resting += npc.StateTicksToday[(int)NpcState.Resting];
|
|
socializing += npc.StateTicksToday[(int)NpcState.Socializing];
|
|
}
|
|
int count = Mathf.Max(1, gm.Npcs.Count);
|
|
|
|
// Buildings
|
|
int done = 0, ruins = 0, modules = 0;
|
|
float cond = 0f, stock = 0f, burn = 0f, granaryFood = 0f;
|
|
foreach (var b in gm.Buildings)
|
|
{
|
|
if (b.IsRuined) { ruins++; continue; }
|
|
if (!b.IsComplete) continue;
|
|
if (b.Kind == BuildingKind.Granary) { granaryFood += b.FoodStock; continue; }
|
|
done++;
|
|
cond += b.Condition;
|
|
stock += b.UpkeepStock;
|
|
burn += b.DailyBurn;
|
|
modules += b.Modules;
|
|
}
|
|
float avgCond = done > 0 ? cond / done : 0f;
|
|
|
|
var row = new StringBuilder();
|
|
row.Append(gm.Day).Append(',');
|
|
row.Append(gm.SeasonName).Append(',');
|
|
row.Append(wood.ToString("0.0", ci)).Append(',');
|
|
row.Append(stone.ToString("0.0", ci)).Append(',');
|
|
row.Append(clay.ToString("0.0", ci)).Append(',');
|
|
row.Append(food.ToString("0.0", ci)).Append(',');
|
|
row.Append(gm.World.HarvestedToday.ToString("0.0", ci)).Append(',');
|
|
row.Append(TradeSystem.TradesToday).Append(',');
|
|
row.Append((fatigue / count).ToString("0.0", ci)).Append(',');
|
|
row.Append((nourish / count).ToString("0.000", ci)).Append(',');
|
|
row.Append((health / count).ToString("0.0", ci)).Append(',');
|
|
row.Append(carried.ToString("0.0", ci)).Append(',');
|
|
row.Append(gathering).Append(',').Append(building).Append(',')
|
|
.Append(trading).Append(',').Append(resting).Append(',').Append(socializing).Append(',');
|
|
row.Append(done).Append(',').Append(ruins).Append(',');
|
|
row.Append(avgCond.ToString("0.000", ci)).Append(',');
|
|
row.Append(stock.ToString("0.0", ci)).Append(',');
|
|
row.Append(burn.ToString("0.0", ci)).Append(',');
|
|
row.Append(modules).Append(',');
|
|
row.Append(granaryFood.ToString("0.0", ci)).Append(',');
|
|
|
|
float debrisSum = 0f;
|
|
foreach (var npc in gm.Npcs) debrisSum += npc.Imprint.Total;
|
|
row.Append((debrisSum / count).ToString("0.0000", ci)).Append(',');
|
|
row.Append(TradeSystem.RefusalsToday).Append(',');
|
|
|
|
int incomplete = 0;
|
|
foreach (var b in gm.Buildings) if (!b.IsComplete) incomplete++;
|
|
row.Append(incomplete).Append(',');
|
|
|
|
float knowSum = 0f;
|
|
foreach (var npc in gm.Npcs) knowSum += npc.Know.Total;
|
|
row.Append((knowSum / count).ToString("0.000", ci)).Append(',');
|
|
row.Append(gm.DeathsToday);
|
|
|
|
File.AppendAllText(_path, row + "\n");
|
|
|
|
// Per-villager day rows: what each person mostly did — the raw
|
|
// material of emergent roles ("the role follows from the life").
|
|
var npcRows = new StringBuilder();
|
|
for (int i = 0; i < gm.Npcs.Count; i++)
|
|
{
|
|
var npc = gm.Npcs[i];
|
|
var t = npc.StateTicksToday;
|
|
npcRows.Append(gm.Day).Append(',')
|
|
.Append(i + 1).Append(',')
|
|
.Append(npc.Name).Append(',')
|
|
.Append(npc.Soul.Type).Append(',')
|
|
.Append(npc.HomeTown).Append(',')
|
|
.Append(npc.DominantStateToday()).Append(',')
|
|
.Append(t[(int)NpcState.Gathering]).Append(',')
|
|
.Append(t[(int)NpcState.Building]).Append(',')
|
|
.Append(t[(int)NpcState.Trading]).Append(',')
|
|
.Append(t[(int)NpcState.Resting]).Append(',')
|
|
.Append(t[(int)NpcState.Socializing]).Append(',')
|
|
.Append(npc.Nourishment.ToString("0.000", ci)).Append(',')
|
|
.Append(npc.Health.ToString("0.0", ci)).Append(',')
|
|
.Append(npc.Imprint.Total.ToString("0.0000", ci)).Append(',')
|
|
.Append(npc.CurrentDemand(gm)).Append(',')
|
|
.Append(npc.RoughNights).Append(',')
|
|
.Append(npc.HungerMemory ? 1 : 0).Append(',')
|
|
.Append(npc.AgeDays.ToString("0", ci)).Append(',')
|
|
.Append(npc.Know[KnowledgeDomain.Forage].ToString("0.000", ci)).Append(',')
|
|
.Append(npc.Know[KnowledgeDomain.Woodcraft].ToString("0.000", ci)).Append(',')
|
|
.Append(npc.Know[KnowledgeDomain.Masonry].ToString("0.000", ci)).Append('\n');
|
|
System.Array.Clear(npc.StateTicksToday, 0, npc.StateTicksToday.Length);
|
|
}
|
|
File.AppendAllText(_npcPath, npcRows.ToString());
|
|
|
|
// Reset per-day counters
|
|
gm.World.HarvestedToday = 0f;
|
|
TradeSystem.TradesToday = 0;
|
|
TradeSystem.RefusalsToday = 0;
|
|
gm.DeathsToday = 0;
|
|
}
|
|
}
|