Files
mmcghen eb4b2fdd19 Toroidal world: boundless wrapping globe, 2x2 even town grid
The map is now a torus - walk off any edge and reappear on the opposite
side, no edges or corners. A new Toroidal helper carries all wrap-aware
distance/direction/movement math, and every position comparison in the sim
routes through it: soul pressure, node + structure sensing, gift/teacher/
neighbor scans, bonding, movement, the spatial grids (NPC and node cell
index both wrap their bucket lookups at the seam), and town centroid
(averaged in anchor-relative deltas, since averaging raw coordinates across
the seam is meaningless).

Towns re-laid as a 2x2 even grid on the torus (quarter/three-quarter points,
320 & 960 on a 1280 world): every town sits exactly 640 units from each of
its two axis-neighbours in all directions, wrap included - no more edge-boxed
North/South that could only forage inward. World grew 1152 -> 1280 with
resources scaled to area.

Verified over a 240-day soak: all four towns alive and balanced (pop 23-25),
population stable ~96, 119 bonds (27 cross-town), cultures still diverge, and
wrapped distances correctly cap at the torus half-diagonal (no false
across-seam blowups). 183s headless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:48:40 -04:00

237 lines
9.3 KiB
C#
Raw Permalink 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 = 1280;
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 (1280² ≈ 1.23× the 1152² world) so
// density holds across the boundless globe.
PlaceMany(MaterialKind.Water, count: 110, amount: float.PositiveInfinity, regen: 0f);
PlaceMany(MaterialKind.Clay, count: 220, amount: 40f, regen: 0.5f);
PlaceMany(MaterialKind.Wood, count: 1330, amount: 30f, regen: 2f);
PlaceMany(MaterialKind.Stone, count: 390, amount: 80f, regen: 0f);
PlaceMany(MaterialKind.Food, count: 1000, amount: 20f, regen: 4f);
PlaceMany(MaterialKind.Ore, count: 110, amount: 60f, regen: 0f);
PlaceMany(MaterialKind.Herb, count: 330, 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 cols = (GridSize + NodeCell - 1) / NodeCell; // cells per axis
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++)
{
int gx = ((cx + dx) % cols + cols) % cols; // wrap at the seam
int gy = ((cy + dy) % cols + cols) % cols;
if (!_nodeCells.TryGetValue((gx, gy), out var list)) continue;
foreach (var node in list)
if (Toroidal.DistanceSquared(position, 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 (Toroidal.DistanceSquared(center, 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 = Toroidal.DistanceSquared(position, 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 = Toroidal.DistanceSquared(position, node.Cell);
if (d < bestDist) { best = node; bestDist = d; }
}
return best;
}
}