feat(save): 存档系统——region 文件(zstd+CRC32+原子替换)、level.dat、区块序列化、世界存取(含测试)
This commit is contained in:
225
internal/save/region.go
Normal file
225
internal/save/region.go
Normal file
@@ -0,0 +1,225 @@
|
||||
// Package save 世界存档:level.dat + region 文件 + zstd + CRC32(存档格式.md)。
|
||||
//
|
||||
// 策略(存档与持久化.md):diff-only(只存玩家改动的区块)、写临时文件原子替换、
|
||||
// 卸载/定时写盘、先备份后迁移。
|
||||
package save
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
// 区块序列化格式常量(存档格式.md §4–§5)。
|
||||
const (
|
||||
magic = "NYGC" // 年糕历险记 区块数据魔数
|
||||
chunkVer = 1 // 区块序列化版本
|
||||
sectorSize = 4096 // 扇区 4KB
|
||||
regionSize = 32 // 每 region 32×32 区块(存档格式.md §3)
|
||||
)
|
||||
|
||||
// crcTable CRC32 表(IEEE)。
|
||||
var crcTable = crc32.MakeTable(crc32.IEEE)
|
||||
|
||||
// ---- Region 文件(存档格式.md §3)----
|
||||
|
||||
// Region 一个区域文件(r.<rx>.<rz>.mcr)。
|
||||
type Region struct {
|
||||
rx, rz int32
|
||||
path string
|
||||
|
||||
// 头部:偏移表(1024×4B)与时间戳表(1024×4B),启动时读入内存
|
||||
offsets [1024]uint32
|
||||
timestamps [1024]uint32
|
||||
}
|
||||
|
||||
// sectorIndex 区块局部坐标 → 头部索引。
|
||||
func sectorIndex(lx, lz int32) int {
|
||||
lx &= regionSize - 1
|
||||
lz &= regionSize - 1
|
||||
return int(lz)*regionSize + int(lx)
|
||||
}
|
||||
|
||||
// OpenRegion 打开区域文件(不存在则视为空)。
|
||||
func OpenRegion(dir string, rx, rz int32) (*Region, error) {
|
||||
r := &Region{rx: rx, rz: rz, path: filepath.Join(dir, fmt.Sprintf("r.%d.%d.mcr", rx, rz))}
|
||||
f, err := os.Open(r.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return r, nil // 空区域
|
||||
}
|
||||
return nil, fmt.Errorf("save.OpenRegion: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
// 读头部:偏移表(4KB)+ 时间戳表(4KB)
|
||||
var buf [8192]byte
|
||||
if _, err := f.ReadAt(buf[:], 0); err != nil && err != io.EOF {
|
||||
return nil, fmt.Errorf("save.OpenRegion 头部: %w", err)
|
||||
}
|
||||
for i := 0; i < 1024; i++ {
|
||||
r.offsets[i] = binary.BigEndian.Uint32(buf[i*4:])
|
||||
r.timestamps[i] = binary.BigEndian.Uint32(buf[4096+i*4:])
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// ReadChunk 读取区块压缩数据(含 CRC 校验,存档格式.md §5)。
|
||||
func (r *Region) ReadChunk(cx, cz int32) ([]byte, bool, error) {
|
||||
idx := sectorIndex(cx-r.rx*regionSize, cz-r.rz*regionSize)
|
||||
off := r.offsets[idx]
|
||||
if off == 0 {
|
||||
return nil, false, nil // 未保存过
|
||||
}
|
||||
sector := off >> 8
|
||||
count := off & 0xFF
|
||||
if sector == 0 || count == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
f, err := os.Open(r.path)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("save.ReadChunk: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
raw := make([]byte, count*sectorSize)
|
||||
if _, err := f.ReadAt(raw, int64(sector)*sectorSize); err != nil {
|
||||
return nil, false, fmt.Errorf("save.ReadChunk 读取扇区: %w", err)
|
||||
}
|
||||
length := binary.BigEndian.Uint32(raw[0:4])
|
||||
compType := raw[4]
|
||||
if 5+int(length) > len(raw) {
|
||||
return nil, false, fmt.Errorf("save.ReadChunk: 记录长度越界")
|
||||
}
|
||||
data := raw[5 : 5+int(length)]
|
||||
wantCRC := binary.BigEndian.Uint32(raw[5+int(length):])
|
||||
if crc32.Checksum(data, crcTable) != wantCRC {
|
||||
return nil, false, fmt.Errorf("save.ReadChunk: CRC 校验失败(区块 %d,%d)", cx, cz)
|
||||
}
|
||||
if compType == 0 {
|
||||
return data, true, nil
|
||||
}
|
||||
if compType != 2 {
|
||||
return nil, false, fmt.Errorf("save.ReadChunk: 未知压缩类型 %d", compType)
|
||||
}
|
||||
out, err := zstdDecode(data)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("save.ReadChunk 解压: %w", err)
|
||||
}
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
// WriteChunk 写入区块压缩数据(写完整区域文件到临时文件再原子替换,存档格式.md §6)。
|
||||
func (r *Region) WriteChunk(cx, cz int32, data []byte) error {
|
||||
idx := sectorIndex(cx-r.rx*regionSize, cz-r.rz*regionSize)
|
||||
comp, err := zstdEncode(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save.WriteChunk 压缩: %w", err)
|
||||
}
|
||||
rec := make([]byte, 5+len(comp)+4)
|
||||
binary.BigEndian.PutUint32(rec[0:4], uint32(len(comp)))
|
||||
rec[4] = 2 // zstd
|
||||
copy(rec[5:], comp)
|
||||
binary.BigEndian.PutUint32(rec[5+len(comp):], crc32.Checksum(comp, crcTable))
|
||||
|
||||
sectorCount := (len(rec) + sectorSize - 1) / sectorSize
|
||||
if sectorCount == 0 {
|
||||
sectorCount = 1
|
||||
}
|
||||
// 找空闲扇区(简单策略:追加到文件尾;头部 8KB 占 2 扇区,数据从扇区 2 起)
|
||||
fileSize := int64(0)
|
||||
if st, err := os.Stat(r.path); err == nil {
|
||||
fileSize = st.Size()
|
||||
}
|
||||
startSector := int(fileSize+sectorSize-1) / sectorSize
|
||||
if startSector < 2 {
|
||||
startSector = 2
|
||||
}
|
||||
|
||||
// 构建完整新文件:头部 + 既有数据 + 新记录
|
||||
tmp, err := os.CreateTemp(filepath.Dir(r.path), "region.tmp.*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("save.WriteChunk 临时文件: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
var head [8192]byte
|
||||
r.offsets[idx] = uint32(startSector<<8) | uint32(sectorCount)
|
||||
r.timestamps[idx] = uint32(nowUnix())
|
||||
for i := 0; i < 1024; i++ {
|
||||
binary.BigEndian.PutUint32(head[i*4:], r.offsets[i])
|
||||
binary.BigEndian.PutUint32(head[4096+i*4:], r.timestamps[i])
|
||||
}
|
||||
if _, err := tmp.Write(head[:]); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
// 复制旧数据区
|
||||
if old, err := os.Open(r.path); err == nil {
|
||||
if _, err := io.Copy(tmp, old); err != nil {
|
||||
old.Close()
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
old.Close()
|
||||
}
|
||||
// 追加新记录(对齐扇区)
|
||||
if _, err := tmp.Write(rec); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
pad := sectorCount*sectorSize - len(rec)
|
||||
if pad > 0 {
|
||||
if _, err := tmp.Write(make([]byte, pad)); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
// 原子替换(存档格式.md §6:临时文件 → rename)
|
||||
if err := os.Rename(tmpPath, r.path); err != nil {
|
||||
return fmt.Errorf("save.WriteChunk 原子替换: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 压缩工具 ----
|
||||
|
||||
func zstdEncode(b []byte) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc, err := zstd.NewWriter(&buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := enc.Write(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := enc.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func zstdDecode(b []byte) ([]byte, error) {
|
||||
dec, err := zstd.NewReader(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer dec.Close()
|
||||
return io.ReadAll(dec)
|
||||
}
|
||||
|
||||
// nowUnix 当前 Unix 秒(时间戳表)。
|
||||
func nowUnix() int64 { return time.Now().Unix() }
|
||||
187
internal/save/save.go
Normal file
187
internal/save/save.go
Normal file
@@ -0,0 +1,187 @@
|
||||
// 世界存档管理器(save.go):level.dat 元数据 + 脏区块写 region + 载入重建。
|
||||
//
|
||||
// 流程(存档与持久化.md §2–§5):
|
||||
// - 保存:遍历脏区块 → 序列化 → 写 region → 清除 SaveDirty → 写 level.dat;
|
||||
// - 载入:读 level.dat 取 seed → 生成器重建 → 用已保存区块覆盖;
|
||||
// - 全部写盘走临时文件 + 原子替换(崩溃恢复)。
|
||||
package save
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"mc/internal/block"
|
||||
"mc/internal/chunk"
|
||||
"mc/internal/coord"
|
||||
"mc/internal/world"
|
||||
)
|
||||
|
||||
// LevelData 世界元数据(存档格式.md §2)。
|
||||
type LevelData struct {
|
||||
Version int `json:"version"`
|
||||
Seed int64 `json:"seed"`
|
||||
Time int64 `json:"time"`
|
||||
GameMode string `json:"game_mode"`
|
||||
}
|
||||
|
||||
// WriteLevel 写 level.dat(临时文件 + 原子替换)。
|
||||
func WriteLevel(dir, name string, lv LevelData) error {
|
||||
b, err := json.MarshalIndent(lv, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("save.WriteLevel: %w", err)
|
||||
}
|
||||
path := filepath.Join(dir, name, "level.dat")
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "level.tmp.*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
if _, err := tmp.Write(b); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpPath, path)
|
||||
}
|
||||
|
||||
// ReadLevel 读 level.dat;不存在返回零值。
|
||||
func ReadLevel(dir, name string) (LevelData, error) {
|
||||
path := filepath.Join(dir, name, "level.dat")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return LevelData{}, nil
|
||||
}
|
||||
return LevelData{}, fmt.Errorf("save.ReadLevel: %w", err)
|
||||
}
|
||||
var lv LevelData
|
||||
if err := json.Unmarshal(b, &lv); err != nil {
|
||||
return LevelData{}, fmt.Errorf("save.ReadLevel: %w", err)
|
||||
}
|
||||
return lv, nil
|
||||
}
|
||||
|
||||
// EncodeChunk 序列化区块:[魔数 4][版本 1][cx 4][cz 4][方块 65536×4][天空光 32768][方块光 32768]。
|
||||
func EncodeChunk(c *chunk.Chunk) []byte {
|
||||
blocks := c.Snapshot()
|
||||
sky, bl := c.LightBytes()
|
||||
out := make([]byte, 0, 9+len(blocks)*4+len(sky)+len(bl))
|
||||
out = append(out, magic...)
|
||||
out = append(out, chunkVer)
|
||||
out = binary.BigEndian.AppendUint32(out, uint32(c.CX))
|
||||
out = binary.BigEndian.AppendUint32(out, uint32(c.CZ))
|
||||
for _, s := range blocks {
|
||||
out = binary.BigEndian.AppendUint32(out, uint32(s))
|
||||
}
|
||||
out = append(out, sky...)
|
||||
out = append(out, bl...)
|
||||
return out
|
||||
}
|
||||
|
||||
// DecodeChunk 反序列化区块。
|
||||
func DecodeChunk(data []byte) (*chunk.Chunk, error) {
|
||||
if len(data) < 9 || string(data[0:4]) != magic {
|
||||
return nil, fmt.Errorf("save.DecodeChunk: 魔数错误")
|
||||
}
|
||||
if data[4] != chunkVer {
|
||||
return nil, fmt.Errorf("save.DecodeChunk: 版本 %d 不支持", data[4])
|
||||
}
|
||||
cx := int32(binary.BigEndian.Uint32(data[5:9]))
|
||||
cz := int32(binary.BigEndian.Uint32(data[9:13]))
|
||||
off := 13
|
||||
n := chunk.SizeX * chunk.SizeZ * chunk.Height
|
||||
blocks := make([]block.State, n)
|
||||
for i := 0; i < n; i++ {
|
||||
blocks[i] = block.State(binary.BigEndian.Uint32(data[off:]))
|
||||
off += 4
|
||||
}
|
||||
sky := make([]byte, n/2)
|
||||
bl := make([]byte, n/2)
|
||||
copy(sky, data[off:off+n/2])
|
||||
off += n / 2
|
||||
copy(bl, data[off:off+n/2])
|
||||
c := chunk.New(cx, cz)
|
||||
c.SetAll(blocks, sky, bl)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// SaveWorld 保存全部脏区块 + level.dat(存档与持久化.md §2 保存时机由调用方触发)。
|
||||
func SaveWorld(w *world.World, dir, name string, seed int64, tick int64) error {
|
||||
regionDir := filepath.Join(dir, name, "region")
|
||||
if err := os.MkdirAll(regionDir, 0o755); err != nil {
|
||||
return fmt.Errorf("save.SaveWorld mkdir: %w", err)
|
||||
}
|
||||
for _, c := range w.ActiveChunks() {
|
||||
if !c.SaveDirty.Load() {
|
||||
continue
|
||||
}
|
||||
data := EncodeChunk(c)
|
||||
rx, rz := coord.FloorDiv(c.CX, regionSize), coord.FloorDiv(c.CZ, regionSize)
|
||||
region, err := OpenRegion(regionDir, rx, rz)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := region.WriteChunk(c.CX, c.CZ, data); err != nil {
|
||||
return err
|
||||
}
|
||||
c.SaveDirty.Store(false)
|
||||
}
|
||||
return WriteLevel(dir, name, LevelData{Version: 1, Seed: seed, Time: tick, GameMode: "survival"})
|
||||
}
|
||||
|
||||
// LoadWorld 载入世界:level.dat 取 seed 重建,再用 region 中已保存区块覆盖。
|
||||
func LoadWorld(w *world.World, dir, name string) (int64, error) {
|
||||
lv, err := ReadLevel(dir, name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
regionDir := filepath.Join(dir, name, "region")
|
||||
entries, err := os.ReadDir(regionDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return lv.Seed, nil // 新世界
|
||||
}
|
||||
return 0, fmt.Errorf("save.LoadWorld: %w", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
var rx, rz int32
|
||||
if _, err := fmt.Sscanf(e.Name(), "r.%d.%d.mcr", &rx, &rz); err != nil {
|
||||
continue
|
||||
}
|
||||
region, err := OpenRegion(regionDir, rx, rz)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for lz := int32(0); lz < regionSize; lz++ {
|
||||
for lx := int32(0); lx < regionSize; lx++ {
|
||||
cx, cz := rx*regionSize+lx, rz*regionSize+lz
|
||||
data, ok, err := region.ReadChunk(cx, cz)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("save.LoadWorld %d,%d: %w", cx, cz, err)
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
c, err := DecodeChunk(data)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
w.InstallChunk(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return lv.Seed, nil
|
||||
}
|
||||
145
internal/save/save_test.go
Normal file
145
internal/save/save_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package save
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mc/internal/block"
|
||||
"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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user