Organic construction: buildings from need, not quotas

Removes every predetermined construction trigger (shelters-per-capita
cap, granary-at-five-shelters milestone). Buildings now emerge from
lived need:

- Shelters have real bed capacity (3 + modules). A full house turns you
  away; three nights sleeping outside and a person gathers stone and
  raises a roof where they actually live. When nobody sleeps outside,
  nobody builds. Ruined shelters spill their sleepers, so settlements
  can migrate over time.
- The first granary is founded by memory: someone who has truly starved
  stockpiles food once the land gives again and builds storage where the
  harvest is. A second granary appears only when the first fills.
- Emergent consequence: freeriders compete for beds but never build,
  so scarce housing squeezes them into the open. The village never
  decides to punish them; the housing supply does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 14:14:29 -04:00
parent 3f2c5e2f18
commit 5359ec922c
3 changed files with 78 additions and 21 deletions
+5
View File
@@ -92,6 +92,11 @@ public class Building
Kind == BuildingKind.Shelter && IsComplete && !IsRuined &&
Modules < ModuleCap && !WantsUpkeepWood;
/// <summary>How many can sleep here. Expansion adds beds — growth has a
/// human meaning, not just a bigger square.</summary>
public int RestCapacity =>
Kind == BuildingKind.Shelter && IsComplete && !IsRuined ? 3 + Modules : 0;
// --- Granary: food store ---------------------------------------------
public const float FoodStockCap = 150f;
+10
View File
@@ -117,6 +117,16 @@ public partial class GameManager : Node2D
}
}
/// <summary>How many villagers are currently sleeping at this shelter.</summary>
public int RestingOccupancy(Building b)
{
int count = 0;
foreach (var npc in Npcs)
if (npc.State == NpcState.Resting && npc.Position.DistanceTo(b.Site) <= 2.5f)
count++;
return count;
}
public int TownPopulation(string town)
{
int count = 0;
+63 -21
View File
@@ -97,6 +97,16 @@ public class Npc
private Npc? _giftSource; // neighbor being asked for food
private float _stateTimer;
// Organic construction: nights spent sleeping outside. Three rough
// nights and a person starts gathering stone for a roof of their own.
private int _unshelteredRests;
private bool _shelteredDuringRest;
// Hunger remembered: someone who has truly starved stockpiles food once
// the land gives again. The town's first granary is founded by memory,
// not by milestone.
private bool _hungerMemory;
private static readonly MaterialKind[] GatherKinds =
{ MaterialKind.Wood, MaterialKind.Stone, MaterialKind.Clay };
@@ -140,6 +150,7 @@ 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);
if (Nourishment < 0.3f) _hungerMemory = true; // real hunger is not forgotten
Nourishment = Mathf.Clamp(Nourishment, 0f, 1f);
// 4. Fatigue deltas (SPEC §10) — scaled per tick
@@ -163,6 +174,7 @@ public class Npc
_targetNpc = null;
_foodSource = null;
_giftSource = null;
_shelteredDuringRest = false;
}
public float TotalCarried()
@@ -198,6 +210,12 @@ public class Npc
// town's hearths. (Helping a neighbor town is a later, deliberate
// system, not an accident of the work queue.)
// 1.5. A roof of your own: kept sleeping outside? Gather stone.
// (The founding itself happens in TickBuilding once you're
// carrying enough — need drives supply drives construction.)
if (_unshelteredRests >= 3)
return MaterialKind.Stone;
// 2. Construction sites.
foreach (var b in gm.Buildings)
if (b.Town == HomeTown && !b.IsComplete && b.NeededMaterial is MaterialKind needed)
@@ -213,6 +231,11 @@ public class Npc
if (b.Town == HomeTown && b.WantsFood)
return MaterialKind.Food;
// 4.5. Hunger remembered, and nowhere to store against it:
// gather the surplus that will found the granary.
if (_hungerMemory && !TownHasFoodStorage(gm))
return MaterialKind.Food;
// 5. Ambition: expansion modules for sound, stocked shelters.
foreach (var b in gm.Buildings)
if (b.Town == HomeTown && b.WantsExpansion)
@@ -229,6 +252,16 @@ public class Npc
return best;
}
/// <summary>True if this town has any granary (built or building) with room.</summary>
private bool TownHasFoodStorage(GameManager gm)
{
foreach (var b in gm.Buildings)
if (b.Town == HomeTown && b.Kind == BuildingKind.Granary &&
(!b.IsComplete || b.FoodStock < Building.FoodStockCap))
return true;
return false;
}
/// <summary>
/// When full but mostly carrying the wrong thing, set the excess down to
/// make room for what's actually needed. Lossy for now —
@@ -431,30 +464,26 @@ public class Npc
if (site == null)
{
// 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++;
}
// Organic founding — need, not quota. No shelter caps, no
// granary milestones. Reaching here means nothing in town
// currently needs delivering, so ask: what does *this person*
// need that doesn't exist yet?
var jitter = new Vector2(PersonalityModifier * 4f, -PersonalityModifier * 4f);
int shelterCap = gm.TownPopulation(HomeTown) / 3;
var jitter = new Vector2(PersonalityModifier * 25f, -PersonalityModifier * 25f);
if (shelters < shelterCap && carriesStone)
// Kept sleeping outside → raise a roof where you actually live.
if (_unshelteredRests >= 3)
{
site = Building.NewShelter(gm.TownCentroid(HomeTown) + jitter);
site = Building.NewShelter(Position + jitter);
site.Town = HomeTown;
gm.Buildings.Add(site);
_unshelteredRests = 0;
}
else if (!granaryExists && shelters >= shelterCap && carriesStone)
// Carrying a surplus with nowhere to put it → the barn goes
// where the harvest is. A second granary appears when the
// first fills; storage scales with abundance.
else if (Inventory.TryGetValue(MaterialKind.Food, out float surplus) && surplus > 8f)
{
site = Building.NewGranary(gm.TownCentroid(HomeTown) + jitter);
site = Building.NewGranary(Position + jitter);
site.Town = HomeTown;
gm.Buildings.Add(site);
}
@@ -481,12 +510,15 @@ public class Npc
private void TickResting(GameManager gm)
{
// Rest at home: walk to the nearest sound shelter and recover faster there.
// Rest at the nearest shelter WITH A FREE BED. Beds are real:
// capacity is 3 + modules, and a full house turns you away.
Building? shelter = null;
float bestDist = float.MaxValue;
foreach (var b in gm.Buildings)
{
if (!b.IsComplete || b.IsRuined) continue;
if (b.RestCapacity <= 0) continue;
bool occupyingHere = Position.DistanceTo(b.Site) <= 2.5f;
if (!occupyingHere && gm.RestingOccupancy(b) >= b.RestCapacity) continue;
float d = Position.DistanceSquaredTo(b.Site);
if (d < bestDist) { bestDist = d; shelter = b; }
}
@@ -501,10 +533,20 @@ public class Npc
: shelter != null ? "walking home to rest"
: "resting in the open";
if (sheltered)
{
_shelteredDuringRest = true;
_unshelteredRests = 0; // a bed found is a grievance forgotten
}
Health = Mathf.Min(100f, Health + (sheltered ? 0.4f : 0.1f));
if (sheltered) Fatigue = Mathf.Max(0f, Fatigue - 0.3f); // on top of the state delta
if (Fatigue < 40f && !gm.IsNight) Enter(NpcState.Gathering);
if (Fatigue < 40f && !gm.IsNight)
{
if (!_shelteredDuringRest) _unshelteredRests++;
Enter(NpcState.Gathering);
}
}
private void TickSocializing(GameManager gm)