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:
+33
-30
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user