0c0c7088f8
Root cause of the homeless East: FoundOwnShelter searched all buildings by proximity (within 30 units), so a lone selfish builder who wandered off to gather wood for the walls came back, missed their own foundation, and founded a NEW one - 5,900+ abandoned foundations per run, none finished. Fix (per the exploration architecture): structures are known like resource nodes. NPCs gain a KnownStructures set; a builder registers the home they lay and returns to it via known-structures memory however far they roamed, instead of re-founding. Children inherit their family's known buildings. Result: 5,953 -> 31 founding events, East now houses itself with COMPLETED homes and is the best-fed town (0.84), still lowest-knowledge/highest-debris as selfishness should be. Also expanded the world 768 -> 1152 with towns pushed to wider cardinal points and resources scaled to area, giving the four cultures more room and less accidental center overlap. Full 240-day four-town run in 189s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
234 lines
9.1 KiB
C#
234 lines
9.1 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using Godot;
|
||
|
||
namespace WorldSim;
|
||
|
||
public enum MaterialKind { Wood, Stone, Clay, Water, Food, Ore, Herb }
|
||
|
||
/// <summary>
|
||
/// A harvestable resource node on the grid (SPEC §6).
|
||
/// Wood regenerates; stone is static; clay sits near water; water is unlimited.
|
||
/// </summary>
|
||
public class ResourceNode
|
||
{
|
||
public MaterialKind Kind;
|
||
public Vector2I Cell;
|
||
public float Amount;
|
||
public float MaxAmount;
|
||
public float RegenPerDay; // 0 for stone; water ignores Amount entirely
|
||
|
||
public bool IsDepleted => Kind != MaterialKind.Water && Amount <= 0f;
|
||
}
|
||
|
||
/// <summary>
|
||
/// World generation, resource placement, and the shared resource pool (SPEC §9).
|
||
/// Grid is 256×256 (SPEC §9 performance target). No scripted town layout —
|
||
/// NPCs are dropped in and the community forms where it forms.
|
||
/// </summary>
|
||
public class World
|
||
{
|
||
public const int GridSize = 1152;
|
||
|
||
public List<ResourceNode> Nodes { get; } = new();
|
||
|
||
/// <summary>Units harvested since the last daily stats snapshot.</summary>
|
||
public float HarvestedToday;
|
||
|
||
private readonly Random _rng;
|
||
|
||
// Node cell index (built once — nodes never move). Only the sensing sweep
|
||
// uses it, but that runs per-NPC constantly, so it's the one place a node
|
||
// index genuinely pays (unlike nearest-of-kind, which stays a linear scan).
|
||
private const int NodeCell = 32;
|
||
private readonly Dictionary<(int, int), List<ResourceNode>> _nodeCells = new();
|
||
|
||
private void IndexNode(ResourceNode n)
|
||
{
|
||
var key = ((int)(n.Cell.X / NodeCell), (int)(n.Cell.Y / NodeCell));
|
||
if (!_nodeCells.TryGetValue(key, out var list))
|
||
_nodeCells[key] = list = new List<ResourceNode>();
|
||
list.Add(n);
|
||
}
|
||
|
||
public World(int seed)
|
||
{
|
||
_rng = new Random(seed);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Place resources: water source tiles first, clay near water,
|
||
/// wood in clusters (groves), stone in a few deposits.
|
||
/// TODO(Week 1): replace uniform scatter with noise-based clustering.
|
||
/// </summary>
|
||
public void Generate()
|
||
{
|
||
// Resource counts scale with area (1152² ≈ 2.25× the 768² world) so
|
||
// density holds as the towns spread to wider cardinal points.
|
||
PlaceMany(MaterialKind.Water, count: 90, amount: float.PositiveInfinity, regen: 0f);
|
||
PlaceMany(MaterialKind.Clay, count: 180, amount: 40f, regen: 0.5f);
|
||
PlaceMany(MaterialKind.Wood, count: 1080, amount: 30f, regen: 2f);
|
||
PlaceMany(MaterialKind.Stone, count: 315, amount: 80f, regen: 0f);
|
||
PlaceMany(MaterialKind.Food, count: 810, amount: 20f, regen: 4f);
|
||
PlaceMany(MaterialKind.Ore, count: 90, amount: 60f, regen: 0f);
|
||
PlaceMany(MaterialKind.Herb, count: 270, amount: 15f, regen: 3f);
|
||
}
|
||
|
||
private void PlaceMany(MaterialKind kind, int count, float amount, float regen)
|
||
{
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
var node = new ResourceNode
|
||
{
|
||
Kind = kind,
|
||
Cell = new Vector2I(_rng.Next(GridSize), _rng.Next(GridSize)),
|
||
Amount = amount,
|
||
MaxAmount = amount,
|
||
RegenPerDay = regen,
|
||
};
|
||
Nodes.Add(node);
|
||
IndexNode(node);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Harvest respecting the harvester's sustainability weight:
|
||
/// a South soul stops at ~50% of the node (SPEC §10) so nodes stay healthy.
|
||
/// Taking from below the commons line marks the taker's soul — it is
|
||
/// taking from everyone, whether anyone sees it or not.
|
||
/// Returns the amount actually taken.
|
||
/// </summary>
|
||
public float Harvest(ResourceNode node, float requested, Npc npc)
|
||
{
|
||
if (node.Kind == MaterialKind.Water) return requested; // unlimited at source
|
||
|
||
// Reciprocity is with the living land: floors and the commons line
|
||
// apply only to what regrows. Minerals quarry to depletion, for
|
||
// every soul equally, with no mark on anyone.
|
||
bool living = node.RegenPerDay > 0f;
|
||
|
||
float floorFraction = living ? npc.Soul.Sustainability * 0.5f : 0f;
|
||
float takeable = Math.Max(0f, node.Amount - node.MaxAmount * floorFraction);
|
||
float taken = Math.Min(requested, takeable);
|
||
node.Amount -= taken;
|
||
HarvestedToday += taken;
|
||
|
||
if (living)
|
||
{
|
||
// The moral line sits below any respectful harvest floor (nature
|
||
// souls stop at 45%) — ordinary sustainable taking never grazes
|
||
// it, while strip-harvesting (selfish ~10%) lives deep inside it.
|
||
float commons = node.MaxAmount * 0.4f;
|
||
if (node.Amount < commons)
|
||
{
|
||
float takenBelow = Math.Min(taken, commons - node.Amount);
|
||
npc.Imprint.OthersRegard =
|
||
Math.Clamp(npc.Imprint.OthersRegard + takenBelow * 0.004f, 0f, 1f);
|
||
}
|
||
}
|
||
|
||
return taken;
|
||
}
|
||
|
||
/// <summary>Invoke action for every node within radius — used by NPCs to
|
||
/// discover nearby nodes (addendum §4 sensing). Cell-indexed: scans only
|
||
/// the buckets the radius touches, not all ~1,260 nodes.</summary>
|
||
public void ForEachNodeNear(Vector2 position, float radius, System.Action<ResourceNode> action)
|
||
{
|
||
float r2 = radius * radius;
|
||
int reach = (int)(radius / NodeCell) + 1;
|
||
int cx = (int)(position.X / NodeCell);
|
||
int cy = (int)(position.Y / NodeCell);
|
||
for (int dx = -reach; dx <= reach; dx++)
|
||
for (int dy = -reach; dy <= reach; dy++)
|
||
{
|
||
if (!_nodeCells.TryGetValue((cx + dx, cy + dy), out var list)) continue;
|
||
foreach (var node in list)
|
||
if (position.DistanceSquaredTo(node.Cell) <= r2)
|
||
action(node);
|
||
}
|
||
}
|
||
|
||
/// <summary>Seed a town's founders with the resources around their home,
|
||
/// so they begin with a working local map instead of blind.</summary>
|
||
public IEnumerable<ResourceNode> NodesWithin(Vector2 center, float radius)
|
||
{
|
||
float r2 = radius * radius;
|
||
foreach (var node in Nodes)
|
||
if (center.DistanceSquaredTo(node.Cell) <= r2)
|
||
yield return node;
|
||
}
|
||
|
||
/// <summary>Nearest node of a kind regardless of amount — for returning
|
||
/// unneeded minerals to the quarry rather than carrying them forever.</summary>
|
||
public ResourceNode? FindNearestAny(MaterialKind kind, Vector2 position)
|
||
{
|
||
ResourceNode? best = null;
|
||
float bestDist = float.MaxValue;
|
||
foreach (var node in Nodes)
|
||
{
|
||
if (node.Kind != kind) continue;
|
||
float d = position.DistanceSquaredTo(node.Cell);
|
||
if (d < bestDist) { best = node; bestDist = d; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Called once per in-game day by GameManager. Season multipliers throttle
|
||
/// regeneration — winter is when the granary earns its clay.
|
||
/// </summary>
|
||
public void RegenerateDaily(float foodMult = 1f, float woodMult = 1f)
|
||
{
|
||
bool frost = foodMult < 0.1f; // deep winter
|
||
|
||
foreach (var node in Nodes)
|
||
{
|
||
// Frost kills standing food — the wild larder rots on the stem.
|
||
// Without this, the sustainability floors act as a huge hidden
|
||
// savings account and winter never reaches an actual villager.
|
||
if (frost && node.Kind == MaterialKind.Food)
|
||
{
|
||
node.Amount = Math.Max(node.Amount * 0.93f, node.MaxAmount * 0.15f);
|
||
continue;
|
||
}
|
||
|
||
if (node.RegenPerDay <= 0f) continue;
|
||
float mult = node.Kind switch
|
||
{
|
||
MaterialKind.Food => foodMult,
|
||
MaterialKind.Wood => woodMult,
|
||
_ => 1f,
|
||
};
|
||
node.Amount = Math.Min(node.MaxAmount, node.Amount + node.RegenPerDay * mult);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Nearest node this particular soul considers harvestable. What counts
|
||
/// as "worth taking" is a moral judgment, not a world fact: a nature
|
||
/// soul walks past a node at half strength; a selfish soul strips it to
|
||
/// the stem. The commons' savings account only exists for those who
|
||
/// honor it.
|
||
/// </summary>
|
||
public ResourceNode? FindNearest(MaterialKind kind, Vector2 position, float minFraction = 0.5f)
|
||
{
|
||
ResourceNode? best = null;
|
||
float bestDist = float.MaxValue;
|
||
foreach (var node in Nodes)
|
||
{
|
||
if (node.Kind != kind || node.IsDepleted) continue;
|
||
// The harvest floor is an ethic of regrowth — it applies only to
|
||
// living, regenerating things. Leaving half a berry bush lets it
|
||
// live; leaving half a stone deposit protects nobody, and once
|
||
// stranded it homelessly, we learned that the hard way (run 9).
|
||
if (node.Kind != MaterialKind.Water && node.RegenPerDay > 0f &&
|
||
node.Amount <= node.MaxAmount * minFraction)
|
||
continue;
|
||
float d = position.DistanceSquaredTo(node.Cell);
|
||
if (d < bestDist) { best = node; bestDist = d; }
|
||
}
|
||
return best;
|
||
}
|
||
}
|