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
+206
View File
@@ -0,0 +1,206 @@
using System.Collections.Generic;
using Godot;
namespace WorldSim;
public enum BuildingKind { Shelter, Granary }
/// <summary>
/// Modular buildings (SPEC §6): physically-sequential construction
/// (foundation → walls → roof) with multi-NPC material delivery.
///
/// Buildings are the world's metabolism:
/// Shelters — burn firewood daily, decay into ruins if neglected, and
/// accept expansion modules that permanently raise their burn.
/// Granary — the village's winter insurance: stores food surplus in the
/// abundant seasons, feeds the hungry when the land gives nothing.
/// Its walls are clay — the first real use for it.
/// </summary>
public class Building
{
public BuildingKind Kind = BuildingKind.Shelter;
public Vector2 Site;
public static Building NewShelter(Vector2 site) => new()
{
Kind = BuildingKind.Shelter,
Site = site,
Stages = new()
{
new("foundation", MaterialKind.Stone, 10f),
new("walls", MaterialKind.Wood, 20f),
new("roof", MaterialKind.Wood, 10f),
},
};
public static Building NewGranary(Vector2 site) => new()
{
Kind = BuildingKind.Granary,
Site = site,
Stages = new()
{
new("foundation", MaterialKind.Stone, 10f),
new("walls", MaterialKind.Clay, 25f),
new("roof", MaterialKind.Wood, 10f),
},
};
// --- Construction ----------------------------------------------------
public record Stage(string Name, MaterialKind Material, float Cost);
public List<Stage> Stages = new();
public int CurrentStage;
public float Delivered; // toward the current stage's cost
public bool IsComplete => CurrentStage >= Stages.Count;
public MaterialKind? NeededMaterial => IsComplete ? null : Stages[CurrentStage].Material;
public float StageProgress =>
IsComplete ? 1f : Delivered / Stages[CurrentStage].Cost;
// --- Shelter: upkeep -------------------------------------------------
public const float UpkeepStockCap = 10f;
public float UpkeepStock; // delivered wood reserve
public float Condition = 1f; // 1 = sound; 0 = ruin
public bool IsRuined => Condition <= 0f;
/// <summary>Base burn plus half a unit per expansion module, forever.</summary>
public float DailyBurn => Kind == BuildingKind.Shelter ? 2f + Modules * 0.5f : 0f;
/// <summary>True when this shelter wants a firewood delivery.</summary>
public bool WantsUpkeepWood =>
Kind == BuildingKind.Shelter && IsComplete && !IsRuined &&
UpkeepStock < UpkeepStockCap * 0.7f;
// --- Shelter: expansion modules --------------------------------------
public const int ModuleCap = 4;
public const float ModuleCost = 15f; // wood per module
public int Modules;
public float ModuleDelivered; // toward the next module
/// <summary>Sound, stocked shelters accept expansion work.</summary>
public bool WantsExpansion =>
Kind == BuildingKind.Shelter && IsComplete && !IsRuined &&
Modules < ModuleCap && !WantsUpkeepWood;
// --- Granary: food store ---------------------------------------------
public const float FoodStockCap = 150f;
public float FoodStock;
/// <summary>True when the granary has room for more food.</summary>
public bool WantsFood =>
Kind == BuildingKind.Granary && IsComplete && FoodStock < FoodStockCap;
/// <summary>Take food out for a hungry villager. Returns amount granted.</summary>
public float WithdrawFood(float requested)
{
float granted = Mathf.Min(requested, FoodStock);
FoodStock -= granted;
return granted;
}
// --- Daily tick -------------------------------------------------------
/// <summary>Called once per in-game day by GameManager.</summary>
public void DailyUpkeep()
{
if (Kind != BuildingKind.Shelter || !IsComplete || IsRuined) return;
if (UpkeepStock >= DailyBurn)
{
UpkeepStock -= DailyBurn;
Condition = Mathf.Min(1f, Condition + 0.05f); // tended buildings mend
}
else
{
UpkeepStock = 0f;
Condition -= 0.1f; // neglect shows in ~10 days
}
}
// --- Delivery --------------------------------------------------------
/// <summary>
/// The visitor delivers what the building currently needs — construction
/// material while building; then firewood/expansion (shelter) or food
/// (granary). Returns false if they had nothing useful to give.
/// </summary>
public bool Deliver(Npc npc)
{
if (IsRuined) return false;
if (!IsComplete)
{
var stage = Stages[CurrentStage];
if (!npc.Inventory.TryGetValue(stage.Material, out float have) || have <= 0f)
return false;
float take = Mathf.Min(have, stage.Cost - Delivered);
npc.Inventory[stage.Material] = have - take;
Delivered += take;
if (Delivered >= stage.Cost)
{
CurrentStage++;
Delivered = 0f;
}
return take > 0f;
}
if (Kind == BuildingKind.Granary)
{
// Deposit real surplus only (keep 5) — and never redeposit what
// was just withdrawn: depositors must arrive carrying > 8.
if (!npc.Inventory.TryGetValue(MaterialKind.Food, out float carried) || carried <= 8f)
return false;
float space = FoodStockCap - FoodStock;
float taken = Mathf.Min(carried - 5f, space);
if (taken <= 0f) return false;
npc.Inventory[MaterialKind.Food] = carried - taken;
FoodStock += taken;
return true;
}
// Shelter: reserve first — never expand a shelter you can't heat.
if (!npc.Inventory.TryGetValue(MaterialKind.Wood, out float wood) || wood <= 0f)
return false;
float woodSpace = UpkeepStockCap - UpkeepStock;
if (woodSpace > 0f)
{
float taken = Mathf.Min(wood, woodSpace);
npc.Inventory[MaterialKind.Wood] = wood - taken;
UpkeepStock += taken;
return taken > 0f;
}
if (Modules < ModuleCap)
{
float take = Mathf.Min(wood, ModuleCost - ModuleDelivered);
npc.Inventory[MaterialKind.Wood] = wood - take;
ModuleDelivered += take;
if (ModuleDelivered >= ModuleCost)
{
Modules++;
ModuleDelivered = 0f;
}
return take > 0f;
}
return false;
}
// Ruins persist in the world — they are not removed. What a place was
// remains visible in what it left behind.
}
+1
View File
@@ -0,0 +1 @@
uid://telyx7lw4jh3
+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.");
}
}
}
+1
View File
@@ -0,0 +1 @@
uid://cvugyvd1fuj7l
+495
View File
@@ -0,0 +1,495 @@
using System.Collections.Generic;
using Godot;
namespace WorldSim;
public enum NpcState { Gathering, Building, Trading, Resting, Socializing }
/// <summary>
/// NPC data + state machine (SPEC §9, §10). A plain simulation object —
/// GameManager ticks it, Visualization draws it. No Node per NPC.
///
/// Constrained emergence: this machine defines what an NPC CAN do;
/// the SoulProfile weights decide which valid option they take.
///
/// The loop is demand-driven so the sim never saturates:
/// the community's current need (construction material, then firewood
/// upkeep, then personal variety) decides what gets gathered; surplus
/// flows to neighbors who lack it or to the buildings that burn it;
/// night pulls everyone home to shelter; morning releases them.
/// </summary>
public class Npc
{
// --- Identity & soul -------------------------------------------------
public string Name = "";
public SoulProfile Soul = SoulProfile.NatureSoul();
public SoulImprint Imprint = new(); // triad — see SPEC §2 amendment
public float PersonalityModifier; // per-NPC variance within a soul type
// --- Body & position -------------------------------------------------
public Vector2 Position;
public float Fatigue; // 0100; forced rest above 80
public float Health = 100f; // forced rest below 30
public float Nourishment = 1f; // 1 = fed; drains daily, eats from pack
// --- State -----------------------------------------------------------
public NpcState State = NpcState.Gathering;
/// <summary>Human-readable description of the current action — for the
/// debug roster only. The player-facing game will never show this.</summary>
public string Activity = "waking up";
// --- Soul-derived behavior (constrained emergence: same possibility
// --- space for everyone; the weights only change the choices) --------
/// <summary>Communal souls do communal work; low-bias souls simply don't.</summary>
public bool IsCommunal => Soul.CommunityBias >= 0.3f;
/// <summary>Hoarders carry double — accumulation is a way of being.</summary>
public float EffectiveCapacity => Soul.Accumulation > 0.5f ? CarryCapacity * 2f : CarryCapacity;
/// <summary>What fraction of a node this soul leaves standing. Nature
/// souls honor the 50% commons floor; selfish souls strip to ~15%.</summary>
public float HarvestSeekFloor => Mathf.Max(0.05f, Soul.Sustainability * 0.5f) + 0.05f;
/// <summary>Ticks spent in each state since the last daily snapshot —
/// the raw material of emergent roles: what a person mostly does is
/// who they are becoming. Indexed by (int)NpcState.</summary>
public int[] StateTicksToday = new int[5];
public NpcState DominantStateToday()
{
int best = 0;
for (int i = 1; i < StateTicksToday.Length; i++)
if (StateTicksToday[i] > StateTicksToday[best]) best = i;
return (NpcState)best;
}
public Dictionary<MaterialKind, float> Inventory = new();
public float CarryCapacity = 20f;
private const float MoveSpeed = 0.8f; // grid cells per tick
private const float ArriveDist = 1.5f;
private ResourceNode? _targetNode;
private Npc? _targetNpc;
private Building? _foodSource; // granary being visited for food
private Npc? _giftSource; // neighbor being asked for food
private float _stateTimer;
private static readonly MaterialKind[] GatherKinds =
{ MaterialKind.Wood, MaterialKind.Stone, MaterialKind.Clay };
/// <summary>
/// One simulation tick = one in-game minute (SPEC §9).
/// Order per SPEC §10: vitals check → day/night bias → state behavior → fatigue.
/// </summary>
public void Tick(World world, GameManager gm)
{
_stateTimer += 1f;
StateTicksToday[(int)State]++;
// 1. Vitals override everything
if (State != NpcState.Resting && (Fatigue > 80f || Health < 30f))
Enter(NpcState.Resting);
// Day/night bias (SPEC §10): work by day, rest/socialize by night
if (gm.IsNight && State is NpcState.Gathering or NpcState.Trading or NpcState.Building)
Enter(Fatigue > 50f ? NpcState.Resting : NpcState.Socializing);
// 23. State behavior
switch (State)
{
case NpcState.Gathering: TickGathering(world, gm); break;
case NpcState.Building: TickBuilding(gm); break;
case NpcState.Trading: TickTrading(gm); break;
case NpcState.Resting: TickResting(gm); break;
case NpcState.Socializing: TickSocializing(gm); break;
}
// Metabolism: nourishment drains slowly; eat from the pack when hungry.
// This is the per-capita demand that keeps the economy from saturating.
Nourishment -= 0.35f / 1440f;
if (Nourishment < 0.7f &&
Inventory.TryGetValue(MaterialKind.Food, out float food) && food >= 1f)
{
Inventory[MaterialKind.Food] = food - 1f;
Nourishment = Mathf.Min(1f, Nourishment + 0.25f);
}
if (Nourishment <= 0.05f) Health = Mathf.Max(0f, Health - 0.01f);
Nourishment = Mathf.Clamp(Nourishment, 0f, 1f);
// 4. Fatigue deltas (SPEC §10) — scaled per tick
Fatigue += State switch
{
NpcState.Gathering => 2f,
NpcState.Building => 2f,
NpcState.Trading => 1f,
NpcState.Resting => -5f,
NpcState.Socializing => -2f,
_ => 0f,
} * 0.1f;
Fatigue = Mathf.Clamp(Fatigue, 0f, 100f);
}
public void Enter(NpcState state)
{
State = state;
_stateTimer = 0f;
_targetNode = null;
_targetNpc = null;
_foodSource = null;
_giftSource = null;
}
public float TotalCarried()
{
float sum = 0f;
foreach (var kv in Inventory) sum += kv.Value;
return sum;
}
private void MoveToward(Vector2 target) =>
Position = Position.MoveToward(target, MoveSpeed);
// --- Demand ----------------------------------------------------------
/// <summary>
/// What the community needs next, in priority order:
/// construction material for an in-progress building, firewood for a
/// shelter running low, else personal variety (least-carried).
/// This is what turns fifteen individuals into a town coordinating.
/// </summary>
private MaterialKind PickGatherTarget(GameManager gm)
{
// 1. Feed yourself — keep a food reserve in the pack. Hoarding souls
// keep a much bigger one, hungry or not.
Inventory.TryGetValue(MaterialKind.Food, out float foodCarried);
float reserveTarget = 3f + Soul.Accumulation * 7f;
if (foodCarried < reserveTarget && (Nourishment < 0.8f || Soul.Accumulation > 0.5f))
return MaterialKind.Food;
if (IsCommunal)
{
// 2. Construction sites.
foreach (var b in gm.Buildings)
if (!b.IsComplete && b.NeededMaterial is MaterialKind needed)
return needed;
// 3. Firewood for shelters running low.
foreach (var b in gm.Buildings)
if (b.WantsUpkeepWood)
return MaterialKind.Wood;
// 4. Winter insurance: stock the granary while the land still gives.
foreach (var b in gm.Buildings)
if (b.WantsFood)
return MaterialKind.Food;
// 5. Ambition: expansion modules for sound, stocked shelters.
foreach (var b in gm.Buildings)
if (b.WantsExpansion)
return MaterialKind.Wood;
}
MaterialKind best = GatherKinds[(int)(Mathf.Abs(PersonalityModifier) * 100f) % GatherKinds.Length];
float least = float.MaxValue;
foreach (var kind in GatherKinds)
{
Inventory.TryGetValue(kind, out float have);
if (have < least) { least = have; best = kind; }
}
return best;
}
/// <summary>
/// When full but mostly carrying the wrong thing, set the excess down to
/// make room for what's actually needed. Lossy for now —
/// TODO(Week 2): ground stockpiles so set-down materials persist.
/// </summary>
private void MakeRoomFor(MaterialKind demanded)
{
Inventory.TryGetValue(demanded, out float haveDemanded);
if (haveDemanded >= EffectiveCapacity * 0.25f) return;
foreach (var kind in GatherKinds)
{
if (kind == demanded) continue;
if (TotalCarried() <= EffectiveCapacity * 0.5f) break;
if (Inventory.TryGetValue(kind, out float have) && have > 0f)
Inventory[kind] = have * 0.5f;
}
}
// --- States ----------------------------------------------------------
private void TickGathering(World world, GameManager gm)
{
// Mid-errand food runs: fetching from the granary or asking a neighbor.
if (_foodSource != null)
{
Activity = "fetching food from the granary";
MoveToward(_foodSource.Site);
if (Position.DistanceTo(_foodSource.Site) < ArriveDist)
{
float got = _foodSource.WithdrawFood(8f);
Inventory.TryGetValue(MaterialKind.Food, out float haveF);
Inventory[MaterialKind.Food] = haveF + got;
_foodSource = null;
}
return;
}
if (_giftSource != null)
{
Activity = $"asking {_giftSource.Name} for food";
MoveToward(_giftSource.Position);
if (Position.DistanceTo(_giftSource.Position) < ArriveDist)
{
if (TradeSystem.CanTrade(_giftSource, this, out var mat))
TradeSystem.Execute(_giftSource, this, mat);
_giftSource = null;
}
return;
}
var demanded = PickGatherTarget(gm);
if (TotalCarried() >= EffectiveCapacity)
{
// A full pack means different things to different souls:
// communal souls take it to the village; hoarders sit on it.
if (!IsCommunal) { Enter(NpcState.Socializing); return; }
Inventory.TryGetValue(demanded, out float haveDemanded);
if (haveDemanded >= EffectiveCapacity * 0.25f) { Enter(NpcState.Building); return; }
MakeRoomFor(demanded);
if (TotalCarried() >= EffectiveCapacity) { Enter(NpcState.Trading); return; }
}
if (_targetNode == null || _targetNode.IsDepleted || _targetNode.Kind != demanded)
{
_targetNode = world.FindNearest(demanded, Position, HarvestSeekFloor);
// Hungry and the land has nothing: the granary, then a neighbor.
// This is where winter turns scarcity into community.
if (_targetNode == null && demanded == MaterialKind.Food)
{
foreach (var b in gm.Buildings)
if (b.Kind == BuildingKind.Granary && b.IsComplete && b.FoodStock > 0f)
{
_foodSource = b;
return;
}
float bestDist = float.MaxValue;
foreach (var other in gm.Npcs)
{
if (other == this) continue;
// Everyone knows better than to ask the ungenerous.
if (other.Soul.Generosity < 0.3f) continue;
if (!other.Inventory.TryGetValue(MaterialKind.Food, out float f) || f <= 3f) continue;
float d = Position.DistanceSquaredTo(other.Position);
if (d < bestDist) { bestDist = d; _giftSource = other; }
}
if (_giftSource != null) return;
}
_targetNode ??= world.FindNearest(MaterialKind.Food, Position, HarvestSeekFloor)
?? world.FindNearest(MaterialKind.Wood, Position, HarvestSeekFloor)
?? world.FindNearest(MaterialKind.Stone, Position, HarvestSeekFloor)
?? world.FindNearest(MaterialKind.Clay, Position, HarvestSeekFloor);
if (_targetNode == null) { Enter(NpcState.Socializing); return; }
}
MoveToward(_targetNode.Cell);
if (Position.DistanceTo(_targetNode.Cell) >= ArriveDist)
{
Activity = $"walking to {_targetNode.Kind.ToString().ToLower()}";
}
else
{
Activity = $"harvesting {_targetNode.Kind.ToString().ToLower()}";
float taken = world.Harvest(_targetNode, requested: 2f, Soul);
if (taken <= 0f) { _targetNode = null; return; } // node at its floor — re-seek
Inventory.TryGetValue(_targetNode.Kind, out float have);
Inventory[_targetNode.Kind] = have + taken;
// Enough of what's needed? Communal souls go deliver it.
Inventory.TryGetValue(PickGatherTarget(gm), out float demandedHave);
if (IsCommunal && demandedHave >= EffectiveCapacity * 0.5f) Enter(NpcState.Building);
}
}
private void TickTrading(GameManager gm)
{
// Give up after a while — surplus can go to the buildings instead.
if (_stateTimer > 120f) { Enter(NpcState.Building); return; }
if (_targetNpc == null)
{
float bestDist = float.MaxValue;
foreach (var other in gm.Npcs)
{
if (other == this) continue;
if (!TradeSystem.CanTrade(this, other, out _)) continue;
float d = Position.DistanceSquaredTo(other.Position);
if (d < bestDist) { bestDist = d; _targetNpc = other; }
}
if (_targetNpc == null) { Enter(NpcState.Building); return; }
}
Activity = $"bringing goods to {_targetNpc.Name}";
MoveToward(_targetNpc.Position);
if (Position.DistanceTo(_targetNpc.Position) < ArriveDist)
{
if (TradeSystem.CanTrade(this, _targetNpc, out var material))
TradeSystem.Execute(this, _targetNpc, material);
Enter(NpcState.Building);
}
}
private void TickBuilding(GameManager gm)
{
// Communal work is for communal souls. The others still sleep in the
// shelters and eat from the granary — freeriding is the whole point.
if (!IsCommunal) { Enter(NpcState.Socializing); return; }
// Priority 1: construction sites. Priority 2: shelters low on firewood.
Building? site = null;
float bestDist = float.MaxValue;
foreach (var b in gm.Buildings)
{
if (b.IsComplete) continue;
float d = Position.DistanceSquaredTo(b.Site);
if (d < bestDist) { bestDist = d; site = b; }
}
if (site == null)
{
foreach (var b in gm.Buildings)
{
if (!b.WantsUpkeepWood) continue;
float d = Position.DistanceSquaredTo(b.Site);
if (d < bestDist) { bestDist = d; site = b; }
}
}
if (site == null)
{
// Granary deposits — only if carrying real food surplus.
Inventory.TryGetValue(MaterialKind.Food, out float foodCarried);
if (foodCarried > 8f)
{
foreach (var b in gm.Buildings)
{
if (!b.WantsFood) continue;
float d = Position.DistanceSquaredTo(b.Site);
if (d < bestDist) { bestDist = d; site = b; }
}
}
}
if (site == null)
{
foreach (var b in gm.Buildings)
{
if (!b.WantsExpansion) continue;
float d = Position.DistanceSquaredTo(b.Site);
if (d < bestDist) { bestDist = d; site = b; }
}
}
if (site == null)
{
// Communal-first founding, near where the community actually
// gathers. Shelters first; once the village stands, the granary.
bool carriesStone = Inventory.TryGetValue(MaterialKind.Stone, out float stone) && stone > 0f;
int shelters = 0;
bool granaryExists = false;
foreach (var b in gm.Buildings)
{
if (b.Kind == BuildingKind.Granary) granaryExists = true;
else shelters++;
}
var jitter = new Vector2(PersonalityModifier * 25f, -PersonalityModifier * 25f);
if (shelters < gm.NpcCount / 3 && carriesStone)
{
site = Building.NewShelter(gm.CommunityCentroid() + jitter);
gm.Buildings.Add(site);
}
else if (!granaryExists && shelters >= gm.NpcCount / 3 && carriesStone)
{
site = Building.NewGranary(gm.CommunityCentroid() + jitter);
gm.Buildings.Add(site);
}
else { Enter(NpcState.Socializing); return; }
}
int siteIndex = gm.Buildings.IndexOf(site) + 1;
string kindName = site.Kind == BuildingKind.Granary ? "granary" : $"shelter {siteIndex}";
Activity = !site.IsComplete
? $"building {kindName} ({site.NeededMaterial.ToString()!.ToLower()})"
: site.Kind == BuildingKind.Granary
? "stocking the granary"
: site.WantsUpkeepWood
? $"hauling firewood to {kindName}"
: $"expanding {kindName}";
MoveToward(site.Site);
if (Position.DistanceTo(site.Site) < ArriveDist)
{
bool delivered = site.Deliver(this);
if (!delivered || site.IsComplete) Enter(NpcState.Socializing);
}
}
private void TickResting(GameManager gm)
{
// Rest at home: walk to the nearest sound shelter and recover faster there.
Building? shelter = null;
float bestDist = float.MaxValue;
foreach (var b in gm.Buildings)
{
if (!b.IsComplete || b.IsRuined) continue;
float d = Position.DistanceSquaredTo(b.Site);
if (d < bestDist) { bestDist = d; shelter = b; }
}
bool sheltered = false;
if (shelter != null)
{
if (Position.DistanceTo(shelter.Site) > 2.5f) MoveToward(shelter.Site);
else sheltered = true;
}
Activity = sheltered ? "resting at shelter"
: shelter != null ? "walking home to rest"
: "resting in the open";
Health = Mathf.Min(100f, Health + (sheltered ? 0.4f : 0.1f));
if (sheltered) Fatigue = Mathf.Max(0f, Fatigue - 0.3f); // on top of the state delta
if (Fatigue < 40f && !gm.IsNight) Enter(NpcState.Gathering);
}
private void TickSocializing(GameManager gm)
{
// Drift toward the nearest neighbor — community forms where feet do.
Npc? nearest = null;
float bestDist = float.MaxValue;
foreach (var other in gm.Npcs)
{
if (other == this) continue;
float d = Position.DistanceSquaredTo(other.Position);
if (d < bestDist) { bestDist = d; nearest = other; }
}
if (nearest != null && Position.DistanceTo(nearest.Position) > 3f)
{
Activity = $"walking over to {nearest.Name}";
MoveToward(nearest.Position);
}
else
{
Activity = nearest != null ? $"chatting with {nearest.Name}" : "idling";
}
// TODO(Week 3): bonds + Generosity-weighted spontaneous help.
if (!gm.IsNight && _stateTimer > 15f) Enter(NpcState.Gathering);
}
}
+1
View File
@@ -0,0 +1 @@
uid://di0nhnfp2o8ev
+34
View File
@@ -0,0 +1,34 @@
using Godot;
namespace WorldSim;
/// <summary>
/// World-state serialization (SPEC §9 amendment).
/// "The world is the save file" — persistence is a first-class MVP system,
/// not a Phase 4 afterthought. Baseline test: save → load → the simulation
/// continues identically (round-trip).
///
/// TODO(Week 4): serialize World.Nodes, Npcs (position, state, inventory,
/// fatigue, health, imprint triad), Buildings, and TotalTicks to JSON at
/// user://worldsim_save.json; load must rebuild identical state.
/// Design note carried from DESIGN.md: when object imprint histories arrive
/// (Phase 3), old imprints compress into net values + a few significant
/// preserved entries — "what the world remembers in detail vs. aggregate"
/// is a design decision expressed as a data policy.
/// </summary>
public static class SaveSystem
{
public static string SavePath => "user://worldsim_save.json";
public static void Save(GameManager gm)
{
// TODO(Week 4): implement JSON round-trip.
GD.Print("[SaveSystem] Save not yet implemented.");
}
public static bool TryLoad(GameManager gm)
{
// TODO(Week 4): implement JSON round-trip.
return false;
}
}
+1
View File
@@ -0,0 +1 @@
uid://ulislfbwqaa8
+80
View File
@@ -0,0 +1,80 @@
using System;
namespace WorldSim;
/// <summary>
/// Soul types — the four founding philosophies (SPEC §1).
/// MVP uses Nature only; the others exist so nothing hardcodes a single type.
/// </summary>
public enum SoulType
{
Nature, // South — take only what you need
Generational, // North — reverence for lineage
Materialist, // West — worth measured in visible possessions
Selfish, // East — personal gain, no guilt attached
}
/// <summary>
/// The soul imprint triad (SPEC §2 amendment, DESIGN.md debris model).
/// The soul itself never changes — these are the layers over it.
/// MVP behavior may read only Total, but all three sources are carried
/// so later phases (residual-soul verdicts, awareness thresholds,
/// source-specific clearing) need no data migration.
/// </summary>
public class SoulImprint
{
public float SelfRegard; // internal — how the soul sees itself
public float OthersRegard; // internal — how the soul sees others
public float ExternalPerception; // external — what others' souls pressed onto it
public float Total => (SelfRegard + OthersRegard + ExternalPerception) / 3f;
public float Radiance => 1f - Total;
public void Clamp()
{
SelfRegard = Math.Clamp(SelfRegard, 0f, 1f);
OthersRegard = Math.Clamp(OthersRegard, 0f, 1f);
ExternalPerception = Math.Clamp(ExternalPerception, 0f, 1f);
}
}
/// <summary>
/// Behavior weights per soul type (SPEC §9). Constrained emergence:
/// these weights pick WHICH valid option an NPC takes, never WHETHER
/// a solution exists. Swappable per type — the engine stays agnostic.
/// </summary>
public class SoulProfile
{
public SoulType Type;
public float Generosity; // trade ratios, spontaneous help
public float Sustainability; // harvest caps (South: ~50% of a node)
public float CommunityBias; // communal-first building, socializing pull
public float StatusBias; // West: visible-wealth preference (unused in MVP)
public float Accumulation; // East: hoarding pressure (unused in MVP)
public static SoulProfile NatureSoul() => new()
{
Type = SoulType.Nature,
Generosity = 0.9f,
Sustainability = 0.9f,
CommunityBias = 0.8f,
StatusBias = 0.1f,
Accumulation = 0.1f,
};
/// <summary>
/// East-weighted profile — not part of MVP behavior, but defined now for
/// the end-of-MVP contrast test (SPEC §11 amendment): 3 selfish NPCs
/// dropped into the South town to prove weights alone change the society.
/// </summary>
public static SoulProfile SelfishSoul() => new()
{
Type = SoulType.Selfish,
Generosity = 0.1f,
Sustainability = 0.2f,
CommunityBias = 0.1f,
StatusBias = 0.5f,
Accumulation = 0.9f,
};
}
+1
View File
@@ -0,0 +1 @@
uid://dvoko7auk4m5g
+155
View File
@@ -0,0 +1,155 @@
using System.Globalization;
using System.IO;
using System.Text;
using Godot;
namespace WorldSim;
/// <summary>
/// Daily stats to CSV (SPEC §11 behavioral validation).
/// One row per simulated day — the soak test's flight recorder.
/// Written to the project folder as soak_stats.csv, fresh each run.
/// </summary>
public static class StatsLogger
{
private static string _path = "";
private static string _npcPath = "";
private const string Header =
"day,season,wood,stone,clay,food," +
"harvested,trades," +
"avg_fatigue,avg_nourish,avg_health,carried," +
"gather_ticks,build_ticks,trade_ticks,rest_ticks,social_ticks," +
"shelters_done,ruins,avg_cond,upkeep_stock,burn_per_day,modules,granary_food";
private const string NpcHeader =
"day,npc,name,soul,dominant,gather_ticks,build_ticks,trade_ticks,rest_ticks,social_ticks";
private const string GiftHeader =
"day,giver,giver_soul,receiver,receiver_soul,material,amount";
private static string _giftPath = "";
public static void Init()
{
string dir = ProjectSettings.GlobalizePath("res://");
_path = Path.Combine(dir, "soak_stats.csv");
_npcPath = Path.Combine(dir, "soak_npc_days.csv");
_giftPath = Path.Combine(dir, "soak_gifts.csv");
File.WriteAllText(_path, Header + "\n");
File.WriteAllText(_npcPath, NpcHeader + "\n");
File.WriteAllText(_giftPath, GiftHeader + "\n");
GD.Print($"[Stats] Logging to {_path}, {_npcPath}, {_giftPath}");
}
/// <summary>Every gift, with names — so we can see who carries the town.</summary>
public static void LogGift(Npc giver, Npc receiver, MaterialKind material, float amount)
{
if (string.IsNullOrEmpty(_giftPath)) return;
int day = GameManager.Instance?.Day ?? -1;
File.AppendAllText(_giftPath,
$"{day},{giver.Name},{giver.Soul.Type},{receiver.Name},{receiver.Soul.Type}," +
$"{material},{amount.ToString("0.0", CultureInfo.InvariantCulture)}\n");
}
public static void LogDay(GameManager gm)
{
var ci = CultureInfo.InvariantCulture;
// World resource totals
float wood = 0f, stone = 0f, clay = 0f, food = 0f;
foreach (var n in gm.World.Nodes)
{
switch (n.Kind)
{
case MaterialKind.Wood: wood += n.Amount; break;
case MaterialKind.Stone: stone += n.Amount; break;
case MaterialKind.Clay: clay += n.Amount; break;
case MaterialKind.Food: food += n.Amount; break;
}
}
// Villagers — state time from full-day tick counts, not a midnight
// snapshot (the night rule made snapshots useless).
float fatigue = 0f, nourish = 0f, health = 0f, carried = 0f;
int gathering = 0, building = 0, trading = 0, resting = 0, socializing = 0;
foreach (var npc in gm.Npcs)
{
fatigue += npc.Fatigue;
nourish += npc.Nourishment;
health += npc.Health;
carried += npc.TotalCarried();
gathering += npc.StateTicksToday[(int)NpcState.Gathering];
building += npc.StateTicksToday[(int)NpcState.Building];
trading += npc.StateTicksToday[(int)NpcState.Trading];
resting += npc.StateTicksToday[(int)NpcState.Resting];
socializing += npc.StateTicksToday[(int)NpcState.Socializing];
}
int count = Mathf.Max(1, gm.Npcs.Count);
// Buildings
int done = 0, ruins = 0, modules = 0;
float cond = 0f, stock = 0f, burn = 0f, granaryFood = 0f;
foreach (var b in gm.Buildings)
{
if (b.IsRuined) { ruins++; continue; }
if (!b.IsComplete) continue;
if (b.Kind == BuildingKind.Granary) { granaryFood += b.FoodStock; continue; }
done++;
cond += b.Condition;
stock += b.UpkeepStock;
burn += b.DailyBurn;
modules += b.Modules;
}
float avgCond = done > 0 ? cond / done : 0f;
var row = new StringBuilder();
row.Append(gm.Day).Append(',');
row.Append(gm.SeasonName).Append(',');
row.Append(wood.ToString("0.0", ci)).Append(',');
row.Append(stone.ToString("0.0", ci)).Append(',');
row.Append(clay.ToString("0.0", ci)).Append(',');
row.Append(food.ToString("0.0", ci)).Append(',');
row.Append(gm.World.HarvestedToday.ToString("0.0", ci)).Append(',');
row.Append(TradeSystem.TradesToday).Append(',');
row.Append((fatigue / count).ToString("0.0", ci)).Append(',');
row.Append((nourish / count).ToString("0.000", ci)).Append(',');
row.Append((health / count).ToString("0.0", ci)).Append(',');
row.Append(carried.ToString("0.0", ci)).Append(',');
row.Append(gathering).Append(',').Append(building).Append(',')
.Append(trading).Append(',').Append(resting).Append(',').Append(socializing).Append(',');
row.Append(done).Append(',').Append(ruins).Append(',');
row.Append(avgCond.ToString("0.000", ci)).Append(',');
row.Append(stock.ToString("0.0", ci)).Append(',');
row.Append(burn.ToString("0.0", ci)).Append(',');
row.Append(modules).Append(',');
row.Append(granaryFood.ToString("0.0", ci));
File.AppendAllText(_path, row + "\n");
// Per-villager day rows: what each person mostly did — the raw
// material of emergent roles ("the role follows from the life").
var npcRows = new StringBuilder();
for (int i = 0; i < gm.Npcs.Count; i++)
{
var npc = gm.Npcs[i];
var t = npc.StateTicksToday;
npcRows.Append(gm.Day).Append(',')
.Append(i + 1).Append(',')
.Append(npc.Name).Append(',')
.Append(npc.Soul.Type).Append(',')
.Append(npc.DominantStateToday()).Append(',')
.Append(t[(int)NpcState.Gathering]).Append(',')
.Append(t[(int)NpcState.Building]).Append(',')
.Append(t[(int)NpcState.Trading]).Append(',')
.Append(t[(int)NpcState.Resting]).Append(',')
.Append(t[(int)NpcState.Socializing]).Append('\n');
System.Array.Clear(npc.StateTicksToday, 0, npc.StateTicksToday.Length);
}
File.AppendAllText(_npcPath, npcRows.ToString());
// Reset per-day counters
gm.World.HarvestedToday = 0f;
TradeSystem.TradesToday = 0;
}
}
+1
View File
@@ -0,0 +1 @@
uid://2fgiwcgq1eh8
+64
View File
@@ -0,0 +1,64 @@
namespace WorldSim;
/// <summary>
/// Trade evaluation and execution (SPEC §9). South MVP: generous, voluntary,
/// no currency — a gift economy where the ratio favors the receiver.
///
/// Soul consequences: a generous completed exchange eases both parties'
/// imprints (external perception — the world pressed kindly on them).
/// Later soul types change the ratio logic only; the mechanism is shared.
/// </summary>
public static class TradeSystem
{
/// <summary>Trades since the last daily stats snapshot.</summary>
public static int TradesToday;
/// <summary>
/// Need-based: the trigger is a person in need, not an inventory gap.
/// Food flows to the hungry first; raw material surplus vs. genuine
/// lack remains as a fallback.
/// </summary>
public static bool CanTrade(Npc giver, Npc receiver, out MaterialKind material)
{
material = MaterialKind.Food;
// The ungenerous do not give. Not from an empty pack — from a full one.
if (giver.Soul.Generosity < 0.3f) return false;
giver.Inventory.TryGetValue(MaterialKind.Food, out float giverFood);
receiver.Inventory.TryGetValue(MaterialKind.Food, out float receiverFood);
if (receiver.Nourishment < 0.6f && receiverFood < 2f && giverFood > 3f)
return true;
foreach (var kv in giver.Inventory)
{
bool receiverLacks = !receiver.Inventory.TryGetValue(kv.Key, out float has) || has < 2f;
if (kv.Value > 5f && receiverLacks) { material = kv.Key; return true; }
}
return false;
}
public static void Execute(Npc giver, Npc receiver, MaterialKind material)
{
TradesToday++;
// Generosity decides how much of the surplus is given.
float surplus = giver.Inventory[material] - 2f;
float given = surplus * giver.Soul.Generosity;
giver.Inventory[material] -= given;
receiver.Inventory.TryGetValue(material, out float has);
receiver.Inventory[material] = has + given;
StatsLogger.LogGift(giver, receiver, material, given);
// The kindness lands on both atmospheres — gently.
giver.Imprint.ExternalPerception -= 0.002f;
receiver.Imprint.ExternalPerception -= 0.002f;
giver.Imprint.Clamp();
receiver.Imprint.Clamp();
// TODO(Week 3): soul-imprint-weighted PARTNER selection (trusted vs
// tainted history) — the constrained-emergence reference pattern (SPEC §7).
}
}
+1
View File
@@ -0,0 +1 @@
uid://hcte3kpn8ton
+173
View File
@@ -0,0 +1,173 @@
using Godot;
namespace WorldSim;
/// <summary>
/// Top-down 2D view + debug roster (SPEC §9).
/// Draws straight from simulation state every frame — no per-NPC nodes,
/// no camera: fixed screen-space layout, map on the left, roster on the
/// right. The player-facing game has no meters; the designer sees everything.
/// </summary>
public partial class Visualization : Node2D
{
private GameManager _gm = null!;
// Layout: 1600×900 window — map fills the left square, panel the right.
private const float MapOffset = 10f;
private const float MapPixels = 880f;
private const float CellPixels = MapPixels / World.GridSize; // ~3.44
private const float PanelX = 910f;
private const float PanelWidth = 680f;
private static readonly Color PanelBg = new(0.10f, 0.10f, 0.12f);
private static readonly Color MapBg = new(0.13f, 0.13f, 0.15f);
private static readonly Color TextDim = new(0.65f, 0.65f, 0.70f);
private static readonly Color TextMain = new(0.92f, 0.92f, 0.95f);
public override void _Ready()
{
_gm = GetParent<GameManager>();
}
public override void _Process(double delta) => QueueRedraw();
private static Color StateColor(NpcState state) => state switch
{
NpcState.Gathering => Colors.White,
NpcState.Building => Colors.Orange,
NpcState.Trading => Colors.Yellow,
NpcState.Resting => new Color(0.45f, 0.45f, 0.95f),
NpcState.Socializing => new Color(0.9f, 0.4f, 0.9f),
_ => Colors.Red,
};
private static Vector2 MapPos(Vector2 gridPos) =>
new(MapOffset + gridPos.X * CellPixels, MapOffset + gridPos.Y * CellPixels);
public override void _Draw()
{
if (_gm.World == null) return;
var font = ThemeDB.FallbackFont;
// --- Backgrounds --------------------------------------------------
DrawRect(new Rect2(MapOffset, MapOffset, MapPixels, MapPixels), MapBg);
DrawRect(new Rect2(PanelX, 0, PanelWidth, 900), PanelBg);
// --- Map: resource nodes -----------------------------------------
foreach (var node in _gm.World.Nodes)
{
Color c = node.Kind switch
{
MaterialKind.Wood => new Color(0.20f, 0.55f, 0.20f),
MaterialKind.Stone => new Color(0.55f, 0.55f, 0.55f),
MaterialKind.Clay => new Color(0.75f, 0.45f, 0.25f),
MaterialKind.Water => new Color(0.25f, 0.45f, 0.85f),
MaterialKind.Food => new Color(0.80f, 0.30f, 0.40f),
_ => Colors.Magenta,
};
// Fade toward depletion so dying groves read at a glance
if (node.Kind != MaterialKind.Water)
c = c.Darkened(0.65f * (1f - node.Amount / node.MaxAmount));
var p = MapPos(node.Cell);
DrawRect(new Rect2(p.X, p.Y, CellPixels, CellPixels), c);
}
// --- Map: buildings ----------------------------------------------
for (int i = 0; i < _gm.Buildings.Count; i++)
{
var b = _gm.Buildings[i];
Color c = b.IsRuined ? new Color(0.25f, 0.23f, 0.20f)
: b.Kind == BuildingKind.Granary
? (b.IsComplete ? new Color(0.55f, 0.70f, 0.40f)
: new Color(0.45f, 0.52f, 0.32f))
: b.IsComplete ? new Color(0.9f, 0.85f, 0.6f).Darkened((1f - b.Condition) * 0.5f)
: new Color(0.6f, 0.5f, 0.3f);
float size = CellPixels + 3f + (b.CurrentStage + b.StageProgress) * 2f + b.Modules * 1.5f;
var p = MapPos(b.Site);
DrawRect(new Rect2(p.X - size / 2f, p.Y - size / 2f, size, size), c);
DrawString(font, p + new Vector2(size / 2f + 2f, 4f), $"{i + 1}",
fontSize: 10, modulate: TextDim);
}
// --- Map: NPCs with roster numbers -------------------------------
for (int i = 0; i < _gm.Npcs.Count; i++)
{
var npc = _gm.Npcs[i];
var p = MapPos(npc.Position);
// Selfish souls get a dark red ring — same dot, different halo.
if (npc.Soul.Type == SoulType.Selfish)
DrawCircle(p, 5.5f, new Color(0.75f, 0.15f, 0.15f));
DrawCircle(p, 3.5f, StateColor(npc.State));
DrawString(font, p + new Vector2(5f, -4f), $"{i + 1}",
fontSize: 11, modulate: TextMain);
}
// --- Panel: clock header -----------------------------------------
DrawString(font, new Vector2(PanelX + 16, 30),
$"Day {_gm.Day} · {_gm.SeasonName} {_gm.MinuteOfDay / 60:00}:{_gm.MinuteOfDay % 60:00} " +
$"{(_gm.IsNight ? "· night" : "· day")} {_gm.TicksPerRealSecond:0}x",
fontSize: 18, modulate: TextMain);
// --- Panel: villager roster --------------------------------------
float y = 66f;
DrawString(font, new Vector2(PanelX + 16, y), "VILLAGERS",
fontSize: 13, modulate: TextDim);
y += 10f;
for (int i = 0; i < _gm.Npcs.Count; i++)
{
var npc = _gm.Npcs[i];
y += 22f;
if (npc.Soul.Type == SoulType.Selfish)
DrawCircle(new Vector2(PanelX + 24, y - 5f), 6f, new Color(0.75f, 0.15f, 0.15f));
DrawCircle(new Vector2(PanelX + 24, y - 5f), 4.5f, StateColor(npc.State));
DrawString(font, new Vector2(PanelX + 36, y),
$"{i + 1,2} {npc.State,-11} {npc.Activity}",
fontSize: 13, modulate: TextMain);
DrawString(font, new Vector2(PanelX + 420, y),
$"F {npc.Fatigue,3:0} N {npc.Nourishment * 100,3:0} C {npc.TotalCarried(),4:0.0}" +
$" mostly {npc.DominantStateToday().ToString().ToLower()[..4]}",
fontSize: 12, modulate: TextDim);
}
// --- Panel: buildings --------------------------------------------
y += 34f;
DrawString(font, new Vector2(PanelX + 16, y), "BUILDINGS",
fontSize: 13, modulate: TextDim);
y += 10f;
for (int i = 0; i < _gm.Buildings.Count; i++)
{
var b = _gm.Buildings[i];
y += 20f;
string status = b.IsRuined ? "RUIN"
: !b.IsComplete
? $"building: {b.Stages[b.CurrentStage].Name} {b.StageProgress * 100,3:0}%"
: b.Kind == BuildingKind.Granary
? $"granary food {b.FoodStock,5:0.0}/{Building.FoodStockCap:0}"
: $"cond {b.Condition * 100,3:0}% wood {b.UpkeepStock,4:0.0}/{Building.UpkeepStockCap:0}" +
$" mods {b.Modules}/{Building.ModuleCap} burn {b.DailyBurn:0.0}/day";
DrawString(font, new Vector2(PanelX + 36, y),
$"{i + 1} {status}", fontSize: 13,
modulate: b.IsRuined ? TextDim : TextMain);
}
// --- Panel: world totals -----------------------------------------
y += 34f;
float wood = 0f, stone = 0f, clay = 0f, foodTotal = 0f;
foreach (var n in _gm.World.Nodes)
{
switch (n.Kind)
{
case MaterialKind.Wood: wood += n.Amount; break;
case MaterialKind.Stone: stone += n.Amount; break;
case MaterialKind.Clay: clay += n.Amount; break;
case MaterialKind.Food: foodTotal += n.Amount; break;
}
}
DrawString(font, new Vector2(PanelX + 16, y),
$"WORLD wood {wood:0} stone {stone:0} clay {clay:0} food {foodTotal:0}",
fontSize: 13, modulate: TextDim);
}
}
+1
View File
@@ -0,0 +1 @@
uid://b822hloehyggd
+142
View File
@@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using Godot;
namespace WorldSim;
public enum MaterialKind { Wood, Stone, Clay, Water, Food }
/// <summary>
/// A harvestable resource node on the grid (SPEC §6).
/// Wood regenerates; stone is static; clay sits near water; water is unlimited.
/// </summary>
public class ResourceNode
{
public MaterialKind Kind;
public Vector2I Cell;
public float Amount;
public float MaxAmount;
public float RegenPerDay; // 0 for stone; water ignores Amount entirely
public bool IsDepleted => Kind != MaterialKind.Water && Amount <= 0f;
}
/// <summary>
/// World generation, resource placement, and the shared resource pool (SPEC §9).
/// Grid is 256×256 (SPEC §9 performance target). No scripted town layout —
/// NPCs are dropped in and the community forms where it forms.
/// </summary>
public class World
{
public const int GridSize = 256;
public List<ResourceNode> Nodes { get; } = new();
/// <summary>Units harvested since the last daily stats snapshot.</summary>
public float HarvestedToday;
private readonly Random _rng;
public World(int seed)
{
_rng = new Random(seed);
}
/// <summary>
/// Place resources: water source tiles first, clay near water,
/// wood in clusters (groves), stone in a few deposits.
/// TODO(Week 1): replace uniform scatter with noise-based clustering.
/// </summary>
public void Generate()
{
PlaceMany(MaterialKind.Water, count: 6, amount: float.PositiveInfinity, regen: 0f);
PlaceMany(MaterialKind.Clay, count: 10, amount: 40f, regen: 0.5f); // TODO: constrain near water
PlaceMany(MaterialKind.Wood, count: 60, amount: 30f, regen: 2f);
PlaceMany(MaterialKind.Stone, count: 12, amount: 80f, regen: 0f);
PlaceMany(MaterialKind.Food, count: 40, amount: 20f, regen: 4f); // South: flora most abundant
}
private void PlaceMany(MaterialKind kind, int count, float amount, float regen)
{
for (int i = 0; i < count; i++)
{
Nodes.Add(new ResourceNode
{
Kind = kind,
Cell = new Vector2I(_rng.Next(GridSize), _rng.Next(GridSize)),
Amount = amount,
MaxAmount = amount,
RegenPerDay = regen,
});
}
}
/// <summary>
/// Harvest respecting the harvester's sustainability weight:
/// a South soul stops at ~50% of the node (SPEC §10) so nodes stay healthy.
/// Returns the amount actually taken.
/// </summary>
public float Harvest(ResourceNode node, float requested, SoulProfile soul)
{
if (node.Kind == MaterialKind.Water) return requested; // unlimited at source
float floorFraction = soul.Sustainability * 0.5f; // how much of the node they leave standing
float takeable = Math.Max(0f, node.Amount - node.MaxAmount * floorFraction);
float taken = Math.Min(requested, takeable);
node.Amount -= taken;
HarvestedToday += taken;
return taken;
}
/// <summary>
/// Called once per in-game day by GameManager. Season multipliers throttle
/// regeneration — winter is when the granary earns its clay.
/// </summary>
public void RegenerateDaily(float foodMult = 1f, float woodMult = 1f)
{
bool frost = foodMult < 0.1f; // deep winter
foreach (var node in Nodes)
{
// Frost kills standing food — the wild larder rots on the stem.
// Without this, the sustainability floors act as a huge hidden
// savings account and winter never reaches an actual villager.
if (frost && node.Kind == MaterialKind.Food)
{
node.Amount = Math.Max(node.Amount * 0.93f, node.MaxAmount * 0.15f);
continue;
}
if (node.RegenPerDay <= 0f) continue;
float mult = node.Kind switch
{
MaterialKind.Food => foodMult,
MaterialKind.Wood => woodMult,
_ => 1f,
};
node.Amount = Math.Min(node.MaxAmount, node.Amount + node.RegenPerDay * mult);
}
}
/// <summary>
/// Nearest node this particular soul considers harvestable. What counts
/// as "worth taking" is a moral judgment, not a world fact: a nature
/// soul walks past a node at half strength; a selfish soul strips it to
/// the stem. The commons' savings account only exists for those who
/// honor it.
/// </summary>
public ResourceNode? FindNearest(MaterialKind kind, Vector2 position, float minFraction = 0.5f)
{
ResourceNode? best = null;
float bestDist = float.MaxValue;
foreach (var node in Nodes)
{
if (node.Kind != kind || node.IsDepleted) continue;
if (node.Kind != MaterialKind.Water && node.Amount <= node.MaxAmount * minFraction)
continue;
float d = position.DistanceSquaredTo(node.Cell);
if (d < bestDist) { best = node; bestDist = d; }
}
return best;
}
}
+1
View File
@@ -0,0 +1 @@
uid://jxxevuacgomc