The world now survives restarts: chunks load from disk (read-through cache) and player edits persist via async autosave + a final SaveAll on shutdown. RegionIO finally does region I/O. - world/regionfile.go: Anvil .mca container — 8192-byte header (offset + timestamp tables), 4096-byte sectors, zlib chunk records. - world/compress.go: zlib deflate/inflate for chunk payloads. - world/store.go: chunk <-> Level-nested NBT (per-section block_states/biomes palettes, WORLD_SURFACE heightmap, DataVersion 4790, yPos -4) via the existing nbt package; Store opens one RegionFile per region with proper floor-division coords. - world/state_names.go: id->name bridge from the embedded blocks.json report so network int-IDs round-trip through the disk named palette. - world/encode.go: GetBiome read accessor for serialization. - world/cache.go: read-through (disk then generation), dirty tracking, StartAutosave (returns a done channel so the saver exits before Close), SaveAll, NewCacheWithStore. - server.go + main.go: Config.WorldDir (default "world"), -world flag, autosave loop every 30s, SaveAll + store Close on signal. - Tests: region round-trip/absent/overwrite, chunk NBT round-trip, end-to-end save-reload, negative chunk coords, autosave persistence.
238 lines
6.2 KiB
Go
238 lines
6.2 KiB
Go
package world
|
|
|
|
import (
|
|
"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), 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.
|
|
//
|
|
// Generation can be expensive; it runs outside the lock to avoid blocking other
|
|
// chunk requests. An eviction policy belongs here once worlds stream far.
|
|
type Cache struct {
|
|
threshold int32
|
|
gen Generator
|
|
store *Store // nil = in-memory only (tests, flat worlds)
|
|
|
|
mu sync.Mutex
|
|
chunks map[[2]int32]*Chunk
|
|
frames map[[2]int32][]byte
|
|
dirty map[[2]int32]struct{}
|
|
}
|
|
|
|
// NewCache returns a world cache that frames packets at the given compression
|
|
// threshold using gen to produce missing chunks. It has no persistence.
|
|
func NewCache(threshold int32, gen Generator) *Cache {
|
|
return &Cache{
|
|
threshold: threshold,
|
|
gen: gen,
|
|
chunks: make(map[[2]int32]*Chunk),
|
|
frames: make(map[[2]int32][]byte),
|
|
dirty: make(map[[2]int32]struct{}),
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
func NewCacheWithStore(threshold int32, gen Generator, store *Store) *Cache {
|
|
c := NewCache(threshold, gen)
|
|
c.store = store
|
|
return c
|
|
}
|
|
|
|
// 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.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 {
|
|
return existing // another goroutine won the race
|
|
}
|
|
c.chunks[key] = ch
|
|
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.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 {
|
|
return existing
|
|
}
|
|
c.frames[key] = frame
|
|
return frame
|
|
}
|
|
|
|
// 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.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
|
|
}
|