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
+5
View File
@@ -94,6 +94,11 @@ public class Building
/// <summary>How many can sleep here. Expansion adds beds — growth has a
/// human meaning, not just a bigger square.</summary>
/// <summary>Sleepers here, recomputed once per tick by GameManager —
/// so resting NPCs read a cached count instead of each re-scanning the
/// whole population per building (that was the sim's O(n²) hot spot).</summary>
public int Occupancy;
public int RestCapacity =>
Kind == BuildingKind.Shelter && IsComplete && !IsRuined ? 3 + Modules : 0;
+26 -1
View File
@@ -123,6 +123,10 @@ public partial class GameManager : Node2D
LifeExpectancyDays = 150 + rng.Next(0, 80),
};
npc.Know.Capacity = 1.0f + (float)rng.NextDouble();
// Founders know their home ground — the resources around the
// town seed. They begin with a working local map, not blind.
foreach (var node in World.NodesWithin(center, 130f))
npc.KnownNodes.Add(node);
Npcs.Add(npc);
}
}
@@ -308,6 +312,10 @@ public partial class GameManager : Node2D
LifeExpectancyDays = 150 + rng.Next(0, 80),
};
child.Know.Capacity = 1.0f + (float)rng.NextDouble();
// A child inherits its parents' map — the family's known grounds,
// passed down like everything else a generation hands forward.
foreach (var node in a.KnownNodes) child.KnownNodes.Add(node);
foreach (var node in b.KnownNodes) child.KnownNodes.Add(node);
newborns.Add(child);
BirthsToday++;
pop[a.HomeTown] = (int)townPop + 1; // count the newborn toward capacity
@@ -331,6 +339,23 @@ public partial class GameManager : Node2D
{
Grid.Rebuild(Npcs);
// Recompute shelter occupancy once per tick (was O(n²): each resting
// NPC re-scanned everyone, per building). Now: one pass, cached — each
// sleeper counts toward the nearest shelter they're actually inside.
foreach (var b in Buildings) b.Occupancy = 0;
foreach (var npc in Npcs)
{
if (npc.State != NpcState.Resting) continue;
Building? at = null; float best = 6.25f; // within 2.5 units
foreach (var b in Buildings)
{
if (b.RestCapacity <= 0) continue;
float d = npc.Position.DistanceSquaredTo(b.Site);
if (d < best) { best = d; at = b; }
}
if (at != null) at.Occupancy++;
}
TotalTicks++;
foreach (var npc in Npcs)
@@ -349,7 +374,7 @@ public partial class GameManager : Node2D
TickReproduction();
StatsLogger.LogDay(this);
if (Day % 10 == 0)
GD.Print($"[WorldSim] Day {Day} complete.");
GD.Print($"[WorldSim] Day {Day} complete (pop {Npcs.Count}).");
if (StopAfterDays > 0 && Day >= StopAfterDays)
{
+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}";
+74 -50
View File
@@ -119,68 +119,92 @@ public partial class Visualization : Node2D
$"{(_gm.IsNight ? "· night" : "· day")} {_gm.TicksPerRealSecond:0}x",
fontSize: 18, modulate: TextMain);
// --- Panel: villager roster --------------------------------------
float y = 66f;
DrawString(font, new Vector2(PanelX + 16, y), "VILLAGERS",
fontSize: 13, modulate: TextDim);
y += 10f;
// --- Panel: per-town summary (scales to any population) ----------
float y = 74f;
for (int i = 0; i < _gm.Npcs.Count; i++)
foreach (var town in _gm.Towns)
{
var npc = _gm.Npcs[i];
y += 20f;
// Aggregate this town's people.
int pop = 0; float nour = 0f, know = 0f, debris = 0f;
int gathering = 0, building = 0, resting = 0, social = 0, other = 0;
foreach (var npc in _gm.Npcs)
{
if (npc.HomeTown != town.Name) continue;
pop++; nour += npc.Nourishment; know += npc.Know.Total; debris += npc.Imprint.Total;
switch (npc.State)
{
case NpcState.Gathering: gathering++; break;
case NpcState.Building: building++; break;
case NpcState.Resting: resting++; break;
case NpcState.Socializing: social++; break;
default: other++; break;
}
}
if (npc.Soul.Type == SoulType.Selfish)
DrawCircle(new Vector2(PanelX + 24, y - 5f), 6f, new Color(0.75f, 0.15f, 0.15f));
DrawCircle(new Vector2(PanelX + 24, y - 5f), 4.5f,
StateColor(npc.State).Darkened(npc.Imprint.Total * 0.7f));
DrawString(font, new Vector2(PanelX + 36, y),
$"{i + 1,2} {npc.State,-11} {npc.Activity}",
fontSize: 13, modulate: TextMain);
DrawString(font, new Vector2(PanelX + 390, y),
$"N {npc.Nourishment * 100,3:0} D {npc.Imprint.Total * 100,2:0}" +
$" K {npc.Know.Total,4:0.00} A {npc.AgeDays,3:0}" +
$" {npc.DominantStateToday().ToString().ToLower()[..4]}",
fontSize: 12, modulate: TextDim);
// Aggregate this town's buildings.
int shelters = 0, ruins = 0; float granary = 0f;
foreach (var b in _gm.Buildings)
{
if (b.Town != town.Name) continue;
if (b.IsRuined) ruins++;
else if (b.Kind == BuildingKind.Granary) granary += b.FoodStock;
else if (b.IsComplete) shelters++;
}
var head = TownColor(town.Soul);
DrawString(font, new Vector2(PanelX + 16, y),
$"{town.Name.ToUpper()} · {town.Soul.ToString().ToLower()}",
fontSize: 15, modulate: pop > 0 ? head : TextDim);
DrawString(font, new Vector2(PanelX + 250, y),
pop > 0 ? $"pop {pop}" : "— extinct —",
fontSize: 15, modulate: pop > 0 ? TextMain : new Color(0.8f, 0.4f, 0.4f));
y += 22f;
if (pop > 0)
{
float inv = 1f / pop;
DrawString(font, new Vector2(PanelX + 28, y),
$"fed {nour * inv * 100,3:0}% know {know * inv,4:0.00} debris {debris * inv * 100,2:0}%",
fontSize: 13, modulate: TextDim);
DrawString(font, new Vector2(PanelX + 320, y),
$"⌂{shelters} ▦{granary:0}" + (ruins > 0 ? $" ✗{ruins}" : ""),
fontSize: 13, modulate: TextDim);
y += 19f;
DrawString(font, new Vector2(PanelX + 28, y),
$"gather {gathering} build {building} rest {resting} social {social}" +
(other > 0 ? $" other {other}" : ""),
fontSize: 12, modulate: TextDim);
y += 19f;
}
y += 12f;
}
// --- Panel: buildings --------------------------------------------
y += 34f;
DrawString(font, new Vector2(PanelX + 16, y), "BUILDINGS",
fontSize: 13, modulate: TextDim);
y += 10f;
for (int i = 0; i < _gm.Buildings.Count; i++)
{
var b = _gm.Buildings[i];
y += 18f;
string status = b.IsRuined ? "RUIN"
: !b.IsComplete
? $"building: {b.Stages[b.CurrentStage].Name} {b.StageProgress * 100,3:0}%"
: b.Kind == BuildingKind.Granary
? $"granary food {b.FoodStock,5:0.0}/{Building.FoodStockCap:0}"
: $"cond {b.Condition * 100,3:0}% wood {b.UpkeepStock,4:0.0}/{Building.UpkeepStockCap:0}" +
$" mods {b.Modules}/{Building.ModuleCap} burn {b.DailyBurn:0.0}/day";
DrawString(font, new Vector2(PanelX + 36, y),
$"{i + 1} {b.Town,-5} {status}", fontSize: 12,
modulate: b.IsRuined ? TextDim : TextMain);
}
// --- Panel: world totals -----------------------------------------
y += 34f;
float wood = 0f, stone = 0f, clay = 0f, foodTotal = 0f;
// --- Panel: world vitals (bottom) --------------------------------
y += 8f;
float wood = 0f, stone = 0f, food = 0f;
foreach (var n in _gm.World.Nodes)
{
switch (n.Kind)
{
case MaterialKind.Wood: wood += n.Amount; break;
case MaterialKind.Stone: stone += n.Amount; break;
case MaterialKind.Clay: clay += n.Amount; break;
case MaterialKind.Food: foodTotal += n.Amount; break;
case MaterialKind.Wood: wood += n.Amount; break;
case MaterialKind.Stone: stone += n.Amount; break;
case MaterialKind.Food: food += n.Amount; break;
}
}
DrawString(font, new Vector2(PanelX + 16, y),
$"WORLD wood {wood:0} stone {stone:0} clay {clay:0} food {foodTotal:0}",
$"WORLD pop {_gm.Npcs.Count} +{_gm.BirthsToday}/{_gm.DeathsToday} today",
fontSize: 14, modulate: TextMain);
y += 20f;
DrawString(font, new Vector2(PanelX + 16, y),
$"land wood {wood:0} stone {stone:0} food {food:0}",
fontSize: 13, modulate: TextDim);
}
private static Color TownColor(SoulType t) => t switch
{
SoulType.Generational => new Color(0.55f, 0.75f, 0.95f), // North — cool
SoulType.Nature => new Color(0.55f, 0.85f, 0.55f), // South — green
SoulType.Materialist => new Color(0.90f, 0.80f, 0.45f), // West — gold
_ => new Color(0.90f, 0.45f, 0.45f), // East — red
};
}
+47 -2
View File
@@ -37,6 +37,20 @@ public class World
private readonly Random _rng;
// Node cell index (built once — nodes never move). Only the sensing sweep
// uses it, but that runs per-NPC constantly, so it's the one place a node
// index genuinely pays (unlike nearest-of-kind, which stays a linear scan).
private const int NodeCell = 32;
private readonly Dictionary<(int, int), List<ResourceNode>> _nodeCells = new();
private void IndexNode(ResourceNode n)
{
var key = ((int)(n.Cell.X / NodeCell), (int)(n.Cell.Y / NodeCell));
if (!_nodeCells.TryGetValue(key, out var list))
_nodeCells[key] = list = new List<ResourceNode>();
list.Add(n);
}
public World(int seed)
{
_rng = new Random(seed);
@@ -63,14 +77,16 @@ public class World
{
for (int i = 0; i < count; i++)
{
Nodes.Add(new ResourceNode
var node = new ResourceNode
{
Kind = kind,
Cell = new Vector2I(_rng.Next(GridSize), _rng.Next(GridSize)),
Amount = amount,
MaxAmount = amount,
RegenPerDay = regen,
});
};
Nodes.Add(node);
IndexNode(node);
}
}
@@ -113,6 +129,35 @@ public class World
return taken;
}
/// <summary>Invoke action for every node within radius — used by NPCs to
/// discover nearby nodes (addendum §4 sensing). Cell-indexed: scans only
/// the buckets the radius touches, not all ~1,260 nodes.</summary>
public void ForEachNodeNear(Vector2 position, float radius, System.Action<ResourceNode> action)
{
float r2 = radius * radius;
int reach = (int)(radius / NodeCell) + 1;
int cx = (int)(position.X / NodeCell);
int cy = (int)(position.Y / NodeCell);
for (int dx = -reach; dx <= reach; dx++)
for (int dy = -reach; dy <= reach; dy++)
{
if (!_nodeCells.TryGetValue((cx + dx, cy + dy), out var list)) continue;
foreach (var node in list)
if (position.DistanceSquaredTo(node.Cell) <= r2)
action(node);
}
}
/// <summary>Seed a town's founders with the resources around their home,
/// so they begin with a working local map instead of blind.</summary>
public IEnumerable<ResourceNode> NodesWithin(Vector2 center, float radius)
{
float r2 = radius * radius;
foreach (var node in Nodes)
if (center.DistanceSquaredTo(node.Cell) <= r2)
yield return node;
}
/// <summary>Nearest node of a kind regardless of amount — for returning
/// unneeded minerals to the quarry rather than carrying them forever.</summary>
public ResourceNode? FindNearestAny(MaterialKind kind, Vector2 position)