diff --git a/FINDINGS.md b/FINDINGS.md
index 13e5335..7566c76 100644
--- a/FINDINGS.md
+++ b/FINDINGS.md
@@ -331,8 +331,39 @@ debris — selfishness now literally fails to sustain a population.** A
town that won't share or cooperate can't feed its people or raise a next
generation. The most damning verdict the sim has produced.
-**Next:** crisis-recovery experiment (North's resilience); save/load;
-then the player pivot.
+## Run 15 — Known-nodes exploration (addendum §4) + the O(n²) hunt
+
+**Built (stage 1 of the exploration addendum):** per-NPC known-nodes — an
+NPC gathers only from resource nodes it has personally sensed (radius 22)
+or been told about. Founders seed with their home ground; children
+inherit their parents' map; node knowledge diffuses between neighbors by
+the same proximity rule as skills. When nothing known qualifies, a
+starving NPC ventures outward (biased away from town anchor) and senses
+as it goes — the non-random, needs-driven venture-out the addendum wants,
+and the seed of first contact. Repopulation explicitly preserved: seeding
++ a survival food fallback keep towns fed (verified: all four 0.56–0.78
+fed, population stable 86–109 over 240 days).
+
+**Debug screen rebuilt:** the per-villager roster (one line per soul,
+~68 rows overflowing the panel at four-town scale) replaced with a
+per-town summary — population, fed%, knowledge, debris, state breakdown,
+buildings, colored by culture, plus world births/deaths. Scales to any
+population.
+
+**The performance lesson, earned properly this time:** the sim slowed to
+a crawl and I burned four rounds guessing (node index — no; periodic
+sensing — barely; node cell-index — barely; gated diffusion — barely).
+Then I added phase timers and *measured*: `npc.Tick` was 94% of cost and
+grew O(n²) with population. The culprit was `TickResting` calling
+`RestingOccupancy` (a full-population scan) once per building per resting
+NPC — O(buildings × NPCs²) per tick. Fixed with a once-per-tick occupancy
+cache on Building. Result: npc phase ~4× faster and now linear; full
+240-day four-town run in **180s, faster than the pre-exploration
+baseline** despite all the new systems. Lesson restated: profile first;
+the bottleneck is never where you guess.
+
+**Next:** stage 2 (soul-weighted venture-out + first contact); crisis-
+recovery experiment; save/load; then the player pivot.
---
diff --git a/docs/SPEC_addendum_exploration.md b/docs/SPEC_addendum_exploration.md
new file mode 100644
index 0000000..9fd9f48
--- /dev/null
+++ b/docs/SPEC_addendum_exploration.md
@@ -0,0 +1,69 @@
+# Soul Reincarnation World Simulation — Exploration & Town Distance Addendum
+
+*Builds on the original design spec. Covers town isolation, needs-based exploration, and personal resource-node knowledge.*
+
+---
+
+## 1. Problem Being Solved
+
+Current state: all NPCs across all four towns can interact from tick one, before settlements have even formed, and NPCs have global knowledge of every resource node's location. This collapses the towns into one shared pool instead of four distinct cultural "echo chambers" that only make contact through deliberate exploration.
+
+Goal: towns should develop in relative isolation first, with cross-town contact emerging naturally as a *result* of resource pressure and exploration — not something possible from the very first tick.
+
+---
+
+## 2. Town Distance / Isolation
+
+- No artificial hard block on socializing is wanted — the fix should come from constraining *what NPCs know and can reach*, not from disabling interaction mechanically.
+- Each town should function as its own local loop (gather/build/trade/socialize) until something pushes individuals beyond it.
+- Cross-town contact becomes a real, observable event — first contact happens because someone *found* the other town, not because the simulation allowed it by default.
+
+---
+
+## 3. Needs-Based Exploration (not random chance)
+
+Rejected: random per-NPC "wander" chance each day.
+
+**Chosen approach:** exploration is driven by real resource supply/demand pressure, using the same soul-weighted decision system already used for gathering and trading. When an NPC's (or town's) locally known resources can't meet demand, venturing outward becomes the logical next step in the state machine — not a dice roll.
+
+### How each town's soul philosophy should shape *when* and *how* they push outward:
+
+- **South (Nature):** sustainable harvesting means local resources rarely deplete. They venture out **late**, mainly once population growth outpaces what the home area can regenerate. Reads as "we've outgrown this valley," not resource panic.
+- **West (Materialistic):** status competition drives overconsumption of visible/display materials faster than regen. They push outward **early**, chasing new sources of status-signaling resources.
+- **East (Selfish):** hoarding causes uneven, fast local depletion in pockets. Expect **individual** NPCs — not the whole town — to venture out well ahead of general need, as opportunistic scouts.
+- **North (Generational):** expansion is driven by needing land for new family compounds as generations multiply. Their venturing is **deliberate/planned**, not reactive to scarcity.
+
+### Mechanical shape
+- Resource nodes: finite with regen (already implemented).
+- Each soul type gets a consumption-rate multiplier against the shared regen rate.
+- When an NPC's (or town's) local supply/demand ratio crosses a threshold, gathering-state logic starts pathing beyond the home radius toward the nearest *known* unclaimed resource — falling out of the existing soul-weighted decision system rather than requiring new randomness.
+
+---
+
+## 4. Personal Resource-Node Knowledge (No Global Map Access)
+
+Current state: NPCs have full knowledge of all node locations in the world, so they beeline to the optimal node instead of exploring.
+
+**Fix:** NPCs should only be able to act on resource nodes they have personally discovered. Discovery becomes the actual bottleneck driving exploration, not just resource pressure alone.
+
+### Structure
+- **Known-nodes list (per NPC):** an NPC's gathering/building logic only ever queries nodes on *their own* known list — never the global resource table.
+- **Sensing radius:** a small "currently visible" radius each tick; any node that enters this radius gets added to the NPC's known-nodes list.
+- **Wander-when-starved fallback:** when an NPC's known nodes are depleted/insufficient and nothing new is in sensing range, that's the concrete trigger for venture-out behavior — move outward (biased away from town center / toward unexplored territory) until sensing radius picks something up.
+- **Reuse of existing systems:** the game's NPC class already tracks personal knowledge for trade relationships. Known-nodes should extend that same "personal knowledge" pattern rather than introduce a new system.
+ - Open question to resolve during implementation: is trade knowledge currently keyed NPC-to-NPC (trust relationships) or NPC-to-entity (things known to exist)? If the latter, known-nodes can likely reuse the identical data structure with a different entity type. If the former, known-nodes probably needs its own parallel structure, since location memory and trust-relationship memory are conceptually distinct.
+- **Knowledge spread via proximity/social diffusion:** once one NPC discovers a new node, nearby townsfolk can learn about it through the same proximity/social diffusion mechanic already designed for skill/craft knowledge transfer (see original spec, Section 5). Discovery spreads organically through a town rather than becoming instantly global. No new diffusion system needs to be built — this is the same rule applied to a second knowledge type (locations, not skills).
+
+---
+
+## 5. Summary of Design Consistency
+
+Both mechanics in this addendum deliberately reuse existing systems rather than introducing new ones:
+
+| New behavior | Reuses |
+|---|---|
+| Needs-based venture-out trigger | Existing soul-weighted state machine (gathering/trading logic) |
+| Per-NPC known-nodes memory | Existing personal-knowledge structure (currently used for trade) |
+| Node-discovery spread between NPCs | Existing proximity/social knowledge-diffusion design (Section 5 of main spec) |
+
+This keeps the engine soul-type-agnostic and modular, consistent with the original MVP design intent: new behaviors should come from tuning weights and thresholds on existing systems, not from bolting on new mechanics per feature.
diff --git a/scripts/Building.cs b/scripts/Building.cs
index bccda8b..9a8f7bf 100644
--- a/scripts/Building.cs
+++ b/scripts/Building.cs
@@ -94,6 +94,11 @@ public class Building
/// How many can sleep here. Expansion adds beds — growth has a
/// human meaning, not just a bigger square.
+ /// 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).
+ public int Occupancy;
+
public int RestCapacity =>
Kind == BuildingKind.Shelter && IsComplete && !IsRuined ? 3 + Modules : 0;
diff --git a/scripts/GameManager.cs b/scripts/GameManager.cs
index bc8c508..ddf351a 100644
--- a/scripts/GameManager.cs
+++ b/scripts/GameManager.cs
@@ -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)
{
diff --git a/scripts/NPC.cs b/scripts/NPC.cs
index 650d163..f717762 100644
--- a/scripts/NPC.cs
+++ b/scripts/NPC.cs
@@ -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 KnownNodes = new();
+
+ public const float SensingRadius = 22f;
+
+ /// Discover any nodes within sensing range this tick.
+ public void SenseNodes(World world)
+ {
+ world.ForEachNodeNear(Position, SensingRadius, n => KnownNodes.Add(n));
+ }
+
+ /// Learn a node from someone who already knows it (social
+ /// diffusion — the same proximity rule as skills, per addendum §4).
+ 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;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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}";
diff --git a/scripts/Visualization.cs b/scripts/Visualization.cs
index 8b40a80..30e3ca7 100644
--- a/scripts/Visualization.cs
+++ b/scripts/Visualization.cs
@@ -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
+ };
}
diff --git a/scripts/World.cs b/scripts/World.cs
index 850b00e..38acaeb 100644
--- a/scripts/World.cs
+++ b/scripts/World.cs
@@ -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> _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();
+ 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;
}
+ /// 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.
+ public void ForEachNodeNear(Vector2 position, float radius, System.Action 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);
+ }
+ }
+
+ /// Seed a town's founders with the resources around their home,
+ /// so they begin with a working local map instead of blind.
+ public IEnumerable NodesWithin(Vector2 center, float radius)
+ {
+ float r2 = radius * radius;
+ foreach (var node in Nodes)
+ if (center.DistanceSquaredTo(node.Cell) <= r2)
+ yield return node;
+ }
+
/// Nearest node of a kind regardless of amount — for returning
/// unneeded minerals to the quarry rather than carrying them forever.
public ResourceNode? FindNearestAny(MaterialKind kind, Vector2 position)