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)
+2 -1
View File
@@ -41,7 +41,8 @@ public class Knowledge
public static KnowledgeDomain DomainFor(MaterialKind kind) => kind switch
{
MaterialKind.Food => KnowledgeDomain.Forage,
MaterialKind.Herb => KnowledgeDomain.Forage, // gathering the living land
MaterialKind.Wood => KnowledgeDomain.Woodcraft,
_ => KnowledgeDomain.Masonry, // stone, clay — builder's craft
_ => KnowledgeDomain.Masonry, // stone, clay, ore — builder's craft
};
}
+64 -8
View File
@@ -40,6 +40,15 @@ public class Npc
// --- Knowledge (SPEC §5) ----------------------------------------------
public Knowledge Know = new();
// --- 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
// relationship — chosen companionship, warmth that compounds, someone
// whose grave you would tend.
public Npc? Partner;
public bool IsBonded => Partner != null;
public bool IsAdult => AgeDays >= 20f; // childhood is the first ~20 days
// --- State -----------------------------------------------------------
public NpcState State = NpcState.Gathering;
@@ -110,6 +119,7 @@ public class Npc
{
if (_targetNpc == dead) _targetNpc = null;
if (_giftSource == dead) _giftSource = null;
if (Partner == dead) Partner = null; // a bond outlives nothing but memory
}
// --- Diagnostics (read-only views for the stats logger) ---------------
@@ -209,6 +219,16 @@ public class Npc
Nourishment = Mathf.Min(1f, Nourishment + 0.25f * (1f - debris * 0.5f));
}
if (Nourishment <= 0.05f) Health = Mathf.Max(0f, Health - 0.01f);
// Herbs are medicine: the hurt who carry them mend faster, and a
// skilled forager gets more from the same leaf. Reason to gather
// herbs, and a trade good worth carrying between towns.
if (Health < 90f &&
Inventory.TryGetValue(MaterialKind.Herb, out float herb) && herb >= 1f)
{
Inventory[MaterialKind.Herb] = herb - 1f;
Health = Mathf.Min(100f, Health + 6f * (1f + Know[KnowledgeDomain.Forage]));
}
if (Nourishment < 0.3f) _hungerMemory = true; // real hunger is not forgotten
Nourishment = Mathf.Clamp(Nourishment, 0f, 1f);
@@ -694,12 +714,12 @@ public class Npc
{
Npc? teacher = null;
float bestWorth = 0.15f; // must actually know more than us
foreach (var other in gm.Npcs)
gm.Grid.ForEachNear(Position, 40f, other =>
{
if (other == this || other.HomeTown != HomeTown) continue;
if (other == this || other.HomeTown != HomeTown) return;
float worth = (other.Know.Total - Know.Total) * (1f + other.Soul.TeachingDrive);
if (worth > bestWorth) { bestWorth = worth; teacher = other; }
}
});
if (teacher != null)
{
if (Position.DistanceTo(teacher.Position) > 3f)
@@ -717,13 +737,46 @@ public class Npc
}
}
// Otherwise: drift toward the nearest neighbor — community forms
// where feet do.
foreach (var other in gm.Npcs)
// A bonded soul keeps their partner's company by preference —
// relationships are a pull the work queue doesn't override.
if (IsBonded && Partner!.Health > 0f)
{
if (other == this) continue;
if (Position.DistanceTo(Partner.Position) > 3f)
{
Activity = $"with {Partner.Name}";
MoveToward(Partner.Position);
}
else
{
RecordEcho(Partner.Name, 0.02f);
Activity = $"beside {Partner.Name}";
}
if (!gm.IsNight && _stateTimer > 20f) Enter(NpcState.Gathering);
return;
}
// Otherwise: drift toward the nearest neighbor — community forms
// where feet do. Grid-scoped so it stays cheap at scale.
Npc? found = null;
float fBest = float.MaxValue;
gm.Grid.ForEachNear(Position, 30f, other =>
{
if (other == this) return;
float d = Position.DistanceSquaredTo(other.Position);
if (d < bestDist) { bestDist = d; nearest = other; }
if (d < fBest) { fBest = d; found = other; }
});
nearest = found;
// Consider bonding: an unbonded adult who keeps warm company with a
// compatible, unbonded neighbor may pair with them. Same town, real
// warmth, both grown — companionship chosen, not assigned.
if (!IsBonded && IsAdult && nearest is { IsBonded: false, IsAdult: true } n &&
n.HomeTown == HomeTown && Position.DistanceTo(n.Position) < 4f &&
GetWarmth(n.Name) > 0.4f && n.GetWarmth(Name) > 0.4f)
{
Partner = n;
n.Partner = this;
Godot.GD.Print($"[Bond] day {gm.Day}: {Name} and {n.Name} ({HomeTown}) pair up.");
}
if (nearest != null && Position.DistanceTo(nearest.Position) > 3f)
{
@@ -732,6 +785,9 @@ public class Npc
}
else if (nearest != null)
{
// Company that isn't teaching still warms — this is how bonds
// become possible: familiarity accrues just by spending time.
RecordEcho(nearest.Name, 0.01f);
Activity = LearnFrom(nearest)
? $"learning from {nearest.Name}"
: $"chatting with {nearest.Name}";
+19
View File
@@ -89,6 +89,25 @@ public class SoulProfile
TeachingDrive = 0.9f, // and to hand it down in turn — the whole culture
};
/// <summary>
/// West-weighted profile: worth measured in visible possessions
/// (DESIGN.md). They build — even communally — but for display, so they
/// expand shelters hard (status is size); moderate generosity, moderate
/// hoarding, little interest in learning or teaching. A functioning town
/// whose surplus goes to ostentation rather than resilience.
/// </summary>
public static SoulProfile MaterialistSoul() => new()
{
Type = SoulType.Materialist,
Generosity = 0.4f,
Sustainability = 0.6f,
CommunityBias = 0.6f,
StatusBias = 0.9f,
Accumulation = 0.6f,
Absorption = 0.9f,
TeachingDrive = 0.2f,
};
/// <summary>
/// East-weighted profile — not part of MVP behavior, but defined now for
/// the end-of-MVP contrast test (SPEC §11 amendment): 3 selfish NPCs
+52
View File
@@ -0,0 +1,52 @@
using System.Collections.Generic;
using Godot;
namespace WorldSim;
/// <summary>
/// A coarse spatial hash over NPC positions so proximity queries stop being
/// O(n²). Rebuilt once per tick from the live NPC list; queries return only
/// the buckets within range. At ~80 NPCs this turns every soul-pressure,
/// neighbor, and teacher scan from "look at everyone" into "look at the few
/// nearby" — the difference between a live view that stutters and one that
/// doesn't.
/// </summary>
public class SpatialGrid
{
private readonly float _cell;
private readonly Dictionary<(int, int), List<Npc>> _buckets = new();
public SpatialGrid(float cellSize) => _cell = cellSize;
private (int, int) Key(Vector2 p) =>
(Mathf.FloorToInt(p.X / _cell), Mathf.FloorToInt(p.Y / _cell));
public void Rebuild(IReadOnlyList<Npc> npcs)
{
foreach (var list in _buckets.Values) list.Clear();
foreach (var npc in npcs)
{
var key = Key(npc.Position);
if (!_buckets.TryGetValue(key, out var list))
_buckets[key] = list = new List<Npc>();
list.Add(npc);
}
}
/// <summary>
/// Invoke <paramref name="action"/> for every NPC within <paramref
/// name="radius"/> of <paramref name="center"/> (excluding none — caller
/// filters). Scans only the buckets the radius touches.
/// </summary>
public void ForEachNear(Vector2 center, float radius, System.Action<Npc> action)
{
int reach = Mathf.CeilToInt(radius / _cell);
var (cx, cy) = Key(center);
for (int dx = -reach; dx <= reach; dx++)
for (int dy = -reach; dy <= reach; dy++)
{
if (_buckets.TryGetValue((cx + dx, cy + dy), out var list))
foreach (var npc in list) action(npc);
}
}
}
+2
View File
@@ -63,6 +63,8 @@ public partial class Visualization : Node2D
MaterialKind.Clay => new Color(0.75f, 0.45f, 0.25f),
MaterialKind.Water => new Color(0.25f, 0.45f, 0.85f),
MaterialKind.Food => new Color(0.80f, 0.30f, 0.40f),
MaterialKind.Ore => new Color(0.65f, 0.55f, 0.75f),
MaterialKind.Herb => new Color(0.45f, 0.80f, 0.55f),
_ => Colors.Magenta,
};
// Fade toward depletion so dying groves read at a glance
+10 -7
View File
@@ -4,7 +4,7 @@ using Godot;
namespace WorldSim;
public enum MaterialKind { Wood, Stone, Clay, Water, Food }
public enum MaterialKind { Wood, Stone, Clay, Water, Food, Ore, Herb }
/// <summary>
/// A harvestable resource node on the grid (SPEC §6).
@@ -28,7 +28,7 @@ public class ResourceNode
/// </summary>
public class World
{
public const int GridSize = 256;
public const int GridSize = 768;
public List<ResourceNode> Nodes { get; } = new();
@@ -49,11 +49,14 @@ public class World
/// </summary>
public void Generate()
{
PlaceMany(MaterialKind.Water, count: 6, amount: float.PositiveInfinity, regen: 0f);
PlaceMany(MaterialKind.Clay, count: 10, amount: 40f, regen: 0.5f); // TODO: constrain near water
PlaceMany(MaterialKind.Wood, count: 60, amount: 30f, regen: 2f);
PlaceMany(MaterialKind.Stone, count: 12, amount: 80f, regen: 0f);
PlaceMany(MaterialKind.Food, count: 40, amount: 20f, regen: 4f); // South: flora most abundant
// Resource counts scale ~9× with the 3× larger world so density holds.
PlaceMany(MaterialKind.Water, count: 40, amount: float.PositiveInfinity, regen: 0f);
PlaceMany(MaterialKind.Clay, count: 80, amount: 40f, regen: 0.5f);
PlaceMany(MaterialKind.Wood, count: 480, amount: 30f, regen: 2f);
PlaceMany(MaterialKind.Stone, count: 140, amount: 80f, regen: 0f); // more deposits — housing was stone-capped
PlaceMany(MaterialKind.Food, count: 360, amount: 20f, regen: 4f);
PlaceMany(MaterialKind.Ore, count: 40, amount: 60f, regen: 0f); // metal source (post-MVP tier)
PlaceMany(MaterialKind.Herb, count: 120, amount: 15f, regen: 3f); // medicine / trade good
}
private void PlaceMany(MaterialKind kind, int count, float amount, float regen)