359 lines
9.4 KiB
Go
359 lines
9.4 KiB
Go
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)
|
||
}
|
||
}
|