package game // Regression tests for the second hardening pass: transactional trigger // effects, ammo preservation on death, and safespot clearing on teleport. import ( "path/filepath" "testing" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/item" "thehouseoficarus/internal/player" ) func seedBatchItems(t *testing.T, dir string) { t.Helper() writeSeedFile(t, filepath.Join(dir, "items", "cowhide.yaml"), []byte("id: cowhide\nname: cowhide\nvalue: 1\n")) writeSeedFile(t, filepath.Join(dir, "items", "leather.yaml"), []byte("id: leather\nname: leather\nvalue: 1\nstackable: true\n")) writeSeedFile(t, filepath.Join(dir, "items", "rock.yaml"), []byte("id: rock\nname: rock\nvalue: 1\n")) writeSeedFile(t, filepath.Join(dir, "items", "arrow.yaml"), []byte("id: arrow\nname: arrow\nvalue: 1\nstackable: true\n")) writeSeedFile(t, filepath.Join(dir, "items", "helm.yaml"), []byte("id: helm\nname: helm\nvalue: 100\n")) writeSeedFile(t, filepath.Join(dir, "items", "body.yaml"), []byte("id: body\nname: body\nvalue: 100\n")) writeSeedFile(t, filepath.Join(dir, "items", "sword.yaml"), []byte("id: sword\nname: sword\nvalue: 100\n")) } // TestApplyStepUnaffordablePurchaseIsAtomic: a step that both sets a flag and // charges credits must apply nothing when the player cannot afford it. func TestApplyStepUnaffordablePurchaseIsAtomic(t *testing.T) { h := newHarness(t) seedBatchItems(t, h.dir) h.g.ItemStore.Reload(h.dir) h.seedAccount("alice") h.seedCharacter("alice", "alice") sess, _ := h.connect("alice") p := sess.Player p.Credits = 0 p.Flags = map[string]any{} step := &behavior.Step{Credits: -10, SetPlayerFlags: map[string]any{"bought": true}} if h.g.applyStep(sess, p, step, p.RoomID, scopePlayer, nil) { t.Fatal("unaffordable purchase reported success") } if _, ok := p.Flags["bought"]; ok { t.Fatal("flag granted on a failed purchase") } if p.Credits != 0 { t.Fatalf("credits changed on a failed purchase: %d", p.Credits) } } // TestApplyStepFullInventoryTradeIsAtomic: a take+give+charge step with no // room for the reward must consume nothing and charge nothing. func TestApplyStepFullInventoryTradeIsAtomic(t *testing.T) { h := newHarness(t) seedBatchItems(t, h.dir) h.g.ItemStore.Reload(h.dir) h.seedAccount("alice") h.seedCharacter("alice", "alice") sess, _ := h.connect("alice") p := sess.Player p.Inventory = make(map[int]*player.InventorySlot) p.SetInvSlot(0, &player.InventorySlot{ItemID: "cowhide", Quantity: 1}) for i := 1; i < 28; i++ { p.SetInvSlot(i, &player.InventorySlot{ItemID: "rock", Quantity: 1}) } p.Credits = 100 step := &behavior.Step{TakeItem: "cowhide", GiveItem: "leather", Credits: -10} if h.g.applyStep(sess, p, step, p.RoomID, scopePlayer, nil) { t.Fatal("full-inventory trade reported success") } if !p.HasItem("cowhide") { t.Fatal("cowhide consumed on a failed trade") } if p.HasItem("leather") { t.Fatal("leather delivered on a failed trade") } if p.Credits != 100 { t.Fatalf("credits charged on a failed trade: %d", p.Credits) } } // TestApplyStepStackableRewardMergesWhenFull: a stackable reward that fits an // existing stack must still be delivered when every slot is occupied. func TestApplyStepStackableRewardMergesWhenFull(t *testing.T) { h := newHarness(t) seedBatchItems(t, h.dir) h.g.ItemStore.Reload(h.dir) h.seedAccount("alice") h.seedCharacter("alice", "alice") sess, _ := h.connect("alice") p := sess.Player p.Inventory = make(map[int]*player.InventorySlot) p.SetInvSlot(0, &player.InventorySlot{ItemID: "leather", Quantity: 1}) for i := 1; i < 28; i++ { p.SetInvSlot(i, &player.InventorySlot{ItemID: "rock", Quantity: 1}) } if !h.g.applyStep(sess, p, &behavior.Step{GiveItem: "leather"}, p.RoomID, scopePlayer, nil) { t.Fatal("stackable reward should merge into a full inventory") } if slot := p.InvSlot(0); slot == nil || slot.Quantity != 2 { t.Fatalf("reward did not merge into the existing stack: %+v", slot) } } // TestDropItemsOnDeathPreservesAmmoStack: an equipped ammo stack must be // dropped in full (and AmmoQty cleared), not silently reduced to one unit. func TestDropItemsOnDeathPreservesAmmoStack(t *testing.T) { h := newHarness(t) seedBatchItems(t, h.dir) h.g.ItemStore.Reload(h.dir) p := player.New("alice") p.RoomID = 1 p.Credits = 0 p.Equipment = map[item.EquipSlot]string{ item.SlotAmmo: "arrow", item.SlotHead: "helm", item.SlotTorso: "body", item.SlotMainHand: "sword", } p.AmmoQty = 50 h.g.RunOnOwner(func() { h.g.dropItemsOnDeath(p) }) if p.AmmoQty != 0 { t.Fatalf("AmmoQty not cleared after the stack dropped: %d", p.AmmoQty) } ground := h.g.World.GroundItems(p.RoomID) if ground["arrow"] != 50 { t.Fatalf("ammo stack not preserved on the ground: %+v", ground) } } // TestTeleportPlayerClearsSafespot: cover immunity must not follow a teleport // into a room with no cover. func TestTeleportPlayerClearsSafespot(t *testing.T) { h := newHarness(t) h.seedAccount("alice") h.seedCharacter("alice", "alice") sess, _ := h.connect("alice") p := sess.Player h.g.safespot.Set(p.Name, SafespotState{Active: true, RoomID: p.RoomID, ObjectDefID: "rock"}) h.g.RunOnOwner(func() { h.g.teleportPlayer(sess, p, 2) }) if h.g.safespot.Has(p.Name) { t.Fatal("safespot survived a teleport") } if p.RoomID != 2 { t.Fatalf("room after teleport = %d, want 2", p.RoomID) } }