Run 14: reproduction from bonds, and towns anchored to the compass

Children from bonds (Phase B): death no longer auto-spawns a replacement;
bonded, fed, adult pairs conceive, logistic-damped toward a per-town
carrying capacity. Population is now dynamic - births vs deaths, a band
not a fixed count. Towns anchored to their cardinal seeds (build-centroid
70% weighted to the fixed compass point) so settlements stay planted
instead of drifting to map-center - North now founds at ~(388,139) by its
(384,130) seed vs West's prior 100+ cell drift.

Tuning took three passes (logged in FINDINGS): explosion, then die-out,
then a generational-gap bug where founders aged out of fertility before
their children matured. Fix: wide fertility window + generous rate.
Result: births 108 ~= deaths 104, healthy population band, and the
four-town thesis sharpened into demographics - North/South thrive
soul-clean, West shrinks with debris, East collapses to a starving
remnant. Selfishness now literally fails to sustain a population.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 19:03:01 -04:00
parent d7448c497d
commit 6cbe4bcf83
3 changed files with 129 additions and 46 deletions
+91 -42
View File
@@ -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;
}
/// <summary>A town's fixed cardinal seed — its permanent home on the
/// map, unaffected by where its people happen to wander.</summary>
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<Npc>();
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
/// <summary>
/// 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.
/// </summary>
/// <summary>Rough carrying capacity per town — beds plus a margin the
/// land can feed. Births taper toward zero as a town fills it.</summary>
private const int TownCarryingCapacity = 28;
private void TickReproduction()
{
var rng = new System.Random(Seed * 7 + (int)TotalTicks);
var newborns = new List<Npc>();
// Town populations, to damp births near carrying capacity.
var pop = new Dictionary<string, int>();
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);
}
/// <summary>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.");