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:
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(',')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user