eb4b2fdd19
The map is now a torus - walk off any edge and reappear on the opposite side, no edges or corners. A new Toroidal helper carries all wrap-aware distance/direction/movement math, and every position comparison in the sim routes through it: soul pressure, node + structure sensing, gift/teacher/ neighbor scans, bonding, movement, the spatial grids (NPC and node cell index both wrap their bucket lookups at the seam), and town centroid (averaged in anchor-relative deltas, since averaging raw coordinates across the seam is meaningless). Towns re-laid as a 2x2 even grid on the torus (quarter/three-quarter points, 320 & 960 on a 1280 world): every town sits exactly 640 units from each of its two axis-neighbours in all directions, wrap included - no more edge-boxed North/South that could only forage inward. World grew 1152 -> 1280 with resources scaled to area. Verified over a 240-day soak: all four towns alive and balanced (pop 23-25), population stable ~96, 119 bonds (27 cross-town), cultures still diverge, and wrapped distances correctly cap at the torus half-diagonal (no false across-seam blowups). 183s headless. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
454 lines
18 KiB
C#
454 lines
18 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()
|
||
{
|
||
// 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
|
||
|
||
/// <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 = 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);
|
||
}
|
||
|
||
/// <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 &&
|
||
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;
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
// 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();
|
||
}
|
||
}
|
||
|
||
/// <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 = 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).");
|
||
}
|
||
}
|
||
|
||
/// <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);
|
||
// 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);
|
||
}
|
||
|
||
/// <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 = 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<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 = 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();
|
||
}
|
||
}
|
||
}
|
||
}
|