Files
TheriapolisV3/Theriapolis.Core/Tactical/ChunkStreamer.cs
T
Christopher Wiebe f57ea0b70c Fix ChunkStreamer.EnsureLoadedAround leaving pre-warmed chunks stuck
EnsureLoadedAround skipped Get() for any active chunk already in
_inflight. That worked for the MonoGame TacticalRenderer, which calls
Get() during its own draw loop and incidentally drains pre-warm tasks.
But subscribers to OnChunkLoaded (e.g. the Godot port) saw no event
when a previously-pre-warmed chunk transitioned into the active set on
a later frame — the chunk stayed in _inflight forever, presenting as
permanently-uncached gaps in the rendered world.

Fix: drop the !_inflight.ContainsKey(cc) guard. Get() already handles
all three paths (cache hit, inflight drain, fresh generate), so passing
every active chunk through Get() guarantees OnChunkLoaded fires once
per chunk regardless of how it was scheduled.

Same flavour of bug as M1's MoistureGen FastNoiseLite race —
cross-process / event-driven consumers exercise paths the in-process
pull-based test fixtures never hit. 708/708 tests still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 20:07:06 -07:00

244 lines
9.6 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 Theriapolis.Core.World;
namespace Theriapolis.Core.Tactical;
/// <summary>
/// In-memory cache of generated tactical chunks. Generation happens lazily on
/// first access; eviction happens whenever the active set is recomputed by
/// <see cref="EnsureLoadedAround"/>.
///
/// The cache is bounded by <see cref="C.CHUNK_CACHE_SOFT_MAX"/>: anything above
/// that count and outside the active window gets dropped (delta flushed first).
///
/// Single-threaded; pre-warming uses background tasks but the streamer itself
/// is mutated only from the caller thread.
/// </summary>
public sealed class ChunkStreamer
{
private readonly ulong _worldSeed;
private readonly WorldState _world;
private readonly IChunkDeltaStore _deltas;
private readonly Dictionary<ChunkCoord, TacticalChunk> _cache = new();
private readonly Dictionary<ChunkCoord, Task<TacticalChunk>> _inflight = new();
private readonly Theriapolis.Core.Data.SettlementContent? _settlementContent;
public IReadOnlyDictionary<ChunkCoord, TacticalChunk> Loaded => _cache;
/// <summary>
/// Phase 5 M5: fires after a chunk is freshly inserted into the cache.
/// Subscribers (typically PlayScreen) use this to spawn NPCs from
/// <see cref="TacticalChunk.Spawns"/>. Does not fire for chunks already
/// in cache.
/// </summary>
public event System.Action<TacticalChunk>? OnChunkLoaded;
/// <summary>
/// Phase 5 M5: fires immediately before a chunk is evicted from cache.
/// Subscribers despawn NPCs sourced from this chunk so the active actor
/// list stays bounded.
/// </summary>
public event System.Action<TacticalChunk>? OnChunkEvicting;
public ChunkStreamer(ulong worldSeed, WorldState world, IChunkDeltaStore deltas)
: this(worldSeed, world, deltas, settlementContent: null) { }
/// <summary>
/// Phase 6 M0 — pass <paramref name="settlementContent"/> to enable
/// templated settlement stamping. <c>null</c> falls back to the
/// Phase-4 plaza+wall-ring placeholder.
/// </summary>
public ChunkStreamer(ulong worldSeed, WorldState world, IChunkDeltaStore deltas,
Theriapolis.Core.Data.SettlementContent? settlementContent)
{
_worldSeed = worldSeed;
_world = world;
_deltas = deltas;
_settlementContent = settlementContent;
}
/// <summary>
/// Returns the chunk for the given coord, generating + applying deltas if
/// necessary. Synchronous: blocks the caller for ~1 ms in the worst case
/// (chunk gen at 64×64 is well under our budget).
/// </summary>
public TacticalChunk Get(ChunkCoord cc)
{
if (_cache.TryGetValue(cc, out var cached)) return cached;
// If a pre-warm Task is in flight for this coord, drain it.
if (_inflight.TryGetValue(cc, out var task))
{
var fromTask = task.GetAwaiter().GetResult();
_inflight.Remove(cc);
_cache[cc] = fromTask;
ApplyDelta(fromTask);
OnChunkLoaded?.Invoke(fromTask);
return fromTask;
}
var chunk = TacticalChunkGen.Generate(_worldSeed, cc, _world, _settlementContent);
ApplyDelta(chunk);
_cache[cc] = chunk;
OnChunkLoaded?.Invoke(chunk);
return chunk;
}
/// <summary>
/// Returns the tactical tile at the given world-pixel coordinate, generating
/// the containing chunk on demand. Out-of-world coords return a default
/// "ocean" tile so callers don't need bounds checks.
/// </summary>
public TacticalTile SampleTile(int tx, int ty)
{
// Tactical tiles outside the world map are deep water — keeps the
// player from walking off the edge.
int worldPxW = C.WORLD_WIDTH_TILES * C.TACTICAL_PER_WORLD_TILE;
int worldPxH = C.WORLD_HEIGHT_TILES * C.TACTICAL_PER_WORLD_TILE;
if ((uint)tx >= worldPxW || (uint)ty >= worldPxH)
return new TacticalTile { Surface = TacticalSurface.DeepWater };
var cc = ChunkCoord.ForTactical(tx, ty);
var chunk = Get(cc);
int lx = tx - chunk.OriginX;
int ly = ty - chunk.OriginY;
return chunk.Tiles[lx, ly];
}
/// <summary>
/// Make sure every chunk overlapping the given world-pixel position +/- the
/// given radius (in world tiles) is loaded; evict everything else above the
/// soft cap. Pre-warms neighbours on the threadpool to hide latency on
/// world-tile crossings.
/// </summary>
public void EnsureLoadedAround(Util.Vec2 worldPixelPos, int worldTileRadius)
{
int playerWX = (int)MathF.Floor(worldPixelPos.X / C.WORLD_TILE_PIXELS);
int playerWY = (int)MathF.Floor(worldPixelPos.Y / C.WORLD_TILE_PIXELS);
// Compute the active set of chunks: every chunk overlapping the
// (2*radius+1)² world-tile window around the player.
var active = new HashSet<ChunkCoord>();
int worldTilesPerChunk = C.TACTICAL_CHUNK_SIZE / C.TACTICAL_PER_WORLD_TILE;
int chunkRadius = worldTileRadius / worldTilesPerChunk + 1;
var centre = ChunkCoord.ForWorldTile(playerWX, playerWY);
for (int cy = centre.Y - chunkRadius; cy <= centre.Y + chunkRadius; cy++)
for (int cx = centre.X - chunkRadius; cx <= centre.X + chunkRadius; cx++)
{
active.Add(new ChunkCoord(cx, cy));
}
// Synchronously make every active chunk live in the cache. Get()
// handles all three paths: hit cache, drain a pre-warmed inflight
// task, or generate fresh. The previous version skipped chunks in
// _inflight, which left them stuck there indefinitely once they
// entered the active set on a later frame — visible to renderers
// that subscribe to OnChunkLoaded (e.g. the Godot port) as
// permanently-uncached gaps. The MonoGame TacticalRenderer dodged
// this by calling Get() inside its draw loop; M4 of the port made
// that drain unnecessary by fixing it here.
foreach (var cc in active)
{
if (!_cache.ContainsKey(cc))
_ = Get(cc); // hits cache, drains inflight, or generates
}
for (int cy = centre.Y - chunkRadius - 1; cy <= centre.Y + chunkRadius + 1; cy++)
for (int cx = centre.X - chunkRadius - 1; cx <= centre.X + chunkRadius + 1; cx++)
{
var cc = new ChunkCoord(cx, cy);
if (active.Contains(cc)) continue;
if (_cache.ContainsKey(cc) || _inflight.ContainsKey(cc)) continue;
// Kick off a background generation; the result will be drained by
// a future Get() call if it's needed.
var t = Task.Run(() => TacticalChunkGen.Generate(_worldSeed, cc, _world, _settlementContent));
_inflight[cc] = t;
}
EvictExcept(active);
}
private void EvictExcept(HashSet<ChunkCoord> keep)
{
// Evict cached chunks that aren't in the active set, oldest-first
// (HashMap iteration order isn't stable; we just take whatever's first).
if (_cache.Count <= C.CHUNK_CACHE_SOFT_MAX) return;
var toEvict = new List<ChunkCoord>();
foreach (var key in _cache.Keys)
if (!keep.Contains(key)) toEvict.Add(key);
foreach (var key in toEvict)
{
if (_cache.Count <= C.CHUNK_CACHE_SOFT_MAX) break;
FlushAndDrop(key);
}
}
/// <summary>
/// Force-flush every loaded chunk's delta back to the store and clear the
/// cache. Called at save time so nothing in-memory is lost.
/// </summary>
public void FlushAll()
{
foreach (var kv in _cache.ToArray())
FlushAndDrop(kv.Key);
_cache.Clear();
}
private void FlushAndDrop(ChunkCoord cc)
{
if (!_cache.TryGetValue(cc, out var chunk)) return;
OnChunkEvicting?.Invoke(chunk);
if (chunk.HasDelta)
{
// Compute the delta vs the deterministic baseline. Cheap because
// the baseline is itself recomputable.
var baseline = TacticalChunkGen.Generate(_worldSeed, cc, _world, _settlementContent);
_deltas.Put(cc, ComputeDelta(baseline, chunk));
}
_cache.Remove(cc);
}
private void ApplyDelta(TacticalChunk fresh)
{
var d = _deltas.Get(fresh.Coord);
if (d is null) return;
foreach (var m in d.TileMods)
{
ref var t = ref fresh.Tiles[m.LocalX, m.LocalY];
t.Surface = m.Surface;
t.Deco = m.Deco;
t.Flags = m.Flags;
}
if (d.SpawnsConsumed) fresh.Spawns.Clear();
fresh.HasDelta = true;
}
private static ChunkDelta ComputeDelta(TacticalChunk baseline, TacticalChunk current)
{
var d = new ChunkDelta();
for (int y = 0; y < C.TACTICAL_CHUNK_SIZE; y++)
for (int x = 0; x < C.TACTICAL_CHUNK_SIZE; x++)
{
var b = baseline.Tiles[x, y];
var c = current.Tiles[x, y];
if (b.Surface != c.Surface || b.Deco != c.Deco || b.Flags != c.Flags || b.Variant != c.Variant)
d.TileMods.Add(new TileMod(x, y, c.Surface, c.Deco, c.Flags));
}
if (current.Spawns.Count == 0 && baseline.Spawns.Count > 0)
d.SpawnsConsumed = true;
return d;
}
/// <summary>Mark a tile as user-modified so the streamer flushes it on eviction.</summary>
public void RecordModification(int tx, int ty)
{
var cc = ChunkCoord.ForTactical(tx, ty);
if (_cache.TryGetValue(cc, out var ch))
{
ch.HasDelta = true;
ch.Dirty = true;
}
}
}