using Theriapolis.Core.World; namespace Theriapolis.Core.Tactical; /// /// In-memory cache of generated tactical chunks. Generation happens lazily on /// first access; eviction happens whenever the active set is recomputed by /// . /// /// The cache is bounded by : 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. /// public sealed class ChunkStreamer { private readonly ulong _worldSeed; private readonly WorldState _world; private readonly IChunkDeltaStore _deltas; private readonly Dictionary _cache = new(); private readonly Dictionary> _inflight = new(); private readonly Theriapolis.Core.Data.SettlementContent? _settlementContent; public IReadOnlyDictionary Loaded => _cache; /// /// Phase 5 M5: fires after a chunk is freshly inserted into the cache. /// Subscribers (typically PlayScreen) use this to spawn NPCs from /// . Does not fire for chunks already /// in cache. /// public event System.Action? OnChunkLoaded; /// /// 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. /// public event System.Action? OnChunkEvicting; public ChunkStreamer(ulong worldSeed, WorldState world, IChunkDeltaStore deltas) : this(worldSeed, world, deltas, settlementContent: null) { } /// /// Phase 6 M0 — pass to enable /// templated settlement stamping. null falls back to the /// Phase-4 plaza+wall-ring placeholder. /// public ChunkStreamer(ulong worldSeed, WorldState world, IChunkDeltaStore deltas, Theriapolis.Core.Data.SettlementContent? settlementContent) { _worldSeed = worldSeed; _world = world; _deltas = deltas; _settlementContent = settlementContent; } /// /// 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). /// 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; } /// /// 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. /// 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]; } /// /// 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. /// 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(); 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 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(); 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); } } /// /// 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. /// 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; } /// Mark a tile as user-modified so the streamer flushes it on eviction. 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; } } }