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

918 lines
25 KiB
Go
Raw Permalink 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 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("开启的拉杆应给相邻红石尘充能")
}
}