chunkAt prefers the store over the generator, and nothing invalidated a stored chunk when the generator changed. Combined with four region files committed to the repo, the chunks around spawn were frozen output from an older generator: worldgen fixes looked like they did nothing in exactly the area you land in when you join, and a fresh clone inherited that world. Chunks now carry a RegionIOGeneratorVersion stamp, written on save and checked on load; a mismatch returns ErrChunkNotFound so the caller regenerates. Chunks written before the stamp existed have no tag, decode as 0, and are invalidated the same way. Bump the constant in any commit that changes generator output. This is deliberately per-chunk and deliberately quiet. The world metadata file already guards the seed, where a mismatch means two incompatible terrains and refusing to open is right. A generator change is routine by comparison and should just regenerate. world/region/*.mca and chat.md are untracked (left on disk) and /world/ is gitignored, superseding the narrower /world/regionio-world.json rule, along with /.refjava/ and root-level session transcripts. CLAUDE.md covers the parts of working here that README does not: the no-dependencies rule, the version-stamp rule and why forgetting it looks like a failed fix, where the vanilla ground truth lives and how to query the jar directly with unzip and javap, the precedent for dumping runtime constants with a throwaway Java program, and an honest list of which layers are bit-exact versus approximated.
550 lines
16 KiB
Go
550 lines
16 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)
|
|
}
|
|
}
|
|
|
|
func TestCacheReconcilesPersistedLightWithLoadedNeighbor(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := NewStore(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
glowstone := nameToStateID("minecraft:glowstone", nil)
|
|
left := NewChunk(0, 0, BiomePlains)
|
|
left.SetBlock(15, 100, 8, glowstone)
|
|
if err := store.SaveChunk(left); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Simulate a chunk saved before its unloaded neighbor gained a light source.
|
|
right := NewChunk(1, 0, BiomePlains)
|
|
right.lightReady = true
|
|
if err := store.SaveChunk(right); 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()
|
|
cache := NewCacheWithLimit(-1, func(cx, cz int32) *Chunk {
|
|
return NewChunk(cx, cz, BiomePlains)
|
|
}, store, 1)
|
|
tickets := cache.NewTicketSet()
|
|
tickets.Replace([]ChunkPos{{X: 1, Z: 0}}, nil)
|
|
loaded, err := cache.chunkAtErr(1, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, block, ready := loaded.LightAt(0, 100, 8); !ready || block != 0 {
|
|
t.Fatalf("persisted pre-reconcile light = %d ready=%v, want stale zero", block, ready)
|
|
}
|
|
if _, err := cache.FrameErr(1, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, block, ready := loaded.LightAt(0, 100, 8); !ready || block != 14 {
|
|
t.Fatalf("reconciled border light = %d ready=%v, want 14", block, ready)
|
|
}
|
|
if err := cache.SaveAll(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tickets.Close()
|
|
if _, err := cache.FrameErr(10, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hasChunk(cache, 1, 0) {
|
|
t.Fatal("released light chunk remained resident after LRU replacement")
|
|
}
|
|
reloadedAfterEviction, err := cache.chunkAtErr(1, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, block, ready := reloadedAfterEviction.LightAt(0, 100, 8); !ready || block != 14 {
|
|
t.Fatalf("light after ticket unload/reload = %d ready=%v, want 14", block, ready)
|
|
}
|
|
if err := store.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store, err = NewStore(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
reloaded, err := store.LoadChunk(1, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, block, ready := reloaded.LightAt(0, 100, 8); !ready || block != 14 {
|
|
t.Fatalf("persisted reconciled light = %d ready=%v, want 14", block, ready)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestGeneratorVersionStampRejectsStaleChunks covers the guard that keeps a
|
|
// saved world from masking generator changes.
|
|
//
|
|
// chunkAt prefers the store over the generator, so without this check a chunk
|
|
// saved by an older build is served forever and every later worldgen fix looks
|
|
// like it did nothing in the already-explored area around spawn. Chunks written
|
|
// before the stamp existed carry no tag and must be rejected the same way.
|
|
func TestGeneratorVersionStampRejectsStaleChunks(t *testing.T) {
|
|
toRoot := func(t *testing.T, c *nbt.Compound) *nbt.Compound {
|
|
t.Helper()
|
|
_, tag, err := nbt.UnmarshalNamed(nbt.MarshalNamed("", c))
|
|
if err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
root, ok := tag.(*nbt.Compound)
|
|
if !ok {
|
|
t.Fatal("root is not a compound")
|
|
}
|
|
return root
|
|
}
|
|
|
|
t.Run("current version loads", func(t *testing.T) {
|
|
root := toRoot(t, chunkToNBT(NewChunk(0, 0, BiomePlains)))
|
|
if _, err := nbtToChunk(root, 0, 0, 0, 0); err != nil {
|
|
t.Fatalf("nbtToChunk on a freshly written chunk: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("older version is rejected", func(t *testing.T) {
|
|
c := chunkToNBT(NewChunk(0, 0, BiomePlains))
|
|
c.Set(generatorVersionTag, nbt.Int(generatorVersion-1))
|
|
if _, err := nbtToChunk(toRoot(t, c), 0, 0, 0, 0); err != ErrChunkNotFound {
|
|
t.Errorf("err = %v, want ErrChunkNotFound so the chunk regenerates", err)
|
|
}
|
|
})
|
|
|
|
t.Run("missing stamp is rejected", func(t *testing.T) {
|
|
// What every chunk written before this guard existed looks like.
|
|
c := chunkToNBT(NewChunk(0, 0, BiomePlains))
|
|
stripped := nbt.NewCompound()
|
|
for _, name := range c.Keys() {
|
|
if name == generatorVersionTag {
|
|
continue
|
|
}
|
|
v, _ := c.Get(name)
|
|
stripped.Set(name, v)
|
|
}
|
|
if _, err := nbtToChunk(toRoot(t, stripped), 0, 0, 0, 0); err != ErrChunkNotFound {
|
|
t.Errorf("err = %v, want ErrChunkNotFound so the chunk regenerates", err)
|
|
}
|
|
})
|
|
}
|