using System.Collections.Generic; using Godot; namespace WorldSim; /// /// Main loop and time system (SPEC §9). /// 1 tick = 1 in-game minute; 1440 ticks = 1 day. /// Day/night drives activity bias: gather/build by day, rest/socialize by night. /// public partial class GameManager : Node2D { public static GameManager? Instance { get; private set; } [Export] public int Seed = 12345; // --- Settlements ------------------------------------------------------ // Expansion phase: multiple towns, each a soul culture with its own // centroid, buildings, and granary. NPCs affiliate with a home town // but roam freely — the space between towns is where cultures meet. [Export] public int SouthPopulation = 12; // nature souls [Export(PropertyHint.Range, "0,15,1")] public int SelfishCount = 3; // east souls living in the South [Export] public int NorthPopulation = 10; // generational souls public record TownDef(string Name, Vector2 Center, SoulType Soul); public IReadOnlyList Towns => _towns; private readonly List _towns = new() { new("South", new Vector2(128f, 190f), SoulType.Nature), new("North", new Vector2(128f, 62f), SoulType.Generational), }; [Export(PropertyHint.Range, "1,2000,1")] public float TicksPerRealSecond = 20f; // sim speed; ~1500 ≈ one day/sec for soaks /// Auto-quit after this many days (0 = run forever). Set for /// headless soaks so runs self-terminate cleanly — no external kill, no /// orphaned process racing the next run's CSV writes. [Export] public int StopAfterDays = 0; public World World { get; private set; } = null!; public List Npcs { get; } = new(); public List Buildings { get; } = new(); public long TotalTicks { get; private set; } public int Day => (int)(TotalTicks / 1440); public int MinuteOfDay => (int)(TotalTicks % 1440); public bool IsNight => MinuteOfDay < 6 * 60 || MinuteOfDay >= 22 * 60; // --- Seasons: the pressure that makes community structure matter ------ [Export] public int DaysPerSeason = 30; public int SeasonIndex => (Day / DaysPerSeason) % 4; public string SeasonName => SeasonIndex switch { 0 => "spring", 1 => "summer", 2 => "autumn", _ => "winter", }; /// Food regen multiplier — winter gives almost nothing. public float SeasonFoodMult => SeasonIndex switch { 0 => 1.5f, 1 => 1.0f, 2 => 0.8f, _ => 0.05f, }; /// Wood regen multiplier — growth slows in the cold. public float SeasonWoodMult => SeasonIndex switch { 0 => 1.2f, 1 => 1.0f, 2 => 1.0f, _ => 0.4f, }; private double _tickAccumulator; public override void _Ready() { Instance = this; TotalTicks = 8 * 60; // start at 08:00 — day one begins mid-morning, not asleep World = new World(Seed); World.Generate(); SpawnNpcs(); StatsLogger.Init(); GD.Print($"[WorldSim] World generated (seed {Seed}), {Npcs.Count} NPCs dropped in."); } private void SpawnNpcs() { // Dropped near their town's seed point with scatter — no scripted // layout. Where each community actually forms is the experiment. var rng = new System.Random(Seed); void Spawn(string town, Vector2 center, SoulProfile soul, int count, string prefix) { for (int i = 0; i < count; i++) { var npc = new Npc { Name = $"{prefix}_{(char)('A' + Npcs.Count % 26)}{(Npcs.Count >= 26 ? Npcs.Count.ToString() : "")}", HomeTown = town, Soul = soul, PersonalityModifier = (float)(rng.NextDouble() * 0.4 - 0.2), Position = center + new Vector2(rng.Next(-12, 13), rng.Next(-12, 13)), // Staggered ages so the founding generation doesn't die // in one terrible week. AgeDays = rng.Next(0, 100), LifeExpectancyDays = 150 + rng.Next(0, 80), }; npc.Know.Capacity = 1.0f + (float)rng.NextDouble(); Npcs.Add(npc); } } foreach (var town in _towns) { if (town.Name == "South") { Spawn(town.Name, town.Center, SoulProfile.NatureSoul(), SouthPopulation, "South"); for (int i = 0; i < SelfishCount; i++) Spawn(town.Name, town.Center, SoulProfile.SelfishSoul(), 1, "South"); } else if (town.Name == "North") { Spawn(town.Name, town.Center, SoulProfile.GenerationalSoul(), NorthPopulation, "North"); } } } /// How many villagers (other than ) /// are currently sleeping at this shelter. public int RestingOccupancy(Building b, Npc? except = null) { int count = 0; foreach (var npc in Npcs) if (npc != except && npc.State == NpcState.Resting && npc.Position.DistanceTo(b.Site) <= 2.5f) count++; return count; } public int TownPopulation(string town) { int count = 0; foreach (var npc in Npcs) if (npc.HomeTown == town) count++; return count; } public Vector2 TownCentroid(string town) { Vector2 sum = Vector2.Zero; int count = 0; foreach (var npc in Npcs) { if (npc.HomeTown != town) continue; sum += npc.Position; count++; } if (count == 0) { foreach (var t in _towns) if (t.Name == town) return t.Center; return new Vector2(World.GridSize / 2f, World.GridSize / 2f); } return sum / count; } public override void _Process(double delta) { _tickAccumulator += delta * TicksPerRealSecond; while (_tickAccumulator >= 1.0) { _tickAccumulator -= 1.0; Tick(); } } /// /// Soul-to-soul atmospheric pressure (ported from the Unity POC's /// SoulFieldSystem): souls in proximity press on each other's /// atmospheres. An encased soul darkens the room; a clear soul makes /// it slightly easier for everyone near them to radiate. Both /// directions apply; the net effect depends on relative debris. /// private void ApplySoulPressure() { const float radius = 8f; const float strength = 0.00004f; for (int i = 0; i < Npcs.Count; i++) { for (int j = i + 1; j < Npcs.Count; j++) { var a = Npcs[i]; var b = Npcs[j]; float dist = a.Position.DistanceTo(b.Position); if (dist > radius) continue; float falloff = 1f - dist / radius; float fromA = Mathf.Lerp(-strength, strength, a.Imprint.Total) * falloff; b.Imprint.ExternalPerception = Mathf.Clamp(b.Imprint.ExternalPerception + fromA, 0f, 1f); float fromB = Mathf.Lerp(-strength, strength, b.Imprint.Total) * falloff; a.Imprint.ExternalPerception = Mathf.Clamp(a.Imprint.ExternalPerception + fromB, 0f, 1f); } } } // --- Mortality: generations end, and what wasn't passed on ends too -- public int DeathsToday { get; set; } private int _bornCount; private void TickMortality() { var dying = new List(); foreach (var npc in Npcs) { npc.AgeDays += 1f; if (npc.AgeDays >= npc.LifeExpectancyDays) dying.Add(npc); } var rng = new System.Random(Seed + (int)TotalTicks); foreach (var dead in Npcs.ToArray()) { if (!dying.Contains(dead)) continue; Npcs.Remove(dead); DeathsToday++; // Everything unshared dies with them: skills, memories, echoes. // What survives is what they taught, built, and stored. foreach (var other in Npcs) other.ForgetPerson(dead); // A youth comes of age in the same town, same culture, knowing // almost nothing — but raised among the living, not in a void. // They spawn beside the town's wisest elder, so the young begin // life within reach of what the community still remembers. The // town's knowledge is only what its living carry — but the young // start where that knowledge is. _bornCount++; Npc? elder = null; float bestKnow = -1f; foreach (var other in Npcs) if (other.HomeTown == dead.HomeTown && other.Know.Total > bestKnow) { bestKnow = other.Know.Total; elder = other; } Vector2 cradle = elder?.Position ?? TownCentroid(dead.HomeTown); var youth = new Npc { Name = $"{dead.HomeTown}_{(char)('A' + _bornCount % 26)}{_bornCount + 25}", HomeTown = dead.HomeTown, Soul = dead.Soul, // raised in the culture that raised them PersonalityModifier = (float)(rng.NextDouble() * 0.4 - 0.2), Position = cradle + new Vector2(rng.Next(-4, 5), rng.Next(-4, 5)), AgeDays = 0f, LifeExpectancyDays = 150 + rng.Next(0, 80), }; youth.Know.Capacity = 1.0f + (float)rng.NextDouble(); Npcs.Add(youth); GD.Print($"[Passing] day {Day}: {dead.Name} dies at {dead.AgeDays:0} days " + $"(knowledge {dead.Know.Total:0.00} lost); {youth.Name} comes of age."); } } /// Where the community's feet actually are — new communal /// buildings are founded here, not at a scripted town center. public Vector2 CommunityCentroid() { if (Npcs.Count == 0) return new Vector2(World.GridSize / 2f, World.GridSize / 2f); Vector2 sum = Vector2.Zero; foreach (var npc in Npcs) sum += npc.Position; return sum / Npcs.Count; } private void Tick() { TotalTicks++; foreach (var npc in Npcs) npc.Tick(World, this); ApplySoulPressure(); if (TotalTicks % 1440 == 0) { World.RegenerateDaily(SeasonFoodMult, SeasonWoodMult); foreach (var building in Buildings) building.DailyUpkeep(); foreach (var npc in Npcs) npc.FadeEchoes(0.005f); TickMortality(); StatsLogger.LogDay(this); if (Day % 10 == 0) GD.Print($"[WorldSim] Day {Day} complete."); if (StopAfterDays > 0 && Day >= StopAfterDays) { GD.Print($"[WorldSim] Reached day {Day} — stopping."); GetTree().Quit(); } } } }