Files
Soul_Game/scripts/GameManager.cs
T
mmcghen eb4b2fdd19 Toroidal world: boundless wrapping globe, 2x2 even town grid
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>
2026-07-23 11:48:40 -04:00

454 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}
}
}