418 lines
12 KiB
Go
418 lines
12 KiB
Go
package world
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"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")
|
|
}
|
|
rx, rz, lx, lz := regionIndex(original.X, original.Z)
|
|
decoded, err := nbtToChunk(root, rx, rz, lx, lz)
|
|
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)
|
|
}
|
|
if decoded.lightReady {
|
|
t.Error("legacy chunk without isLightOn loaded as light-ready")
|
|
}
|
|
}
|
|
|
|
func TestStoreLightRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := NewStore(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cache := NewCacheWithStore(-1, func(cx, cz int32) *Chunk {
|
|
return NewChunk(cx, cz, BiomePlains)
|
|
}, store)
|
|
left := cache.chunkAt(0, 0)
|
|
right := cache.chunkAt(1, 0)
|
|
if _, err := cache.FrameErr(0, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := cache.FrameErr(1, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
glowstone := nameToStateID("minecraft:glowstone", nil)
|
|
if valid, _ := cache.SetBlockWithLight(15, 0, 8, glowstone); !valid {
|
|
t.Fatal("glowstone edit rejected")
|
|
}
|
|
wantLeftSky, wantLeftBlock, ready := left.LightAt(15, 0, 8)
|
|
if !ready || wantLeftBlock != 15 {
|
|
t.Fatalf("pre-save source light = sky %d block %d ready %v", wantLeftSky, wantLeftBlock, ready)
|
|
}
|
|
wantRightSky, wantRightBlock, ready := right.LightAt(0, 0, 8)
|
|
if !ready || wantRightBlock != 14 {
|
|
t.Fatalf("pre-save neighbor light = sky %d block %d ready %v", wantRightSky, wantRightBlock, ready)
|
|
}
|
|
if err := cache.SaveAll(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
store, err = NewStore(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
loadedLeft, err := store.LoadChunk(0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gotSky, gotBlock, gotReady := loadedLeft.LightAt(15, 0, 8)
|
|
if !gotReady || gotSky != wantLeftSky || gotBlock != wantLeftBlock {
|
|
t.Fatalf("loaded source light = sky %d block %d ready %v; want sky %d block %d ready", gotSky, gotBlock, gotReady, wantLeftSky, wantLeftBlock)
|
|
}
|
|
loadedRight, err := store.LoadChunk(1, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gotSky, gotBlock, gotReady = loadedRight.LightAt(0, 0, 8)
|
|
if !gotReady || gotSky != wantRightSky || gotBlock != wantRightBlock {
|
|
t.Fatalf("loaded neighbor light = sky %d block %d ready %v; want sky %d block %d ready", gotSky, gotBlock, gotReady, wantRightSky, wantRightBlock)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
func TestStoreRejectsSeedMismatch(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := NewStoreForSeed(dir, 12345)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store, err = NewStoreForSeed(dir, 12345)
|
|
if err != nil {
|
|
t.Fatalf("reopen with matching seed: %v", err)
|
|
}
|
|
if err := store.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := NewStoreForSeed(dir, 54321); err == nil {
|
|
t.Fatal("opening a world with a different seed succeeded")
|
|
}
|
|
}
|
|
|
|
func TestCacheDoesNotRegenerateCorruptStoredChunk(t *testing.T) {
|
|
dir := t.TempDir()
|
|
regionDir := filepath.Join(dir, "region")
|
|
rf, err := OpenRegion(regionDir, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
corrupt := []byte("not an nbt document")
|
|
if err := rf.WriteChunk(0, 0, corrupt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := rf.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
store, err := NewStore(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
var generated atomic.Bool
|
|
cache := NewCacheWithStore(256, func(cx, cz int32) *Chunk {
|
|
generated.Store(true)
|
|
return NewChunk(cx, cz, BiomePlains)
|
|
}, store)
|
|
if _, err := cache.FrameErr(0, 0); err == nil {
|
|
t.Fatal("FrameErr succeeded for corrupt stored chunk")
|
|
}
|
|
if generated.Load() {
|
|
t.Fatal("generator ran after a stored chunk read error")
|
|
}
|
|
if cache.SetBlock(0, SeaLevel, 0, StateBedrock) {
|
|
t.Fatal("SetBlock accepted an edit over a corrupt stored chunk")
|
|
}
|
|
if err := cache.SaveAll(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, _, lx, lz := regionIndex(0, 0)
|
|
rf = store.regions[[2]int{0, 0}]
|
|
raw, err := rf.ReadChunk(lx, lz)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(raw, corrupt) {
|
|
t.Fatalf("stored corrupt payload was overwritten: %q", raw)
|
|
}
|
|
}
|
|
|
|
func TestConcurrentAutosavePreservesLatestEdit(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := NewStore(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cache := NewCacheWithStore(256, flatGen(), store)
|
|
cache.chunkAt(0, 0)
|
|
|
|
done := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := 0; i < 100; i++ {
|
|
state := StateStone
|
|
if i%2 == 0 {
|
|
state = StateDirt
|
|
}
|
|
cache.SetBlock(5, SeaLevel, 5, state)
|
|
}
|
|
close(done)
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
default:
|
|
_ = cache.flushDirty()
|
|
}
|
|
}
|
|
}()
|
|
wg.Wait()
|
|
cache.SetBlock(5, SeaLevel, 5, StateBedrock)
|
|
if err := cache.SaveAll(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
store, err = NewStore(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
loaded, err := store.LoadChunk(0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := loaded.GetBlock(5, SeaLevel, 5); got != StateBedrock {
|
|
t.Fatalf("persisted final block = %d, want %d", got, StateBedrock)
|
|
}
|
|
}
|