431 lines
12 KiB
Go
431 lines
12 KiB
Go
package world
|
||
|
||
import (
|
||
"container/list"
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"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
|
||
lightMu sync.Mutex
|
||
chunks map[[2]int32]*Chunk
|
||
frames map[[2]int32][]byte
|
||
dirty map[[2]int32]uint64
|
||
// 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
|
||
loads map[[2]int32]*chunkLoad
|
||
}
|
||
|
||
// chunkLoad coordinates concurrent misses for the same coordinate. The first
|
||
// caller performs disk I/O or generation; all others wait for that exact result.
|
||
type chunkLoad struct {
|
||
done chan struct{}
|
||
ch *Chunk
|
||
err error
|
||
}
|
||
|
||
// 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]uint64),
|
||
loads: make(map[[2]int32]*chunkLoad),
|
||
}
|
||
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)
|
||
if maxChunks < 0 {
|
||
maxChunks = 0
|
||
}
|
||
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
|
||
}
|
||
checked := 0
|
||
for len(c.chunks) > c.maxChunks && checked < len(c.chunks) {
|
||
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)
|
||
checked++
|
||
continue
|
||
}
|
||
delete(c.chunks, key)
|
||
delete(c.frames, key)
|
||
c.order.Remove(back)
|
||
delete(c.index, key)
|
||
checked = 0
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
ch, _ := c.chunkAtErr(cx, cz)
|
||
return ch
|
||
}
|
||
|
||
// chunkAtErr is the error-preserving form used by network and mutation paths.
|
||
// A corrupt or unreadable stored chunk is never replaced by generated terrain.
|
||
func (c *Cache) chunkAtErr(cx, cz int32) (*Chunk, error) {
|
||
key := [2]int32{cx, cz}
|
||
|
||
c.mu.Lock()
|
||
if ch, ok := c.chunks[key]; ok {
|
||
c.touch(key)
|
||
c.mu.Unlock()
|
||
return ch, nil
|
||
}
|
||
if pending, ok := c.loads[key]; ok {
|
||
c.mu.Unlock()
|
||
<-pending.done
|
||
return pending.ch, pending.err
|
||
}
|
||
pending := &chunkLoad{done: make(chan struct{})}
|
||
c.loads[key] = pending
|
||
c.mu.Unlock()
|
||
|
||
// Try disk before generation so saved edits survive restarts.
|
||
var ch *Chunk
|
||
var loadErr error
|
||
if c.store != nil {
|
||
if loaded, err := c.store.LoadChunk(cx, cz); err == nil {
|
||
ch = loaded
|
||
} else if !errors.Is(err, ErrChunkNotFound) {
|
||
loadErr = fmt.Errorf("world: load chunk (%d,%d): %w", cx, cz, err)
|
||
}
|
||
}
|
||
if ch == nil && loadErr == nil {
|
||
ch = c.gen(cx, cz) // generate outside the lock
|
||
if ch == nil {
|
||
loadErr = fmt.Errorf("world: generator returned nil chunk (%d,%d)", cx, cz)
|
||
}
|
||
}
|
||
|
||
c.mu.Lock()
|
||
if loadErr == nil {
|
||
c.chunks[key] = ch
|
||
c.touch(key)
|
||
c.evictIfNeeded()
|
||
}
|
||
pending.ch, pending.err = ch, loadErr
|
||
delete(c.loads, key)
|
||
close(pending.done)
|
||
c.mu.Unlock()
|
||
return ch, loadErr
|
||
}
|
||
|
||
// 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 {
|
||
frame, _ := c.FrameErr(cx, cz)
|
||
return frame
|
||
}
|
||
|
||
// FrameErr returns a framed chunk packet while preserving storage failures.
|
||
// Callers serving clients should prefer it to Frame so corruption is observable.
|
||
func (c *Cache) FrameErr(cx, cz int32) ([]byte, error) {
|
||
key := [2]int32{cx, cz}
|
||
|
||
for {
|
||
c.mu.Lock()
|
||
if f, ok := c.frames[key]; ok {
|
||
c.touch(key)
|
||
c.mu.Unlock()
|
||
return f, nil
|
||
}
|
||
c.mu.Unlock()
|
||
|
||
ch, err := c.chunkAtErr(cx, cz)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := c.ensureLight(ch); err != nil {
|
||
return nil, err
|
||
}
|
||
snapshot, revision := ch.snapshot()
|
||
frame := protocol.AppendPacket(nil, c.threshold, protocol.PlayLevelChunk, snapshot.encode())
|
||
|
||
c.mu.Lock()
|
||
if existing, ok := c.frames[key]; ok {
|
||
c.touch(key)
|
||
c.mu.Unlock()
|
||
return existing, nil
|
||
}
|
||
// An edit or eviction while the frame was being built makes it stale.
|
||
// Retry from a fresh snapshot instead of publishing old bytes forever.
|
||
if c.chunks[key] != ch || ch.currentRevision() != revision {
|
||
c.mu.Unlock()
|
||
continue
|
||
}
|
||
c.frames[key] = frame
|
||
c.touch(key)
|
||
c.evictIfNeeded()
|
||
c.mu.Unlock()
|
||
return frame, nil
|
||
}
|
||
}
|
||
|
||
// 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, err := c.chunkAtErr(cx, cz)
|
||
if err != nil {
|
||
return StateAir
|
||
}
|
||
return ch.GetBlock(x, y, z)
|
||
}
|
||
|
||
// LightUpdate returns the standalone light_update body for a loaded chunk.
|
||
func (c *Cache) LightUpdate(cx, cz int32) ([]byte, error) {
|
||
ch, err := c.chunkAtErr(cx, cz)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := c.ensureLight(ch); err != nil {
|
||
return nil, err
|
||
}
|
||
return ch.EncodeLightUpdate(), nil
|
||
}
|
||
|
||
// SetBlock changes a block and incrementally updates lighting. Callers that need
|
||
// to broadcast every affected light chunk should use SetBlockWithLight.
|
||
func (c *Cache) SetBlock(x, y, z int, state uint16) bool {
|
||
valid, _ := c.SetBlockWithLight(x, y, z, state)
|
||
return valid
|
||
}
|
||
|
||
// ChunkPos identifies a chunk changed by a lighting update.
|
||
type ChunkPos struct {
|
||
X, Z int32
|
||
}
|
||
|
||
// SetBlockWithLight changes a block and returns the loaded chunks whose stored
|
||
// light changed. Lighting operations are serialized so concurrent edits cannot
|
||
// publish mutually stale propagation results.
|
||
func (c *Cache) SetBlockWithLight(x, y, z int, state uint16) (bool, []ChunkPos) {
|
||
if y < MinY || y >= MinY+WorldHeight {
|
||
return false, nil
|
||
}
|
||
cx := int32(x >> 4)
|
||
cz := int32(z >> 4)
|
||
ch, err := c.chunkAtErr(cx, cz)
|
||
if err != nil {
|
||
return false, nil
|
||
}
|
||
|
||
c.lightMu.Lock()
|
||
defer c.lightMu.Unlock()
|
||
live := c.cachedLightNeighborhood(cx, cz)
|
||
for _, neighbor := range live {
|
||
if err := c.ensureLightLocked(neighbor); err != nil {
|
||
return false, nil
|
||
}
|
||
}
|
||
_, changed := ch.setBlock(x, y, z, state)
|
||
if !changed {
|
||
return true, nil
|
||
}
|
||
lightChanged, err := c.updateLightAfterBlockLocked(x, y, z, live)
|
||
if err != nil {
|
||
// The block edit is still valid and dirty; a later Frame call will rebuild
|
||
// its light from the authoritative blocks.
|
||
ch.mu.Lock()
|
||
ch.lightReady = false
|
||
ch.mu.Unlock()
|
||
lightChanged = []ChunkPos{{X: cx, Z: cz}}
|
||
}
|
||
|
||
c.mu.Lock()
|
||
key := [2]int32{cx, cz}
|
||
delete(c.frames, key)
|
||
if c.store != nil {
|
||
c.dirty[key] = ch.currentRevision()
|
||
}
|
||
c.touch(key) // edited chunk is most-recently-used
|
||
c.mu.Unlock()
|
||
return true, lightChanged
|
||
}
|
||
|
||
// 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()
|
||
key := [2]int32{cx, cz}
|
||
if ch := c.chunks[key]; ch != nil {
|
||
c.dirty[key] = ch.currentRevision()
|
||
}
|
||
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.mu.Unlock()
|
||
|
||
var firstErr error
|
||
for _, k := range keys {
|
||
ch := chunks[k]
|
||
if ch == nil {
|
||
c.mu.Lock()
|
||
delete(c.dirty, k)
|
||
c.mu.Unlock()
|
||
continue
|
||
}
|
||
snapshot, savedRevision := ch.snapshot()
|
||
if err := c.store.saveSnapshot(snapshot); err != nil {
|
||
if firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
continue
|
||
}
|
||
c.mu.Lock()
|
||
if dirtyRevision, ok := c.dirty[k]; ok && dirtyRevision <= savedRevision {
|
||
delete(c.dirty, k)
|
||
}
|
||
c.evictIfNeeded()
|
||
c.mu.Unlock()
|
||
}
|
||
return firstErr
|
||
}
|
||
|
||
// 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
|
||
}
|
||
return c.flushDirty()
|
||
}
|