2026-04-30 20:40:51 -07:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-01 20:07:06 -07:00
|
|
|
|
// 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.
|
2026-04-30 20:40:51 -07:00
|
|
|
|
foreach (var cc in active)
|
|
|
|
|
|
{
|
2026-05-01 20:07:06 -07:00
|
|
|
|
if (!_cache.ContainsKey(cc))
|
|
|
|
|
|
_ = Get(cc); // hits cache, drains inflight, or generates
|
2026-04-30 20:40:51 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|