From 5359ec922cfdedef2305d538109f72bb78300a26 Mon Sep 17 00:00:00 2001 From: Mike McGhen Date: Tue, 21 Jul 2026 14:14:29 -0400 Subject: [PATCH] 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 --- scripts/Building.cs | 5 +++ scripts/GameManager.cs | 10 +++++ scripts/NPC.cs | 84 +++++++++++++++++++++++++++++++----------- 3 files changed, 78 insertions(+), 21 deletions(-) diff --git a/scripts/Building.cs b/scripts/Building.cs index a0fa056..b0c8e17 100644 --- a/scripts/Building.cs +++ b/scripts/Building.cs @@ -92,6 +92,11 @@ public class Building Kind == BuildingKind.Shelter && IsComplete && !IsRuined && Modules < ModuleCap && !WantsUpkeepWood; + /// How many can sleep here. Expansion adds beds — growth has a + /// human meaning, not just a bigger square. + public int RestCapacity => + Kind == BuildingKind.Shelter && IsComplete && !IsRuined ? 3 + Modules : 0; + // --- Granary: food store --------------------------------------------- public const float FoodStockCap = 150f; diff --git a/scripts/GameManager.cs b/scripts/GameManager.cs index 0866be9..87983d5 100644 --- a/scripts/GameManager.cs +++ b/scripts/GameManager.cs @@ -117,6 +117,16 @@ public partial class GameManager : Node2D } } + /// How many villagers are currently sleeping at this shelter. + 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; diff --git a/scripts/NPC.cs b/scripts/NPC.cs index c0d1a64..d15e75e 100644 --- a/scripts/NPC.cs +++ b/scripts/NPC.cs @@ -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; } + /// True if this town has any granary (built or building) with room. + 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; + } + /// /// 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)