Soul layer: imprints, warmth echoes, and consequences

Ports the Unity POC soul systems into the sim:
- Warmth echoes: villagers remember who fed them (+0.15) and who turned
  them away (-0.25). Ask-for-food targeting runs on memory, not
  omniscience; the remembered-cold are not asked. Echoes fade daily.
- Refusing the hungry while holding food marks the refuser (others-regard
  debris) and is logged; empty-handed is not refusal.
- Harvesting below the 50% commons line marks the taker per unit taken.
- Soul-to-soul atmospheric pressure (SoulFieldSystem port): encased souls
  darken nearby atmospheres, clear souls lighten them.
- Debris costs biologically: nourishment drains faster and meals restore
  less as the soul encases.
- Instrumentation: avg_debris + refusals in daily stats; per-villager
  nourish/health/debris; refusals in the contact log; map dots dim with
  encasement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 13:07:33 -04:00
parent 0ba5341f0e
commit ed78d560ff
6 changed files with 156 additions and 22 deletions
+36 -11
View File
@@ -52,6 +52,26 @@ public class Npc
/// 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;
// --- Social memory: warmth echoes (Unity POC port) --------------------
// Not knowledge — feeling. A villager doesn't know who is generous;
// they remember who fed them and who turned them away.
private readonly Dictionary<string, float> _echoes = new();
public float GetWarmth(string name) => _echoes.TryGetValue(name, out float w) ? w : 0f;
public void RecordEcho(string name, float delta) =>
_echoes[name] = Mathf.Clamp(GetWarmth(name) + delta, -1f, 1f);
/// <summary>Echoes drift back toward neutral — called once per day.
/// A single contact fades in weeks; a pattern of contact holds.</summary>
public void FadeEchoes(float amount)
{
var keys = new List<string>(_echoes.Keys);
foreach (var key in keys)
_echoes[key] = Mathf.MoveToward(_echoes[key], 0f, amount);
}
/// <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>
@@ -107,13 +127,16 @@ public class Npc
}
// 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;
// The soul layer bites here (Unity POC rule): a burdened soul wears
// faster, and the same meal gives it less. Debris is not a scoreboard —
// it is a way of being that costs.
float debris = Imprint.Total;
Nourishment -= (0.35f / 1440f) * (1f + debris);
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);
Nourishment = Mathf.Min(1f, Nourishment + 0.25f * (1f - debris * 0.5f));
}
if (Nourishment <= 0.05f) Health = Mathf.Max(0f, Health - 0.01f);
Nourishment = Mathf.Clamp(Nourishment, 0f, 1f);
@@ -244,8 +267,7 @@ public class Npc
MoveToward(_giftSource.Position);
if (Position.DistanceTo(_giftSource.Position) < ArriveDist)
{
if (TradeSystem.CanTrade(_giftSource, this, out var mat))
TradeSystem.Execute(_giftSource, this, mat);
TradeSystem.RequestGift(_giftSource, this);
_giftSource = null;
}
return;
@@ -280,15 +302,18 @@ public class Npc
return;
}
float bestDist = float.MaxValue;
// Ask whoever might help — chosen by memory, not omniscience.
// The warm are asked first; the remembered-cold aren't asked
// at all. A stranger gets the benefit of the doubt once.
float bestScore = float.MinValue;
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;
float warmth = GetWarmth(other.Name);
if (warmth < -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; }
float score = warmth * 40f - Position.DistanceTo(other.Position);
if (score > bestScore) { bestScore = score; _giftSource = other; }
}
if (_giftSource != null) return;
}
@@ -308,7 +333,7 @@ public class Npc
else
{
Activity = $"harvesting {_targetNode.Kind.ToString().ToLower()}";
float taken = world.Harvest(_targetNode, requested: 2f, Soul);
float taken = world.Harvest(_targetNode, requested: 2f, this);
if (taken <= 0f) { _targetNode = null; return; } // node at its floor — re-seek
Inventory.TryGetValue(_targetNode.Kind, out float have);
Inventory[_targetNode.Kind] = have + taken;