South town MVP: world sim, seasons, granary, contrast test + findings

Godot 4.7 C# world simulation per SPEC.md (with 2026-07-21 amendments).
15 NPCs, 5-state machine, demand-driven work, soul-imprint triad data
model, seasons with winter frost, granary, need-based gift economy,
selfish-soul contrast wiring, daily/per-villager/gift CSV logging.

FINDINGS.md documents the four soak experiments: the dead paradise,
the buffers winning, the emergent hungry gap, and the contrast test
proving the four-town thesis (and that selfishness wins until the
soul-consequence layer exists).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 12:55:30 -04:00
commit 721acbd1aa
31 changed files with 5661 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
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;
[Export] public int NpcCount = 15;
[Export(PropertyHint.Range, "0,15,1")]
public int SelfishCount = 3; // the contrast test (SPEC §11 amendment)
[Export(PropertyHint.Range, "1,2000,1")]
public float TicksPerRealSecond = 20f; // sim speed; ~1500 ≈ one day/sec for soaks
public World World { get; private set; } = null!;
public List<Npc> Npcs { get; } = new();
public List<Building> Buildings { get; } = new();
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 the map center with scatter — no scripted town layout.
// Where the community forms is the experiment (SPEC §11).
var rng = new System.Random(Seed);
for (int i = 0; i < NpcCount; i++)
{
bool selfish = i >= NpcCount - SelfishCount;
Npcs.Add(new Npc
{
Name = $"Villager_{(char)('A' + i)}",
Soul = selfish ? SoulProfile.SelfishSoul() : SoulProfile.NatureSoul(),
PersonalityModifier = (float)(rng.NextDouble() * 0.4 - 0.2),
Position = new Vector2(
World.GridSize / 2f + rng.Next(-20, 21),
World.GridSize / 2f + rng.Next(-20, 21)),
});
}
}
public override void _Process(double delta)
{
_tickAccumulator += delta * TicksPerRealSecond;
while (_tickAccumulator >= 1.0)
{
_tickAccumulator -= 1.0;
Tick();
}
}
/// <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()
{
TotalTicks++;
foreach (var npc in Npcs)
npc.Tick(World, this);
if (TotalTicks % 1440 == 0)
{
World.RegenerateDaily(SeasonFoodMult, SeasonWoodMult);
foreach (var building in Buildings)
building.DailyUpkeep();
StatsLogger.LogDay(this);
if (Day % 10 == 0)
GD.Print($"[WorldSim] Day {Day} complete.");
}
}
}