Run 10: organic recovery achieved - minerals are not a commons

Recovery blocker was material, not motivational. Two fixes: (1) harvest
floors and commons debris apply only to living, regenerating resources -
stone does not regrow, so "leave half" merely stranded the world supply
and kept the homeless homeless forever; minerals now quarry to depletion
for every soul equally. (2) Matter conservation: idle variety-gathering
touches only renewables, and packs may only shed what the land replaces
- lossy make-room had milled 900+ stone to dust during demand flapping.

Verified over 260 headless days: year-1 winter still wrecks the naive
town, it rebuilds in 20 days, famine survivors found the first granaries
from hunger memory, and year-2 winter passes with zero ruins on stored
reserves. The town has a history now: naive youth, catastrophe,
learning, resilient maturity.

Remaining tuning in FINDINGS.md: granary sprawl, post-famine nourishment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 15:23:18 -04:00
parent 676a9f3f31
commit e661a3f739
7 changed files with 7011 additions and 7030 deletions
+19 -3
View File
@@ -78,6 +78,11 @@ public class Npc
/// who they are becoming. Indexed by (int)NpcState.</summary>
public int[] StateTicksToday = new int[5];
// --- Diagnostics (read-only views for the stats logger) ---------------
public MaterialKind CurrentDemand(GameManager gm) => PickGatherTarget(gm);
public int RoughNights => _unshelteredRests;
public bool HungerMemory => _hungerMemory;
public NpcState DominantStateToday()
{
int best = 0;
@@ -116,6 +121,17 @@ public class Npc
private static readonly MaterialKind[] GatherKinds =
{ MaterialKind.Wood, MaterialKind.Stone, MaterialKind.Clay };
// Idle variety only touches what regrows. Minerals are mined on purpose
// (construction, a roof of your own) — never as a hobby. Run 10 taught
// us that idle mining + lossy packs can mill a world's stone to dust.
private static readonly MaterialKind[] VarietyKinds =
{ MaterialKind.Wood, MaterialKind.Food };
// What a pack may shed to make room: only matter the land replaces.
// Set-down stone is stone destroyed — and the world doesn't make more.
private static readonly MaterialKind[] DroppableKinds =
{ MaterialKind.Wood, MaterialKind.Food, MaterialKind.Clay };
/// <summary>
/// One simulation tick = one in-game minute (SPEC §9).
/// Order per SPEC §10: vitals check → day/night bias → state behavior → fatigue.
@@ -253,9 +269,9 @@ public class Npc
return MaterialKind.Wood;
}
MaterialKind best = GatherKinds[(int)(Mathf.Abs(PersonalityModifier) * 100f) % GatherKinds.Length];
MaterialKind best = VarietyKinds[(int)(Mathf.Abs(PersonalityModifier) * 100f) % VarietyKinds.Length];
float least = float.MaxValue;
foreach (var kind in GatherKinds)
foreach (var kind in VarietyKinds)
{
Inventory.TryGetValue(kind, out float have);
if (have < least) { least = have; best = kind; }
@@ -292,7 +308,7 @@ public class Npc
Inventory.TryGetValue(demanded, out float haveDemanded);
if (haveDemanded >= EffectiveCapacity * 0.25f) return;
foreach (var kind in GatherKinds)
foreach (var kind in DroppableKinds)
{
if (kind == demanded) continue;
if (TotalCarried() <= EffectiveCapacity * 0.5f) break;
+5 -2
View File
@@ -25,7 +25,7 @@ public static class StatsLogger
private const string NpcHeader =
"day,npc,name,soul,town,dominant,gather_ticks,build_ticks,trade_ticks,rest_ticks,social_ticks," +
"nourish,health,debris";
"nourish,health,debris,demand,rough_nights,hunger_mem";
private const string GiftHeader =
"day,giver,giver_soul,receiver,receiver_soul,material,amount";
@@ -167,7 +167,10 @@ public static class StatsLogger
.Append(t[(int)NpcState.Socializing]).Append(',')
.Append(npc.Nourishment.ToString("0.000", ci)).Append(',')
.Append(npc.Health.ToString("0.0", ci)).Append(',')
.Append(npc.Imprint.Total.ToString("0.0000", ci)).Append('\n');
.Append(npc.Imprint.Total.ToString("0.0000", ci)).Append(',')
.Append(npc.CurrentDemand(gm)).Append(',')
.Append(npc.RoughNights).Append(',')
.Append(npc.HungerMemory ? 1 : 0).Append('\n');
System.Array.Clear(npc.StateTicksToday, 0, npc.StateTicksToday.Length);
}
File.AppendAllText(_npcPath, npcRows.ToString());
+23 -10
View File
@@ -82,21 +82,29 @@ public class World
{
if (node.Kind == MaterialKind.Water) return requested; // unlimited at source
float floorFraction = npc.Soul.Sustainability * 0.5f; // how much of the node they leave standing
// Reciprocity is with the living land: floors and the commons line
// apply only to what regrows. Minerals quarry to depletion, for
// every soul equally, with no mark on anyone.
bool living = node.RegenPerDay > 0f;
float floorFraction = living ? npc.Soul.Sustainability * 0.5f : 0f;
float takeable = Math.Max(0f, node.Amount - node.MaxAmount * floorFraction);
float taken = Math.Min(requested, takeable);
node.Amount -= taken;
HarvestedToday += taken;
// The moral line sits below any respectful harvest floor (nature souls
// stop at 45%) — ordinary sustainable taking never grazes it, while
// strip-harvesting (selfish floor ~10%) lives deep inside it.
float commons = node.MaxAmount * 0.4f;
if (node.Amount < commons)
if (living)
{
float takenBelow = Math.Min(taken, commons - node.Amount);
npc.Imprint.OthersRegard =
Math.Clamp(npc.Imprint.OthersRegard + takenBelow * 0.004f, 0f, 1f);
// The moral line sits below any respectful harvest floor (nature
// souls stop at 45%) — ordinary sustainable taking never grazes
// it, while strip-harvesting (selfish ~10%) lives deep inside it.
float commons = node.MaxAmount * 0.4f;
if (node.Amount < commons)
{
float takenBelow = Math.Min(taken, commons - node.Amount);
npc.Imprint.OthersRegard =
Math.Clamp(npc.Imprint.OthersRegard + takenBelow * 0.004f, 0f, 1f);
}
}
return taken;
@@ -146,7 +154,12 @@ public class World
foreach (var node in Nodes)
{
if (node.Kind != kind || node.IsDepleted) continue;
if (node.Kind != MaterialKind.Water && node.Amount <= node.MaxAmount * minFraction)
// The harvest floor is an ethic of regrowth — it applies only to
// living, regenerating things. Leaving half a berry bush lets it
// live; leaving half a stone deposit protects nobody, and once
// stranded it homelessly, we learned that the hard way (run 9).
if (node.Kind != MaterialKind.Water && node.RegenPerDay > 0f &&
node.Amount <= node.MaxAmount * minFraction)
continue;
float d = position.DistanceSquaredTo(node.Cell);
if (d < bestDist) { best = node; bestDist = d; }