Files
Soul_Game/scripts/World.cs
T
mmcghen 8aab1a763b Calibrate the soul layer: innocent harvests, sane gifts, healing path
Run 5 findings: justice arrived (selfish health 31-64 vs nature 100, all
108 refusals theirs) but two pathologies emerged: the whole village maxed
others-regard debris because the commons penalty line (50%) sat above the
nature harvest floor (45%) and debris had no clearing path; and warmth-
weighted asking + near-total gift transfers produced a 2,501-gift winter
food-swapping loop.

Fixes: commons line lowered to 40% (respectful harvesting never grazes
it; strip-harvesting lives deep inside it); gifts capped at 3 with a
kept reserve of 5; asking gated on genuine hunger; and giving now heals
the giver slightly (-0.003 others-regard vs +0.02 per refusal) — harm
stays an order of magnitude easier than healing.

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

157 lines
5.7 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 }
/// <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 = 256;
public List<ResourceNode> Nodes { get; } = new();
/// <summary>Units harvested since the last daily stats snapshot.</summary>
public float HarvestedToday;
private readonly Random _rng;
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()
{
PlaceMany(MaterialKind.Water, count: 6, amount: float.PositiveInfinity, regen: 0f);
PlaceMany(MaterialKind.Clay, count: 10, amount: 40f, regen: 0.5f); // TODO: constrain near water
PlaceMany(MaterialKind.Wood, count: 60, amount: 30f, regen: 2f);
PlaceMany(MaterialKind.Stone, count: 12, amount: 80f, regen: 0f);
PlaceMany(MaterialKind.Food, count: 40, amount: 20f, regen: 4f); // South: flora most abundant
}
private void PlaceMany(MaterialKind kind, int count, float amount, float regen)
{
for (int i = 0; i < count; i++)
{
Nodes.Add(new ResourceNode
{
Kind = kind,
Cell = new Vector2I(_rng.Next(GridSize), _rng.Next(GridSize)),
Amount = amount,
MaxAmount = amount,
RegenPerDay = regen,
});
}
}
/// <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
float floorFraction = npc.Soul.Sustainability * 0.5f; // how much of the node they leave standing
float takeable = Math.Max(0f, node.Amount - node.MaxAmount * floorFraction);
float taken = Math.Min(requested, takeable);
node.Amount -= taken;
HarvestedToday += taken;
// The moral line sits below any respectful harvest floor (nature souls
// stop at 45%) — ordinary sustainable taking never grazes it, while
// strip-harvesting (selfish floor ~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>
/// 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;
if (node.Kind != MaterialKind.Water && node.Amount <= node.MaxAmount * minFraction)
continue;
float d = position.DistanceSquaredTo(node.Cell);
if (d < bestDist) { best = node; bestDist = d; }
}
return best;
}
}