324 lines
8.8 KiB
Go
324 lines
8.8 KiB
Go
package world
|
||
|
||
import (
|
||
"container/list"
|
||
"context"
|
||
"log/slog"
|
||
"sync"
|
||
"time"
|
||
|
||
"regionio/internal/protocol"
|
||
)
|
||
|
||
// Generator produces the chunk at the given coordinate.
|
||
type Generator func(cx, cz int32) *Chunk
|
||
|
||
// Cache is the live world: it owns the mutable chunk data and memoizes the
|
||
// framed, compression-ready level_chunk packet for each chunk. A block edit
|
||
// mutates the chunk and invalidates its cached frame so the next request
|
||
// re-encodes it.
|
||
//
|
||
// When a Store is attached (NewCacheWithStore / NewCacheWithLimit), the cache is
|
||
// read-through — a chunk miss first tries disk, then generation — and edits mark
|
||
// chunks dirty for the background autosave. Frames are built for a fixed
|
||
// compression threshold shared by all play connections, so one frame is valid
|
||
// for every client.
|
||
//
|
||
// When maxChunks > 0, the cache evicts least-recently-used chunks to keep memory
|
||
// bounded (LRU via a doubly-linked list + index map, O(1) touch/evict). Dirty
|
||
// chunks are never evicted until the autosave flushes them, so no edit is lost.
|
||
//
|
||
// Generation can be expensive; it runs outside the lock to avoid blocking other
|
||
// chunk requests.
|
||
type Cache struct {
|
||
threshold int32
|
||
gen Generator
|
||
store *Store // nil = in-memory only (tests, flat worlds)
|
||
maxChunks int // LRU capacity; 0 = unbounded
|
||
|
||
mu sync.Mutex
|
||
chunks map[[2]int32]*Chunk
|
||
frames map[[2]int32][]byte
|
||
dirty map[[2]int32]struct{}
|
||
// LRU bookkeeping: order is MRU(front)→LRU(back); index gives O(1) lookup.
|
||
order *list.List // elements are *[2]int32; nil when maxChunks==0
|
||
index map[[2]int32]*list.Element
|
||
}
|
||
|
||
// NewCache returns a world cache that frames packets at the given compression
|
||
// threshold using gen to produce missing chunks. It has no persistence and no
|
||
// eviction limit (unbounded; for tests/flat worlds).
|
||
func NewCache(threshold int32, gen Generator) *Cache {
|
||
c := &Cache{
|
||
threshold: threshold,
|
||
gen: gen,
|
||
chunks: make(map[[2]int32]*Chunk),
|
||
frames: make(map[[2]int32][]byte),
|
||
dirty: make(map[[2]int32]struct{}),
|
||
}
|
||
return c
|
||
}
|
||
|
||
// NewCacheWithStore returns a cache backed by store: chunk misses load from disk
|
||
// first (then fall back to gen), and edits are persisted by the autosave loop.
|
||
// The cache is unbounded.
|
||
func NewCacheWithStore(threshold int32, gen Generator, store *Store) *Cache {
|
||
c := NewCache(threshold, gen)
|
||
c.store = store
|
||
return c
|
||
}
|
||
|
||
// NewCacheWithLimit is the full constructor: persistence (store may be nil) and
|
||
// an LRU cap of maxChunks chunks (0 = unbounded). When bounded, the cache evicts
|
||
// least-recently-used chunks on miss, keeping memory near maxChunks×(chunk+frame)
|
||
// ≈ maxChunks×200KiB.
|
||
func NewCacheWithLimit(threshold int32, gen Generator, store *Store, maxChunks int) *Cache {
|
||
c := NewCacheWithStore(threshold, gen, store)
|
||
c.maxChunks = maxChunks
|
||
if maxChunks > 0 {
|
||
c.order = list.New()
|
||
c.index = make(map[[2]int32]*list.Element)
|
||
}
|
||
return c
|
||
}
|
||
|
||
// touch marks key as most-recently-used. Must be called under c.mu.
|
||
func (c *Cache) touch(key [2]int32) {
|
||
if c.maxChunks == 0 {
|
||
return
|
||
}
|
||
if e, ok := c.index[key]; ok {
|
||
c.order.MoveToFront(e)
|
||
} else {
|
||
c.index[key] = c.order.PushFront(&key)
|
||
}
|
||
}
|
||
|
||
// evictIfNeeded drops least-recently-used chunks until len(chunks) <= maxChunks.
|
||
// Dirty chunks are skipped (moved back to MRU and the eviction halts) so the
|
||
// autosave can persist them first. Must be called under c.mu.
|
||
func (c *Cache) evictIfNeeded() {
|
||
if c.maxChunks == 0 {
|
||
return
|
||
}
|
||
for len(c.chunks) > c.maxChunks {
|
||
back := c.order.Back()
|
||
if back == nil {
|
||
return
|
||
}
|
||
key := *back.Value.(*[2]int32)
|
||
// Never drop a dirty chunk: it has unsaved edits. Bump it to MRU and
|
||
// stop evicting this cycle; the autosave flush will clear it and the
|
||
// next eviction pass can reclaim it.
|
||
if _, dirty := c.dirty[key]; dirty && c.store != nil {
|
||
c.order.MoveToFront(back)
|
||
break
|
||
}
|
||
delete(c.chunks, key)
|
||
delete(c.frames, key)
|
||
c.order.Remove(back)
|
||
delete(c.index, key)
|
||
}
|
||
}
|
||
|
||
// chunkAt returns the chunk at (cx, cz). Resolution order: in-memory cache →
|
||
// disk (if a store is attached) → generation. Generation and disk reads run
|
||
// outside the lock.
|
||
func (c *Cache) chunkAt(cx, cz int32) *Chunk {
|
||
key := [2]int32{cx, cz}
|
||
|
||
c.mu.Lock()
|
||
if ch, ok := c.chunks[key]; ok {
|
||
c.touch(key)
|
||
c.mu.Unlock()
|
||
return ch
|
||
}
|
||
c.mu.Unlock()
|
||
|
||
// Try disk before generation so saved edits survive restarts.
|
||
var ch *Chunk
|
||
if c.store != nil {
|
||
if loaded, err := c.store.LoadChunk(cx, cz); err == nil {
|
||
ch = loaded
|
||
}
|
||
}
|
||
if ch == nil {
|
||
ch = c.gen(cx, cz) // generate outside the lock
|
||
}
|
||
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
if existing, ok := c.chunks[key]; ok {
|
||
c.touch(key)
|
||
return existing // another goroutine won the race
|
||
}
|
||
c.chunks[key] = ch
|
||
c.touch(key)
|
||
c.evictIfNeeded()
|
||
return ch
|
||
}
|
||
|
||
// Frame returns the prebuilt level_chunk packet for (cx, cz), building it on
|
||
// first request and caching until the chunk is edited. The slice must not be
|
||
// mutated.
|
||
func (c *Cache) Frame(cx, cz int32) []byte {
|
||
key := [2]int32{cx, cz}
|
||
|
||
c.mu.Lock()
|
||
if f, ok := c.frames[key]; ok {
|
||
c.touch(key)
|
||
c.mu.Unlock()
|
||
return f
|
||
}
|
||
c.mu.Unlock()
|
||
|
||
ch := c.chunkAt(cx, cz)
|
||
frame := protocol.AppendPacket(nil, c.threshold, protocol.PlayLevelChunk, ch.Encode())
|
||
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
if existing, ok := c.frames[key]; ok {
|
||
c.touch(key)
|
||
return existing
|
||
}
|
||
c.frames[key] = frame
|
||
c.touch(key)
|
||
c.evictIfNeeded()
|
||
return frame
|
||
}
|
||
|
||
// GetBlock returns the block state at world coordinates (x, y, z).
|
||
// It loads or generates the chunk if necessary.
|
||
func (c *Cache) GetBlock(x, y, z int) uint16 {
|
||
if y < MinY || y >= MinY+WorldHeight {
|
||
return 0 // StateAir
|
||
}
|
||
cx := int32(x >> 4)
|
||
cz := int32(z >> 4)
|
||
ch := c.chunkAt(cx, cz)
|
||
return ch.GetBlock(x, y, z)
|
||
}
|
||
|
||
// SetBlock changes the block at world coordinates (x, y, z), invalidating the
|
||
// affected chunk's cached frame and marking it dirty for autosave. It reports
|
||
// whether a chunk was actually touched (false if y is out of range).
|
||
func (c *Cache) SetBlock(x, y, z int, state uint16) bool {
|
||
if y < MinY || y >= MinY+WorldHeight {
|
||
return false
|
||
}
|
||
cx := int32(x >> 4)
|
||
cz := int32(z >> 4)
|
||
ch := c.chunkAt(cx, cz)
|
||
|
||
ch.SetBlock(x, y, z, state)
|
||
|
||
c.mu.Lock()
|
||
key := [2]int32{cx, cz}
|
||
delete(c.frames, key)
|
||
if c.store != nil {
|
||
c.dirty[key] = struct{}{}
|
||
}
|
||
c.touch(key) // edited chunk is most-recently-used
|
||
c.mu.Unlock()
|
||
return true
|
||
}
|
||
|
||
// markDirty flags the chunk at (cx, cz) for the next autosave. Public so tests
|
||
// can simulate edits that the generator made worth persisting.
|
||
func (c *Cache) markDirty(cx, cz int32) {
|
||
if c.store == nil {
|
||
return
|
||
}
|
||
c.mu.Lock()
|
||
c.dirty[[2]int32{cx, cz}] = struct{}{}
|
||
c.mu.Unlock()
|
||
}
|
||
|
||
// StartAutosave launches a goroutine that periodically persists dirty chunks
|
||
// until ctx is cancelled, then performs a final flush. It returns a done channel
|
||
// that is closed once the goroutine has fully exited (including the final
|
||
// SaveAll) — callers must wait on it before closing the underlying Store to
|
||
// avoid racing the saver against Close. Call this once per server lifetime.
|
||
func (c *Cache) StartAutosave(ctx context.Context, log *slog.Logger, interval time.Duration) <-chan struct{} {
|
||
done := make(chan struct{})
|
||
if c.store == nil {
|
||
close(done)
|
||
return done // in-memory cache: nothing to save
|
||
}
|
||
go func() {
|
||
defer close(done)
|
||
t := time.NewTicker(interval)
|
||
defer t.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
if err := c.SaveAll(); err != nil && log != nil {
|
||
log.Error("world: final autosave failed", "err", err)
|
||
}
|
||
return
|
||
case <-t.C:
|
||
if err := c.flushDirty(); err != nil && log != nil {
|
||
log.Error("world: autosave failed", "err", err)
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
return done
|
||
}
|
||
|
||
// flushDirty saves every chunk currently marked dirty and clears the set.
|
||
func (c *Cache) flushDirty() error {
|
||
c.mu.Lock()
|
||
keys := make([][2]int32, 0, len(c.dirty))
|
||
for k := range c.dirty {
|
||
keys = append(keys, k)
|
||
}
|
||
chunks := make(map[[2]int32]*Chunk, len(keys))
|
||
for _, k := range keys {
|
||
chunks[k] = c.chunks[k]
|
||
}
|
||
c.dirty = make(map[[2]int32]struct{})
|
||
c.mu.Unlock()
|
||
|
||
for _, k := range keys {
|
||
ch := chunks[k]
|
||
if ch == nil {
|
||
continue
|
||
}
|
||
if err := c.store.SaveChunk(ch); err != nil {
|
||
c.mu.Lock()
|
||
c.dirty[k] = struct{}{} // re-mark; retry next cycle
|
||
c.mu.Unlock()
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SaveAll synchronously persists every chunk currently in memory. Used at
|
||
// shutdown to guarantee no edit is lost.
|
||
func (c *Cache) SaveAll() error {
|
||
if c.store == nil {
|
||
return nil
|
||
}
|
||
c.mu.Lock()
|
||
keys := make([][2]int32, 0, len(c.chunks))
|
||
for k := range c.chunks {
|
||
keys = append(keys, k)
|
||
}
|
||
chunks := make(map[[2]int32]*Chunk, len(keys))
|
||
for _, k := range keys {
|
||
chunks[k] = c.chunks[k]
|
||
}
|
||
c.mu.Unlock()
|
||
|
||
var firstErr error
|
||
for _, k := range keys {
|
||
if ch := chunks[k]; ch != nil {
|
||
if err := c.store.SaveChunk(ch); err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
}
|
||
}
|
||
return firstErr
|
||
}
|