using System.Collections.Generic; using Godot; namespace WorldSim; public enum NpcState { Gathering, Building, Trading, Resting, Socializing } /// /// NPC data + state machine (SPEC §9, §10). A plain simulation object — /// GameManager ticks it, Visualization draws it. No Node per NPC. /// /// Constrained emergence: this machine defines what an NPC CAN do; /// the SoulProfile weights decide which valid option they take. /// /// The loop is demand-driven so the sim never saturates: /// the community's current need (construction material, then firewood /// upkeep, then personal variety) decides what gets gathered; surplus /// flows to neighbors who lack it or to the buildings that burn it; /// night pulls everyone home to shelter; morning releases them. /// public class Npc { // --- Identity & soul ------------------------------------------------- public string Name = ""; public SoulProfile Soul = SoulProfile.NatureSoul(); public SoulImprint Imprint = new(); // triad — see SPEC §2 amendment public float PersonalityModifier; // per-NPC variance within a soul type // --- Body & position ------------------------------------------------- public Vector2 Position; public float Fatigue; // 0–100; forced rest above 80 public float Health = 100f; // forced rest below 30 public float Nourishment = 1f; // 1 = fed; drains daily, eats from pack // --- State ----------------------------------------------------------- public NpcState State = NpcState.Gathering; /// Human-readable description of the current action — for the /// debug roster only. The player-facing game will never show this. public string Activity = "waking up"; // --- Soul-derived behavior (constrained emergence: same possibility // --- space for everyone; the weights only change the choices) -------- /// Communal souls do communal work; low-bias souls simply don't. public bool IsCommunal => Soul.CommunityBias >= 0.3f; /// Hoarders carry double — accumulation is a way of being. public float EffectiveCapacity => Soul.Accumulation > 0.5f ? CarryCapacity * 2f : CarryCapacity; /// What fraction of a node this soul leaves standing. Nature /// 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. public int[] StateTicksToday = new int[5]; public NpcState DominantStateToday() { int best = 0; for (int i = 1; i < StateTicksToday.Length; i++) if (StateTicksToday[i] > StateTicksToday[best]) best = i; return (NpcState)best; } public Dictionary Inventory = new(); public float CarryCapacity = 20f; private const float MoveSpeed = 0.8f; // grid cells per tick private const float ArriveDist = 1.5f; private ResourceNode? _targetNode; private Npc? _targetNpc; private Building? _foodSource; // granary being visited for food private Npc? _giftSource; // neighbor being asked for food private float _stateTimer; private static readonly MaterialKind[] GatherKinds = { MaterialKind.Wood, MaterialKind.Stone, MaterialKind.Clay }; /// /// One simulation tick = one in-game minute (SPEC §9). /// Order per SPEC §10: vitals check → day/night bias → state behavior → fatigue. /// public void Tick(World world, GameManager gm) { _stateTimer += 1f; StateTicksToday[(int)State]++; // 1. Vitals override everything if (State != NpcState.Resting && (Fatigue > 80f || Health < 30f)) Enter(NpcState.Resting); // Day/night bias (SPEC §10): work by day, rest/socialize by night if (gm.IsNight && State is NpcState.Gathering or NpcState.Trading or NpcState.Building) Enter(Fatigue > 50f ? NpcState.Resting : NpcState.Socializing); // 2–3. State behavior switch (State) { case NpcState.Gathering: TickGathering(world, gm); break; case NpcState.Building: TickBuilding(gm); break; case NpcState.Trading: TickTrading(gm); break; case NpcState.Resting: TickResting(gm); break; case NpcState.Socializing: TickSocializing(gm); break; } // Metabolism: nourishment drains slowly; eat from the pack when hungry. // 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 * (1f - debris * 0.5f)); } if (Nourishment <= 0.05f) Health = Mathf.Max(0f, Health - 0.01f); Nourishment = Mathf.Clamp(Nourishment, 0f, 1f); // 4. Fatigue deltas (SPEC §10) — scaled per tick Fatigue += State switch { NpcState.Gathering => 2f, NpcState.Building => 2f, NpcState.Trading => 1f, NpcState.Resting => -5f, NpcState.Socializing => -2f, _ => 0f, } * 0.1f; Fatigue = Mathf.Clamp(Fatigue, 0f, 100f); } public void Enter(NpcState state) { State = state; _stateTimer = 0f; _targetNode = null; _targetNpc = null; _foodSource = null; _giftSource = null; } public float TotalCarried() { float sum = 0f; foreach (var kv in Inventory) sum += kv.Value; return sum; } private void MoveToward(Vector2 target) => Position = Position.MoveToward(target, MoveSpeed); // --- Demand ---------------------------------------------------------- /// /// What the community needs next, in priority order: /// construction material for an in-progress building, firewood for a /// shelter running low, else personal variety (least-carried). /// This is what turns fifteen individuals into a town coordinating. /// private MaterialKind PickGatherTarget(GameManager gm) { // 1. Feed yourself — keep a food reserve in the pack. Hoarding souls // keep a much bigger one, hungry or not. Inventory.TryGetValue(MaterialKind.Food, out float foodCarried); float reserveTarget = 3f + Soul.Accumulation * 7f; if (foodCarried < reserveTarget && (Nourishment < 0.8f || Soul.Accumulation > 0.5f)) return MaterialKind.Food; if (IsCommunal) { // 2. Construction sites. foreach (var b in gm.Buildings) if (!b.IsComplete && b.NeededMaterial is MaterialKind needed) return needed; // 3. Firewood for shelters running low. foreach (var b in gm.Buildings) if (b.WantsUpkeepWood) return MaterialKind.Wood; // 4. Winter insurance: stock the granary while the land still gives. foreach (var b in gm.Buildings) if (b.WantsFood) return MaterialKind.Food; // 5. Ambition: expansion modules for sound, stocked shelters. foreach (var b in gm.Buildings) if (b.WantsExpansion) return MaterialKind.Wood; } MaterialKind best = GatherKinds[(int)(Mathf.Abs(PersonalityModifier) * 100f) % GatherKinds.Length]; float least = float.MaxValue; foreach (var kind in GatherKinds) { Inventory.TryGetValue(kind, out float have); if (have < least) { least = have; best = kind; } } return best; } /// /// When full but mostly carrying the wrong thing, set the excess down to /// make room for what's actually needed. Lossy for now — /// TODO(Week 2): ground stockpiles so set-down materials persist. /// private void MakeRoomFor(MaterialKind demanded) { Inventory.TryGetValue(demanded, out float haveDemanded); if (haveDemanded >= EffectiveCapacity * 0.25f) return; foreach (var kind in GatherKinds) { if (kind == demanded) continue; if (TotalCarried() <= EffectiveCapacity * 0.5f) break; if (Inventory.TryGetValue(kind, out float have) && have > 0f) Inventory[kind] = have * 0.5f; } } // --- States ---------------------------------------------------------- private void TickGathering(World world, GameManager gm) { // Mid-errand food runs: fetching from the granary or asking a neighbor. if (_foodSource != null) { Activity = "fetching food from the granary"; MoveToward(_foodSource.Site); if (Position.DistanceTo(_foodSource.Site) < ArriveDist) { float got = _foodSource.WithdrawFood(8f); Inventory.TryGetValue(MaterialKind.Food, out float haveF); Inventory[MaterialKind.Food] = haveF + got; _foodSource = null; } return; } if (_giftSource != null) { Activity = $"asking {_giftSource.Name} for food"; MoveToward(_giftSource.Position); if (Position.DistanceTo(_giftSource.Position) < ArriveDist) { TradeSystem.RequestGift(_giftSource, this); _giftSource = null; } return; } var demanded = PickGatherTarget(gm); if (TotalCarried() >= EffectiveCapacity) { // A full pack means different things to different souls: // communal souls take it to the village; hoarders sit on it. if (!IsCommunal) { Enter(NpcState.Socializing); return; } Inventory.TryGetValue(demanded, out float haveDemanded); if (haveDemanded >= EffectiveCapacity * 0.25f) { Enter(NpcState.Building); return; } MakeRoomFor(demanded); if (TotalCarried() >= EffectiveCapacity) { Enter(NpcState.Trading); return; } } if (_targetNode == null || _targetNode.IsDepleted || _targetNode.Kind != demanded) { _targetNode = world.FindNearest(demanded, Position, HarvestSeekFloor); // Hungry and the land has nothing: the granary, then a neighbor. // This is where winter turns scarcity into community. if (_targetNode == null && demanded == MaterialKind.Food) { foreach (var b in gm.Buildings) if (b.Kind == BuildingKind.Granary && b.IsComplete && b.FoodStock > 0f) { _foodSource = b; return; } // 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; float warmth = GetWarmth(other.Name); if (warmth < -0.3f) continue; if (!other.Inventory.TryGetValue(MaterialKind.Food, out float f) || f <= 3f) continue; float score = warmth * 40f - Position.DistanceTo(other.Position); if (score > bestScore) { bestScore = score; _giftSource = other; } } if (_giftSource != null) return; } _targetNode ??= world.FindNearest(MaterialKind.Food, Position, HarvestSeekFloor) ?? world.FindNearest(MaterialKind.Wood, Position, HarvestSeekFloor) ?? world.FindNearest(MaterialKind.Stone, Position, HarvestSeekFloor) ?? world.FindNearest(MaterialKind.Clay, Position, HarvestSeekFloor); if (_targetNode == null) { Enter(NpcState.Socializing); return; } } MoveToward(_targetNode.Cell); if (Position.DistanceTo(_targetNode.Cell) >= ArriveDist) { Activity = $"walking to {_targetNode.Kind.ToString().ToLower()}"; } else { Activity = $"harvesting {_targetNode.Kind.ToString().ToLower()}"; 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; // Enough of what's needed? Communal souls go deliver it. Inventory.TryGetValue(PickGatherTarget(gm), out float demandedHave); if (IsCommunal && demandedHave >= EffectiveCapacity * 0.5f) Enter(NpcState.Building); } } private void TickTrading(GameManager gm) { // Give up after a while — surplus can go to the buildings instead. if (_stateTimer > 120f) { Enter(NpcState.Building); return; } if (_targetNpc == null) { float bestDist = float.MaxValue; foreach (var other in gm.Npcs) { if (other == this) continue; if (!TradeSystem.CanTrade(this, other, out _)) continue; float d = Position.DistanceSquaredTo(other.Position); if (d < bestDist) { bestDist = d; _targetNpc = other; } } if (_targetNpc == null) { Enter(NpcState.Building); return; } } Activity = $"bringing goods to {_targetNpc.Name}"; MoveToward(_targetNpc.Position); if (Position.DistanceTo(_targetNpc.Position) < ArriveDist) { if (TradeSystem.CanTrade(this, _targetNpc, out var material)) TradeSystem.Execute(this, _targetNpc, material); Enter(NpcState.Building); } } private void TickBuilding(GameManager gm) { // Communal work is for communal souls. The others still sleep in the // shelters and eat from the granary — freeriding is the whole point. if (!IsCommunal) { Enter(NpcState.Socializing); return; } // Priority 1: construction sites. Priority 2: shelters low on firewood. Building? site = null; float bestDist = float.MaxValue; foreach (var b in gm.Buildings) { if (b.IsComplete) continue; float d = Position.DistanceSquaredTo(b.Site); if (d < bestDist) { bestDist = d; site = b; } } if (site == null) { foreach (var b in gm.Buildings) { if (!b.WantsUpkeepWood) continue; float d = Position.DistanceSquaredTo(b.Site); if (d < bestDist) { bestDist = d; site = b; } } } if (site == null) { // Granary deposits — only if carrying real food surplus. Inventory.TryGetValue(MaterialKind.Food, out float foodCarried); if (foodCarried > 8f) { foreach (var b in gm.Buildings) { if (!b.WantsFood) continue; float d = Position.DistanceSquaredTo(b.Site); if (d < bestDist) { bestDist = d; site = b; } } } } if (site == null) { foreach (var b in gm.Buildings) { if (!b.WantsExpansion) continue; float d = Position.DistanceSquaredTo(b.Site); if (d < bestDist) { bestDist = d; site = b; } } } if (site == null) { // Communal-first founding, near where the community actually // gathers. Shelters first; once the village stands, the granary. bool carriesStone = Inventory.TryGetValue(MaterialKind.Stone, out float stone) && stone > 0f; int shelters = 0; bool granaryExists = false; foreach (var b in gm.Buildings) { if (b.Kind == BuildingKind.Granary) granaryExists = true; else shelters++; } var jitter = new Vector2(PersonalityModifier * 25f, -PersonalityModifier * 25f); if (shelters < gm.NpcCount / 3 && carriesStone) { site = Building.NewShelter(gm.CommunityCentroid() + jitter); gm.Buildings.Add(site); } else if (!granaryExists && shelters >= gm.NpcCount / 3 && carriesStone) { site = Building.NewGranary(gm.CommunityCentroid() + jitter); gm.Buildings.Add(site); } else { Enter(NpcState.Socializing); return; } } int siteIndex = gm.Buildings.IndexOf(site) + 1; string kindName = site.Kind == BuildingKind.Granary ? "granary" : $"shelter {siteIndex}"; Activity = !site.IsComplete ? $"building {kindName} ({site.NeededMaterial.ToString()!.ToLower()})" : site.Kind == BuildingKind.Granary ? "stocking the granary" : site.WantsUpkeepWood ? $"hauling firewood to {kindName}" : $"expanding {kindName}"; MoveToward(site.Site); if (Position.DistanceTo(site.Site) < ArriveDist) { bool delivered = site.Deliver(this); if (!delivered || site.IsComplete) Enter(NpcState.Socializing); } } private void TickResting(GameManager gm) { // Rest at home: walk to the nearest sound shelter and recover faster there. Building? shelter = null; float bestDist = float.MaxValue; foreach (var b in gm.Buildings) { if (!b.IsComplete || b.IsRuined) continue; float d = Position.DistanceSquaredTo(b.Site); if (d < bestDist) { bestDist = d; shelter = b; } } bool sheltered = false; if (shelter != null) { if (Position.DistanceTo(shelter.Site) > 2.5f) MoveToward(shelter.Site); else sheltered = true; } Activity = sheltered ? "resting at shelter" : shelter != null ? "walking home to rest" : "resting in the open"; Health = Mathf.Min(100f, Health + (sheltered ? 0.4f : 0.1f)); if (sheltered) Fatigue = Mathf.Max(0f, Fatigue - 0.3f); // on top of the state delta if (Fatigue < 40f && !gm.IsNight) Enter(NpcState.Gathering); } private void TickSocializing(GameManager gm) { // Drift toward the nearest neighbor — community forms where feet do. Npc? nearest = null; float bestDist = float.MaxValue; foreach (var other in gm.Npcs) { if (other == this) continue; float d = Position.DistanceSquaredTo(other.Position); if (d < bestDist) { bestDist = d; nearest = other; } } if (nearest != null && Position.DistanceTo(nearest.Position) > 3f) { Activity = $"walking over to {nearest.Name}"; MoveToward(nearest.Position); } else { Activity = nearest != null ? $"chatting with {nearest.Name}" : "idling"; } // TODO(Week 3): bonds + Generosity-weighted spontaneous help. if (!gm.IsNight && _stateTimer > 15f) Enter(NpcState.Gathering); } }