Files
TheriapolisV3/Theriapolis.Core/Tactical/ChunkStreamer.cs
T
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

237 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 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 generate any missing active chunks (the player needs
// them this frame). Pre-warm the next ring on the threadpool.
foreach (var cc in active)
{
if (!_cache.ContainsKey(cc) && !_inflight.ContainsKey(cc))
_ = Get(cc); // synchronous generate + cache
}
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;
}
}
}