Chunk persistence to Anvil .mca region files

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.
This commit is contained in:
Master290 2026-06-25 00:40:14 +03:00
parent 4dcf938a85
commit d1cc29bb60
10 changed files with 305112 additions and 20 deletions

View file

@ -0,0 +1,225 @@
package world
import (
"context"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"regionio/internal/nbt"
)
// TestRegionFileRoundTrip writes arbitrary NBT bytes into a region file and
// reads them back, confirming the Anvil container (header + sectors + zlib)
// preserves the payload exactly.
func TestRegionFileRoundTrip(t *testing.T) {
dir := t.TempDir()
rf, err := OpenRegion(dir, 0, 0)
if err != nil {
t.Fatalf("OpenRegion: %v", err)
}
defer rf.Close()
payload := []byte("hello-regionio-chunk-nbt-payload-0123456789")
if err := rf.WriteChunk(3, 7, payload); err != nil {
t.Fatalf("WriteChunk: %v", err)
}
got, err := rf.ReadChunk(3, 7)
if err != nil {
t.Fatalf("ReadChunk: %v", err)
}
if string(got) != string(payload) {
t.Errorf("payload mismatch: got %q want %q", got, payload)
}
}
// TestRegionFileAbsentChunk confirms ReadChunk returns ErrChunkNotFound for a
// chunk that was never written (offset table entry is 0).
func TestRegionFileAbsentChunk(t *testing.T) {
rf, err := OpenRegion(t.TempDir(), 0, 0)
if err != nil {
t.Fatal(err)
}
defer rf.Close()
if _, err := rf.ReadChunk(5, 5); err != ErrChunkNotFound {
t.Errorf("absent chunk err = %v, want ErrChunkNotFound", err)
}
}
// TestRegionFileOverwrite confirms writing the same chunk twice keeps the
// latest data (the second write replaces the first).
func TestRegionFileOverwrite(t *testing.T) {
rf, err := OpenRegion(t.TempDir(), 0, 0)
if err != nil {
t.Fatal(err)
}
defer rf.Close()
if err := rf.WriteChunk(1, 1, []byte("first")); err != nil {
t.Fatal(err)
}
if err := rf.WriteChunk(1, 1, []byte("second")); err != nil {
t.Fatal(err)
}
got, err := rf.ReadChunk(1, 1)
if err != nil {
t.Fatal(err)
}
if string(got) != "second" {
t.Errorf("after overwrite got %q, want %q", got, "second")
}
}
// TestStoreChunkRoundTrip encodes a chunk to NBT, decodes it back, and confirms
// the blocks/biomes match. This validates the chunkToNBT/nbtToChunk bridge.
func TestStoreChunkRoundTrip(t *testing.T) {
original := NewChunk(10, -5, BiomePlains)
// Build a recognizable section: a grass surface over stone, with one water
// block, so the palette has >1 entry and exercises the packed long array.
si := (SeaLevel - MinY) >> 4
original.section(si)
s := original.sections[si]
for x := 0; x < 16; x++ {
for z := 0; z < 16; z++ {
s[blockIndex(x, SeaLevel, z)] = StateGrass
s[blockIndex(x, SeaLevel-1, z)] = StateDirt
}
}
s[blockIndex(0, SeaLevel, 0)] = StateWater
nbtBytes := nbt.MarshalNamed("", chunkToNBT(original))
if len(nbtBytes) == 0 {
t.Fatal("empty NBT")
}
_, tag, err := nbt.UnmarshalNamed(nbtBytes)
if err != nil {
t.Fatalf("decode: %v", err)
}
root, ok := tag.(*nbt.Compound)
if !ok {
t.Fatal("root not compound")
}
decoded, err := nbtToChunk(root, 0, 0, 0, 0)
if err != nil {
t.Fatalf("nbtToChunk: %v", err)
}
// Spot-check the recognizable blocks.
if got := decoded.GetBlock(0, SeaLevel, 0); got != StateWater {
t.Errorf("block (0, %d, 0) = %d, want water %d", SeaLevel, got, StateWater)
}
if got := decoded.GetBlock(5, SeaLevel, 5); got != StateGrass {
t.Errorf("block (5, %d, 5) = %d, want grass %d", SeaLevel, got, StateGrass)
}
if got := decoded.GetBlock(5, SeaLevel-1, 5); got != StateDirt {
t.Errorf("block (5, %d, 5) = %d, want dirt %d", SeaLevel-1, got, StateDirt)
}
if decoded.X != 10 || decoded.Z != -5 {
t.Errorf("coords = (%d,%d), want (10,-5)", decoded.X, decoded.Z)
}
}
// TestStoreSaveLoadIntegration is the end-to-end "world survives restart" test:
// generate a chunk via a store-backed cache, edit a block, SaveAll, then open a
// fresh cache over the same store and confirm the edit is present.
func TestStoreSaveLoadIntegration(t *testing.T) {
dir := t.TempDir()
store, err := NewStore(dir)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
gen := NewVanillaGenerator(12345)
cache := NewCacheWithStore(int32(256), gen, store)
// Force chunk (1,2) into the cache, then edit a block near the surface.
ch := cache.chunkAt(1, 2)
targetY := SeaLevel
// Find the top solid block in column (0,0) to place our marker above it.
markerY := targetY + 1
if !cache.SetBlock(1*16, markerY, 2*16, StateBedrock) {
t.Fatalf("SetBlock out of range y=%d", markerY)
}
_ = ch
if err := cache.SaveAll(); err != nil {
t.Fatalf("SaveAll: %v", err)
}
store.Close()
// New cache, same store — simulates a restart.
store2, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
defer store2.Close()
cache2 := NewCacheWithStore(int32(256), NewVanillaGenerator(12345), store2)
loaded := cache2.chunkAt(1, 2)
if loaded == nil {
t.Fatal("nil loaded chunk")
}
if got := loaded.GetBlock(1*16, markerY, 2*16); got != StateBedrock {
t.Errorf("after reload marker block = %d, want bedrock %d", got, StateBedrock)
}
}
// TestStoreNegativeCoords exercises the floor-division region math for negative
// chunk coordinates (e.g. chunk -1 belongs to region -1, local 31).
func TestStoreNegativeCoords(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer store.Close()
c := NewChunk(-1, -1, BiomePlains)
c.section((SeaLevel - MinY) >> 4)
c.SetBlock(0, SeaLevel, 0, StateGrass)
if err := store.SaveChunk(c); err != nil {
t.Fatalf("SaveChunk(-1,-1): %v", err)
}
loaded, err := store.LoadChunk(-1, -1)
if err != nil {
t.Fatalf("LoadChunk(-1,-1): %v", err)
}
if loaded.X != -1 || loaded.Z != -1 {
t.Errorf("coords = (%d,%d), want (-1,-1)", loaded.X, loaded.Z)
}
// Confirm the file landed in region r.-1.-1.mca.
if _, err := os.Stat(filepath.Join(store.dir, "region", "r.-1.-1.mca")); err != nil {
t.Errorf("expected r.-1.-1.mca: %v", err)
}
}
// TestCacheAutosavePersistsEdits starts an autosave loop with a short interval,
// edits a block, and confirms a second cache sees the edit after the interval.
func TestCacheAutosavePersistsEdits(t *testing.T) {
dir := t.TempDir()
store, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
gen := NewVanillaGenerator(12345)
cache := NewCacheWithStore(int32(256), gen, store)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
autosaveDone := cache.StartAutosave(ctx, slog.Default(), 50*time.Millisecond)
cache.chunkAt(0, 0)
cache.SetBlock(8, SeaLevel, 8, StateBedrock)
// Wait for at least one autosave cycle.
time.Sleep(250 * time.Millisecond)
cancel() // stop the autosave loop so it releases the store
<-autosaveDone // wait for the goroutine to fully exit
store.Close()
// Fresh cache over the same store should see the edit without SaveAll.
store2, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
defer store2.Close()
cache2 := NewCacheWithStore(int32(256), gen, store2)
loaded := cache2.chunkAt(0, 0)
if got := loaded.GetBlock(8, SeaLevel, 8); got != StateBedrock {
t.Errorf("autosaved block = %d, want bedrock %d", got, StateBedrock)
}
}