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 string HomeTown = ""; // settlement affiliation — home, not a cage 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 // --- Mortality: knowledge is generational only if generations end ---- public float AgeDays; public float LifeExpectancyDays = 180f; // --- Knowledge (SPEC §5) ---------------------------------------------- public Knowledge Know = new(); // --- Known nodes: the world is only what you've found (addendum §4) ---- // An NPC gathers only from resource nodes it has personally discovered // (by coming within sensing range) or been told about. This is what // makes towns insular echo chambers — no global map, so a town works its // own known ground until scarcity pushes someone to explore. Distinct // from the warmth-echo map (that's NPC-to-NPC trust); this is // NPC-to-place memory, its own structure per the addendum's open Q. public readonly HashSet KnownNodes = new(); public const float SensingRadius = 22f; /// Discover any nodes within sensing range this tick. public void SenseNodes(World world) { world.ForEachNodeNear(Position, SensingRadius, n => KnownNodes.Add(n)); } /// Learn a node from someone who already knows it (social /// diffusion — the same proximity rule as skills, per addendum §4). public void ShareNodeKnowledge(Npc other) { // Tell them about a few of what we know they don't — word of a good // grove travels through a town without a global broadcast. int shared = 0; foreach (var n in KnownNodes) { if (other.KnownNodes.Add(n)) shared++; if (shared >= 3) break; } } /// /// Venture toward unexplored ground and sense as you go (addendum §4 /// wander-when-starved). Bias outward from the town anchor so exploration /// heads into new territory rather than re-treading known home ground. /// Stage 1: a plain outward walk that discovers new nodes. Stage 2 will /// make direction and eagerness soul-weighted. /// private void Explore(GameManager gm) { Activity = "exploring for new ground"; Vector2 anchor = gm.TownAnchor(HomeTown); Vector2 outward = (Position - anchor); if (outward.LengthSquared() < 1f) outward = new Vector2(PersonalityModifier, 1f - Mathf.Abs(PersonalityModifier)); outward = outward.Normalized(); // A little personality-driven veer so explorers fan out, not conga-line. outward = outward.Rotated(PersonalityModifier * 1.2f); Position += outward * MoveSpeed; SenseNodes(gm.World); } /// /// Nearest node of a kind this NPC knows about AND considers worth /// taking (same regrowth-floor ethic as World.FindNearest). Returns null /// if none of their known nodes qualify — the concrete signal to explore. /// private ResourceNode? FindNearestKnown(MaterialKind kind, float minFraction) { ResourceNode? best = null; float bestDist = float.MaxValue; foreach (var node in KnownNodes) { if (node.Kind != kind || node.IsDepleted) continue; if (node.Kind != MaterialKind.Water && node.RegenPerDay > 0f && node.Amount <= node.MaxAmount * minFraction) continue; float d = Position.DistanceSquaredTo(node.Cell); if (d < bestDist) { bestDist = d; best = node; } } return best; } // --- Bonds: the first social verb beyond work (relationships) --------- // A pair-bond forms between two settled adults who keep warm company. // Reproduction is deferred (bonds-first); for now a bond is a standing // relationship — chosen companionship, warmth that compounds, someone // whose grave you would tend. public Npc? Partner; public bool IsBonded => Partner != null; public bool IsAdult => AgeDays >= 20f; // childhood is the first ~20 days // --- 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]; /// /// Learn from another by proximity (SPEC §5). Rate scales with your own /// absorption and their drive to teach. Returns true if anything passed. /// 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; } /// Clear live references to someone who has died. public void ForgetPerson(Npc dead) { if (_targetNpc == dead) _targetNpc = null; if (_giftSource == dead) _giftSource = null; if (Partner == dead) Partner = null; // a bond outlives nothing but memory } // --- 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; 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 int _senseTimer = -1; // ticks until next node-sensing sweep; <0 = stagger on first tick // Organic construction: nights spent sleeping outside. Three rough // nights and a person starts gathering stone for a roof of their own. private int _unshelteredRests; private bool _shelteredDuringRest; private bool _nightDuringRest; // Hunger remembered: someone who has truly starved stockpiles food once // the land gives again. The town's first granary is founded by memory, // not by milestone. private bool _hungerMemory; // Where this person actually sleeps — homes are founded where you live, // not where you happened to be standing with a pack full of stone. private Vector2 _homeSpot; private bool _hasHomeSpot; 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 }; /// /// 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]++; // See what's around you as you live — discovery is passive (addendum // §4). Sensed periodically, not every tick: you don't miss a grove by // checking every ~30 ticks instead of every one, and a full-node scan // per NPC per tick is the sim's heaviest cost otherwise. if (_senseTimer < 0) _senseTimer = (int)(Mathf.Abs(PersonalityModifier) * 150f) % 30; // stagger the herd else if (--_senseTimer <= 0) { SenseNodes(world); _senseTimer = 30; } // 1. Vitals override everything if (State != NpcState.Resting && (Fatigue > 80f || Health < 30f)) Enter(NpcState.Resting); // Night sends everyone to bed — home if they have one, the open sky // if they don't. (Evenings 18:00–22:00 remain the social hours; the // old rule let low-fatigue villagers socialize all night, which // meant beds went unused and rough nights were never experienced.) if (gm.IsNight && State != NpcState.Resting) Enter(NpcState.Resting); // 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); // Herbs are medicine: the hurt who carry them mend faster, and a // skilled forager gets more from the same leaf. Reason to gather // herbs, and a trade good worth carrying between towns. if (Health < 90f && Inventory.TryGetValue(MaterialKind.Herb, out float herb) && herb >= 1f) { Inventory[MaterialKind.Herb] = herb - 1f; Health = Mathf.Min(100f, Health + 6f * (1f + Know[KnowledgeDomain.Forage])); } if (Nourishment < 0.3f) _hungerMemory = true; // real hunger is not forgotten 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; _shelteredDuringRest = false; _nightDuringRest = false; } 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; // Homeless too long → gather stone for a roof, whatever your soul. // Even the selfish build private shelter; they just won't build it // for anyone else. (Communal souls also queue this below, guarded by // whether the town already has a shelter under construction.) if (!IsCommunal && _unshelteredRests >= 3) return MaterialKind.Stone; if (IsCommunal) { // Communal duty is to your own settlement — you heat your own // town's hearths. (Helping a neighbor town is a later, deliberate // system, not an accident of the work queue.) // 2. Construction sites — always first among works. Finishing // the walls of a half-built shelter beats founding another // foundation (the day-40 collapse taught us this one). foreach (var b in gm.Buildings) if (b.Town == HomeTown && !b.IsComplete && b.NeededMaterial is MaterialKind needed) return needed; // 2.5. Kept sleeping outside, and nothing in town is being // built: gather stone to found a roof of your own. if (_unshelteredRests >= 3 && !TownHasIncompleteShelter(gm)) return MaterialKind.Stone; // 3. Firewood for shelters running low. foreach (var b in gm.Buildings) if (b.Town == HomeTown && b.WantsUpkeepWood) return MaterialKind.Wood; // 4. Winter insurance: stock the granary while the land still gives. foreach (var b in gm.Buildings) if (b.Town == HomeTown && b.WantsFood) return MaterialKind.Food; // 4.5. Hunger remembered, and nowhere to store against it: // gather the surplus that will found the granary — until the // town's storage could carry a winter. Scars motivate; they // shouldn't compel forever. Enough is a real quantity. if (_hungerMemory && !TownHasFoodStorage(gm) && !TownStorageSated(gm)) return MaterialKind.Food; // 5. Ambition: expansion modules for sound, stocked shelters. foreach (var b in gm.Buildings) if (b.Town == HomeTown && b.WantsExpansion) return MaterialKind.Wood; } MaterialKind best = VarietyKinds[(int)(Mathf.Abs(PersonalityModifier) * 100f) % VarietyKinds.Length]; float least = float.MaxValue; foreach (var kind in VarietyKinds) { Inventory.TryGetValue(kind, out float have); if (have < least) { least = have; best = kind; } } return best; } /// True if this town has a shelter currently under construction. private bool TownHasIncompleteShelter(GameManager gm) { foreach (var b in gm.Buildings) if (b.Town == HomeTown && b.Kind == BuildingKind.Shelter && !b.IsComplete && !b.IsRuined) return true; return false; } /// True if this town has any granary (built or building) with room. private bool TownHasFoodStorage(GameManager gm) { foreach (var b in gm.Buildings) if (b.Town == HomeTown && b.Kind == BuildingKind.Granary && (!b.IsComplete || b.FoodStock < Building.FoodStockCap)) return true; return false; } /// /// Enough is a real quantity: true when the town's total granary /// capacity (built or building) could carry its people through a /// winter (~45 food per resident). Past this, no new storage rises. /// private bool TownStorageSated(GameManager gm) { float capacity = 0f; foreach (var b in gm.Buildings) if (b.Town == HomeTown && b.Kind == BuildingKind.Granary && !b.IsRuined) capacity += Building.FoodStockCap; return capacity >= gm.TownPopulation(HomeTown) * 45f; } /// /// 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 DroppableKinds) { 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 — // unless they're homeless and carrying stone, in which case even // a selfish soul goes to raise their own roof. if (!IsCommunal) { bool hasStone = Inventory.TryGetValue(MaterialKind.Stone, out float s) && s > 0f; if (_unshelteredRests >= 3 && hasStone) { Enter(NpcState.Building); return; } 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) { // Only ever gather from nodes we personally know (addendum §4). _targetNode = FindNearestKnown(demanded, HarvestSeekFloor); // Don't know a source for what's needed? Explore: move toward // unexplored ground (away from town center) and sense as you go. // This is the stage-1 discovery trigger — a minimal, non-random // venture-out that keeps a hungry NPC from starving atop // undiscovered abundance. (Stage 2 makes it soul-weighted.) if (_targetNode == null && demanded != MaterialKind.Food) { Explore(gm); return; } // Hungry and no known food: the granary, then a neighbor — and // if all else fails, explore for new forage. 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. // Asking is for the genuinely hungry, not for topping a reserve — // this keeps gifts meaningful instead of a food-swapping loop. if (Nourishment >= 0.55f) { Enter(NpcState.Socializing); return; } float bestScore = float.MinValue; Npc? giftFound = null; gm.Grid.ForEachNear(Position, 60f, other => { if (other == this) return; float warmth = GetWarmth(other.Name); if (warmth < -0.3f) return; if (!other.Inventory.TryGetValue(MaterialKind.Food, out float f) || f <= 3f) return; float score = warmth * 40f - Position.DistanceTo(other.Position); if (score > bestScore) { bestScore = score; giftFound = other; } }); _giftSource = giftFound; if (_giftSource != null) return; } // Known food anywhere we've been, at any amount when truly // hungry (survival overrides the commons floor). _targetNode ??= FindNearestKnown(MaterialKind.Food, 0f); // Still nothing known to eat: explore for new forage rather than // starve. This is the safety valve that keeps a town alive while // its known ground recovers — and the seed of first contact. if (_targetNode == null) { Explore(gm); return; } } MoveToward(_targetNode.Cell); if (Position.DistanceTo(_targetNode.Cell) >= ArriveDist) { Activity = $"walking to {_targetNode.Kind.ToString().ToLower()}"; } else { Activity = $"harvesting {_targetNode.Kind.ToString().ToLower()}"; // 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; // Enough of what's needed? Communal souls go deliver it; a // homeless selfish soul with enough stone goes to build its own. Inventory.TryGetValue(PickGatherTarget(gm), out float demandedHave); if (IsCommunal && demandedHave >= EffectiveCapacity * 0.5f) Enter(NpcState.Building); else if (!IsCommunal && _unshelteredRests >= 3 && Inventory.TryGetValue(MaterialKind.Stone, out float st) && st >= 10f) 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; Npc? partner = null; gm.Grid.ForEachNear(Position, 60f, other => { if (other == this) return; if (!TradeSystem.CanTrade(this, other, out _)) return; float d = Position.DistanceSquaredTo(other.Position); if (d < bestDist) { bestDist = d; partner = other; } }); _targetNpc = partner; 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); } } /// /// A self-interested soul raising a roof for itself — private property, /// not communal service. Contributes to their own in-progress shelter, /// or founds one at their spot if carrying stone. This is why an /// all-selfish town (the East) has homes at all: not cooperation, just /// each person eventually housing themselves. /// private void FoundOwnShelter(GameManager gm) { // Contribute to an unfinished shelter near me (likely my own). Building? mine = null; float best = 900f; // within 30 units foreach (var b in gm.Buildings) { if (b.Town != HomeTown || b.IsComplete || b.Kind != BuildingKind.Shelter) continue; float d = Position.DistanceSquaredTo(b.Site); if (d < best) { best = d; mine = b; } } if (mine == null) { // Found one where I sleep — the selfish homestead where they // please, no clustering toward the town's shared center. if (!Inventory.TryGetValue(MaterialKind.Stone, out float stone) || stone <= 0f) { Enter(NpcState.Gathering); // no stone yet — go get it return; } Vector2 spot = _hasHomeSpot ? _homeSpot : Position; mine = Building.NewShelter(spot); mine.Town = HomeTown; gm.Buildings.Add(mine); _unshelteredRests = 0; Godot.GD.Print($"[Homestead] day {gm.Day}: {Name} ({HomeTown}) raises a private roof."); } Activity = "building my own shelter"; MoveToward(mine.Site); if (Position.DistanceTo(mine.Site) < ArriveDist) { bool delivered = mine.Deliver(this); if (!delivered || mine.IsComplete) Enter(NpcState.Socializing); } } private void TickBuilding(GameManager gm) { // Communal work is for communal souls — the others don't tend the // village's hearths or fill its granary. But even a selfish soul // builds a roof for ITSELF: a shelter is private property, and after // enough rough nights they'll raise one (the East's "personal wealth // infrastructure"). So non-communal souls pass only when personally // homeless, and only to found their own shelter — not to serve. if (!IsCommunal) { if (_unshelteredRests < 3) { Enter(NpcState.Socializing); return; } FoundOwnShelter(gm); return; } // Serve your own settlement's buildings, nearest first: // construction → firewood → granary deposits → expansion. Building? site = null; float bestDist = float.MaxValue; foreach (var b in gm.Buildings) { if (b.Town != HomeTown || 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.Town != HomeTown || !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.Town != HomeTown || !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.Town != HomeTown || !b.WantsExpansion) continue; float d = Position.DistanceSquaredTo(b.Site); if (d < bestDist) { bestDist = d; site = b; } } } if (site == null) { // Organic founding — need, not quota. No shelter caps, no // granary milestones. Reaching here means nothing in town // currently needs delivering, so ask: what does *this person* // need that doesn't exist yet? var jitter = new Vector2(PersonalityModifier * 4f, -PersonalityModifier * 4f); // Kept sleeping outside → raise a roof where you actually live: // your sleeping spot, not wherever the stone happened to be. if (_unshelteredRests >= 3) { // Build where you live — but within reach of your people. // A communal soul doesn't homestead a hundred cells from // everyone they know. Vector2 centroid = gm.TownCentroid(HomeTown); Vector2 home = _hasHomeSpot ? _homeSpot : centroid; Vector2 offset = home - centroid; if (offset.Length() > 20f) home = centroid + offset.Normalized() * 20f; site = Building.NewShelter(home + jitter); site.Town = HomeTown; gm.Buildings.Add(site); _unshelteredRests = 0; GD.Print($"[Found] day {gm.Day}: {Name} founds shelter at {site.Site} " + $"(centroid dist {(site.Site - gm.TownCentroid(HomeTown)).Length():0})"); } // Hunger remembered + a surplus with nowhere to put it → the // barn goes where the harvest is. Founded by scarcity survived, // not by abundance alone — and only until the town's storage // could carry a winter. Enough is enough. else if (_hungerMemory && !TownStorageSated(gm) && Inventory.TryGetValue(MaterialKind.Food, out float surplus) && surplus > 8f) { site = Building.NewGranary(Position + jitter); site.Town = HomeTown; gm.Buildings.Add(site); } // Nothing needs your stone: take it back to the quarry rather // than carry a house on your back forever. Matter is conserved; // packs unclog; the quarry is the town's bank. else if (Inventory.TryGetValue(MaterialKind.Stone, out float stoneCarried) && stoneCarried > 5f) { var quarry = gm.World.FindNearestAny(MaterialKind.Stone, Position); if (quarry == null) { Enter(NpcState.Socializing); return; } Activity = "returning stone to the quarry"; MoveToward(quarry.Cell); if (Position.DistanceTo(quarry.Cell) < ArriveDist) { quarry.Amount = Mathf.Min(quarry.MaxAmount, quarry.Amount + stoneCarried); Inventory[MaterialKind.Stone] = 0f; Enter(NpcState.Socializing); } return; } 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 the nearest shelter WITH A FREE BED. Beds are real: // capacity is 3 + modules, and a full house turns you away. Building? shelter = null; float bestDist = float.MaxValue; foreach (var b in gm.Buildings) { if (b.RestCapacity <= 0) continue; // Read the cached per-tick occupancy; +1 to leave room for us if // we're not already counted there. bool alreadyHere = Position.DistanceSquaredTo(b.Site) < 6.25f; int taken = alreadyHere ? b.Occupancy - 1 : b.Occupancy; if (taken >= b.RestCapacity) continue; float d = Position.DistanceSquaredTo(b.Site); if (d < bestDist) { bestDist = d; shelter = b; } } // A bed is only yours if it exists when you lie down in it — twenty // people on the floor of a three-bed house are not "sheltered." bool sheltered = false; if (shelter != null) { if (Position.DistanceTo(shelter.Site) > 2.5f) MoveToward(shelter.Site); else sheltered = true; // we selected a shelter with a free bed } Activity = sheltered ? "resting at shelter" : shelter != null ? "walking home to rest" : "resting in the open"; if (sheltered) { _shelteredDuringRest = true; _unshelteredRests = 0; // a bed found is a grievance forgotten } if (gm.IsNight) _nightDuringRest = true; // Where you habitually sleep is where you live — a running average, // so one night camping at a mine doesn't redefine your life. if (sheltered || shelter == null) { _homeSpot = _hasHomeSpot ? _homeSpot.Lerp(Position, 0.2f) : Position; _hasHomeSpot = true; } 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) { // Only unsheltered NIGHTS count as grievances — a rough nap // in a field is life; a rough night is a reason to build. if (!_shelteredDuringRest && _nightDuringRest) _unshelteredRests++; Enter(NpcState.Gathering); } } private void TickSocializing(GameManager gm) { // 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; if (Soul.Absorption > 1.2f && Know.Total < Know.Capacity * 0.6f) { Npc? teacher = null; float bestWorth = 0.15f; // must actually know more than us gm.Grid.ForEachNear(Position, 40f, other => { if (other == this || other.HomeTown != HomeTown) return; 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; } } // A bonded soul keeps their partner's company by preference — // relationships are a pull the work queue doesn't override. if (IsBonded && Partner!.Health > 0f) { if (Position.DistanceTo(Partner.Position) > 3f) { Activity = $"with {Partner.Name}"; MoveToward(Partner.Position); } else { RecordEcho(Partner.Name, 0.02f); Activity = $"beside {Partner.Name}"; } if (!gm.IsNight && _stateTimer > 20f) Enter(NpcState.Gathering); return; } // Otherwise: drift toward the nearest neighbor — community forms // where feet do. Grid-scoped so it stays cheap at scale. Npc? found = null; float fBest = float.MaxValue; gm.Grid.ForEachNear(Position, 30f, other => { if (other == this) return; float d = Position.DistanceSquaredTo(other.Position); if (d < fBest) { fBest = d; found = other; } }); nearest = found; // Consider bonding: an unbonded adult who keeps warm company with a // compatible, unbonded neighbor may pair with them — real warmth, // both grown, companionship chosen not assigned. Town is NOT a // requirement: usually you bond within your own people (that's who // you're near), but a wanderer who warms to someone from another // town can shack up across cultures — the first quiet thread of // contact between two peoples. if (!IsBonded && IsAdult && nearest is { IsBonded: false, IsAdult: true } n && Position.DistanceTo(n.Position) < 4f && GetWarmth(n.Name) > 0.4f && n.GetWarmth(Name) > 0.4f) { Partner = n; n.Partner = this; string kind = n.HomeTown == HomeTown ? HomeTown : $"{HomeTown}+{n.HomeTown}"; Godot.GD.Print($"[Bond] day {gm.Day}: {Name} and {n.Name} ({kind}) pair up."); } if (nearest != null && Position.DistanceTo(nearest.Position) > 3f) { Activity = $"walking over to {nearest.Name}"; MoveToward(nearest.Position); } else if (nearest != null) { // Company that isn't teaching still warms — this is how bonds // become possible: familiarity accrues just by spending time. RecordEcho(nearest.Name, 0.01f); // Word of good ground travels between neighbors — the same // proximity diffusion as skills, applied to places (addendum §4). // Gated to the sense cadence so it isn't re-run every tick (the // set-iteration was a real per-tick cost at scale). if (_senseTimer >= 29) { nearest.ShareNodeKnowledge(this); ShareNodeKnowledge(nearest); } Activity = LearnFrom(nearest) ? $"learning from {nearest.Name}" : $"chatting with {nearest.Name}"; } else { Activity = "idling"; } // TODO(Week 3): bonds + Generosity-weighted spontaneous help. if (!gm.IsNight && _stateTimer > 15f) Enter(NpcState.Gathering); } }