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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user