using System.Collections.Generic;
using Godot;
namespace WorldSim;
///
/// 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.
///
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 Towns => _towns;
private readonly List _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
/// 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.
[Export] public int StopAfterDays = 0;
public World World { get; private set; } = null!;
public List Npcs { get; } = new();
public List Buildings { get; } = new();
/// Rebuilt each tick; proximity queries go through it so the
/// hot loops stay near-linear as population grows.
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",
};
/// Food regen multiplier — winter gives almost nothing.
public float SeasonFoodMult => SeasonIndex switch
{
0 => 1.5f, 1 => 1.0f, 2 => 0.8f, _ => 0.05f,
};
/// Wood regen multiplier — growth slows in the cold.
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();
Npcs.Add(npc);
}
}
foreach (var town in _towns)
Spawn(town.Name, town.Center, ProfileFor(town.Soul), TownPopulationEach, town.Name);
}
/// How many villagers (other than )
/// are currently sleeping at this shelter.
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;
}
/// 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;
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();
}
}
///
/// 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.
///
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).");
}
}
///
/// 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 = $"{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();
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
/// buildings are founded here, not at a scripted town center.
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);
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.");
if (StopAfterDays > 0 && Day >= StopAfterDays)
{
GD.Print($"[WorldSim] Reached day {Day} — stopping.");
GetTree().Quit();
}
}
}
}