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
+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}";