Run 15: known-nodes exploration (addendum stage 1), per-town debug, O(n^2) fix

Exploration addendum stage 1: per-NPC known-nodes. NPCs gather only from
resources they've personally sensed (radius 22) or been told about;
founders seed with home ground, children inherit parents' maps, node
knowledge diffuses between neighbors like skills. Starving NPCs venture
outward and sense as they go - non-random needs-driven exploration, the
seed of first contact. Repopulation preserved (seeding + survival food
fallback): all towns fed, population stable 86-109 over 240 days.

Debug screen rebuilt: the ~68-row per-villager roster (overflowing at
four-town scale) replaced with a per-town summary - pop, fed%, knowledge,
debris, state breakdown, buildings, culture-colored, plus world
births/deaths. Scales to any population.

Performance: added phase timers and measured instead of guessing - npc.Tick
was 94% of cost, O(n^2) via TickResting calling RestingOccupancy (full
population scan) per building per resting NPC. Fixed with a once-per-tick
occupancy cache on Building. npc phase ~4x faster and now linear; full
240-day four-town run in 180s, faster than the pre-exploration baseline.
Profiling scaffolding removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 21:17:24 -04:00
parent 6cbe4bcf83
commit 10e772d087
7 changed files with 378 additions and 66 deletions
+124 -11
View File
@@ -40,6 +40,79 @@ public class Npc
// --- 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<ResourceNode> KnownNodes = new();
public const float SensingRadius = 22f;
/// <summary>Discover any nodes within sensing range this tick.</summary>
public void SenseNodes(World world)
{
world.ForEachNodeNear(Position, SensingRadius, n => KnownNodes.Add(n));
}
/// <summary>Learn a node from someone who already knows it (social
/// diffusion — the same proximity rule as skills, per addendum §4).</summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
private void Explore(GameManager gm)
{
Activity = "exploring for new ground";
Vector2 anchor = gm.TownAnchor(HomeTown);
Vector2 outward = (Position - anchor);
if (outward.LengthSquared() < 1f)
outward = new Vector2(PersonalityModifier, 1f - Mathf.Abs(PersonalityModifier));
outward = outward.Normalized();
// A little personality-driven veer so explorers fan out, not conga-line.
outward = outward.Rotated(PersonalityModifier * 1.2f);
Position += outward * MoveSpeed;
SenseNodes(gm.World);
}
/// <summary>
/// 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.
/// </summary>
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
@@ -145,6 +218,7 @@ public class Npc
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.
@@ -185,6 +259,18 @@ public class Npc
_stateTimer += 1f;
StateTicksToday[(int)State]++;
// See what's around you as you live — discovery is passive (addendum
// §4). Sensed periodically, not every tick: you don't miss a grove by
// checking every ~30 ticks instead of every one, and a full-node scan
// per NPC per tick is the sim's heaviest cost otherwise.
if (_senseTimer < 0)
_senseTimer = (int)(Mathf.Abs(PersonalityModifier) * 150f) % 30; // stagger the herd
else if (--_senseTimer <= 0)
{
SenseNodes(world);
_senseTimer = 30;
}
// 1. Vitals override everything
if (State != NpcState.Resting && (Fatigue > 80f || Health < 30f))
Enter(NpcState.Resting);
@@ -433,10 +519,22 @@ public class Npc
if (_targetNode == null || _targetNode.IsDepleted || _targetNode.Kind != demanded)
{
_targetNode = world.FindNearest(demanded, Position, HarvestSeekFloor);
// Only ever gather from nodes we personally know (addendum §4).
_targetNode = FindNearestKnown(demanded, HarvestSeekFloor);
// Hungry and the land has nothing: the granary, then a neighbor.
// This is where winter turns scarcity into community.
// 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)
@@ -467,11 +565,14 @@ public class Npc
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; }
// 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);
@@ -660,7 +761,11 @@ public class Npc
foreach (var b in gm.Buildings)
{
if (b.RestCapacity <= 0) continue;
if (gm.RestingOccupancy(b, this) >= b.RestCapacity) 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; }
}
@@ -671,7 +776,7 @@ public class Npc
if (shelter != null)
{
if (Position.DistanceTo(shelter.Site) > 2.5f) MoveToward(shelter.Site);
else sheltered = gm.RestingOccupancy(shelter, this) < shelter.RestCapacity;
else sheltered = true; // we selected a shelter with a free bed
}
Activity = sheltered ? "resting at shelter"
: shelter != null ? "walking home to rest"
@@ -712,7 +817,6 @@ public class Npc
// 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)
{
@@ -792,6 +896,15 @@ public class Npc
// 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}";