Run 13: the full world - four towns, resources, bonds, spatial grid

World scaled 3x (768x768) with all four towns at compass points, each
running its own soul culture: North generational, South nature, West
materialist (new profile), East selfish. Two new resources: Ore (metal
tier) and Herb (medicine - mends health, reason to forage, trade good).
Spatial hash grid replaces O(n^2) proximity scans (soul pressure,
neighbor, teacher) - 3.6x headless speedup at ~80 NPCs. First social
verb beyond work: pair-bonds - settled adults who keep warm company pair
up; warmth now accrues from proximity not only gifts; bonds break at
death. Reproduction deferred (bonds-first per plan).

The four-town thesis fully realized on one engine (240-day run):
North know 1.27 clean, South nourish 0.85 clean, West prosperous but
debris 0.23, East hungriest 0.60 and lowest knowledge 1.08. Positive
cultures stay soul-clean, negative ones carry real debris, all from
behavior weights - nothing scripted. 99 bonds, 80 deaths, four living
societies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 17:04:19 -04:00
parent 3e4e44c623
commit f9e22e872f
12 changed files with 21725 additions and 6819 deletions
+33 -30
View File
@@ -19,18 +19,28 @@ public partial class GameManager : Node2D
// centroid, buildings, and granary. NPCs affiliate with a home town
// but roam freely — the space between towns is where cultures meet.
[Export] public int SouthPopulation = 12; // nature souls
[Export(PropertyHint.Range, "0,15,1")]
public int SelfishCount = 3; // east souls living in the South
[Export] public int NorthPopulation = 10; // generational souls
[Export] public int TownPopulationEach = 20; // per settlement (~80 total)
public record TownDef(string Name, Vector2 Center, SoulType Soul);
// Four towns at the compass points of the world, each its own culture
// (per DESIGN.md): North reveres lineage, South lives with the land,
// West performs material wealth, East schemes for self.
public IReadOnlyList<TownDef> Towns => _towns;
private readonly List<TownDef> _towns = new()
{
new("South", new Vector2(128f, 190f), SoulType.Nature),
new("North", new Vector2(128f, 62f), SoulType.Generational),
new("North", new Vector2(384f, 130f), SoulType.Generational),
new("South", new Vector2(384f, 638f), SoulType.Nature),
new("West", new Vector2(130f, 384f), SoulType.Materialist),
new("East", new Vector2(638f, 384f), SoulType.Selfish),
};
private static SoulProfile ProfileFor(SoulType t) => t switch
{
SoulType.Generational => SoulProfile.GenerationalSoul(),
SoulType.Nature => SoulProfile.NatureSoul(),
SoulType.Materialist => SoulProfile.MaterialistSoul(),
_ => SoulProfile.SelfishSoul(),
};
[Export(PropertyHint.Range, "1,2000,1")]
@@ -45,6 +55,10 @@ public partial class GameManager : Node2D
public List<Npc> Npcs { get; } = new();
public List<Building> Buildings { get; } = new();
/// <summary>Rebuilt each tick; proximity queries go through it so the
/// hot loops stay near-linear as population grows.</summary>
public SpatialGrid Grid { get; } = new(cellSize: 12f);
public long TotalTicks { get; private set; }
public int Day => (int)(TotalTicks / 1440);
public int MinuteOfDay => (int)(TotalTicks % 1440);
@@ -114,18 +128,7 @@ public partial class GameManager : Node2D
}
foreach (var town in _towns)
{
if (town.Name == "South")
{
Spawn(town.Name, town.Center, SoulProfile.NatureSoul(), SouthPopulation, "South");
for (int i = 0; i < SelfishCount; i++)
Spawn(town.Name, town.Center, SoulProfile.SelfishSoul(), 1, "South");
}
else if (town.Name == "North")
{
Spawn(town.Name, town.Center, SoulProfile.GenerationalSoul(), NorthPopulation, "North");
}
}
Spawn(town.Name, town.Center, ProfileFor(town.Soul), TownPopulationEach, town.Name);
}
/// <summary>How many villagers (other than <paramref name="except"/>)
@@ -189,23 +192,21 @@ public partial class GameManager : Node2D
const float radius = 8f;
const float strength = 0.00004f;
for (int i = 0; i < Npcs.Count; i++)
// Spatial grid: each soul presses only on the few within reach,
// not on everyone. Directed pass (a→b for every nearby b) so each
// ordered pair is handled once from the source's side.
foreach (var a in Npcs)
{
for (int j = i + 1; j < Npcs.Count; j++)
Grid.ForEachNear(a.Position, radius, b =>
{
var a = Npcs[i];
var b = Npcs[j];
if (b == a) return;
float dist = a.Position.DistanceTo(b.Position);
if (dist > radius) continue;
if (dist > radius) return;
float falloff = 1f - dist / radius;
float fromA = Mathf.Lerp(-strength, strength, a.Imprint.Total) * falloff;
b.Imprint.ExternalPerception = Mathf.Clamp(b.Imprint.ExternalPerception + fromA, 0f, 1f);
float fromB = Mathf.Lerp(-strength, strength, b.Imprint.Total) * falloff;
a.Imprint.ExternalPerception = Mathf.Clamp(a.Imprint.ExternalPerception + fromB, 0f, 1f);
}
b.Imprint.ExternalPerception =
Mathf.Clamp(b.Imprint.ExternalPerception + fromA, 0f, 1f);
});
}
}
@@ -280,6 +281,8 @@ public partial class GameManager : Node2D
private void Tick()
{
Grid.Rebuild(Npcs);
TotalTicks++;
foreach (var npc in Npcs)