Files
Soul_Game/scripts/NPC.cs
T
mmcghen 3f2c5e2f18 Expansion phase: settlements — the North joins the world
Runs 5-6 logged in FINDINGS.md (soul layer validated: nature debris
0.000 vs selfish 0.225 after two years; the ledger works).

Towns are now real spatial structure: TownDef seeds (South at the
bottom of the map, North at the top), NPCs affiliate with a home town,
communal duty serves your own settlement (buildings are town-tagged,
founding uses the town centroid and per-town shelter caps), and each
town raises its own shelters and granary. Generational (North) soul
profile added — temperament only until the knowledge system lands.
Map shows town labels; rosters and CSVs carry town columns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:02:59 -04:00

535 lines
22 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
// --- 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];
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;
private static readonly MaterialKind[] GatherKinds =
{ MaterialKind.Wood, MaterialKind.Stone, 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);
// Day/night bias (SPEC §10): work by day, rest/socialize by night
if (gm.IsNight && State is NpcState.Gathering or NpcState.Trading or NpcState.Building)
Enter(Fatigue > 50f ? NpcState.Resting : NpcState.Socializing);
// 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);
Nourishment = Mathf.Clamp(Nourishment, 0f, 1f);
// 4. Fatigue deltas (SPEC §10) — scaled per tick
Fatigue += State switch
{
NpcState.Gathering => 2f,
NpcState.Building => 2f,
NpcState.Trading => 1f,
NpcState.Resting => -5f,
NpcState.Socializing => -2f,
_ => 0f,
} * 0.1f;
Fatigue = Mathf.Clamp(Fatigue, 0f, 100f);
}
public void Enter(NpcState state)
{
State = state;
_stateTimer = 0f;
_targetNode = null;
_targetNpc = null;
_foodSource = null;
_giftSource = null;
}
public float TotalCarried()
{
float sum = 0f;
foreach (var kv in Inventory) sum += kv.Value;
return sum;
}
private void MoveToward(Vector2 target) =>
Position = Position.MoveToward(target, MoveSpeed);
// --- Demand ----------------------------------------------------------
/// <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.
foreach (var b in gm.Buildings)
if (b.Town == HomeTown && !b.IsComplete && b.NeededMaterial is MaterialKind needed)
return needed;
// 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;
// 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 = GatherKinds[(int)(Mathf.Abs(PersonalityModifier) * 100f) % GatherKinds.Length];
float least = float.MaxValue;
foreach (var kind in GatherKinds)
{
Inventory.TryGetValue(kind, out float have);
if (have < least) { least = have; best = kind; }
}
return best;
}
/// <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 GatherKinds)
{
if (kind == demanded) continue;
if (TotalCarried() <= EffectiveCapacity * 0.5f) break;
if (Inventory.TryGetValue(kind, out float have) && have > 0f)
Inventory[kind] = have * 0.5f;
}
}
// --- States ----------------------------------------------------------
private void TickGathering(World world, GameManager gm)
{
// Mid-errand food runs: fetching from the granary or asking a neighbor.
if (_foodSource != null)
{
Activity = "fetching food from the granary";
MoveToward(_foodSource.Site);
if (Position.DistanceTo(_foodSource.Site) < ArriveDist)
{
float got = _foodSource.WithdrawFood(8f);
Inventory.TryGetValue(MaterialKind.Food, out float haveF);
Inventory[MaterialKind.Food] = haveF + got;
_foodSource = null;
}
return;
}
if (_giftSource != null)
{
Activity = $"asking {_giftSource.Name} for food";
MoveToward(_giftSource.Position);
if (Position.DistanceTo(_giftSource.Position) < ArriveDist)
{
TradeSystem.RequestGift(_giftSource, this);
_giftSource = null;
}
return;
}
var demanded = PickGatherTarget(gm);
if (TotalCarried() >= EffectiveCapacity)
{
// A full pack means different things to different souls:
// communal souls take it to the village; hoarders sit on it.
if (!IsCommunal) { Enter(NpcState.Socializing); return; }
Inventory.TryGetValue(demanded, out float haveDemanded);
if (haveDemanded >= EffectiveCapacity * 0.25f) { Enter(NpcState.Building); return; }
MakeRoomFor(demanded);
if (TotalCarried() >= EffectiveCapacity) { Enter(NpcState.Trading); return; }
}
if (_targetNode == null || _targetNode.IsDepleted || _targetNode.Kind != demanded)
{
_targetNode = world.FindNearest(demanded, Position, HarvestSeekFloor);
// Hungry and the land has nothing: the granary, then a neighbor.
// This is where winter turns scarcity into community.
if (_targetNode == null && demanded == MaterialKind.Food)
{
foreach (var b in gm.Buildings)
if (b.Kind == BuildingKind.Granary && b.IsComplete && b.FoodStock > 0f)
{
_foodSource = b;
return;
}
// Ask whoever might help — chosen by memory, not omniscience.
// The warm are asked first; the remembered-cold aren't asked
// at all. A stranger gets the benefit of the doubt once.
// 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)
{
// Communal-first founding, near where this settlement's people
// actually gather. Shelters first; once the village stands, the
// granary.
bool carriesStone = Inventory.TryGetValue(MaterialKind.Stone, out float stone) && stone > 0f;
int shelters = 0;
bool granaryExists = false;
foreach (var b in gm.Buildings)
{
if (b.Town != HomeTown) continue;
if (b.Kind == BuildingKind.Granary) granaryExists = true;
else shelters++;
}
int shelterCap = gm.TownPopulation(HomeTown) / 3;
var jitter = new Vector2(PersonalityModifier * 25f, -PersonalityModifier * 25f);
if (shelters < shelterCap && carriesStone)
{
site = Building.NewShelter(gm.TownCentroid(HomeTown) + jitter);
site.Town = HomeTown;
gm.Buildings.Add(site);
}
else if (!granaryExists && shelters >= shelterCap && carriesStone)
{
site = Building.NewGranary(gm.TownCentroid(HomeTown) + jitter);
site.Town = HomeTown;
gm.Buildings.Add(site);
}
else { Enter(NpcState.Socializing); return; }
}
int siteIndex = gm.Buildings.IndexOf(site) + 1;
string kindName = site.Kind == BuildingKind.Granary ? "granary" : $"shelter {siteIndex}";
Activity = !site.IsComplete
? $"building {kindName} ({site.NeededMaterial.ToString()!.ToLower()})"
: site.Kind == BuildingKind.Granary
? "stocking the granary"
: site.WantsUpkeepWood
? $"hauling firewood to {kindName}"
: $"expanding {kindName}";
MoveToward(site.Site);
if (Position.DistanceTo(site.Site) < ArriveDist)
{
bool delivered = site.Deliver(this);
if (!delivered || site.IsComplete) Enter(NpcState.Socializing);
}
}
private void TickResting(GameManager gm)
{
// Rest at home: walk to the nearest sound shelter and recover faster there.
Building? shelter = null;
float bestDist = float.MaxValue;
foreach (var b in gm.Buildings)
{
if (!b.IsComplete || b.IsRuined) continue;
float d = Position.DistanceSquaredTo(b.Site);
if (d < bestDist) { bestDist = d; shelter = b; }
}
bool sheltered = false;
if (shelter != null)
{
if (Position.DistanceTo(shelter.Site) > 2.5f) MoveToward(shelter.Site);
else sheltered = true;
}
Activity = sheltered ? "resting at shelter"
: shelter != null ? "walking home to rest"
: "resting in the open";
Health = Mathf.Min(100f, Health + (sheltered ? 0.4f : 0.1f));
if (sheltered) Fatigue = Mathf.Max(0f, Fatigue - 0.3f); // on top of the state delta
if (Fatigue < 40f && !gm.IsNight) Enter(NpcState.Gathering);
}
private void TickSocializing(GameManager gm)
{
// Drift toward the nearest neighbor — community forms where feet do.
Npc? nearest = null;
float bestDist = float.MaxValue;
foreach (var other in gm.Npcs)
{
if (other == this) continue;
float d = Position.DistanceSquaredTo(other.Position);
if (d < bestDist) { bestDist = d; nearest = other; }
}
if (nearest != null && Position.DistanceTo(nearest.Position) > 3f)
{
Activity = $"walking over to {nearest.Name}";
MoveToward(nearest.Position);
}
else
{
Activity = nearest != null ? $"chatting with {nearest.Name}" : "idling";
}
// TODO(Week 3): bonds + Generosity-weighted spontaneous help.
if (!gm.IsNight && _stateTimer > 15f) Enter(NpcState.Gathering);
}
}