using System.Globalization; using System.IO; using System.Text; using Godot; namespace WorldSim; /// /// 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. /// 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"; private const string NpcHeader = "day,npc,name,soul,dominant,gather_ticks,build_ticks,trade_ticks,rest_ticks,social_ticks"; private const string GiftHeader = "day,giver,giver_soul,receiver,receiver_soul,material,amount"; private static string _giftPath = ""; public static void Init() { string dir = ProjectSettings.GlobalizePath("res://"); _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}"); } /// Every gift, with names — so we can see who carries the town. 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"); } 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)); 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.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('\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; } }