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:
+124
-11
@@ -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}";
|
||||
|
||||
Reference in New Issue
Block a user