diff --git a/FINDINGS.md b/FINDINGS.md index 8b4dd95..13e5335 100644 --- a/FINDINGS.md +++ b/FINDINGS.md @@ -300,8 +300,39 @@ is hungriest (teaches no one, shares nothing). 99 bonds, 80 deaths — a living, pairing, generational population in four distinct societies, all from behavior weights. Nothing is scripted; the cultures are emergent. -**Next:** reproduction on top of bonds (Phase B); crisis-recovery -experiment (North's resilience); save/load; then the player pivot. +## Run 14 — Reproduction, and towns anchored to the compass + +**Built:** children from bonds (Phase B). Death no longer auto-spawns a +replacement — people die and are gone; bonded, fed, adult pairs conceive +(logistic-damped toward a per-town carrying capacity, so population is a +band, not an explosion or a collapse). Population is now genuinely +dynamic: births vs deaths. Also anchored towns to their cardinal seeds — +the build-centroid is now 70% weighted to the fixed compass point, so +settlements stay planted (North founds at ~(388,139) by its (384,130) +seed) instead of drifting to map-center where foraging paths overlap +(West had wandered 100+ cells before). + +**Tuning story (three failed calibrations, logged as the lesson):** +first pass exploded (80→211 by day 80 — young founders, nobody dying, +every couple breeding); over-damped to fix it and the world died out +(80→6, East extinct); the real bug was a *generational gap* — founders +aged out of fertility before their children matured and bonded, so +births stopped even as room opened. Fix: wide fertility window (to 85% +of life) + generous rate (0.12/couple/day × room). Population dynamics +are a control loop; the birth rate must track the death rate or the +system runs away in whichever direction it's biased. + +**Result — reproduction sharpens the four-town thesis:** births (108) ≈ +deaths (104), population settled in a healthy band (~68–109). And the +cultures now diverge in *demographics*, not just soul-state: North (26) +and South (22) thrive and stay soul-clean; West shrinks (16) with debris +creeping in; **East collapses to 4, starving (nourishment 0.00), highest +debris — selfishness now literally fails to sustain a population.** A +town that won't share or cooperate can't feed its people or raise a next +generation. The most damning verdict the sim has produced. + +**Next:** crisis-recovery experiment (North's resilience); save/load; +then the player pivot. --- diff --git a/scripts/GameManager.cs b/scripts/GameManager.cs index 6d613d7..bc8c508 100644 --- a/scripts/GameManager.cs +++ b/scripts/GameManager.cs @@ -19,7 +19,7 @@ public partial class GameManager : Node2D // 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 = 20; // per settlement (~80 total) + [Export] public int TownPopulationEach = 16; // per settlement (~64 total, room to grow) public record TownDef(string Name, Vector2 Center, SoulType Soul); @@ -151,6 +151,15 @@ public partial class GameManager : Node2D 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); + } + public Vector2 TownCentroid(string town) { Vector2 sum = Vector2.Zero; @@ -161,13 +170,13 @@ public partial class GameManager : Node2D 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; + if (count == 0) return TownAnchor(town); + + // Anchor-weighted: the settlement's build-center stays near its + // cardinal seed even as villagers roam outward to distant resources. + // Towns keep their place on the compass instead of drifting to the + // middle of the map where everyone's foraging paths overlap. + return (sum / count).Lerp(TownAnchor(town), 0.7f); } public override void _Process(double delta) @@ -210,63 +219,102 @@ public partial class GameManager : Node2D } } - // --- Mortality: generations end, and what wasn't passed on ends too -- + // --- 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 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; + 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, and stored. + // What survives is what they taught, built, stored — and bore. 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); + GD.Print($"[Passing] day {Day}: {dead.Name} dies at {dead.AgeDays:0} days " + + $"(knowledge {dead.Know.Total:0.00} lost)."); + } + } - var youth = new Npc + /// + /// 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 = $"{dead.HomeTown}_{(char)('A' + _bornCount % 26)}{_bornCount + 25}", - HomeTown = dead.HomeTown, - Soul = dead.Soul, // raised in the culture that raised them + 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 = cradle + new Vector2(rng.Next(-4, 5), rng.Next(-4, 5)), + Position = a.Position + new Vector2(rng.Next(-3, 4), rng.Next(-3, 4)), 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."); + child.Know.Capacity = 1.0f + (float)rng.NextDouble(); + 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); } /// Where the community's feet actually are — new communal @@ -298,6 +346,7 @@ public partial class GameManager : Node2D foreach (var npc in Npcs) npc.FadeEchoes(0.005f); TickMortality(); + TickReproduction(); StatsLogger.LogDay(this); if (Day % 10 == 0) GD.Print($"[WorldSim] Day {Day} complete."); diff --git a/scripts/StatsLogger.cs b/scripts/StatsLogger.cs index 29c22fb..aeb3c5c 100644 --- a/scripts/StatsLogger.cs +++ b/scripts/StatsLogger.cs @@ -21,7 +21,7 @@ public static class StatsLogger "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"; + "avg_debris,refusals,incomplete_sites,avg_knowledge,deaths,births,population"; private const string NpcHeader = "day,npc,name,soul,town,dominant,gather_ticks,build_ticks,trade_ticks,rest_ticks,social_ticks," + @@ -153,7 +153,9 @@ public static class StatsLogger 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); + row.Append(gm.DeathsToday).Append(','); + row.Append(gm.BirthsToday).Append(','); + row.Append(gm.Npcs.Count); File.AppendAllText(_path, row + "\n"); @@ -194,5 +196,6 @@ public static class StatsLogger TradeSystem.TradesToday = 0; TradeSystem.RefusalsToday = 0; gm.DeathsToday = 0; + gm.BirthsToday = 0; } }