9e8ee1d748
Shelters now wear their town's culture color on the map (North cool-blue, South green, West gold, East red), dimmed by wear; granaries stay green, ruins dark. And bonds can now cross town lines: usually you pair within your own people (that's who you're near), but a wanderer who warms to someone from another town shacks up across cultures - the first quiet thread of contact between two peoples. When two towns share a roof, the house is drawn split diagonally, half each culture, visible across the map. Mechanics: buildings track their top-two resident home-towns (recomputed with occupancy, throttled to every 30 ticks since it's map-only); the bonding rule dropped its same-town requirement. A 240-day run produced 126 bonds, 40 of them cross-town (East+North, West+South, all combos), with populations still balanced - inter-cultural contact without destabilizing the towns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
442 lines
17 KiB
C#
442 lines
17 KiB
C#
using System.Collections.Generic;
|
|
using Godot;
|
|
|
|
namespace WorldSim;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<TownDef> Towns => _towns;
|
|
private readonly List<TownDef> _towns = new()
|
|
{
|
|
new("North", new Vector2(384f, 130f), SoulType.Generational),
|
|
new("South", new Vector2(384f, 638f), SoulType.Nature),
|
|
new("West", new Vector2(130f, 384f), SoulType.Materialist),
|
|
new("East", new Vector2(638f, 384f), SoulType.Selfish),
|
|
};
|
|
|
|
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
|
|
|
|
/// <summary>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.</summary>
|
|
[Export] public int StopAfterDays = 0;
|
|
|
|
public World World { get; private set; } = null!;
|
|
public List<Npc> Npcs { get; } = new();
|
|
public List<Building> Buildings { get; } = new();
|
|
|
|
/// <summary>Rebuilt each tick; proximity queries go through it so the
|
|
/// hot loops stay near-linear as population grows.</summary>
|
|
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",
|
|
};
|
|
|
|
/// <summary>Food regen multiplier — winter gives almost nothing.</summary>
|
|
public float SeasonFoodMult => SeasonIndex switch
|
|
{
|
|
0 => 1.5f, 1 => 1.0f, 2 => 0.8f, _ => 0.05f,
|
|
};
|
|
|
|
/// <summary>Wood regen multiplier — growth slows in the cold.</summary>
|
|
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();
|
|
// 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);
|
|
}
|
|
|
|
/// <summary>How many villagers (other than <paramref name="except"/>)
|
|
/// are currently sleeping at this shelter.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
|
|
/// <summary>The soul culture of a named town (for map coloring).</summary>
|
|
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)
|
|
{
|
|
Vector2 sum = Vector2.Zero;
|
|
int count = 0;
|
|
foreach (var npc in Npcs)
|
|
{
|
|
if (npc.HomeTown != town) continue;
|
|
sum += npc.Position;
|
|
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)
|
|
{
|
|
_tickAccumulator += delta * TicksPerRealSecond;
|
|
while (_tickAccumulator >= 1.0)
|
|
{
|
|
_tickAccumulator -= 1.0;
|
|
Tick();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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 = a.Position.DistanceTo(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).");
|
|
}
|
|
}
|
|
|
|
/// <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 = $"{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);
|
|
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>
|
|
/// 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.
|
|
/// </summary>
|
|
private void RecomputeHouseholds()
|
|
{
|
|
var counts = new Dictionary<Building, Dictionary<string, int>>();
|
|
|
|
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 = npc.Position.DistanceSquaredTo(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<string, int>();
|
|
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 : "";
|
|
}
|
|
}
|
|
|
|
/// <summary>Where the community's feet actually are — new communal
|
|
/// buildings are founded here, not at a scripted town center.</summary>
|
|
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 = npc.Position.DistanceSquaredTo(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();
|
|
}
|
|
}
|
|
}
|
|
}
|