Files
Soul_Game/scripts/World.cs
T
mmcghen 10e772d087 Run 15: known-nodes exploration (addendum stage 1), per-town debug, O(n^2) fix
Exploration addendum stage 1: per-NPC known-nodes. NPCs gather only from
resources they've personally sensed (radius 22) or been told about;
founders seed with home ground, children inherit parents' maps, node
knowledge diffuses between neighbors like skills. Starving NPCs venture
outward and sense as they go - non-random needs-driven exploration, the
seed of first contact. Repopulation preserved (seeding + survival food
fallback): all towns fed, population stable 86-109 over 240 days.

Debug screen rebuilt: the ~68-row per-villager roster (overflowing at
four-town scale) replaced with a per-town summary - pop, fed%, knowledge,
debris, state breakdown, buildings, culture-colored, plus world
births/deaths. Scales to any population.

Performance: added phase timers and measured instead of guessing - npc.Tick
was 94% of cost, O(n^2) via TickResting calling RestingOccupancy (full
population scan) per building per resting NPC. Fixed with a once-per-tick
occupancy cache on Building. npc phase ~4x faster and now linear; full
240-day four-town run in 180s, faster than the pre-exploration baseline.
Profiling scaffolding removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 21:17:24 -04:00

233 lines
9.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = 768;
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 ~9× with the 3× larger world so density holds.
PlaceMany(MaterialKind.Water, count: 40, amount: float.PositiveInfinity, regen: 0f);
PlaceMany(MaterialKind.Clay, count: 80, amount: 40f, regen: 0.5f);
PlaceMany(MaterialKind.Wood, count: 480, amount: 30f, regen: 2f);
PlaceMany(MaterialKind.Stone, count: 140, amount: 80f, regen: 0f); // more deposits — housing was stone-capped
PlaceMany(MaterialKind.Food, count: 360, amount: 20f, regen: 4f);
PlaceMany(MaterialKind.Ore, count: 40, amount: 60f, regen: 0f); // metal source (post-MVP tier)
PlaceMany(MaterialKind.Herb, count: 120, amount: 15f, regen: 3f); // medicine / trade good
}
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;
}
}