From ed78d560ff27d4f498333d2ce0743d8d7656bf6c Mon Sep 17 00:00:00 2001 From: Mike McGhen Date: Tue, 21 Jul 2026 13:07:33 -0400 Subject: [PATCH] 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 --- scripts/GameManager.cs | 36 ++++++++++++++++++++++++++++++ scripts/NPC.cs | 47 ++++++++++++++++++++++++++++++---------- scripts/StatsLogger.cs | 28 ++++++++++++++++++++---- scripts/TradeSystem.cs | 40 ++++++++++++++++++++++++++++++++++ scripts/Visualization.cs | 12 +++++----- scripts/World.cs | 15 +++++++++++-- 6 files changed, 156 insertions(+), 22 deletions(-) diff --git a/scripts/GameManager.cs b/scripts/GameManager.cs index fc92527..9440e52 100644 --- a/scripts/GameManager.cs +++ b/scripts/GameManager.cs @@ -96,6 +96,38 @@ public partial class GameManager : Node2D } } + /// + /// Soul-to-soul atmospheric pressure (ported from the Unity POC's + /// SoulFieldSystem): souls in proximity press on each other's + /// atmospheres. An encased soul darkens the room; a clear soul makes + /// it slightly easier for everyone near them to radiate. Both + /// directions apply; the net effect depends on relative debris. + /// + private void ApplySoulPressure() + { + const float radius = 8f; + const float strength = 0.00004f; + + for (int i = 0; i < Npcs.Count; i++) + { + for (int j = i + 1; j < Npcs.Count; j++) + { + var a = Npcs[i]; + var b = Npcs[j]; + float dist = a.Position.DistanceTo(b.Position); + if (dist > radius) continue; + + float falloff = 1f - dist / radius; + + float fromA = Mathf.Lerp(-strength, strength, a.Imprint.Total) * falloff; + b.Imprint.ExternalPerception = Mathf.Clamp(b.Imprint.ExternalPerception + fromA, 0f, 1f); + + float fromB = Mathf.Lerp(-strength, strength, b.Imprint.Total) * falloff; + a.Imprint.ExternalPerception = Mathf.Clamp(a.Imprint.ExternalPerception + fromB, 0f, 1f); + } + } + } + /// Where the community's feet actually are — new communal /// buildings are founded here, not at a scripted town center. public Vector2 CommunityCentroid() @@ -113,11 +145,15 @@ public partial class GameManager : Node2D foreach (var npc in Npcs) npc.Tick(World, this); + ApplySoulPressure(); + if (TotalTicks % 1440 == 0) { World.RegenerateDaily(SeasonFoodMult, SeasonWoodMult); foreach (var building in Buildings) building.DailyUpkeep(); + foreach (var npc in Npcs) + npc.FadeEchoes(0.005f); StatsLogger.LogDay(this); if (Day % 10 == 0) GD.Print($"[WorldSim] Day {Day} complete."); diff --git a/scripts/NPC.cs b/scripts/NPC.cs index 9575add..a923c01 100644 --- a/scripts/NPC.cs +++ b/scripts/NPC.cs @@ -52,6 +52,26 @@ public class Npc /// souls honor the 50% commons floor; selfish souls strip to ~15%. 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 _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); + + /// Echoes drift back toward neutral — called once per day. + /// A single contact fades in weeks; a pattern of contact holds. + public void FadeEchoes(float amount) + { + var keys = new List(_echoes.Keys); + foreach (var key in keys) + _echoes[key] = Mathf.MoveToward(_echoes[key], 0f, amount); + } + /// 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. @@ -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; diff --git a/scripts/StatsLogger.cs b/scripts/StatsLogger.cs index 86a0854..cdc4bf1 100644 --- a/scripts/StatsLogger.cs +++ b/scripts/StatsLogger.cs @@ -20,10 +20,12 @@ public static class StatsLogger "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"; + "shelters_done,ruins,avg_cond,upkeep_stock,burn_per_day,modules,granary_food," + + "avg_debris,refusals"; private const string NpcHeader = - "day,npc,name,soul,dominant,gather_ticks,build_ticks,trade_ticks,rest_ticks,social_ticks"; + "day,npc,name,soul,dominant,gather_ticks,build_ticks,trade_ticks,rest_ticks,social_ticks," + + "nourish,health,debris"; private const string GiftHeader = "day,giver,giver_soul,receiver,receiver_soul,material,amount"; @@ -52,6 +54,15 @@ public static class StatsLogger $"{material},{amount.ToString("0.0", CultureInfo.InvariantCulture)}\n"); } + /// Every refusal too — the gift log is really a contact log. + public static void LogRefusal(Npc giver, Npc asker) + { + if (string.IsNullOrEmpty(_giftPath)) return; + int day = GameManager.Instance?.Day ?? -1; + File.AppendAllText(_giftPath, + $"{day},{giver.Name},{giver.Soul.Type},{asker.Name},{asker.Soul.Type},Refusal,0.0\n"); + } + public static void LogDay(GameManager gm) { var ci = CultureInfo.InvariantCulture; @@ -123,7 +134,12 @@ public static class StatsLogger 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)); + row.Append(granaryFood.ToString("0.0", ci)).Append(','); + + float debrisSum = 0f; + foreach (var npc in gm.Npcs) debrisSum += npc.Imprint.Total; + row.Append((debrisSum / count).ToString("0.0000", ci)).Append(','); + row.Append(TradeSystem.RefusalsToday); File.AppendAllText(_path, row + "\n"); @@ -143,7 +159,10 @@ public static class StatsLogger .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'); + .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'); System.Array.Clear(npc.StateTicksToday, 0, npc.StateTicksToday.Length); } File.AppendAllText(_npcPath, npcRows.ToString()); @@ -151,5 +170,6 @@ public static class StatsLogger // Reset per-day counters gm.World.HarvestedToday = 0f; TradeSystem.TradesToday = 0; + TradeSystem.RefusalsToday = 0; } } diff --git a/scripts/TradeSystem.cs b/scripts/TradeSystem.cs index b0b8fb8..70aa76e 100644 --- a/scripts/TradeSystem.cs +++ b/scripts/TradeSystem.cs @@ -13,6 +13,42 @@ public static class TradeSystem /// Trades since the last daily stats snapshot. public static int TradesToday; + /// Refusals since the last daily stats snapshot. + public static int RefusalsToday; + + /// + /// A hungry villager asks another for food, face to face. + /// + /// Willingness comes from the soul (or from a warm history with this + /// particular person). Refusing the hungry while holding food is a + /// deliberate darkening of another — it marks the refuser's soul + /// (others-regard debris, per DESIGN.md) and leaves a cold echo in the + /// asker's memory. The world keeps score the only way it knows how. + /// + public static void RequestGift(Npc giver, Npc asker) + { + giver.Inventory.TryGetValue(MaterialKind.Food, out float giverFood); + bool hasFood = giverFood > 3f; + bool willing = giver.Soul.Generosity >= 0.3f || giver.GetWarmth(asker.Name) > 0.5f; + + if (hasFood && willing) + { + Execute(giver, asker, MaterialKind.Food); + return; + } + + if (hasFood && !willing) + { + // Refused with a full pack — the act that costs. + RefusalsToday++; + asker.RecordEcho(giver.Name, -0.25f); + giver.Imprint.OthersRegard = Godot.Mathf.Clamp(giver.Imprint.OthersRegard + 0.02f, 0f, 1f); + StatsLogger.LogRefusal(giver, asker); + } + // Empty-handed is not refusal: no debris, no echo. You cannot darken + // your soul with what you do not have. + } + /// /// Need-based: the trigger is a person in need, not an inventory gap. /// Food flows to the hungry first; raw material surplus vs. genuine @@ -52,6 +88,10 @@ public static class TradeSystem StatsLogger.LogGift(giver, receiver, material, given); + // Warmth lands on both sides — more on the one who was fed. + receiver.RecordEcho(giver.Name, +0.15f); + giver.RecordEcho(receiver.Name, +0.05f); + // The kindness lands on both atmospheres — gently. giver.Imprint.ExternalPerception -= 0.002f; receiver.Imprint.ExternalPerception -= 0.002f; diff --git a/scripts/Visualization.cs b/scripts/Visualization.cs index 8bd3516..21b2672 100644 --- a/scripts/Visualization.cs +++ b/scripts/Visualization.cs @@ -97,7 +97,8 @@ public partial class Visualization : Node2D // 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)); + // Debris dims the dot — encasement made visible. + DrawCircle(p, 3.5f, StateColor(npc.State).Darkened(npc.Imprint.Total * 0.7f)); DrawString(font, p + new Vector2(5f, -4f), $"{i + 1}", fontSize: 11, modulate: TextMain); } @@ -121,13 +122,14 @@ public partial class Visualization : Node2D 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)); + DrawCircle(new Vector2(PanelX + 24, y - 5f), 4.5f, + StateColor(npc.State).Darkened(npc.Imprint.Total * 0.7f)); 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]}", + 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]}", fontSize: 12, modulate: TextDim); } diff --git a/scripts/World.cs b/scripts/World.cs index 39d19d7..56001c6 100644 --- a/scripts/World.cs +++ b/scripts/World.cs @@ -74,17 +74,28 @@ public class World /// /// Harvest respecting the harvester's sustainability weight: /// a South soul stops at ~50% of the node (SPEC §10) so nodes stay healthy. + /// Taking from below the commons line marks the taker's soul — it is + /// taking from everyone, whether anyone sees it or not. /// Returns the amount actually taken. /// - public float Harvest(ResourceNode node, float requested, SoulProfile soul) + public float Harvest(ResourceNode node, float requested, Npc npc) { 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 floorFraction = npc.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; + + float commons = node.MaxAmount * 0.5f; + 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; }