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:
@@ -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.
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://telyx7lw4jh3
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cvugyvd1fuj7l
|
||||
+495
@@ -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; // 0–100; 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);
|
||||
|
||||
// 2–3. 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://di0nhnfp2o8ev
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://ulislfbwqaa8
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dvoko7auk4m5g
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://2fgiwcgq1eh8
|
||||
@@ -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).
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://hcte3kpn8ton
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://b822hloehyggd
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://jxxevuacgomc
|
||||
Reference in New Issue
Block a user