package game // Regression: returning the equipped ammo stack to the inventory must never // delete it. The equipment entry may only be cleared once the stack has been // merged into an inventory stack or placed in a free slot, so a full // inventory refuses the swap/removal instead of losing the ammo. The previous // code deleted Equipment[SlotAmmo] and zeroed AmmoQty unconditionally. import ( "path/filepath" "strings" "testing" "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) const ( hardeningArrowsID = "test_arrows" hardeningBoltsID = "test_bolts" ) // seedAmmoItems installs two stackable ammo items so a swap has a target. func seedAmmoItems(t *testing.T, h *harness) { t.Helper() writeSeedFile(t, filepath.Join(h.dir, "items", hardeningArrowsID+".yaml"), []byte("id: test_arrows\nname: test arrows\nstackable: true\nequipment:\n type: ammo\n slot: ammo\n")) writeSeedFile(t, filepath.Join(h.dir, "items", hardeningBoltsID+".yaml"), []byte("id: test_bolts\nname: test bolts\nstackable: true\nequipment:\n type: ammo\n slot: ammo\n")) } // fillAllSlots occupies every inventory slot and returns the session used by // the wear/remove paths. func fillAllSlots(p *player.Player) *net.Session { for i := 0; i < 28; i++ { p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1}) } sess := &net.Session{Conn: newChanConn(), Player: p} sess.SetState(net.StateGame) return sess } func TestUnequipAmmoSwapKeepsStackWhenInventoryFull(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") seedAmmoItems(t, h) h.g.RunOnOwner(func() { h.g.ItemStore.Reload(h.dir) sess := fillAllSlots(p) p.SetInvSlot(3, &player.InventorySlot{ItemID: hardeningBoltsID, Quantity: 7}) p.Equipment[item.SlotAmmo] = hardeningArrowsID p.AmmoQty = 42 h.g.doWear(sess, "test bolts") if got := p.Equipment[item.SlotAmmo]; got != hardeningArrowsID { t.Errorf("equipped ammo = %q, want %q: a refused swap must not clear equipment", got, hardeningArrowsID) } if p.AmmoQty != 42 { t.Errorf("AmmoQty = %d, want 42", p.AmmoQty) } bolts := p.InvSlot(3) if bolts == nil || bolts.ItemID != hardeningBoltsID || bolts.Quantity != 7 { t.Errorf("source stack changed by a refused swap: %+v", bolts) } if out := sess.Conn.(*chanConn).Bytes(); !strings.Contains(string(out), "inventory is full") { t.Errorf("expected a full-inventory message, got %q", out) } }) } func TestUnequipAmmoSwapMovesStackToFreeSlot(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") seedAmmoItems(t, h) h.g.RunOnOwner(func() { h.g.ItemStore.Reload(h.dir) sess := fillAllSlots(p) p.SetInvSlot(0, nil) p.SetInvSlot(3, &player.InventorySlot{ItemID: hardeningBoltsID, Quantity: 7}) p.Equipment[item.SlotAmmo] = hardeningArrowsID p.AmmoQty = 42 h.g.doWear(sess, "test bolts") if got := p.Equipment[item.SlotAmmo]; got != hardeningBoltsID { t.Fatalf("equipped ammo = %q, want %q", got, hardeningBoltsID) } if p.AmmoQty != 7 { t.Errorf("AmmoQty = %d, want 7", p.AmmoQty) } stored := p.InvSlot(0) if stored == nil || stored.ItemID != hardeningArrowsID || stored.Quantity != 42 { t.Fatalf("unequipped arrows not stored in the free slot: %+v", stored) } }) } func TestRemoveAllAmmoKeepsStackWhenInventoryFull(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") seedAmmoItems(t, h) h.g.RunOnOwner(func() { h.g.ItemStore.Reload(h.dir) sess := fillAllSlots(p) p.Equipment[item.SlotAmmo] = hardeningArrowsID p.AmmoQty = 42 h.g.removeAllAmmo(sess, p, hardeningArrowsID) if got := p.Equipment[item.SlotAmmo]; got != hardeningArrowsID { t.Errorf("equipped ammo = %q, want %q", got, hardeningArrowsID) } if p.AmmoQty != 42 { t.Errorf("AmmoQty = %d, want 42", p.AmmoQty) } out := string(sess.Conn.(*chanConn).Bytes()) if strings.Contains(out, "You remove") { t.Errorf("nothing was removed but the removal was reported: %q", out) } if !strings.Contains(out, "Nowhere to put that!") { t.Errorf("expected a failure message, got %q", out) } }) } func TestRemoveAllKeepsAmmoWhenInventoryFull(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") seedAmmoItems(t, h) h.g.RunOnOwner(func() { h.g.ItemStore.Reload(h.dir) sess := fillAllSlots(p) p.Equipment[item.SlotAmmo] = hardeningArrowsID p.AmmoQty = 42 h.g.doRemoveAll(sess) if got := p.Equipment[item.SlotAmmo]; got != hardeningArrowsID || p.AmmoQty != 42 { t.Errorf("remove all lost the equipped ammo: %q x%d", got, p.AmmoQty) } if got := p.CountItem(hardeningArrowsID); got != 0 { t.Errorf("remove all invented %d arrows", got) } }) } func TestRemoveAllAmmoMovesStackToFreeSlot(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") seedAmmoItems(t, h) h.g.RunOnOwner(func() { h.g.ItemStore.Reload(h.dir) sess := fillAllSlots(p) p.SetInvSlot(0, nil) p.Equipment[item.SlotAmmo] = hardeningArrowsID p.AmmoQty = 42 h.g.removeAllAmmo(sess, p, hardeningArrowsID) if _, ok := p.Equipment[item.SlotAmmo]; ok { t.Error("equipment entry survived a successful removal") } if p.AmmoQty != 0 { t.Errorf("AmmoQty = %d, want 0", p.AmmoQty) } stored := p.InvSlot(0) if stored == nil || stored.ItemID != hardeningArrowsID || stored.Quantity != 42 { t.Fatalf("removed arrows not stored in the free slot: %+v", stored) } if out := string(sess.Conn.(*chanConn).Bytes()); !strings.Contains(out, "You remove") { t.Errorf("removal not reported: %q", out) } }) }