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 TownPopulationEach = 16; // per settlement (~64 total, room to grow) public record TownDef(string Name, Vector2 Center, SoulType Soul); // Four towns at the compass points of the world, each its own culture // (per DESIGN.md): North reveres lineage, South lives with the land, // West performs material wealth, East schemes for self. public IReadOnlyList Towns => _towns; private readonly List _towns = new() { // 2×2 even grid on the torus: towns at the quarter/three-quarter // points of each axis (320 and 960 on a 1280 world). Every town is // exactly 640 units from each of its two axis-neighbours in all // directions, wrap included — no edges, no corners, no town boxed in. new("North", new Vector2(320f, 320f), SoulType.Generational), new("East", new Vector2(960f, 320f), SoulType.Selfish), new("West", new Vector2(320f, 960f), SoulType.Materialist), new("South", new Vector2(960f, 960f), SoulType.Nature), }; private static SoulProfile ProfileFor(SoulType t) => t switch { SoulType.Generational => SoulProfile.GenerationalSoul(), SoulType.Nature => SoulProfile.NatureSoul(), SoulType.Materialist => SoulProfile.MaterialistSoul(), _ => SoulProfile.SelfishSoul(), }; [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(); /// Rebuilt each tick; proximity queries go through it so the /// hot loops stay near-linear as population grows. public SpatialGrid Grid { get; } = new(cellSize: 12f); 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 = Toroidal.Wrap(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(); // Founders know their home ground — the resources around the // town seed. They begin with a working local map, not blind. foreach (var node in World.NodesWithin(center, 130f)) npc.KnownNodes.Add(node); Npcs.Add(npc); } } foreach (var town in _towns) Spawn(town.Name, town.Center, ProfileFor(town.Soul), TownPopulationEach, town.Name); } /// 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 && Toroidal.Distance(npc.Position, 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; } /// A town's fixed cardinal seed — its permanent home on the /// map, unaffected by where its people happen to wander. public Vector2 TownAnchor(string town) { foreach (var t in _towns) if (t.Name == town) return t.Center; return new Vector2(World.GridSize / 2f, World.GridSize / 2f); } /// The soul culture of a named town (for map coloring). public SoulType TownSoul(string town) { foreach (var t in _towns) if (t.Name == town) return t.Soul; return SoulType.Nature; } public Vector2 TownCentroid(string town) { // Averaged in ANCHOR-RELATIVE torus deltas, not raw positions — // averaging absolute coordinates across the wrap seam gives garbage // (two people on opposite edges are neighbours, but their coordinates // average to the far side of the world). Deltas keep the seam honest. Vector2 anchor = TownAnchor(town); Vector2 sum = Vector2.Zero; int count = 0; foreach (var npc in Npcs) { if (npc.HomeTown != town) continue; sum += Toroidal.Delta(anchor, npc.Position); count++; } if (count == 0) return anchor; // Anchor-weighted (0.7 toward the seed) so a settlement keeps its // place as villagers roam, then wrapped back into the world. Vector2 mean = sum / count; return Toroidal.Wrap(anchor + mean * 0.3f); } 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; // Spatial grid: each soul presses only on the few within reach, // not on everyone. Directed pass (a→b for every nearby b) so each // ordered pair is handled once from the source's side. foreach (var a in Npcs) { Grid.ForEachNear(a.Position, radius, b => { if (b == a) return; float dist = Toroidal.Distance(a.Position, b.Position); if (dist > radius) return; 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); }); } } // --- Mortality & birth: generations end, and new ones are born -------- // Death no longer auto-spawns a replacement. People die and are simply // gone; children come from bonded pairs (TickReproduction). Population // is now births vs deaths — a town can grow, shrink, or die out. public int DeathsToday { get; set; } public int BirthsToday { get; set; } private int _bornCount; private void TickMortality() { var rng = new System.Random(Seed + (int)TotalTicks); foreach (var dead in Npcs.ToArray()) { dead.AgeDays += 1f; if (dead.AgeDays < dead.LifeExpectancyDays) continue; Npcs.Remove(dead); DeathsToday++; // Everything unshared dies with them: skills, memories, echoes. // What survives is what they taught, built, stored — and bore. foreach (var other in Npcs) other.ForgetPerson(dead); GD.Print($"[Passing] day {Day}: {dead.Name} dies at {dead.AgeDays:0} days " + $"(knowledge {dead.Know.Total:0.00} lost)."); } } /// /// Children come from bonds (Phase B). A bonded pair — both grown adults, /// both fed, with a roof in town — may conceive once per season. The /// child spawns beside its parents, inherits their town and culture, and /// begins knowing almost nothing but growing up among those who do. This /// is how a town persists: not by respawn-on-death, but by families. /// /// Rough carrying capacity per town — beds plus a margin the /// land can feed. Births taper toward zero as a town fills it. private const int TownCarryingCapacity = 28; private void TickReproduction() { var rng = new System.Random(Seed * 7 + (int)TotalTicks); var newborns = new List(); // Town populations, to damp births near carrying capacity. var pop = new Dictionary(); foreach (var npc in Npcs) pop[npc.HomeTown] = pop.GetValueOrDefault(npc.HomeTown) + 1; foreach (var a in Npcs) { var b = a.Partner; if (b == null) continue; if (a.GetHashCode() >= b.GetHashCode()) continue; // count each pair once if (!a.IsAdult || !b.IsAdult) continue; if (a.Nourishment < 0.5f || b.Nourishment < 0.5f) continue; // both fed // Fertile from adulthood through most of life — a wide window so // generations overlap and a town isn't one bad cohort from ruin. if (a.AgeDays > a.LifeExpectancyDays * 0.85f) continue; // Logistic damping: a full town barely breeds; an emptied one // (after a plague) breeds back toward capacity. This is what // keeps population in a band instead of exploding or dying out. float townPop = pop.GetValueOrDefault(a.HomeTown); float roomFactor = Mathf.Clamp(1f - townPop / TownCarryingCapacity, 0f, 1f); if (roomFactor <= 0f) continue; // High enough to outrun the founding cohort's die-off and keep // generations overlapping; logistic damping caps the ceiling so // it can't explode. Deliberately generous — a shrinking town is // a worse failure than a full one. double dailyChance = 0.12 * roomFactor; if (rng.NextDouble() > dailyChance) continue; _bornCount++; var child = new Npc { Name = $"{a.HomeTown}_{(char)('a' + _bornCount % 26)}{_bornCount + 25}", HomeTown = a.HomeTown, Soul = a.Soul, // raised in the culture that bore them PersonalityModifier = (float)(rng.NextDouble() * 0.4 - 0.2), Position = a.Position + new Vector2(rng.Next(-3, 4), rng.Next(-3, 4)), AgeDays = 0f, LifeExpectancyDays = 150 + rng.Next(0, 80), }; child.Know.Capacity = 1.0f + (float)rng.NextDouble(); // A child inherits its parents' map — the family's known grounds, // passed down like everything else a generation hands forward. foreach (var node in a.KnownNodes) child.KnownNodes.Add(node); foreach (var node in b.KnownNodes) child.KnownNodes.Add(node); // A child also inherits the family's known buildings — the homes // and stores of the people who raised them. foreach (var s in a.KnownStructures) child.KnownStructures.Add(s); foreach (var s in b.KnownStructures) child.KnownStructures.Add(s); newborns.Add(child); BirthsToday++; pop[a.HomeTown] = (int)townPop + 1; // count the newborn toward capacity GD.Print($"[Birth] day {Day}: {a.Name} & {b.Name} ({a.HomeTown}) → {child.Name}."); } Npcs.AddRange(newborns); } /// /// Tally which home-towns sleep under each shelter, storing the top two. /// A house with residents from two towns is a mixed household — someone /// shacked up across cultures — and gets drawn split on the map. /// private void RecomputeHouseholds() { var counts = new Dictionary>(); foreach (var npc in Npcs) { if (npc.State != NpcState.Resting) continue; Building? at = null; float best = 6.25f; foreach (var b in Buildings) { if (b.RestCapacity <= 0) continue; float d = Toroidal.DistanceSquared(npc.Position, b.Site); if (d < best) { best = d; at = b; } } if (at == null) continue; if (!counts.TryGetValue(at, out var byTown)) counts[at] = byTown = new Dictionary(); byTown[npc.HomeTown] = byTown.GetValueOrDefault(npc.HomeTown) + 1; } foreach (var b in Buildings) { b.ResidentTownA = ""; b.ResidentTownB = ""; if (!counts.TryGetValue(b, out var byTown)) continue; string a = "", bTown = ""; int ca = 0, cb = 0; foreach (var kv in byTown) { if (kv.Value > ca) { bTown = a; cb = ca; a = kv.Key; ca = kv.Value; } else if (kv.Value > cb) { bTown = kv.Key; cb = kv.Value; } } b.ResidentTownA = a; b.ResidentTownB = cb > 0 ? bTown : ""; } } /// 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() { Grid.Rebuild(Npcs); // Recompute shelter occupancy once per tick (was O(n²): each resting // NPC re-scanned everyone, per building). Now: one pass, cached — each // sleeper counts toward the nearest shelter they're actually inside. foreach (var b in Buildings) b.Occupancy = 0; foreach (var npc in Npcs) { if (npc.State != NpcState.Resting) continue; Building? at = null; float best = 6.25f; // within 2.5 units foreach (var b in Buildings) { if (b.RestCapacity <= 0) continue; float d = Toroidal.DistanceSquared(npc.Position, b.Site); if (d < best) { best = d; at = b; } } if (at != null) at.Occupancy++; } // Household composition (which cultures share each roof) — for the // map only, so it's cheap and refreshed occasionally, not every tick. if (TotalTicks % 30 == 0) RecomputeHouseholds(); 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(); TickReproduction(); StatsLogger.LogDay(this); if (Day % 10 == 0) GD.Print($"[WorldSim] Day {Day} complete (pop {Npcs.Count})."); if (StopAfterDays > 0 && Day >= StopAfterDays) { GD.Print($"[WorldSim] Reached day {Day} — stopping."); GetTree().Quit(); } } } }