Known-structures memory + bigger map: fix the East re-founding loop

Root cause of the homeless East: FoundOwnShelter searched all buildings by
proximity (within 30 units), so a lone selfish builder who wandered off to
gather wood for the walls came back, missed their own foundation, and
founded a NEW one - 5,900+ abandoned foundations per run, none finished.

Fix (per the exploration architecture): structures are known like resource
nodes. NPCs gain a KnownStructures set; a builder registers the home they
lay and returns to it via known-structures memory however far they roamed,
instead of re-founding. Children inherit their family's known buildings.
Result: 5,953 -> 31 founding events, East now houses itself with COMPLETED
homes and is the best-fed town (0.84), still lowest-knowledge/highest-debris
as selfishness should be.

Also expanded the world 768 -> 1152 with towns pushed to wider cardinal
points and resources scaled to area, giving the four cultures more room and
less accidental center overlap. Full 240-day four-town run in 189s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 22:11:59 -04:00
parent 9e8ee1d748
commit 0c0c7088f8
3 changed files with 102 additions and 36 deletions
+8 -4
View File
@@ -29,10 +29,10 @@ public partial class GameManager : Node2D
public IReadOnlyList<TownDef> Towns => _towns; public IReadOnlyList<TownDef> Towns => _towns;
private readonly List<TownDef> _towns = new() private readonly List<TownDef> _towns = new()
{ {
new("North", new Vector2(384f, 130f), SoulType.Generational), new("North", new Vector2(576f, 190f), SoulType.Generational),
new("South", new Vector2(384f, 638f), SoulType.Nature), new("South", new Vector2(576f, 962f), SoulType.Nature),
new("West", new Vector2(130f, 384f), SoulType.Materialist), new("West", new Vector2(190f, 576f), SoulType.Materialist),
new("East", new Vector2(638f, 384f), SoulType.Selfish), new("East", new Vector2(962f, 576f), SoulType.Selfish),
}; };
private static SoulProfile ProfileFor(SoulType t) => t switch private static SoulProfile ProfileFor(SoulType t) => t switch
@@ -324,6 +324,10 @@ public partial class GameManager : Node2D
// passed down like everything else a generation hands forward. // passed down like everything else a generation hands forward.
foreach (var node in a.KnownNodes) child.KnownNodes.Add(node); foreach (var node in a.KnownNodes) child.KnownNodes.Add(node);
foreach (var node in b.KnownNodes) child.KnownNodes.Add(node); foreach (var node in b.KnownNodes) child.KnownNodes.Add(node);
// A child also inherits the family's known buildings — the homes
// and stores of the people who raised them.
foreach (var s in a.KnownStructures) child.KnownStructures.Add(s);
foreach (var s in b.KnownStructures) child.KnownStructures.Add(s);
newborns.Add(child); newborns.Add(child);
BirthsToday++; BirthsToday++;
pop[a.HomeTown] = (int)townPop + 1; // count the newborn toward capacity pop[a.HomeTown] = (int)townPop + 1; // count the newborn toward capacity
+84 -23
View File
@@ -49,6 +49,22 @@ public class Npc
// NPC-to-place memory, its own structure per the addendum's open Q. // NPC-to-place memory, its own structure per the addendum's open Q.
public readonly HashSet<ResourceNode> KnownNodes = new(); public readonly HashSet<ResourceNode> KnownNodes = new();
// Known structures: same principle as known nodes — you can only use or
// return to a building you've discovered or raised yourself. This is what
// lets a lone builder finish their OWN home (they remember the foundation
// they laid) instead of re-founding a new one every time they wander off
// for wood, and it keeps towns insular: you don't beeline to a granary
// across the world you've never seen.
public readonly HashSet<Building> KnownStructures = new();
/// <summary>Discover buildings within sensing range this tick.</summary>
public void SenseStructures(GameManager gm)
{
foreach (var b in gm.Buildings)
if (Position.DistanceSquaredTo(b.Site) <= SensingRadius * SensingRadius)
KnownStructures.Add(b);
}
public const float SensingRadius = 22f; public const float SensingRadius = 22f;
/// <summary>Discover any nodes within sensing range this tick.</summary> /// <summary>Discover any nodes within sensing range this tick.</summary>
@@ -268,6 +284,7 @@ public class Npc
else if (--_senseTimer <= 0) else if (--_senseTimer <= 0)
{ {
SenseNodes(world); SenseNodes(world);
SenseStructures(gm);
_senseTimer = 30; _senseTimer = 30;
} }
@@ -370,12 +387,17 @@ public class Npc
if (foodCarried < reserveTarget && (Nourishment < 0.8f || Soul.Accumulation > 0.5f)) if (foodCarried < reserveTarget && (Nourishment < 0.8f || Soul.Accumulation > 0.5f))
return MaterialKind.Food; return MaterialKind.Food;
// Homeless too long → gather stone for a roof, whatever your soul. // Homeless too long → build a roof, whatever your soul. Even the
// Even the selfish build private shelter; they just won't build it // selfish build private shelter (just never for anyone else). If they
// for anyone else. (Communal souls also queue this below, guarded by // already have one under construction, gather what its NEXT stage
// whether the town already has a shelter under construction.) // needs — a lone builder must supply their own build end to end, or
// it stalls at the walls (this is why East homes never finished).
if (!IsCommunal && _unshelteredRests >= 3) if (!IsCommunal && _unshelteredRests >= 3)
return MaterialKind.Stone; {
var mine = MyUnfinishedShelter(gm);
if (mine?.NeededMaterial is MaterialKind need) return need;
return MaterialKind.Stone; // none yet — gather the foundation
}
if (IsCommunal) if (IsCommunal)
{ {
@@ -520,8 +542,9 @@ public class Npc
// a selfish soul goes to raise their own roof. // a selfish soul goes to raise their own roof.
if (!IsCommunal) if (!IsCommunal)
{ {
bool hasStone = Inventory.TryGetValue(MaterialKind.Stone, out float s) && s > 0f; // Homeless and carrying what their roof needs next? Go build.
if (_unshelteredRests >= 3 && hasStone) { Enter(NpcState.Building); return; } if (_unshelteredRests >= 3 && CarriesShelterMaterial(gm))
{ Enter(NpcState.Building); return; }
Enter(NpcState.Socializing); Enter(NpcState.Socializing);
return; return;
} }
@@ -612,8 +635,7 @@ public class Npc
Inventory.TryGetValue(PickGatherTarget(gm), out float demandedHave); Inventory.TryGetValue(PickGatherTarget(gm), out float demandedHave);
if (IsCommunal && demandedHave >= EffectiveCapacity * 0.5f) if (IsCommunal && demandedHave >= EffectiveCapacity * 0.5f)
Enter(NpcState.Building); Enter(NpcState.Building);
else if (!IsCommunal && _unshelteredRests >= 3 && else if (!IsCommunal && _unshelteredRests >= 3 && CarriesShelterMaterial(gm))
Inventory.TryGetValue(MaterialKind.Stone, out float st) && st >= 10f)
Enter(NpcState.Building); Enter(NpcState.Building);
} }
} }
@@ -648,6 +670,34 @@ public class Npc
} }
} }
/// <summary>True if this homeless builder is carrying enough of the
/// material their private roof needs next (or enough stone to found one).</summary>
private bool CarriesShelterMaterial(GameManager gm)
{
var mine = MyUnfinishedShelter(gm);
var need = mine?.NeededMaterial ?? MaterialKind.Stone;
return Inventory.TryGetValue(need, out float have) && have >= 8f;
}
/// <summary>This soul's own in-progress private shelter, if any — the
/// nearest unfinished shelter they KNOW about in their town. Because it
/// searches known structures (not all buildings by proximity), a builder
/// reliably returns to the foundation they laid instead of forgetting it
/// the moment they wander off and founding another.</summary>
private Building? MyUnfinishedShelter(GameManager gm)
{
Building? mine = null;
float best = float.MaxValue;
foreach (var b in KnownStructures)
{
if (b.Town != HomeTown || b.IsComplete || b.IsRuined ||
b.Kind != BuildingKind.Shelter) continue;
float d = Position.DistanceSquaredTo(b.Site);
if (d < best) { best = d; mine = b; }
}
return mine;
}
/// <summary> /// <summary>
/// A self-interested soul raising a roof for itself — private property, /// A self-interested soul raising a roof for itself — private property,
/// not communal service. Contributes to their own in-progress shelter, /// not communal service. Contributes to their own in-progress shelter,
@@ -657,15 +707,10 @@ public class Npc
/// </summary> /// </summary>
private void FoundOwnShelter(GameManager gm) private void FoundOwnShelter(GameManager gm)
{ {
// Contribute to an unfinished shelter near me (likely my own). // My own unfinished home — found via known-structures memory, so I
Building? mine = null; // return to the foundation I laid however far I wandered for wood,
float best = 900f; // within 30 units // rather than re-founding a new one each trip.
foreach (var b in gm.Buildings) Building? mine = MyUnfinishedShelter(gm);
{
if (b.Town != HomeTown || b.IsComplete || b.Kind != BuildingKind.Shelter) continue;
float d = Position.DistanceSquaredTo(b.Site);
if (d < best) { best = d; mine = b; }
}
if (mine == null) if (mine == null)
{ {
@@ -680,16 +725,30 @@ public class Npc
mine = Building.NewShelter(spot); mine = Building.NewShelter(spot);
mine.Town = HomeTown; mine.Town = HomeTown;
gm.Buildings.Add(mine); gm.Buildings.Add(mine);
_unshelteredRests = 0; KnownStructures.Add(mine); // you never forget the home you laid
Godot.GD.Print($"[Homestead] day {gm.Day}: {Name} ({HomeTown}) raises a private roof."); // Don't clear the homeless drive yet — a foundation isn't a home.
// It resets only once the roof is on (below), so the builder keeps
// supplying walls and roof instead of stalling at the foundation.
Godot.GD.Print($"[Homestead] day {gm.Day}: {Name} ({HomeTown}) breaks ground on a private home.");
} }
Activity = "building my own shelter"; Activity = "building my own home";
MoveToward(mine.Site); MoveToward(mine.Site);
if (Position.DistanceTo(mine.Site) < ArriveDist) if (Position.DistanceTo(mine.Site) < ArriveDist)
{ {
bool delivered = mine.Deliver(this); mine.Deliver(this);
if (!delivered || mine.IsComplete) Enter(NpcState.Socializing); if (mine.IsComplete)
{
_unshelteredRests = 0; // a home at last — drive satisfied
Enter(NpcState.Socializing);
}
else
{
// Foundation down but walls/roof still needed — go fetch the
// next material and come back. A lone builder finishes their
// own house or it never gets finished.
Enter(NpcState.Gathering);
}
} }
} }
@@ -773,6 +832,7 @@ public class Npc
site = Building.NewShelter(home + jitter); site = Building.NewShelter(home + jitter);
site.Town = HomeTown; site.Town = HomeTown;
gm.Buildings.Add(site); gm.Buildings.Add(site);
KnownStructures.Add(site);
_unshelteredRests = 0; _unshelteredRests = 0;
GD.Print($"[Found] day {gm.Day}: {Name} founds shelter at {site.Site} " + GD.Print($"[Found] day {gm.Day}: {Name} founds shelter at {site.Site} " +
$"(centroid dist {(site.Site - gm.TownCentroid(HomeTown)).Length():0})"); $"(centroid dist {(site.Site - gm.TownCentroid(HomeTown)).Length():0})");
@@ -787,6 +847,7 @@ public class Npc
site = Building.NewGranary(Position + jitter); site = Building.NewGranary(Position + jitter);
site.Town = HomeTown; site.Town = HomeTown;
gm.Buildings.Add(site); gm.Buildings.Add(site);
KnownStructures.Add(site);
} }
// Nothing needs your stone: take it back to the quarry rather // Nothing needs your stone: take it back to the quarry rather
// than carry a house on your back forever. Matter is conserved; // than carry a house on your back forever. Matter is conserved;
+10 -9
View File
@@ -28,7 +28,7 @@ public class ResourceNode
/// </summary> /// </summary>
public class World public class World
{ {
public const int GridSize = 768; public const int GridSize = 1152;
public List<ResourceNode> Nodes { get; } = new(); public List<ResourceNode> Nodes { get; } = new();
@@ -63,14 +63,15 @@ public class World
/// </summary> /// </summary>
public void Generate() public void Generate()
{ {
// Resource counts scale ~9× with the 3× larger world so density holds. // Resource counts scale with area (1152² ≈ 2.25× the 768² world) so
PlaceMany(MaterialKind.Water, count: 40, amount: float.PositiveInfinity, regen: 0f); // density holds as the towns spread to wider cardinal points.
PlaceMany(MaterialKind.Clay, count: 80, amount: 40f, regen: 0.5f); PlaceMany(MaterialKind.Water, count: 90, amount: float.PositiveInfinity, regen: 0f);
PlaceMany(MaterialKind.Wood, count: 480, amount: 30f, regen: 2f); PlaceMany(MaterialKind.Clay, count: 180, amount: 40f, regen: 0.5f);
PlaceMany(MaterialKind.Stone, count: 140, amount: 80f, regen: 0f); // more deposits — housing was stone-capped PlaceMany(MaterialKind.Wood, count: 1080, amount: 30f, regen: 2f);
PlaceMany(MaterialKind.Food, count: 360, amount: 20f, regen: 4f); PlaceMany(MaterialKind.Stone, count: 315, amount: 80f, regen: 0f);
PlaceMany(MaterialKind.Ore, count: 40, amount: 60f, regen: 0f); // metal source (post-MVP tier) PlaceMany(MaterialKind.Food, count: 810, amount: 20f, regen: 4f);
PlaceMany(MaterialKind.Herb, count: 120, amount: 15f, regen: 3f); // medicine / trade good PlaceMany(MaterialKind.Ore, count: 90, amount: 60f, regen: 0f);
PlaceMany(MaterialKind.Herb, count: 270, amount: 15f, regen: 3f);
} }
private void PlaceMany(MaterialKind kind, int count, float amount, float regen) private void PlaceMany(MaterialKind kind, int count, float amount, float regen)