Expansion phase: settlements — the North joins the world

Runs 5-6 logged in FINDINGS.md (soul layer validated: nature debris
0.000 vs selfish 0.225 after two years; the ledger works).

Towns are now real spatial structure: TownDef seeds (South at the
bottom of the map, North at the top), NPCs affiliate with a home town,
communal duty serves your own settlement (buildings are town-tagged,
founding uses the town centroid and per-town shelter caps), and each
town raises its own shelters and granary. Generational (North) soul
profile added — temperament only until the knowledge system lands.
Map shows town labels; rosters and CSVs carry town columns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 14:02:59 -04:00
parent 8aab1a763b
commit 3f2c5e2f18
11 changed files with 4124 additions and 6541 deletions
+3
View File
@@ -21,6 +21,9 @@ public class Building
public BuildingKind Kind = BuildingKind.Shelter;
public Vector2 Site;
/// <summary>Which settlement this building belongs to.</summary>
public string Town = "";
public static Building NewShelter(Vector2 site) => new()
{
Kind = BuildingKind.Shelter,
+72 -14
View File
@@ -13,10 +13,25 @@ public partial class GameManager : Node2D
public static GameManager? Instance { get; private set; }
[Export] public int Seed = 12345;
[Export] public int NpcCount = 15;
// --- Settlements ------------------------------------------------------
// Expansion phase: multiple towns, each a soul culture with its own
// 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; // the contrast test (SPEC §11 amendment)
public int SelfishCount = 3; // east souls living in the South
[Export] public int NorthPopulation = 10; // generational souls
public record TownDef(string Name, Vector2 Center, SoulType Soul);
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),
};
[Export(PropertyHint.Range, "1,2000,1")]
public float TicksPerRealSecond = 20f; // sim speed; ~1500 ≈ one day/sec for soaks
@@ -68,22 +83,65 @@ public partial class GameManager : Node2D
private void SpawnNpcs()
{
// Dropped near the map center with scatter — no scripted town layout.
// Where the community forms is the experiment (SPEC §11).
// Dropped near their town's seed point with scatter — no scripted
// layout. Where each community actually forms is the experiment.
var rng = new System.Random(Seed);
for (int i = 0; i < NpcCount; i++)
void Spawn(string town, Vector2 center, SoulProfile soul, int count, string prefix)
{
bool selfish = i >= NpcCount - SelfishCount;
Npcs.Add(new Npc
for (int i = 0; i < count; i++)
{
Name = $"Villager_{(char)('A' + i)}",
Soul = selfish ? SoulProfile.SelfishSoul() : SoulProfile.NatureSoul(),
PersonalityModifier = (float)(rng.NextDouble() * 0.4 - 0.2),
Position = new Vector2(
World.GridSize / 2f + rng.Next(-20, 21),
World.GridSize / 2f + rng.Next(-20, 21)),
});
Npcs.Add(new Npc
{
Name = $"{prefix}_{(char)('A' + Npcs.Count % 26)}{(Npcs.Count >= 26 ? Npcs.Count.ToString() : "")}",
HomeTown = town,
Soul = soul,
PersonalityModifier = (float)(rng.NextDouble() * 0.4 - 0.2),
Position = center + new Vector2(rng.Next(-12, 13), rng.Next(-12, 13)),
});
}
}
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");
}
}
}
public int TownPopulation(string town)
{
int count = 0;
foreach (var npc in Npcs)
if (npc.HomeTown == town) count++;
return count;
}
public Vector2 TownCentroid(string town)
{
Vector2 sum = Vector2.Zero;
int count = 0;
foreach (var npc in Npcs)
{
if (npc.HomeTown != town) continue;
sum += npc.Position;
count++;
}
if (count == 0)
{
foreach (var t in _towns)
if (t.Name == town) return t.Center;
return new Vector2(World.GridSize / 2f, World.GridSize / 2f);
}
return sum / count;
}
public override void _Process(double delta)
+26 -15
View File
@@ -22,6 +22,7 @@ public class Npc
{
// --- Identity & soul -------------------------------------------------
public string Name = "";
public string HomeTown = ""; // settlement affiliation — home, not a cage
public SoulProfile Soul = SoulProfile.NatureSoul();
public SoulImprint Imprint = new(); // triad — see SPEC §2 amendment
public float PersonalityModifier; // per-NPC variance within a soul type
@@ -193,24 +194,28 @@ public class Npc
if (IsCommunal)
{
// Communal duty is to your own settlement — you heat your own
// town's hearths. (Helping a neighbor town is a later, deliberate
// system, not an accident of the work queue.)
// 2. Construction sites.
foreach (var b in gm.Buildings)
if (!b.IsComplete && b.NeededMaterial is MaterialKind needed)
if (b.Town == HomeTown && !b.IsComplete && b.NeededMaterial is MaterialKind needed)
return needed;
// 3. Firewood for shelters running low.
foreach (var b in gm.Buildings)
if (b.WantsUpkeepWood)
if (b.Town == HomeTown && b.WantsUpkeepWood)
return MaterialKind.Wood;
// 4. Winter insurance: stock the granary while the land still gives.
foreach (var b in gm.Buildings)
if (b.WantsFood)
if (b.Town == HomeTown && b.WantsFood)
return MaterialKind.Food;
// 5. Ambition: expansion modules for sound, stocked shelters.
foreach (var b in gm.Buildings)
if (b.WantsExpansion)
if (b.Town == HomeTown && b.WantsExpansion)
return MaterialKind.Wood;
}
@@ -381,12 +386,13 @@ public class Npc
// shelters and eat from the granary — freeriding is the whole point.
if (!IsCommunal) { Enter(NpcState.Socializing); return; }
// Priority 1: construction sites. Priority 2: shelters low on firewood.
// Serve your own settlement's buildings, nearest first:
// construction → firewood → granary deposits → expansion.
Building? site = null;
float bestDist = float.MaxValue;
foreach (var b in gm.Buildings)
{
if (b.IsComplete) continue;
if (b.Town != HomeTown || b.IsComplete) continue;
float d = Position.DistanceSquaredTo(b.Site);
if (d < bestDist) { bestDist = d; site = b; }
}
@@ -394,7 +400,7 @@ public class Npc
{
foreach (var b in gm.Buildings)
{
if (!b.WantsUpkeepWood) continue;
if (b.Town != HomeTown || !b.WantsUpkeepWood) continue;
float d = Position.DistanceSquaredTo(b.Site);
if (d < bestDist) { bestDist = d; site = b; }
}
@@ -407,7 +413,7 @@ public class Npc
{
foreach (var b in gm.Buildings)
{
if (!b.WantsFood) continue;
if (b.Town != HomeTown || !b.WantsFood) continue;
float d = Position.DistanceSquaredTo(b.Site);
if (d < bestDist) { bestDist = d; site = b; }
}
@@ -417,7 +423,7 @@ public class Npc
{
foreach (var b in gm.Buildings)
{
if (!b.WantsExpansion) continue;
if (b.Town != HomeTown || !b.WantsExpansion) continue;
float d = Position.DistanceSquaredTo(b.Site);
if (d < bestDist) { bestDist = d; site = b; }
}
@@ -425,26 +431,31 @@ public class Npc
if (site == null)
{
// Communal-first founding, near where the community actually
// gathers. Shelters first; once the village stands, the granary.
// Communal-first founding, near where this settlement's people
// actually gather. Shelters first; once the village stands, the
// granary.
bool carriesStone = Inventory.TryGetValue(MaterialKind.Stone, out float stone) && stone > 0f;
int shelters = 0;
bool granaryExists = false;
foreach (var b in gm.Buildings)
{
if (b.Town != HomeTown) continue;
if (b.Kind == BuildingKind.Granary) granaryExists = true;
else shelters++;
}
int shelterCap = gm.TownPopulation(HomeTown) / 3;
var jitter = new Vector2(PersonalityModifier * 25f, -PersonalityModifier * 25f);
if (shelters < gm.NpcCount / 3 && carriesStone)
if (shelters < shelterCap && carriesStone)
{
site = Building.NewShelter(gm.CommunityCentroid() + jitter);
site = Building.NewShelter(gm.TownCentroid(HomeTown) + jitter);
site.Town = HomeTown;
gm.Buildings.Add(site);
}
else if (!granaryExists && shelters >= gm.NpcCount / 3 && carriesStone)
else if (!granaryExists && shelters >= shelterCap && carriesStone)
{
site = Building.NewGranary(gm.CommunityCentroid() + jitter);
site = Building.NewGranary(gm.TownCentroid(HomeTown) + jitter);
site.Town = HomeTown;
gm.Buildings.Add(site);
}
else { Enter(NpcState.Socializing); return; }
+16
View File
@@ -63,6 +63,22 @@ public class SoulProfile
Accumulation = 0.1f,
};
/// <summary>
/// North-weighted profile: reverence for lineage, strong communal pull,
/// modest material appetite. Their signature system — generational
/// knowledge transfer (SPEC §5) — arrives with the expansion phase;
/// until then they differ from the South in temperament, not verbs.
/// </summary>
public static SoulProfile GenerationalSoul() => new()
{
Type = SoulType.Generational,
Generosity = 0.8f,
Sustainability = 0.8f,
CommunityBias = 0.95f,
StatusBias = 0.15f,
Accumulation = 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
+2 -1
View File
@@ -24,7 +24,7 @@ public static class StatsLogger
"avg_debris,refusals";
private const string NpcHeader =
"day,npc,name,soul,dominant,gather_ticks,build_ticks,trade_ticks,rest_ticks,social_ticks," +
"day,npc,name,soul,town,dominant,gather_ticks,build_ticks,trade_ticks,rest_ticks,social_ticks," +
"nourish,health,debris";
private const string GiftHeader =
@@ -154,6 +154,7 @@ public static class StatsLogger
.Append(i + 1).Append(',')
.Append(npc.Name).Append(',')
.Append(npc.Soul.Type).Append(',')
.Append(npc.HomeTown).Append(',')
.Append(npc.DominantStateToday()).Append(',')
.Append(t[(int)NpcState.Gathering]).Append(',')
.Append(t[(int)NpcState.Building]).Append(',')
+11 -3
View File
@@ -72,6 +72,14 @@ public partial class Visualization : Node2D
DrawRect(new Rect2(p.X, p.Y, CellPixels, CellPixels), c);
}
// --- Map: town labels --------------------------------------------
foreach (var town in _gm.Towns)
{
var tp = MapPos(town.Center);
DrawString(font, tp + new Vector2(-20f, -14f), town.Name.ToUpper(),
fontSize: 13, modulate: TextDim);
}
// --- Map: buildings ----------------------------------------------
for (int i = 0; i < _gm.Buildings.Count; i++)
{
@@ -118,7 +126,7 @@ public partial class Visualization : Node2D
for (int i = 0; i < _gm.Npcs.Count; i++)
{
var npc = _gm.Npcs[i];
y += 22f;
y += 20f;
if (npc.Soul.Type == SoulType.Selfish)
DrawCircle(new Vector2(PanelX + 24, y - 5f), 6f, new Color(0.75f, 0.15f, 0.15f));
@@ -142,7 +150,7 @@ public partial class Visualization : Node2D
for (int i = 0; i < _gm.Buildings.Count; i++)
{
var b = _gm.Buildings[i];
y += 20f;
y += 18f;
string status = b.IsRuined ? "RUIN"
: !b.IsComplete
? $"building: {b.Stages[b.CurrentStage].Name} {b.StageProgress * 100,3:0}%"
@@ -151,7 +159,7 @@ public partial class Visualization : Node2D
: $"cond {b.Condition * 100,3:0}% wood {b.UpkeepStock,4:0.0}/{Building.UpkeepStockCap:0}" +
$" mods {b.Modules}/{Building.ModuleCap} burn {b.DailyBurn:0.0}/day";
DrawString(font, new Vector2(PanelX + 36, y),
$"{i + 1} {status}", fontSize: 13,
$"{i + 1} {b.Town,-5} {status}", fontSize: 12,
modulate: b.IsRuined ? TextDim : TextMain);
}