Files
ngzz-mc/internal/save/save_test.go
2026-09-19 14:21:08 +08:00

359 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package save
import (
"encoding/binary"
"os"
"path/filepath"
"testing"
"time"
"mc/internal/block"
"mc/internal/blockentity"
"mc/internal/world"
"mc/internal/worldgen"
)
// loadSaveReg 加载注册表。
func loadSaveReg(t *testing.T) *block.Registry {
t.Helper()
r, err := block.Load(filepath.Join("..", "..", "assets", "config", "blocks.json"))
if err != nil {
t.Fatalf("加载注册表失败: %v", err)
}
return r
}
// newGen 创建生成器。
func newGen(t *testing.T, reg *block.Registry, seed int64) *worldgen.Generator {
t.Helper()
g, err := worldgen.New(reg, seed, 0.005, 0.01, 0.1)
if err != nil {
t.Fatalf("创建生成器失败: %v", err)
}
return g
}
// TestChunkRoundtrip 区块序列化往返(存档格式.md §4
func TestChunkRoundtrip(t *testing.T) {
reg := loadSaveReg(t)
g := newGen(t, reg, 777)
c := g.Generate(3, -5)
c.SetBlockLight(8, 70, 8, 13)
dec, err := DecodeChunk(EncodeChunk(c))
if err != nil {
t.Fatalf("反序列化失败: %v", err)
}
if dec.CX != 3 || dec.CZ != -5 {
t.Fatalf("坐标不一致: %d,%d", dec.CX, dec.CZ)
}
if dec.Block(8, 70, 8) != c.Block(8, 70, 8) {
t.Fatal("方块数据不一致")
}
if dec.BlockLight(8, 70, 8) != 13 {
t.Fatalf("方块光不一致: %d", dec.BlockLight(8, 70, 8))
}
if dec.SkyLight(8, 100, 8) != c.SkyLight(8, 100, 8) {
t.Fatal("天空光不一致")
}
}
// TestRegionWriteRead 区域文件写入/读取(含 CRC 与跨重启重开)。
func TestRegionWriteRead(t *testing.T) {
reg := loadSaveReg(t)
g := newGen(t, reg, 99)
dir := t.TempDir()
data := EncodeChunk(g.Generate(1, 1))
region, err := OpenRegion(dir, 0, 0)
if err != nil {
t.Fatalf("打开区域失败: %v", err)
}
if err := region.WriteChunk(1, 1, data); err != nil {
t.Fatalf("写区块失败: %v", err)
}
// 模拟重启:重新打开区域
region2, err := OpenRegion(dir, 0, 0)
if err != nil {
t.Fatalf("重开区域失败: %v", err)
}
got, ok, err := region2.ReadChunk(1, 1)
if err != nil || !ok {
t.Fatalf("读区块失败: ok=%v err=%v", ok, err)
}
if string(got) != string(data) {
t.Fatal("读取数据不一致")
}
// 未保存区块应返回 ok=false
if _, ok, _ := region2.ReadChunk(2, 2); ok {
t.Fatal("未保存区块应返回 false")
}
}
func TestLoadWorldSkipsCorruptRegion(t *testing.T) {
reg := loadSaveReg(t)
g := newGen(t, reg, 7)
dir := t.TempDir()
const name = "W"
regionDir := filepath.Join(dir, name, "region")
if err := os.MkdirAll(regionDir, 0o755); err != nil {
t.Fatal(err)
}
if err := WriteLevel(dir, name, LevelData{Version: 1, Seed: 7, GameMode: "survival"}); err != nil {
t.Fatal(err)
}
data := EncodeChunk(g.Generate(3, 4))
region, err := OpenRegion(regionDir, 0, 0)
if err != nil {
t.Fatal(err)
}
if err := region.WriteChunk(3, 4, data); err != nil {
t.Fatal(err)
}
regionPath := filepath.Join(regionDir, "r.0.0.mcr")
raw, err := os.ReadFile(regionPath)
if err != nil {
t.Fatal(err)
}
if len(raw) < 8192+8 {
t.Fatal("region 过短")
}
// 把第一条记录的长度改成巨大值,模拟旧版追加损坏(记录长度越界)。
binary.BigEndian.PutUint32(raw[8192:8196], 0x7fffffff)
if err := os.WriteFile(regionPath, raw, 0o644); err != nil {
t.Fatal(err)
}
got, ok, err := region.ReadChunk(3, 4)
if err != nil || ok || got != nil {
t.Fatalf("损坏记录应视为未保存 ok=%v err=%v", ok, err)
}
w2, err := world.New(reg, g, 3)
if err != nil {
t.Fatal(err)
}
defer w2.Close()
seed, err := LoadWorld(w2, dir, name)
if err != nil {
t.Fatalf("损坏区块不应中止整世界载入: %v", err)
}
if seed != 7 {
t.Fatalf("seed=%d", seed)
}
}
func TestRegionWriteTwice(t *testing.T) {
reg := loadSaveReg(t)
g := newGen(t, reg, 99)
dir := t.TempDir()
a := EncodeChunk(g.Generate(1, 1))
b := EncodeChunk(g.Generate(2, 2))
region, err := OpenRegion(dir, 0, 0)
if err != nil {
t.Fatal(err)
}
if err := region.WriteChunk(1, 1, a); err != nil {
t.Fatal(err)
}
if err := region.WriteChunk(2, 2, b); err != nil {
t.Fatal(err)
}
if err := region.WriteChunk(1, 1, a); err != nil {
t.Fatal(err)
}
region2, err := OpenRegion(dir, 0, 0)
if err != nil {
t.Fatal(err)
}
gotA, ok, err := region2.ReadChunk(1, 1)
if err != nil || !ok || string(gotA) != string(a) {
t.Fatalf("区块 A 往返失败 ok=%v err=%v", ok, err)
}
gotB, ok, err := region2.ReadChunk(2, 2)
if err != nil || !ok || string(gotB) != string(b) {
t.Fatalf("区块 B 往返失败 ok=%v err=%v", ok, err)
}
}
// TestSaveLoadWorld 世界级存取:放火把 → 保存 → 载入 → 火把与光仍在。
func TestSaveLoadWorld(t *testing.T) {
reg := loadSaveReg(t)
seed := int64(2024)
g := newGen(t, reg, seed)
w, err := world.New(reg, g, 3)
if err != nil {
t.Fatalf("创建世界失败: %v", err)
}
defer w.Close()
// 生成并等待区块
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
w.Update(8, 64, 8, 8*time.Millisecond)
if w.Block(8, 0, 8) != block.Air {
break
}
time.Sleep(2 * time.Millisecond)
}
// 放一个火把
torch, _ := reg.ID("torch")
var surf int32
for y := int32(255); y >= 0; y-- {
if w.Block(8, y, 8) != block.Air {
surf = y
break
}
}
w.SetBlock(8, surf+1, 8, block.NewState(torch, 0))
dir := t.TempDir()
if err := SaveWorld(w, dir, "TestWorld", seed, 1000); err != nil {
t.Fatalf("保存失败: %v", err)
}
// 载入到新世界
g2 := newGen(t, reg, seed)
w2, err := world.New(reg, g2, 3)
if err != nil {
t.Fatalf("创建世界失败: %v", err)
}
defer w2.Close()
if gotSeed, err := LoadWorld(w2, dir, "TestWorld"); err != nil {
t.Fatalf("载入失败: %v", err)
} else if gotSeed != seed {
t.Fatalf("种子不一致: %d", gotSeed)
}
// 火把及其光
if s := w2.Block(8, surf+1, 8); s.ID() != torch {
t.Fatalf("火把丢失: %v", s)
}
if l := w2.BlockLight(7, surf+1, 8); l != 13 {
t.Fatalf("火把光丢失: %d", l)
}
}
func TestListCreateDeleteWorld(t *testing.T) {
dir := t.TempDir()
if err := WriteLevel(dir, "Alpha", LevelData{Version: 1, Seed: 1, GameMode: "survival"}); err != nil {
t.Fatal(err)
}
time.Sleep(20 * time.Millisecond)
if err := WriteLevel(dir, "Beta", LevelData{Version: 1, Seed: 2, GameMode: "creative"}); err != nil {
t.Fatal(err)
}
list, err := ListWorlds(dir)
if err != nil || len(list) != 2 {
t.Fatalf("列表 %v err=%v", list, err)
}
if list[0].Name != "Beta" {
t.Fatalf("应按修改时间新→旧,首位 %s", list[0].Name)
}
if UniqueWorldName(dir, "Alpha") != "Alpha-2" {
t.Fatalf("冲突名应为 Alpha-2实际 %s", UniqueWorldName(dir, "Alpha"))
}
if err := DeleteWorld(dir, "Alpha"); err != nil {
t.Fatal(err)
}
list, _ = ListWorlds(dir)
if len(list) != 1 || list[0].Name != "Beta" {
t.Fatalf("删除后 %v", list)
}
if err := DeleteWorld(dir, "../x"); err == nil {
t.Fatal("非法名应拒绝")
}
if ParseWorldSeed("42") != 42 {
t.Fatal("数字种子")
}
if ParseWorldSeed("") == ParseWorldSeed("abc") && ParseWorldSeed("abc") == 0 {
t.Fatal("空种子与哈希不应都为 0 且相等")
}
}
func TestLevelBlockEntitiesRoundtrip(t *testing.T) {
dir := t.TempDir()
lv := LevelData{
Version: 1, Seed: 9, GameMode: "creative",
BlockEntities: []blockentity.Snapshot{{X: 1, Y: 2, Z: 3, Kind: "furnace", CookTicks: 9}},
Player: &PlayerData{Y: 10, UnlockedRecipes: []string{"stick"}},
}
if err := WriteLevel(dir, "BE", lv); err != nil {
t.Fatal(err)
}
got, err := ReadLevel(dir, "BE")
if err != nil {
t.Fatal(err)
}
if got.GameMode != "creative" || len(got.BlockEntities) != 1 || got.BlockEntities[0].CookTicks != 9 {
t.Fatalf("level.dat 方块实体往返失败: %+v", got)
}
if got.Player == nil || len(got.Player.UnlockedRecipes) != 1 {
t.Fatalf("解锁配方未写入: %+v", got.Player)
}
}
func TestSavePool(t *testing.T) {
reg := loadSaveReg(t)
const seed int64 = 7
g := newGen(t, reg, seed)
w, err := world.New(reg, g, 2)
if err != nil {
t.Fatal(err)
}
defer w.Close()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
w.Update(8, 64, 8, 8*time.Millisecond)
if w.Block(8, 0, 8) != block.Air {
break
}
time.Sleep(2 * time.Millisecond)
}
torch, _ := reg.ID("torch")
w.SetBlock(8, 80, 8, block.NewState(torch, 0))
dir := t.TempDir()
pool := NewSavePool(nil)
if err := EnqueueDirty(w, pool, filepath.Join(dir, "P", "region")); err != nil {
t.Fatal(err)
}
if err := WriteLevel(dir, "P", LevelData{Version: 1, Seed: seed}); err != nil {
t.Fatal(err)
}
pool.Close()
g2 := newGen(t, reg, seed)
w2, err := world.New(reg, g2, 2)
if err != nil {
t.Fatal(err)
}
defer w2.Close()
if _, err := LoadWorld(w2, dir, "P"); err != nil {
t.Fatal(err)
}
if w2.Block(8, 80, 8).ID() != torch {
t.Fatal("SavePool 写盘后火把应仍在")
}
}
// TestSavePoolWaitAllowsReuse Wait 只 drain 不关池(存档与持久化.md §4
func TestSavePoolWaitAllowsReuse(t *testing.T) {
reg := loadSaveReg(t)
g := newGen(t, reg, 3)
dir := t.TempDir()
p := NewSavePool(nil)
defer p.Close()
p.Enqueue(ChunkJob{RegionDir: dir, CX: 0, CZ: 0, Data: EncodeChunk(g.Generate(0, 0))})
p.Wait()
p.Enqueue(ChunkJob{RegionDir: dir, CX: 2, CZ: 0, Data: EncodeChunk(g.Generate(2, 0))})
p.Wait()
r, err := OpenRegion(dir, 0, 0)
if err != nil {
t.Fatal(err)
}
if _, ok, err := r.ReadChunk(0, 0); err != nil || !ok {
t.Fatalf("第一块应可读 ok=%v err=%v", ok, err)
}
if _, ok, err := r.ReadChunk(2, 0); err != nil || !ok {
t.Fatalf("Wait 后再入队应可读 ok=%v err=%v", ok, err)
}
}