Files
Soul_Game/scripts/NPC.cs
T
mmcghen d7448c497d Grid-scope the food-gift and trade-partner searches
Route the two remaining full-population scans (a hungry villager seeking
someone with food; a trader seeking a partner) through the NPC spatial
grid with a 60-cell reach instead of scanning all ~80 villagers. Realistic
reach, cheaper at scale.

(A resource-node spatial index was also tried and reverted: benchmarked
~13% slower - at ~1,260 nodes the linear array scan is cache-friendly and
beats a dictionary-of-lists ring search. The node scan was never the
bottleneck; profile before optimizing next time.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 18:28:35 -04:00

808 lines
34 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using Godot;
namespace WorldSim;
public enum NpcState { Gathering, Building, Trading, Resting, Socializing }
/// <summary>
/// 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.
/// </summary>
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; // 0100; 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();
// --- 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;
/// <summary>Human-readable description of the current action — for the
/// debug roster only. The player-facing game will never show this.</summary>
public string Activity = "waking up";
// --- Soul-derived behavior (constrained emergence: same possibility
// --- space for everyone; the weights only change the choices) --------
/// <summary>Communal souls do communal work; low-bias souls simply don't.</summary>
public bool IsCommunal => Soul.CommunityBias >= 0.3f;
/// <summary>Hoarders carry double — accumulation is a way of being.</summary>
public float EffectiveCapacity => Soul.Accumulation > 0.5f ? CarryCapacity * 2f : CarryCapacity;
/// <summary>What fraction of a node this soul leaves standing. Nature
/// souls honor the 50% commons floor; selfish souls strip to ~15%.</summary>
public float HarvestSeekFloor => Mathf.Max(0.05f, Soul.Sustainability * 0.5f) + 0.05f;
// --- Social memory: warmth echoes (Unity POC port) --------------------
// Not knowledge — feeling. A villager doesn't know who is generous;
// they remember who fed them and who turned them away.
private readonly Dictionary<string, float> _echoes = new();
public float GetWarmth(string name) => _echoes.TryGetValue(name, out float w) ? w : 0f;
public void RecordEcho(string name, float delta) =>
_echoes[name] = Mathf.Clamp(GetWarmth(name) + delta, -1f, 1f);
/// <summary>Echoes drift back toward neutral — called once per day.
/// A single contact fades in weeks; a pattern of contact holds.</summary>
public void FadeEchoes(float amount)
{
var keys = new List<string>(_echoes.Keys);
foreach (var key in keys)
_echoes[key] = Mathf.MoveToward(_echoes[key], 0f, amount);
}
/// <summary>Ticks spent in each state since the last daily snapshot —
/// the raw material of emergent roles: what a person mostly does is
/// who they are becoming. Indexed by (int)NpcState.</summary>
public int[] StateTicksToday = new int[5];
/// <summary>
/// Learn from another by proximity (SPEC §5). Rate scales with your own
/// absorption and their drive to teach. Returns true if anything passed.
/// </summary>
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;
}
/// <summary>Clear live references to someone who has died.</summary>
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<MaterialKind, float> 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;
// 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 };
/// <summary>
/// One simulation tick = one in-game minute (SPEC §9).
/// Order per SPEC §10: vitals check → day/night bias → state behavior → fatigue.
/// </summary>
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);
// Night sends everyone to bed — home if they have one, the open sky
// if they don't. (Evenings 18:0022: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);
// 23. 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 ----------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
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)
{
// 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;
}
/// <summary>True if this town has a shelter currently under construction.</summary>
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;
}
/// <summary>True if this town has any granary (built or building) with room.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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.
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.
// 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;
}
_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()}";
// 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.
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;
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);
}
}
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; }
// 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;
if (gm.RestingOccupancy(b, this) >= 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 = gm.RestingOccupancy(shelter, this) < shelter.RestCapacity;
}
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;
float bestDist = float.MaxValue;
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. Same town, real
// warmth, both grown — companionship chosen, not assigned.
if (!IsBonded && IsAdult && nearest is { IsBonded: false, IsAdult: true } n &&
n.HomeTown == HomeTown && Position.DistanceTo(n.Position) < 4f &&
GetWarmth(n.Name) > 0.4f && n.GetWarmth(Name) > 0.4f)
{
Partner = n;
n.Partner = this;
Godot.GD.Print($"[Bond] day {gm.Day}: {Name} and {n.Name} ({HomeTown}) 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);
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);
}
}