3ffde3d9e9
Granary satiation: hunger scars motivate storage only until town capacity could carry a winter (pop x 45); sprawl of ~10 granaries falls to the sated level. Quarry return: packs stuck with undroppable stone after rebuilds made starving porters; unneeded stone goes back to the quarry (matter conserved, packs unclogged). Also corrects the record: run 10's zero-ruin year-2 claim came from CSV contamination by orphaned headless child processes (console wrapper kills didn't kill the tree). Clean verified result now: year-1 catastrophe and rebuild, then year-2 at 0.77 nourishment, zero new ruins, 100% condition, textbook granary sawtooth. Open in FINDINGS: accessible stone exhaustion plateaus housing below population - a Phase 3 economy question (deeper quarrying, stockpiles). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
671 lines
28 KiB
C#
671 lines
28 KiB
C#
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; // 0–100; forced rest above 80
|
||
public float Health = 100f; // forced rest below 30
|
||
public float Nourishment = 1f; // 1 = fed; drains daily, eats from pack
|
||
|
||
// --- State -----------------------------------------------------------
|
||
public NpcState State = NpcState.Gathering;
|
||
|
||
/// <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];
|
||
|
||
// --- 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: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);
|
||
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;
|
||
foreach (var other in gm.Npcs)
|
||
{
|
||
if (other == this) continue;
|
||
float warmth = GetWarmth(other.Name);
|
||
if (warmth < -0.3f) continue;
|
||
if (!other.Inventory.TryGetValue(MaterialKind.Food, out float f) || f <= 3f) continue;
|
||
float score = warmth * 40f - Position.DistanceTo(other.Position);
|
||
if (score > bestScore) { bestScore = score; _giftSource = other; }
|
||
}
|
||
if (_giftSource != null) return;
|
||
}
|
||
|
||
_targetNode ??= world.FindNearest(MaterialKind.Food, Position, HarvestSeekFloor)
|
||
?? world.FindNearest(MaterialKind.Wood, Position, HarvestSeekFloor)
|
||
?? world.FindNearest(MaterialKind.Stone, Position, HarvestSeekFloor)
|
||
?? world.FindNearest(MaterialKind.Clay, Position, HarvestSeekFloor);
|
||
if (_targetNode == null) { Enter(NpcState.Socializing); return; }
|
||
}
|
||
|
||
MoveToward(_targetNode.Cell);
|
||
if (Position.DistanceTo(_targetNode.Cell) >= ArriveDist)
|
||
{
|
||
Activity = $"walking to {_targetNode.Kind.ToString().ToLower()}";
|
||
}
|
||
else
|
||
{
|
||
Activity = $"harvesting {_targetNode.Kind.ToString().ToLower()}";
|
||
float taken = world.Harvest(_targetNode, requested: 2f, this);
|
||
if (taken <= 0f) { _targetNode = null; return; } // node at its floor — re-seek
|
||
Inventory.TryGetValue(_targetNode.Kind, out float have);
|
||
Inventory[_targetNode.Kind] = have + taken;
|
||
|
||
// Enough of what's needed? Communal souls go deliver it.
|
||
Inventory.TryGetValue(PickGatherTarget(gm), out float demandedHave);
|
||
if (IsCommunal && demandedHave >= EffectiveCapacity * 0.5f) Enter(NpcState.Building);
|
||
}
|
||
}
|
||
|
||
private void TickTrading(GameManager gm)
|
||
{
|
||
// Give up after a while — surplus can go to the buildings instead.
|
||
if (_stateTimer > 120f) { Enter(NpcState.Building); return; }
|
||
|
||
if (_targetNpc == null)
|
||
{
|
||
float bestDist = float.MaxValue;
|
||
foreach (var other in gm.Npcs)
|
||
{
|
||
if (other == this) continue;
|
||
if (!TradeSystem.CanTrade(this, other, out _)) continue;
|
||
float d = Position.DistanceSquaredTo(other.Position);
|
||
if (d < bestDist) { bestDist = d; _targetNpc = other; }
|
||
}
|
||
if (_targetNpc == null) { Enter(NpcState.Building); return; }
|
||
}
|
||
|
||
Activity = $"bringing goods to {_targetNpc.Name}";
|
||
MoveToward(_targetNpc.Position);
|
||
if (Position.DistanceTo(_targetNpc.Position) < ArriveDist)
|
||
{
|
||
if (TradeSystem.CanTrade(this, _targetNpc, out var material))
|
||
TradeSystem.Execute(this, _targetNpc, material);
|
||
Enter(NpcState.Building);
|
||
}
|
||
}
|
||
|
||
private void TickBuilding(GameManager gm)
|
||
{
|
||
// Communal work is for communal souls. The others still sleep in the
|
||
// shelters and eat from the granary — freeriding is the whole point.
|
||
if (!IsCommunal) { Enter(NpcState.Socializing); return; }
|
||
|
||
// 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)
|
||
{
|
||
// Drift toward the nearest neighbor — community forms where feet do.
|
||
Npc? nearest = null;
|
||
float bestDist = float.MaxValue;
|
||
foreach (var other in gm.Npcs)
|
||
{
|
||
if (other == this) continue;
|
||
float d = Position.DistanceSquaredTo(other.Position);
|
||
if (d < bestDist) { bestDist = d; nearest = other; }
|
||
}
|
||
if (nearest != null && Position.DistanceTo(nearest.Position) > 3f)
|
||
{
|
||
Activity = $"walking over to {nearest.Name}";
|
||
MoveToward(nearest.Position);
|
||
}
|
||
else
|
||
{
|
||
Activity = nearest != null ? $"chatting with {nearest.Name}" : "idling";
|
||
}
|
||
|
||
// TODO(Week 3): bonds + Generosity-weighted spontaneous help.
|
||
if (!gm.IsNight && _stateTimer > 15f) Enter(NpcState.Gathering);
|
||
}
|
||
}
|