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();
// Known structures: same principle as known nodes — you can only use or
// return to a building you've discovered or raised yourself. This is what
// lets a lone builder finish their OWN home (they remember the foundation
// they laid) instead of re-founding a new one every time they wander off
// for wood, and it keeps towns insular: you don't beeline to a granary
// across the world you've never seen.
public readonly HashSet KnownStructures = new();
/// Discover buildings within sensing range this tick.
public void SenseStructures(GameManager gm)
{
foreach (var b in gm.Buildings)
if (Position.DistanceSquaredTo(b.Site) <= SensingRadius * SensingRadius)
KnownStructures.Add(b);
}
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 fromHome = Position - anchor;
float distHome = fromHome.Length();
// Stay in the home range. A person forages OUT from their village and
// back, they don't march to the horizon — so past a search radius they
// turn homeward. This keeps a strip-happy town (the East) from slowly
// migrating into its neighbors instead of settling its own ground.
const float SearchRadius = 140f;
Vector2 dir;
if (distHome > SearchRadius)
{
dir = (-fromHome).Normalized(); // head back toward home
dir = dir.Rotated(PersonalityModifier * 0.6f); // slight veer, still homeward
}
else
{
// Outward from home in a per-soul direction. When right at the
// anchor there's no outward vector, so pick an angle from the
// soul's identity — NOT a fixed vector (the old default leaned
// hard +Y, i.e. south, quietly funnelling every town's explorers
// into the southern corner).
if (distHome > 1f)
dir = fromHome.Normalized();
else
{
float ang = (Name.GetHashCode() & 0xFFFF) / 65535f * Mathf.Tau;
dir = new Vector2(Mathf.Cos(ang), Mathf.Sin(ang));
}
dir = dir.Rotated(PersonalityModifier * 1.4f + 0.6f); // circle the home range
}
Position += dir * 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);
SenseStructures(gm);
_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 → build a roof, whatever your soul. Even the
// selfish build private shelter (just never for anyone else). If they
// already have one under construction, gather what its NEXT stage
// needs — a lone builder must supply their own build end to end, or
// it stalls at the walls (this is why East homes never finished).
if (!IsCommunal && _unshelteredRests >= 3)
{
var mine = MyUnfinishedShelter(gm);
if (mine?.NeededMaterial is MaterialKind need) return need;
return MaterialKind.Stone; // none yet — gather the foundation
}
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)
{
// Homeless and carrying what their roof needs next? Go build.
if (_unshelteredRests >= 3 && CarriesShelterMaterial(gm))
{ 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 && CarriesShelterMaterial(gm))
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);
}
}
/// True if this homeless builder is carrying enough of the
/// material their private roof needs next (or enough stone to found one).
private bool CarriesShelterMaterial(GameManager gm)
{
var mine = MyUnfinishedShelter(gm);
var need = mine?.NeededMaterial ?? MaterialKind.Stone;
return Inventory.TryGetValue(need, out float have) && have >= 8f;
}
/// This soul's own in-progress private shelter, if any — the
/// nearest unfinished shelter they KNOW about in their town. Because it
/// searches known structures (not all buildings by proximity), a builder
/// reliably returns to the foundation they laid instead of forgetting it
/// the moment they wander off and founding another.
private Building? MyUnfinishedShelter(GameManager gm)
{
Building? mine = null;
float best = float.MaxValue;
foreach (var b in KnownStructures)
{
if (b.Town != HomeTown || b.IsComplete || b.IsRuined ||
b.Kind != BuildingKind.Shelter) continue;
float d = Position.DistanceSquaredTo(b.Site);
if (d < best) { best = d; mine = b; }
}
return mine;
}
///
/// 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)
{
// My own unfinished home — found via known-structures memory, so I
// return to the foundation I laid however far I wandered for wood,
// rather than re-founding a new one each trip.
Building? mine = MyUnfinishedShelter(gm);
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;
}
// Homestead on your OWN town's land — selfish souls sprawl (a
// wide range, no clustering to the shared center) but they build
// in the east if they're an East soul, not wherever they drifted
// into a neighbor's territory.
Vector2 anchor = gm.TownAnchor(HomeTown);
Vector2 spot = _hasHomeSpot ? _homeSpot : Position;
Vector2 off = spot - anchor;
if (off.Length() > 120f) spot = anchor + off.Normalized() * 120f;
mine = Building.NewShelter(spot);
mine.Town = HomeTown;
gm.Buildings.Add(mine);
KnownStructures.Add(mine); // you never forget the home you laid
// Don't clear the homeless drive yet — a foundation isn't a home.
// It resets only once the roof is on (below), so the builder keeps
// supplying walls and roof instead of stalling at the foundation.
Godot.GD.Print($"[Homestead] day {gm.Day}: {Name} ({HomeTown}) breaks ground at " +
$"({mine.Site.X:0},{mine.Site.Y:0}) — {(mine.Site - gm.TownAnchor(HomeTown)).Length():0} from home.");
}
Activity = "building my own home";
MoveToward(mine.Site);
if (Position.DistanceTo(mine.Site) < ArriveDist)
{
mine.Deliver(this);
if (mine.IsComplete)
{
_unshelteredRests = 0; // a home at last — drive satisfied
Enter(NpcState.Socializing);
}
else
{
// Foundation down but walls/roof still needed — go fetch the
// next material and come back. A lone builder finishes their
// own house or it never gets finished.
Enter(NpcState.Gathering);
}
}
}
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);
KnownStructures.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);
KnownStructures.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);
}
}