f9e22e872f
World scaled 3x (768x768) with all four towns at compass points, each running its own soul culture: North generational, South nature, West materialist (new profile), East selfish. Two new resources: Ore (metal tier) and Herb (medicine - mends health, reason to forage, trade good). Spatial hash grid replaces O(n^2) proximity scans (soul pressure, neighbor, teacher) - 3.6x headless speedup at ~80 NPCs. First social verb beyond work: pair-bonds - settled adults who keep warm company pair up; warmth now accrues from proximity not only gifts; bonds break at death. Reproduction deferred (bonds-first per plan). The four-town thesis fully realized on one engine (240-day run): North know 1.27 clean, South nourish 0.85 clean, West prosperous but debris 0.23, East hungriest 0.60 and lowest knowledge 1.08. Positive cultures stay soul-clean, negative ones carry real debris, all from behavior weights - nothing scripted. 99 bonds, 80 deaths, four living societies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
313 lines
11 KiB
C#
313 lines
11 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 = 20; // per settlement (~80 total)
|
|
|
|
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();
|
|
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;
|
|
}
|
|
|
|
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)
|
|
{
|
|
foreach (var t in _towns)
|
|
if (t.Name == town) return t.Center;
|
|
return new Vector2(World.GridSize / 2f, World.GridSize / 2f);
|
|
}
|
|
return sum / count;
|
|
}
|
|
|
|
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: generations end, and what wasn't passed on ends too --
|
|
|
|
public int DeathsToday { 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;
|
|
Npcs.Remove(dead);
|
|
DeathsToday++;
|
|
|
|
// Everything unshared dies with them: skills, memories, echoes.
|
|
// What survives is what they taught, built, and stored.
|
|
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);
|
|
|
|
var youth = new Npc
|
|
{
|
|
Name = $"{dead.HomeTown}_{(char)('A' + _bornCount % 26)}{_bornCount + 25}",
|
|
HomeTown = dead.HomeTown,
|
|
Soul = dead.Soul, // raised in the culture that raised them
|
|
PersonalityModifier = (float)(rng.NextDouble() * 0.4 - 0.2),
|
|
Position = cradle + new Vector2(rng.Next(-4, 5), rng.Next(-4, 5)),
|
|
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.");
|
|
}
|
|
}
|
|
|
|
/// <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);
|
|
|
|
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();
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|