918 lines
25 KiB
Go
918 lines
25 KiB
Go
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{Species: 0}
|
||
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("死亡后实体应被移除")
|
||
}
|
||
// 掉落物:肉类(按 Animal.Species)
|
||
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)
|
||
}
|
||
}
|
||
|
||
func countItem(s *Session, name string) int {
|
||
n := 0
|
||
for _, st := range s.Inv.Slots {
|
||
if st.Name == name {
|
||
n += int(st.Count)
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
func TestStoneDropRequiresPickaxe(t *testing.T) {
|
||
s := newTestSession(t)
|
||
s.BeginBreak()
|
||
h := s.BreakTarget()
|
||
s.EndBreak()
|
||
sid, ok := s.Reg.ID("stone")
|
||
if !ok {
|
||
t.Fatal("stone 未注册")
|
||
}
|
||
s.World.SetBlock(h.X, h.Y, h.Z, block.NewState(sid, 0))
|
||
s.Inv.Slots[0] = item.Stack{}
|
||
s.BeginBreak()
|
||
for s.breaking {
|
||
s.Tick(1.0 / 20)
|
||
}
|
||
time.Sleep(1100 * time.Millisecond)
|
||
s.Tick(1.0 / 20)
|
||
if n := countItem(s, "cobblestone"); n != 0 {
|
||
t.Fatalf("空手挖石头不应掉圆石,实际 %d", n)
|
||
}
|
||
s.World.SetBlock(h.X, h.Y, h.Z, block.NewState(sid, 0))
|
||
s.Inv.Slots[0] = item.Stack{Name: "diamond_pickaxe", Count: 1}
|
||
s.BeginBreak()
|
||
for s.breaking {
|
||
s.Tick(1.0 / 20)
|
||
}
|
||
time.Sleep(1100 * time.Millisecond)
|
||
s.Tick(1.0 / 20)
|
||
if n := countItem(s, "cobblestone"); n < 1 {
|
||
t.Fatal("镐挖石头应掉圆石")
|
||
}
|
||
}
|
||
|
||
func TestEatFood(t *testing.T) {
|
||
s := newTestSession(t)
|
||
s.Hunger = 10
|
||
s.Inv.Slots[0] = item.Stack{Name: "beef", Count: 2}
|
||
if !s.TryEat() {
|
||
t.Fatal("饥饿未满应能吃牛肉")
|
||
}
|
||
for i := 0; i < 40; i++ {
|
||
s.Tick(1.0 / 20)
|
||
}
|
||
if s.Hunger <= 10 {
|
||
t.Fatalf("食用后饥饿应增加,实际 %d", s.Hunger)
|
||
}
|
||
if s.Inv.Slots[0].Count != 1 {
|
||
t.Fatalf("应消耗 1 个牛肉,剩余 %d", s.Inv.Slots[0].Count)
|
||
}
|
||
}
|
||
|
||
// TestHoldBreakContinues 按住续挖:同目标不重置;挖完再 BeginBreak 能锁定下一块。
|
||
func TestHoldBreakContinues(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
|
||
s.Tick(1.0 / 20)
|
||
prog := s.BreakProgress()
|
||
s.ContinueBreak()
|
||
if s.BreakTarget().X != tx || s.BreakTarget().Y != ty || s.BreakTarget().Z != tz {
|
||
t.Fatal("同准星不应换目标")
|
||
}
|
||
if s.BreakProgress() < prog-1e-9 {
|
||
t.Fatalf("同目标不应重置进度: %v → %v", prog, s.BreakProgress())
|
||
}
|
||
for s.Breaking() {
|
||
s.Tick(1.0 / 20)
|
||
}
|
||
if s.World.Block(tx, ty, tz) != block.Air {
|
||
t.Fatal("第一块应已破坏")
|
||
}
|
||
s.BeginBreak()
|
||
if !s.Breaking() {
|
||
t.Fatal("挖完后应能立刻开始下一块")
|
||
}
|
||
}
|
||
|
||
func TestAttackSoundsNoGeneric(t *testing.T) {
|
||
s := newTestSession(t)
|
||
s.Cam.Pitch = -0.2
|
||
e := s.Entities.Spawn(entity.KindAnimal, s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2]-3)
|
||
e.Data = &entity.Animal{Species: 0}
|
||
var keys []string
|
||
s.Sound = func(k string, _, _, _ float64) { keys = append(keys, k) }
|
||
if !s.Attack() {
|
||
t.Fatal("应命中")
|
||
}
|
||
if len(keys) != 2 || keys[0] != "entity.player.attack.strong" || keys[1] != hurtSound(e) {
|
||
t.Fatalf("命中应先 strong 再物种 hurt,实际 %v", keys)
|
||
}
|
||
for _, k := range keys {
|
||
if k == "entity.player.attack.nodamage" {
|
||
t.Fatal("命中不应播 nodamage")
|
||
}
|
||
}
|
||
for i := 0; i < 10; i++ {
|
||
cur := s.Entities.Get(e.ID)
|
||
if cur == nil || cur.Dead {
|
||
break
|
||
}
|
||
s.Attack()
|
||
}
|
||
if len(keys) < 3 || keys[len(keys)-1] != deathSound(e) {
|
||
t.Fatalf("致死应播 death,实际 %v", keys)
|
||
}
|
||
for _, k := range keys {
|
||
if k == "entity.generic.hurt" {
|
||
t.Fatal("不应叠播 generic.hurt")
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestSwingMissNoBlock(t *testing.T) {
|
||
s := newTestSession(t)
|
||
s.Cam.Pitch = 1.2 // 抬头:无实体无方块
|
||
var keys []string
|
||
s.Sound = func(k string, _, _, _ float64) { keys = append(keys, k) }
|
||
if s.Attack() {
|
||
t.Fatal("抬头不应命中实体")
|
||
}
|
||
if _, ok := s.Cast(5); ok {
|
||
t.Fatal("抬头不应命中方块")
|
||
}
|
||
s.SwingMiss()
|
||
if len(keys) != 1 || keys[0] != "entity.player.attack.nodamage" {
|
||
t.Fatalf("挥空应只播 nodamage,实际 %v", keys)
|
||
}
|
||
}
|
||
|
||
func TestFallSounds(t *testing.T) {
|
||
s := newTestSession(t)
|
||
s.Player.Flying = false
|
||
s.Player.InWater = false
|
||
var keys []string
|
||
s.Sound = func(k string, _, _, _ float64) { keys = append(keys, k) }
|
||
|
||
s.Player.OnGround = false
|
||
s.tickFallDamage(1, -3.5) // 累计 fallDist=3.5
|
||
s.Player.OnGround = true
|
||
s.tickFallDamage(0.05, -4) // 无伤害 → small_fall
|
||
if len(keys) != 1 || keys[0] != "entity.player.small_fall" {
|
||
t.Fatalf("无伤害落地应 small_fall,实际 %v", keys)
|
||
}
|
||
|
||
keys = nil
|
||
s.Player.OnGround = false
|
||
s.tickFallDamage(1, -5)
|
||
s.Player.OnGround = true
|
||
s.tickFallDamage(0.05, -20) // 有伤害 → big_fall + hurt
|
||
if len(keys) < 2 || keys[0] != "entity.player.big_fall" || keys[1] != "entity.player.hurt" {
|
||
t.Fatalf("有伤害落地应 big_fall+hurt,实际 %v", keys)
|
||
}
|
||
|
||
keys = nil
|
||
s.Player.Flying = true
|
||
s.fallDist = 8
|
||
s.Player.OnGround = true
|
||
s.tickFallDamage(0.05, -20)
|
||
if len(keys) != 0 {
|
||
t.Fatalf("飞行不应播坠落音,实际 %v", keys)
|
||
}
|
||
}
|
||
|
||
func TestSpawnAnimalSpecies(t *testing.T) {
|
||
s := newTestSession(t)
|
||
for i := 0; i < 16; i++ {
|
||
s.animalTimer = 0
|
||
s.spawnAnimals(0)
|
||
}
|
||
var e *entity.Entity
|
||
for _, it := range s.Entities.List() {
|
||
if it.Kind == entity.KindAnimal {
|
||
e = it
|
||
break
|
||
}
|
||
}
|
||
if e == nil {
|
||
e = s.Entities.Spawn(entity.KindAnimal, s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2]-4)
|
||
e.Data = &entity.Animal{Species: s.rng.Intn(4)}
|
||
}
|
||
d, ok := e.Data.(*entity.Animal)
|
||
if !ok {
|
||
t.Fatal("刷怪应写入 Animal.Data")
|
||
}
|
||
if d.Species < 0 || d.Species > 3 {
|
||
t.Fatalf("Species 越界: %d", d.Species)
|
||
}
|
||
if entity.AnimalSpecies(e) != d.Species {
|
||
t.Fatalf("AnimalSpecies=%d Species=%d", entity.AnimalSpecies(e), d.Species)
|
||
}
|
||
}
|
||
|
||
func TestMineHitEvery4Ticks(t *testing.T) {
|
||
s := newTestSession(t)
|
||
obs, ok := s.Reg.ID("obsidian")
|
||
if !ok {
|
||
t.Fatal("无黑曜石")
|
||
}
|
||
h, ok := s.Cast(5)
|
||
if !ok {
|
||
t.Fatal("应对准方块")
|
||
}
|
||
s.World.SetBlock(h.X, h.Y, h.Z, block.NewState(obs, 0))
|
||
var hits int
|
||
s.Sound = func(k string, _, _, _ float64) {
|
||
if len(k) >= 4 && k[len(k)-4:] == ".hit" {
|
||
hits++
|
||
}
|
||
}
|
||
s.BeginBreak()
|
||
if hits != 0 {
|
||
t.Fatal("BeginBreak 不应单独播 hit")
|
||
}
|
||
for i := 0; i < 8; i++ {
|
||
s.Tick(1.0 / 20)
|
||
}
|
||
if hits < 2 {
|
||
t.Fatalf("8 tick 应至少 2 次 hit(0 与 4),实际 %d", hits)
|
||
}
|
||
}
|
||
|
||
func TestFootstepWalkDist(t *testing.T) {
|
||
s := newTestSession(t)
|
||
s.Player.OnGround = true
|
||
s.Player.Vel = [3]float64{10, 0, 0}
|
||
s.walkDist = 0.99
|
||
s.nextStep = 1
|
||
var steps int
|
||
s.Sound = func(k string, _, _, _ float64) {
|
||
if len(k) >= 5 && k[len(k)-5:] == ".step" {
|
||
steps++
|
||
}
|
||
}
|
||
s.tickFootsteps(0.05)
|
||
if steps != 1 {
|
||
t.Fatalf("跨越整数应播 1 次脚步,实际 %d", steps)
|
||
}
|
||
}
|
||
|
||
func TestAnimalAmbientSound(t *testing.T) {
|
||
s := newTestSession(t)
|
||
e := s.Entities.Spawn(entity.KindAnimal, s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2]-2)
|
||
e.AmbientT = 1000
|
||
var got []string
|
||
s.Sound = func(k string, _, _, _ float64) { got = append(got, k) }
|
||
s.tickEntitySounds(0.05)
|
||
want := ambientSound(e)
|
||
found := false
|
||
for _, k := range got {
|
||
if k == want {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("应播 %s,实际 %v", want, got)
|
||
}
|
||
if e.AmbientT != -80 {
|
||
t.Fatalf("ambient 后应重置为 -80,实际 %d", e.AmbientT)
|
||
}
|
||
}
|
||
|
||
// TestInvCraft2x2 物品栏 2×2:木板 → 工作台;关闭余料退回。
|
||
func TestInvCraft2x2(t *testing.T) {
|
||
s := newTestSession(t)
|
||
for i := 0; i < 4; i++ {
|
||
s.InvCraftClick(i, item.Stack{Name: "oak_planks", Count: 1}, false)
|
||
}
|
||
res, ok := s.InvCraftResult()
|
||
if !ok || res.Name != "crafting_table" {
|
||
t.Fatalf("2×2 木板应产出工作台,实际 %+v ok=%v", res, ok)
|
||
}
|
||
taken, ok := s.InvCraftTakeResult()
|
||
if !ok || taken.Name != "crafting_table" {
|
||
t.Fatalf("TakeResult 应返回工作台,实际 %+v", taken)
|
||
}
|
||
// 3×3 工具配方不应在 2×2 匹配
|
||
s.InvCraftClick(0, item.Stack{Name: "oak_planks", Count: 1}, false)
|
||
s.InvCraftClick(1, item.Stack{Name: "oak_planks", Count: 1}, false)
|
||
s.InvCraftClick(2, item.Stack{Name: "oak_planks", Count: 1}, false)
|
||
if _, ok := s.InvCraftResult(); ok {
|
||
t.Fatal("2×2 不应匹配 3 宽镐配方")
|
||
}
|
||
s.CloseInvCraft()
|
||
have := 0
|
||
for _, st := range s.Inv.Slots {
|
||
if st.Name == "oak_planks" {
|
||
have += int(st.Count)
|
||
}
|
||
}
|
||
if have < 3 {
|
||
t.Fatalf("关闭后木板应退回,实际 %d", have)
|
||
}
|
||
}
|
||
|
||
// TestCraftingTakeResultAll Shift 批量:8 原木无序合成 32 木板进背包。
|
||
func TestCraftingTakeResultAll(t *testing.T) {
|
||
s := newTestSession(t)
|
||
s.Inv.Slots[1] = item.Stack{} // 去掉默认 10 木板,便于计数
|
||
s.OpenCrafting()
|
||
s.CraftingSwap(0, item.Stack{Name: "oak_log", Count: 8})
|
||
n := s.CraftingTakeResultAll()
|
||
if n != 8 {
|
||
t.Fatalf("应批量 8 次,实际 %d", n)
|
||
}
|
||
have := 0
|
||
for _, st := range s.Inv.Slots {
|
||
if st.Name == "oak_planks" {
|
||
have += int(st.Count)
|
||
}
|
||
}
|
||
if have != 32 {
|
||
t.Fatalf("应产出 32 木板,实际 %d", have)
|
||
}
|
||
}
|
||
|
||
// TestCraftingFillRecipe 配方书填格:背包有材料才填。
|
||
func TestCraftingFillRecipe(t *testing.T) {
|
||
s := newTestSession(t)
|
||
s.Inv.Slots[1] = item.Stack{}
|
||
s.OpenCrafting()
|
||
var table *recipe.Recipe
|
||
for _, rc := range s.Recipes.CraftingRecipes() {
|
||
if rc.Result.Item == "crafting_table" {
|
||
table = rc
|
||
break
|
||
}
|
||
}
|
||
if table == nil {
|
||
t.Fatal("缺少工作台配方")
|
||
}
|
||
if s.CraftingFillRecipe(table) {
|
||
t.Fatal("材料不足不应填格")
|
||
}
|
||
s.Inv.Add(item.Stack{Name: "oak_planks", Count: 4})
|
||
if !s.CraftingFillRecipe(table) {
|
||
t.Fatal("有 4 木板应能填工作台配方")
|
||
}
|
||
res, ok := s.CraftingResult()
|
||
if !ok || res.Name != "crafting_table" {
|
||
t.Fatalf("填格后应可合成工作台,实际 %+v", res)
|
||
}
|
||
}
|
||
|
||
func TestOpenFurnaceAndSmelt(t *testing.T) {
|
||
s := newTestSession(t)
|
||
s.Inv.Slots[1] = item.Stack{}
|
||
fid, ok := s.Reg.ID("furnace")
|
||
if !ok {
|
||
t.Fatal("缺少熔炉方块")
|
||
}
|
||
ey := int32(s.Player.Eye()[1])
|
||
s.World.SetBlock(8, ey, 7, block.NewState(fid, 0))
|
||
s.BlockEntities[[3]int32{8, ey, 7}] = blockentity.NewFurnace(8, ey, 7)
|
||
s.Player.Pos = [3]float64{8.5, s.Player.Pos[1], 8.5}
|
||
s.Cam.Pos = s.Player.Eye()
|
||
s.Cam.Yaw, s.Cam.Pitch = 0, -0.15
|
||
if !s.TryOpenFurnace() {
|
||
t.Fatal("应打开熔炉")
|
||
}
|
||
be := s.ContainerEntity()
|
||
be.Slots[blockentity.SlotInput] = item.Stack{Name: "iron_ore", Count: 1}
|
||
be.Slots[blockentity.SlotFuel] = item.Stack{Name: "coal", Count: 1}
|
||
for i := 0; i < 220; i++ {
|
||
s.Tick(0.05)
|
||
}
|
||
if be.Slots[blockentity.SlotOutput].Name != "iron_ingot" {
|
||
t.Fatalf("烧完应产出铁锭,实际 %+v", be.Slots[blockentity.SlotOutput])
|
||
}
|
||
}
|
||
|
||
func TestChestPutTakeClose(t *testing.T) {
|
||
s := newTestSession(t)
|
||
s.Inv.Slots[1] = item.Stack{}
|
||
cid, ok := s.Reg.ID("chest")
|
||
if !ok {
|
||
t.Fatal("缺少箱子方块")
|
||
}
|
||
ey := int32(s.Player.Eye()[1])
|
||
s.World.SetBlock(8, ey, 7, block.NewState(cid, 0))
|
||
s.BlockEntities[[3]int32{8, ey, 7}] = blockentity.NewChest(8, ey, 7)
|
||
s.Player.Pos = [3]float64{8.5, s.Player.Pos[1], 8.5}
|
||
s.Cam.Pos = s.Player.Eye()
|
||
s.Cam.Yaw, s.Cam.Pitch = 0, -0.15
|
||
if !s.TryOpenChest() {
|
||
t.Fatal("应打开箱子")
|
||
}
|
||
be := s.ContainerEntity()
|
||
be.Slots[0] = item.Stack{Name: "oak_planks", Count: 5}
|
||
s.CloseContainer()
|
||
if s.ContainerOpen() != 0 {
|
||
t.Fatal("关闭后不应仍打开")
|
||
}
|
||
if !s.TryOpenChest() {
|
||
t.Fatal("重开箱子")
|
||
}
|
||
be = s.ContainerEntity()
|
||
if be.Slots[0].Name != "oak_planks" || be.Slots[0].Count != 5 {
|
||
t.Fatalf("关箱不应丢物品: %+v", be.Slots[0])
|
||
}
|
||
}
|
||
|
||
func TestBlockEntityPersist(t *testing.T) {
|
||
s := newTestSession(t)
|
||
f := blockentity.NewFurnace(3, 64, 4)
|
||
f.Slots[blockentity.SlotInput] = item.Stack{Name: "iron_ore", Count: 2}
|
||
f.Slots[blockentity.SlotFuel] = item.Stack{Name: "oak_planks", Count: 3}
|
||
f.BurnTicks, f.BurnMaxTicks, f.CookTicks = 80, 300, 40
|
||
s.BlockEntities[[3]int32{3, 64, 4}] = f
|
||
snaps := s.ExportBlockEntities()
|
||
s.ImportBlockEntities(snaps)
|
||
got := s.BlockEntities[[3]int32{3, 64, 4}]
|
||
if got == nil || got.CookTicks != 40 || got.Slots[blockentity.SlotFuel].Count != 3 {
|
||
t.Fatalf("方块实体读回应保留进度: %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestUnlockAndDiscover(t *testing.T) {
|
||
s := newTestSession(t)
|
||
s.Inv.Slots[1] = item.Stack{Name: "oak_planks", Count: 64}
|
||
s.DiscoverCraftable()
|
||
if !s.RecipeUnlocked("crafting_table") && !s.RecipeUnlocked("stick") {
|
||
t.Fatal("背包有大量木板应解锁工作台或木棍")
|
||
}
|
||
s.UnlockRecipe("chest")
|
||
if !s.RecipeUnlocked("chest") {
|
||
t.Fatal("UnlockRecipe 应写入")
|
||
}
|
||
list := s.ExportUnlocked()
|
||
s.ImportUnlocked(list)
|
||
if !s.RecipeUnlocked("chest") {
|
||
t.Fatal("解锁列表读回应保留")
|
||
}
|
||
}
|
||
|
||
func TestCloseInvCraftNoLoss(t *testing.T) {
|
||
s := newTestSession(t)
|
||
s.InvCraftClick(0, item.Stack{Name: "oak_log", Count: 3}, false)
|
||
s.CloseInvCraft()
|
||
have := 0
|
||
for _, st := range s.Inv.Slots {
|
||
if st.Name == "oak_log" {
|
||
have += int(st.Count)
|
||
}
|
||
}
|
||
if have != 3 {
|
||
t.Fatalf("关闭物品栏不应丢原木,实际 %d", have)
|
||
}
|
||
}
|
||
|
||
func TestToolDurabilityBreaksAtZero(t *testing.T) {
|
||
s := newTestSession(t)
|
||
s.Inv.Slots[0] = item.Stack{Name: "diamond_pickaxe", Count: 1, Durability: 1}
|
||
s.Inv.Slots[1] = item.Stack{}
|
||
s.BeginBreak()
|
||
if !s.breaking {
|
||
t.Fatal("应锁定破坏目标")
|
||
}
|
||
for s.breaking {
|
||
s.Tick(1.0 / 20)
|
||
}
|
||
if !s.Inv.Slots[0].Empty() {
|
||
t.Fatalf("耐久归零应销毁工具,实际 %+v", s.Inv.Slots[0])
|
||
}
|
||
}
|
||
|
||
func TestHungerDrainAndStarve(t *testing.T) {
|
||
s := newTestSession(t)
|
||
s.Hunger = 20
|
||
s.Saturation = 0
|
||
s.AddExhaustion(4)
|
||
if s.Hunger != 19 {
|
||
t.Fatalf("4 点消耗应扣 1 饥饿,实际 %d", s.Hunger)
|
||
}
|
||
s.Hunger = 0
|
||
s.Health = 5
|
||
s.starveAcc = 0
|
||
for i := 0; i < 85; i++ { // ≥4 秒
|
||
s.Tick(1.0 / 20)
|
||
}
|
||
if s.Health >= 5 {
|
||
t.Fatalf("饥饿 0 应掉血,实际 %d", s.Health)
|
||
}
|
||
}
|
||
|
||
func TestDeathDoesNotInstantRespawn(t *testing.T) {
|
||
s := newTestSession(t)
|
||
s.Spawn = s.Player.Pos
|
||
s.Health = 1
|
||
s.kill("测试")
|
||
if !s.IsDead() || s.Health != 0 {
|
||
t.Fatal("致死应进入死亡状态且生命为 0")
|
||
}
|
||
pos := s.Player.Pos
|
||
s.Tick(1.0 / 20)
|
||
if s.Player.Pos != pos {
|
||
t.Fatal("死亡后不应瞬移")
|
||
}
|
||
if s.Health != 0 {
|
||
t.Fatalf("未点重生前生命应为 0,实际 %d", s.Health)
|
||
}
|
||
s.Respawn()
|
||
if s.IsDead() || s.Health != 20 {
|
||
t.Fatal("重生应满血并清除死亡")
|
||
}
|
||
}
|
||
|
||
func TestTickAnimalWired(t *testing.T) {
|
||
s := newTestSession(t)
|
||
e := s.Entities.Spawn(entity.KindAnimal, s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2])
|
||
e.Data = &entity.Animal{Species: 0, BreedCooldown: 3}
|
||
s.Entities.Tick(1.0/20, s.World, s.Player.Pos)
|
||
if e.Data.(*entity.Animal).BreedCooldown != 2 {
|
||
t.Fatalf("TickAnimal 应扣冷却,实际 %d", e.Data.(*entity.Animal).BreedCooldown)
|
||
}
|
||
}
|
||
|
||
func TestExecuteTrade(t *testing.T) {
|
||
s := newTestSession(t)
|
||
tbl, err := entity.LoadTrades(filepath.Join("..", "..", "assets", "config", "villager_trades.json"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
s.Trades = tbl
|
||
v := s.Entities.Spawn(entity.KindVillager, s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2])
|
||
v.Data = &entity.Villager{Profession: 0}
|
||
s.tradeOpen = true
|
||
s.tradeID = v.ID
|
||
s.Inv.Slots[2] = item.Stack{Name: "wheat", Count: 20}
|
||
if !s.ExecuteTrade(0) {
|
||
t.Fatal("20 小麦应能换腐肉")
|
||
}
|
||
if s.Inv.CountItem("rotten_flesh") < 1 {
|
||
t.Fatal("应得到腐肉")
|
||
}
|
||
if s.Inv.CountItem("wheat") != 0 {
|
||
t.Fatalf("小麦应耗尽,剩余 %d", s.Inv.CountItem("wheat"))
|
||
}
|
||
}
|
||
|
||
func TestLeverPowersDust(t *testing.T) {
|
||
s := newTestSession(t)
|
||
dust, ok1 := s.Reg.ID("redstone_dust")
|
||
lever, ok2 := s.Reg.ID("lever")
|
||
if !ok1 || !ok2 {
|
||
t.Fatal("缺少红石尘/拉杆方块")
|
||
}
|
||
y := int32(s.Player.Pos[1])
|
||
s.World.SetBlock(8, y, 8, block.NewState(lever, 1))
|
||
s.World.SetBlock(9, y, 8, block.NewState(dust, 0))
|
||
s.rsDirty = true
|
||
s.refreshWires()
|
||
if s.World.Block(9, y, 8).Meta() == 0 {
|
||
t.Fatal("开启的拉杆应给相邻红石尘充能")
|
||
}
|
||
}
|