package game import ( "math" "mc/internal/blockentity" "path/filepath" "testing" "time" "mc/internal/block" "mc/internal/camera" "mc/internal/entity" "mc/internal/inventory" "mc/internal/item" "mc/internal/physics" "mc/internal/recipe" "mc/internal/world" "mc/internal/worldgen" ) // newTestSession 构造真实世界上的游戏会话(等待区块生成)。 func newTestSession(t *testing.T) *Session { t.Helper() reg, err := block.Load(filepath.Join("..", "..", "assets", "config", "blocks.json")) if err != nil { t.Fatalf("加载方块注册表失败: %v", err) } items, err := item.Load(filepath.Join("..", "..", "assets", "config", "items.json")) if err != nil { t.Fatalf("加载物品注册表失败: %v", err) } recipes, err := recipe.Load(filepath.Join("..", "..", "assets", "config", "recipes.json")) if err != nil { t.Fatalf("加载配方失败: %v", err) } gen, err := worldgen.New(reg, 42, 0.005, 0.01, 0.1) if err != nil { t.Fatalf("创建生成器失败: %v", err) } w, err := world.New(reg, gen, 3) if err != nil { t.Fatalf("创建世界失败: %v", err) } t.Cleanup(w.Close) // 等待 (0,0) 区块 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) } // 找地表 var surf int32 for y := int32(255); y >= 0; y-- { if w.Block(8, y, 8) != block.Air { surf = y break } } p := physics.NewPlayer(8.5, float64(surf+1), 8.5) cam := camera.New(p.Eye(), 0, -math.Pi/2, 70) inv := inventory.New(items) inv.Slots[0] = item.Stack{Name: "diamond_pickaxe", Count: 1} // 选中的工具 inv.Slots[1] = item.Stack{Name: "oak_planks", Count: 10} sess := NewSession(w, reg, items, recipes, p, inv, cam) // 测试环境禁用自然刷怪(僵尸生成依赖区块加载时序,会造成 flake) sess.spawnTimer = 1e9 return sess } // TestBreakAndPlace 完整闭环:破坏地表 → 放木板(方块交互与动画.md §3、§6)。 func TestBreakAndPlace(t *testing.T) { s := newTestSession(t) // 破坏脚下地表的草方块(向下看) s.BeginBreak() if !s.breaking { t.Fatal("应锁定破坏目标") } tx, ty, tz := s.BreakTarget().X, s.BreakTarget().Y, s.BreakTarget().Z steps := 0 for s.breaking && steps < 600 { s.Tick(1.0 / 20) steps++ } if s.breaking { t.Fatal("钻石镐 600 tick 未破坏草方块") } if s.World.Block(tx, ty, tz) != block.Air { t.Fatal("破坏后应为空气") } // 放置木板(切换到槽 1):先抬升玩家脱离坑位(否则放置位与玩家 AABB 重叠会被正确拒绝) s.Inv.Selected = 1 s.Player.Pos[1] += 2 s.Tick(1.0 / 20) // 相机跟随 s.PlaceSelected() if name := s.Reg.Name(s.World.Block(tx, ty, tz).ID()); name != "oak_planks" { t.Fatalf("放置后应为橡木板,实际 %s", name) } if s.Inv.Slots[1].Count != 9 { t.Fatalf("木板应剩 9,实际 %d", s.Inv.Slots[1].Count) } } // TestBreakSpeed 验证工具速度公式(挖矿与矿物.md §3)。 func TestBreakSpeed(t *testing.T) { s := newTestSession(t) stone := s.Reg.Get(mustID(t, s.Reg, "stone")) // 空手 if got := s.breakSpeed(stone, item.Stack{}); got < 1/1.5-0.01 || got > 1/1.5+0.01 { t.Fatalf("空手速度期望约 1/1.5,实际 %v", got) } // 钻石镐(speed 8;Count 必须 ≥1 才是非空栈) withTool := item.Stack{Name: "diamond_pickaxe", Count: 1} if got := s.breakSpeed(stone, withTool); got < 8/1.5-0.01 || got > 8/1.5+0.01 { t.Fatalf("钻石镐速度期望约 8/1.5,实际 %v", got) } } // TestFurnaceFlow 放置熔炉 → 投料 → 烧炼产出(物品与背包.md §6 会话接线)。 func TestFurnaceFlow(t *testing.T) { s := newTestSession(t) // 给玩家熔炉 + 煤炭 + 铁矿石 s.Inv.Slots[1] = item.Stack{Name: "furnace", Count: 1} s.Inv.Slots[2] = item.Stack{Name: "coal", Count: 2} s.Inv.Slots[3] = item.Stack{Name: "iron_ore", Count: 2} s.Inv.Selected = 1 // 放置熔炉(向下看,站在地表) s.BeginBreak() for s.breaking { s.Tick(1.0 / 20) } s.Player.Pos[1] += 2 s.Tick(1.0 / 20) s.PlaceSelected() // 找到熔炉方块实体 var furnace *blockentity.Entity for _, be := range s.BlockEntities { if be.Kind == blockentity.KindFurnace { furnace = be } } if furnace == nil { t.Fatal("未创建熔炉方块实体") } // 投料 furnace.Insert(blockentity.SlotFuel, item.Stack{Name: "coal", Count: 1}, s.Items) furnace.Insert(blockentity.SlotInput, item.Stack{Name: "iron_ore", Count: 1}, s.Items) // 烧炼 200 tick(10 秒) for i := 0; i < 300; i++ { s.Tick(1.0 / 20) } if furnace.Slots[blockentity.SlotOutput].Name != "iron_ingot" { t.Fatalf("输出应为铁锭,实际 %+v", furnace.Slots[blockentity.SlotOutput]) } } // mustID 取方块 ID(测试辅助)。 func mustID(t *testing.T, reg *block.Registry, name string) uint16 { t.Helper() id, ok := reg.ID(name) if !ok { t.Fatalf("方块 %q 未注册", name) } return id } // fillGrid 往合成网格第 i 槽放入 1 个物品(测试辅助)。 func fillGrid(s *Session, i int, name string) { s.CraftingSwap(i, item.Stack{Name: name, Count: 1}) } // TestCraftingShaped 工作台有序合成:2×2 木板 → 工作台(工作台与合成表.md §4.1/§5)。 func TestCraftingShaped(t *testing.T) { s := newTestSession(t) s.OpenCrafting() if !s.CraftingOpen() { t.Fatal("应已打开合成界面") } // 空网格无产物 if _, ok := s.CraftingResult(); ok { t.Fatal("空网格不应有产物") } // 2×2 木板 fillGrid(s, 0, "oak_planks") fillGrid(s, 1, "oak_planks") fillGrid(s, 3, "oak_planks") fillGrid(s, 4, "oak_planks") res, ok := s.CraftingResult() if !ok || res.Name != "crafting_table" || res.Count != 1 { t.Fatalf("2×2 木板应产出工作台×1,实际 %+v ok=%v", res, ok) } // 执行合成:材料消耗、产物返回 taken, ok := s.CraftingTakeResult() if !ok || taken.Name != "crafting_table" { t.Fatalf("TakeResult 应返回工作台,实际 %+v", taken) } for _, i := range []int{0, 1, 3, 4} { if !s.CraftSlot(i).Empty() { t.Fatalf("合成后网格槽 %d 应清空,实际 %+v", i, s.CraftSlot(i)) } } if _, ok := s.CraftingResult(); ok { t.Fatal("材料耗尽后不应再有产物") } } // TestCraftingShapeless 无序合成:1 原木 → 4 木板(工作台与合成表.md §4.2)。 func TestCraftingShapeless(t *testing.T) { s := newTestSession(t) s.OpenCrafting() fillGrid(s, 4, "oak_log") res, ok := s.CraftingResult() if !ok || res.Name != "oak_planks" || res.Count != 4 { t.Fatalf("原木应产出 4 木板,实际 %+v ok=%v", res, ok) } taken, ok := s.CraftingTakeResult() if !ok || taken.Count != 4 { t.Fatalf("应产出 4 木板,实际 %+v", taken) } } // TestCraftingTools 工具配方:3 木板 + 2 木棍 → 木镐(工作台与合成表.md §4.1 多符号)。 func TestCraftingTools(t *testing.T) { s := newTestSession(t) s.OpenCrafting() // 顶行 3 木板,中/下行第 2 列木棍 for i := 0; i < 3; i++ { fillGrid(s, i, "oak_planks") } fillGrid(s, 4, "stick") fillGrid(s, 7, "stick") res, ok := s.CraftingResult() if !ok || res.Name != "wooden_pickaxe" { t.Fatalf("木板+木棍应产出木镐,实际 %+v ok=%v", res, ok) } } // TestCraftingCloseReturn 关闭界面:网格剩余材料退回背包,放不下掉落(工作台与合成表.md §5)。 func TestCraftingCloseReturn(t *testing.T) { s := newTestSession(t) s.Inv.Slots[1] = item.Stack{} // 清空 1 号槽方便计数 s.OpenCrafting() fillGrid(s, 0, "oak_planks") fillGrid(s, 0, "oak_planks") // 同槽合并 → 2 个 fillGrid(s, 8, "stick") s.CloseCrafting() if s.CraftingOpen() { t.Fatal("关闭后不应仍处于打开状态") } // 网格清空 for i := 0; i < 9; i++ { if !s.CraftSlot(i).Empty() { t.Fatalf("关闭后网格槽 %d 应清空", i) } } // 背包收回 havePlanks, haveStick := 0, 0 for _, st := range s.Inv.Slots { if st.Name == "oak_planks" { havePlanks += int(st.Count) } if st.Name == "stick" { haveStick += int(st.Count) } } if havePlanks < 2 || haveStick < 1 { t.Fatalf("材料应退回背包,实际 planks=%d stick=%d", havePlanks, haveStick) } } // TestAttackAnimal 攻击动物:伤害递减、死亡掉落牛肉并移除(动物体系.md §5)。 func TestAttackAnimal(t *testing.T) { s := newTestSession(t) s.Cam.Pitch = -0.2 // 平视前方(测试会话默认向下看,攻击需改平视) // 玩家正前方 3 格生成动物(与相机射线同向) e := s.Entities.Spawn(entity.KindAnimal, s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2]-3) e.Data = &entity.Animal{} if !s.Attack() { t.Fatal("正前方动物应可攻击") } if got := s.Entities.Get(e.ID).Health; got != 18 { t.Fatalf("攻击后血量应为 18,实际 %v", got) } // 连续攻击致死(2 点 × 10 次) for i := 0; i < 10; i++ { cur := s.Entities.Get(e.ID) if cur == nil || cur.Dead { break } s.Attack() } if s.Entities.Get(e.ID) != nil { t.Fatal("死亡后实体应被移除") } // 掉落物:肉类(按实体 ID 轮换 beef/porkchop/mutton/chicken) found := false for _, d := range s.Entities.List() { if d.Kind == entity.KindItemDrop { if drop, ok := d.Data.(*entity.Drop); ok { switch drop.Item { case "beef", "porkchop", "mutton", "chicken": found = true } } } } if !found { t.Fatal("动物死亡应掉落肉类") } } // TestBlockDropVanilla 原版掉落:草方块掉泥土(挖矿与矿物.md §5)。 // 注意:掉落紧邻玩家会被立即拾取进背包,故断言背包而非掉落实体。 func TestBlockDropVanilla(t *testing.T) { s := newTestSession(t) before := 0 for _, st := range s.Inv.Slots { if st.Name == "dirt" { before += int(st.Count) } } // 破坏草方块(向下看,钻石镐) s.BeginBreak() for s.breaking { s.Tick(1.0 / 20) } // 掉落有 1 秒真实时间拾取延迟(Born 为墙钟时间):等待后 tick 触发拾取 time.Sleep(1100 * time.Millisecond) for i := 0; i < 2; i++ { s.Tick(1.0 / 20) } after := 0 for _, st := range s.Inv.Slots { if st.Name == "dirt" { after += int(st.Count) } } if after <= before { t.Fatalf("草方块破坏应掉落泥土(原版行为),破坏前 %d 后 %d", before, after) } }