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
+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)