Run 12: knowledge system, mortality, and the North's generational soul
SPEC §5 built: three skill domains (forage/woodcraft/masonry) that pay off and grow by practice; proximity diffusion soul-calibrated by absorption x teaching-drive; and mortality - the piece that makes knowledge generational. Elders die (~150-230 days), everything unshared dies with them, a youth comes of age near knowing nothing. North soul (absorption 1.6, teaching 0.9) vs South (1.0, 0.3); East teaches no one. Two findings: (1) headless soaks were contaminated by orphaned child processes writing later runs' CSVs - fixed with StopAfterDays clean self-terminate; (2) passive diffusion barely helped - the North's real culture is the young seeking elders on purpose, so added deliberate teacher-seeking and youths now cradle beside the wisest elder. North now leads knowledge at every checkpoint, but a stable town approaches the ceiling regardless, so teaching is a recovery advantage not an equilibrium one. The crisis-recovery experiment is the real test, noted in FINDINGS as next. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+6
-2
@@ -151,9 +151,13 @@ public class Building
|
||||
if (!npc.Inventory.TryGetValue(stage.Material, out float have) || have <= 0f)
|
||||
return false;
|
||||
|
||||
float take = Mathf.Min(have, stage.Cost - Delivered);
|
||||
// A practiced mason wastes less: the same materials go further
|
||||
// in skilled hands — and the building is the teacher.
|
||||
float masonry = npc.Know[KnowledgeDomain.Masonry];
|
||||
float take = Mathf.Min(have, (stage.Cost - Delivered) / (1f + masonry * 0.5f));
|
||||
npc.Inventory[stage.Material] = have - take;
|
||||
Delivered += take;
|
||||
Delivered += take * (1f + masonry * 0.5f);
|
||||
npc.Know.Gain(KnowledgeDomain.Masonry, 0.002f);
|
||||
|
||||
if (Delivered >= stage.Cost)
|
||||
{
|
||||
|
||||
+79
-2
@@ -36,6 +36,11 @@ public partial class GameManager : Node2D
|
||||
[Export(PropertyHint.Range, "1,2000,1")]
|
||||
public float TicksPerRealSecond = 20f; // sim speed; ~1500 ≈ one day/sec for soaks
|
||||
|
||||
/// <summary>Auto-quit after this many days (0 = run forever). Set for
|
||||
/// headless soaks so runs self-terminate cleanly — no external kill, no
|
||||
/// orphaned process racing the next run's CSV writes.</summary>
|
||||
[Export] public int StopAfterDays = 0;
|
||||
|
||||
public World World { get; private set; } = null!;
|
||||
public List<Npc> Npcs { get; } = new();
|
||||
public List<Building> Buildings { get; } = new();
|
||||
@@ -91,14 +96,20 @@ public partial class GameManager : Node2D
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Npcs.Add(new Npc
|
||||
var npc = new Npc
|
||||
{
|
||||
Name = $"{prefix}_{(char)('A' + Npcs.Count % 26)}{(Npcs.Count >= 26 ? Npcs.Count.ToString() : "")}",
|
||||
HomeTown = town,
|
||||
Soul = soul,
|
||||
PersonalityModifier = (float)(rng.NextDouble() * 0.4 - 0.2),
|
||||
Position = center + new Vector2(rng.Next(-12, 13), rng.Next(-12, 13)),
|
||||
});
|
||||
// Staggered ages so the founding generation doesn't die
|
||||
// in one terrible week.
|
||||
AgeDays = rng.Next(0, 100),
|
||||
LifeExpectancyDays = 150 + rng.Next(0, 80),
|
||||
};
|
||||
npc.Know.Capacity = 1.0f + (float)rng.NextDouble();
|
||||
Npcs.Add(npc);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +209,65 @@ public partial class GameManager : Node2D
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mortality: generations end, and what wasn't passed on ends too --
|
||||
|
||||
public int DeathsToday { get; set; }
|
||||
private int _bornCount;
|
||||
|
||||
private void TickMortality()
|
||||
{
|
||||
var dying = new List<Npc>();
|
||||
foreach (var npc in Npcs)
|
||||
{
|
||||
npc.AgeDays += 1f;
|
||||
if (npc.AgeDays >= npc.LifeExpectancyDays)
|
||||
dying.Add(npc);
|
||||
}
|
||||
|
||||
var rng = new System.Random(Seed + (int)TotalTicks);
|
||||
foreach (var dead in Npcs.ToArray())
|
||||
{
|
||||
if (!dying.Contains(dead)) continue;
|
||||
Npcs.Remove(dead);
|
||||
DeathsToday++;
|
||||
|
||||
// Everything unshared dies with them: skills, memories, echoes.
|
||||
// What survives is what they taught, built, and stored.
|
||||
foreach (var other in Npcs)
|
||||
other.ForgetPerson(dead);
|
||||
|
||||
// A youth comes of age in the same town, same culture, knowing
|
||||
// almost nothing — but raised among the living, not in a void.
|
||||
// They spawn beside the town's wisest elder, so the young begin
|
||||
// life within reach of what the community still remembers. The
|
||||
// town's knowledge is only what its living carry — but the young
|
||||
// start where that knowledge is.
|
||||
_bornCount++;
|
||||
Npc? elder = null;
|
||||
float bestKnow = -1f;
|
||||
foreach (var other in Npcs)
|
||||
if (other.HomeTown == dead.HomeTown && other.Know.Total > bestKnow)
|
||||
{ bestKnow = other.Know.Total; elder = other; }
|
||||
Vector2 cradle = elder?.Position ?? TownCentroid(dead.HomeTown);
|
||||
|
||||
var youth = new Npc
|
||||
{
|
||||
Name = $"{dead.HomeTown}_{(char)('A' + _bornCount % 26)}{_bornCount + 25}",
|
||||
HomeTown = dead.HomeTown,
|
||||
Soul = dead.Soul, // raised in the culture that raised them
|
||||
PersonalityModifier = (float)(rng.NextDouble() * 0.4 - 0.2),
|
||||
Position = cradle + new Vector2(rng.Next(-4, 5), rng.Next(-4, 5)),
|
||||
AgeDays = 0f,
|
||||
LifeExpectancyDays = 150 + rng.Next(0, 80),
|
||||
};
|
||||
youth.Know.Capacity = 1.0f + (float)rng.NextDouble();
|
||||
Npcs.Add(youth);
|
||||
|
||||
GD.Print($"[Passing] day {Day}: {dead.Name} dies at {dead.AgeDays:0} days " +
|
||||
$"(knowledge {dead.Know.Total:0.00} lost); {youth.Name} comes of age.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Where the community's feet actually are — new communal
|
||||
/// buildings are founded here, not at a scripted town center.</summary>
|
||||
public Vector2 CommunityCentroid()
|
||||
@@ -224,9 +294,16 @@ public partial class GameManager : Node2D
|
||||
building.DailyUpkeep();
|
||||
foreach (var npc in Npcs)
|
||||
npc.FadeEchoes(0.005f);
|
||||
TickMortality();
|
||||
StatsLogger.LogDay(this);
|
||||
if (Day % 10 == 0)
|
||||
GD.Print($"[WorldSim] Day {Day} complete.");
|
||||
|
||||
if (StopAfterDays > 0 && Day >= StopAfterDays)
|
||||
{
|
||||
GD.Print($"[WorldSim] Reached day {Day} — stopping.");
|
||||
GetTree().Quit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using Godot;
|
||||
|
||||
namespace WorldSim;
|
||||
|
||||
/// <summary>Domains of practical knowledge (SPEC §5 — deliberately few).</summary>
|
||||
public enum KnowledgeDomain { Forage, Woodcraft, Masonry }
|
||||
|
||||
/// <summary>
|
||||
/// What a person knows how to do (SPEC §5). Not an object in the world —
|
||||
/// it lives in people, spreads by proximity, grows by practice, and dies
|
||||
/// with its holder unless it was passed on. Every death is a small library
|
||||
/// burning; a teaching culture is a culture of copies.
|
||||
///
|
||||
/// Capacity is personal and randomized: some people can hold more than
|
||||
/// others, and what fills first is what they lived.
|
||||
/// </summary>
|
||||
public class Knowledge
|
||||
{
|
||||
public const int DomainCount = 3;
|
||||
|
||||
/// <summary>0–1 per domain.</summary>
|
||||
public float[] Skill = new float[DomainCount];
|
||||
|
||||
/// <summary>Total learning this mind can hold, across all domains.</summary>
|
||||
public float Capacity = 1.5f;
|
||||
|
||||
public float Total => Skill[0] + Skill[1] + Skill[2];
|
||||
public float Headroom => Mathf.Max(0f, Capacity - Total);
|
||||
|
||||
public float this[KnowledgeDomain d] => Skill[(int)d];
|
||||
|
||||
/// <summary>Learn, limited by personal capacity.</summary>
|
||||
public void Gain(KnowledgeDomain d, float amount)
|
||||
{
|
||||
if (amount <= 0f || Headroom <= 0f) return;
|
||||
float a = Mathf.Min(amount, Headroom);
|
||||
Skill[(int)d] = Mathf.Clamp(Skill[(int)d] + a, 0f, 1f);
|
||||
}
|
||||
|
||||
/// <summary>Which domain a material teaches and benefits from.</summary>
|
||||
public static KnowledgeDomain DomainFor(MaterialKind kind) => kind switch
|
||||
{
|
||||
MaterialKind.Food => KnowledgeDomain.Forage,
|
||||
MaterialKind.Wood => KnowledgeDomain.Woodcraft,
|
||||
_ => KnowledgeDomain.Masonry, // stone, clay — builder's craft
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://f5ki6rajbww1
|
||||
+80
-3
@@ -33,6 +33,13 @@ public class Npc
|
||||
public float Health = 100f; // forced rest below 30
|
||||
public float Nourishment = 1f; // 1 = fed; drains daily, eats from pack
|
||||
|
||||
// --- Mortality: knowledge is generational only if generations end ----
|
||||
public float AgeDays;
|
||||
public float LifeExpectancyDays = 180f;
|
||||
|
||||
// --- Knowledge (SPEC §5) ----------------------------------------------
|
||||
public Knowledge Know = new();
|
||||
|
||||
// --- State -----------------------------------------------------------
|
||||
public NpcState State = NpcState.Gathering;
|
||||
|
||||
@@ -78,6 +85,33 @@ public class Npc
|
||||
/// who they are becoming. Indexed by (int)NpcState.</summary>
|
||||
public int[] StateTicksToday = new int[5];
|
||||
|
||||
/// <summary>
|
||||
/// Learn from another by proximity (SPEC §5). Rate scales with your own
|
||||
/// absorption and their drive to teach. Returns true if anything passed.
|
||||
/// </summary>
|
||||
private bool LearnFrom(Npc other)
|
||||
{
|
||||
bool learned = false;
|
||||
float rate = 0.0016f * Soul.Absorption * (1f + other.Soul.TeachingDrive * 2.5f);
|
||||
for (int d = 0; d < Knowledge.DomainCount; d++)
|
||||
{
|
||||
float gap = other.Know.Skill[d] - Know.Skill[d];
|
||||
if (gap > 0.03f)
|
||||
{
|
||||
Know.Gain((KnowledgeDomain)d, gap * rate);
|
||||
learned = true;
|
||||
}
|
||||
}
|
||||
return learned;
|
||||
}
|
||||
|
||||
/// <summary>Clear live references to someone who has died.</summary>
|
||||
public void ForgetPerson(Npc dead)
|
||||
{
|
||||
if (_targetNpc == dead) _targetNpc = null;
|
||||
if (_giftSource == dead) _giftSource = null;
|
||||
}
|
||||
|
||||
// --- Diagnostics (read-only views for the stats logger) ---------------
|
||||
public MaterialKind CurrentDemand(GameManager gm) => PickGatherTarget(gm);
|
||||
public int RoughNights => _unshelteredRests;
|
||||
@@ -426,8 +460,12 @@ public class Npc
|
||||
else
|
||||
{
|
||||
Activity = $"harvesting {_targetNode.Kind.ToString().ToLower()}";
|
||||
float taken = world.Harvest(_targetNode, requested: 2f, this);
|
||||
// Skill pays: a practiced hand takes more per reach — and the
|
||||
// work itself is the teacher.
|
||||
var domain = Knowledge.DomainFor(_targetNode.Kind);
|
||||
float taken = world.Harvest(_targetNode, requested: 2f * (1f + Know[domain]), this);
|
||||
if (taken <= 0f) { _targetNode = null; return; } // node at its floor — re-seek
|
||||
Know.Gain(domain, 0.0006f);
|
||||
Inventory.TryGetValue(_targetNode.Kind, out float have);
|
||||
Inventory[_targetNode.Kind] = have + taken;
|
||||
|
||||
@@ -645,9 +683,42 @@ public class Npc
|
||||
|
||||
private void TickSocializing(GameManager gm)
|
||||
{
|
||||
// Drift toward the nearest neighbor — community forms where feet do.
|
||||
// The young seek a teacher. A soul that still has much to learn and
|
||||
// the drive to learn it looks for the wisest elder in town rather
|
||||
// than drifting to whoever's closest. This is the North's culture in
|
||||
// motion: the young sitting at the feet of the old, on purpose.
|
||||
Npc? nearest = null;
|
||||
float bestDist = float.MaxValue;
|
||||
|
||||
if (Soul.Absorption > 1.2f && Know.Total < Know.Capacity * 0.6f)
|
||||
{
|
||||
Npc? teacher = null;
|
||||
float bestWorth = 0.15f; // must actually know more than us
|
||||
foreach (var other in gm.Npcs)
|
||||
{
|
||||
if (other == this || other.HomeTown != HomeTown) continue;
|
||||
float worth = (other.Know.Total - Know.Total) * (1f + other.Soul.TeachingDrive);
|
||||
if (worth > bestWorth) { bestWorth = worth; teacher = other; }
|
||||
}
|
||||
if (teacher != null)
|
||||
{
|
||||
if (Position.DistanceTo(teacher.Position) > 3f)
|
||||
{
|
||||
Activity = $"seeking out {teacher.Name} to learn";
|
||||
MoveToward(teacher.Position);
|
||||
}
|
||||
else
|
||||
{
|
||||
LearnFrom(teacher);
|
||||
Activity = $"learning from {teacher.Name}";
|
||||
}
|
||||
if (!gm.IsNight && _stateTimer > 15f) Enter(NpcState.Gathering);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise: drift toward the nearest neighbor — community forms
|
||||
// where feet do.
|
||||
foreach (var other in gm.Npcs)
|
||||
{
|
||||
if (other == this) continue;
|
||||
@@ -659,9 +730,15 @@ public class Npc
|
||||
Activity = $"walking over to {nearest.Name}";
|
||||
MoveToward(nearest.Position);
|
||||
}
|
||||
else if (nearest != null)
|
||||
{
|
||||
Activity = LearnFrom(nearest)
|
||||
? $"learning from {nearest.Name}"
|
||||
: $"chatting with {nearest.Name}";
|
||||
}
|
||||
else
|
||||
{
|
||||
Activity = nearest != null ? $"chatting with {nearest.Name}" : "idling";
|
||||
Activity = "idling";
|
||||
}
|
||||
|
||||
// TODO(Week 3): bonds + Generosity-weighted spontaneous help.
|
||||
|
||||
@@ -53,6 +53,12 @@ public class SoulProfile
|
||||
public float StatusBias; // West: visible-wealth preference (unused in MVP)
|
||||
public float Accumulation; // East: hoarding pressure (unused in MVP)
|
||||
|
||||
// Knowledge culture (SPEC §5): how readily this soul absorbs from
|
||||
// others, and how deliberately it passes on what it knows. The North's
|
||||
// whole identity lives in these two numbers.
|
||||
public float Absorption = 1f; // learning rate multiplier
|
||||
public float TeachingDrive = 0f; // how much being near this soul teaches
|
||||
|
||||
public static SoulProfile NatureSoul() => new()
|
||||
{
|
||||
Type = SoulType.Nature,
|
||||
@@ -61,6 +67,8 @@ public class SoulProfile
|
||||
CommunityBias = 0.8f,
|
||||
StatusBias = 0.1f,
|
||||
Accumulation = 0.1f,
|
||||
Absorption = 1.0f,
|
||||
TeachingDrive = 0.3f, // knowledge passes casually, by living together
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -77,6 +85,8 @@ public class SoulProfile
|
||||
CommunityBias = 0.95f,
|
||||
StatusBias = 0.15f,
|
||||
Accumulation = 0.2f,
|
||||
Absorption = 1.6f, // raised to receive what elders hand down
|
||||
TeachingDrive = 0.9f, // and to hand it down in turn — the whole culture
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -92,5 +102,7 @@ public class SoulProfile
|
||||
CommunityBias = 0.1f,
|
||||
StatusBias = 0.5f,
|
||||
Accumulation = 0.9f,
|
||||
Absorption = 0.7f, // they take, but rarely sit still to learn
|
||||
TeachingDrive = 0.0f, // and teach no one anything, ever
|
||||
};
|
||||
}
|
||||
|
||||
+15
-4
@@ -21,11 +21,12 @@ public static class StatsLogger
|
||||
"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," +
|
||||
"avg_debris,refusals,incomplete_sites";
|
||||
"avg_debris,refusals,incomplete_sites,avg_knowledge,deaths";
|
||||
|
||||
private const string NpcHeader =
|
||||
"day,npc,name,soul,town,dominant,gather_ticks,build_ticks,trade_ticks,rest_ticks,social_ticks," +
|
||||
"nourish,health,debris,demand,rough_nights,hunger_mem";
|
||||
"nourish,health,debris,demand,rough_nights,hunger_mem," +
|
||||
"age,know_forage,know_wood,know_masonry";
|
||||
|
||||
private const string GiftHeader =
|
||||
"day,giver,giver_soul,receiver,receiver_soul,material,amount";
|
||||
@@ -143,7 +144,12 @@ public static class StatsLogger
|
||||
|
||||
int incomplete = 0;
|
||||
foreach (var b in gm.Buildings) if (!b.IsComplete) incomplete++;
|
||||
row.Append(incomplete);
|
||||
row.Append(incomplete).Append(',');
|
||||
|
||||
float knowSum = 0f;
|
||||
foreach (var npc in gm.Npcs) knowSum += npc.Know.Total;
|
||||
row.Append((knowSum / count).ToString("0.000", ci)).Append(',');
|
||||
row.Append(gm.DeathsToday);
|
||||
|
||||
File.AppendAllText(_path, row + "\n");
|
||||
|
||||
@@ -170,7 +176,11 @@ public static class StatsLogger
|
||||
.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');
|
||||
.Append(npc.HungerMemory ? 1 : 0).Append(',')
|
||||
.Append(npc.AgeDays.ToString("0", ci)).Append(',')
|
||||
.Append(npc.Know[KnowledgeDomain.Forage].ToString("0.000", ci)).Append(',')
|
||||
.Append(npc.Know[KnowledgeDomain.Woodcraft].ToString("0.000", ci)).Append(',')
|
||||
.Append(npc.Know[KnowledgeDomain.Masonry].ToString("0.000", ci)).Append('\n');
|
||||
System.Array.Clear(npc.StateTicksToday, 0, npc.StateTicksToday.Length);
|
||||
}
|
||||
File.AppendAllText(_npcPath, npcRows.ToString());
|
||||
@@ -179,5 +189,6 @@ public static class StatsLogger
|
||||
gm.World.HarvestedToday = 0f;
|
||||
TradeSystem.TradesToday = 0;
|
||||
TradeSystem.RefusalsToday = 0;
|
||||
gm.DeathsToday = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,9 +135,10 @@ public partial class Visualization : Node2D
|
||||
DrawString(font, new Vector2(PanelX + 36, y),
|
||||
$"{i + 1,2} {npc.State,-11} {npc.Activity}",
|
||||
fontSize: 13, modulate: TextMain);
|
||||
DrawString(font, new Vector2(PanelX + 400, y),
|
||||
$"F {npc.Fatigue,3:0} N {npc.Nourishment * 100,3:0} D {npc.Imprint.Total * 100,2:0}" +
|
||||
$" C {npc.TotalCarried(),4:0.0} {npc.DominantStateToday().ToString().ToLower()[..4]}",
|
||||
DrawString(font, new Vector2(PanelX + 390, y),
|
||||
$"N {npc.Nourishment * 100,3:0} D {npc.Imprint.Total * 100,2:0}" +
|
||||
$" K {npc.Know.Total,4:0.00} A {npc.AgeDays,3:0}" +
|
||||
$" {npc.DominantStateToday().ToString().ToLower()[..4]}",
|
||||
fontSize: 12, modulate: TextDim);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user