package game // Regression: every stack-creation and stack-merge site must agree on the // Quality/MaxQuality a fresh stack gets (newInventorySlot / stackProps). // Pickups merge only into a stack with those exact properties, so a stack // produced by crafting, harvesting, or ammo removal with Quality 0 could no // longer be merged into - a full inventory then refused a pickup of an item // the player already carried. import ( "path/filepath" "testing" "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) const correctQualityItemYAML = `id: correct_quality_gem name: correct quality gem stackable: true max_quality: 5 craft: - type: crafting ingredients: - items: - correct_quality_shard quantity: 1 xp: 1 ticks_per_cycle: 1 ` func seedCorrectQualityItems(t *testing.T, h *harness) { t.Helper() writeSeedFile(t, filepath.Join(h.dir, "items", "correct_quality_gem.yaml"), []byte(correctQualityItemYAML)) writeSeedFile(t, filepath.Join(h.dir, "items", "correct_quality_shard.yaml"), []byte("id: correct_quality_shard\nname: correct quality shard\nstackable: true\nmax_quality: 5\nvalue: 1\n")) } // fillInventoryExcept occupies every slot but slot 0 with filler. func fillInventoryExcept(p *player.Player) { for i := 0; i < 28; i++ { if i != 0 { p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1}) } } } // TestStackPropsMatchNewInventorySlot guards the one rule that stack creation // and stack merging both depend on: the helper the craft/harvest/ammo sites // use must stamp what newInventorySlot stamps. func TestStackPropsMatchNewInventorySlot(t *testing.T) { h := newHarness(t) seedCorrectQualityItems(t, h) h.g.RunOnOwner(func() { h.g.ItemStore.Reload(h.dir) gem := h.g.newInventorySlot("correct_quality_gem", 1) if q, mq := stackProps(mustLoadItem(t, h.g, "correct_quality_gem")); q != gem.Quality || mq != gem.MaxQuality { t.Errorf("stackProps = (%d,%d), newInventorySlot = (%d,%d)", q, mq, gem.Quality, gem.MaxQuality) } shard := h.g.newInventorySlot("correct_quality_shard", 1) if q, mq := stackProps(mustLoadItem(t, h.g, "correct_quality_shard")); q != shard.Quality || mq != shard.MaxQuality { t.Errorf("quality-free stackProps = (%d,%d), newInventorySlot = (%d,%d)", q, mq, shard.Quality, shard.MaxQuality) } }) } func TestCraftOutputMergesIntoQualityStack(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") seedCorrectQualityItems(t, h) h.g.RunOnOwner(func() { h.g.ItemStore.Reload(h.dir) def := mustLoadItem(t, h.g, "correct_quality_gem") p.SetInvSlot(0, &player.InventorySlot{ItemID: def.ID, Quantity: 10, Quality: 5, MaxQuality: 5}) fillInventoryExcept(p) if !craftOutputFits(p, def.ID, def) { t.Fatal("a full inventory must accept a craft output that merges into an identical stack") } if !placeCraftOutput(p, def.ID, def, 2) { t.Fatal("craft output was not placed") } if slot := p.InvSlot(0); slot.Quantity != 12 || slot.Quality != 5 || slot.MaxQuality != 5 { t.Errorf("merged stack = %+v, want quantity 12 at quality 5", slot) } // A differently qualified stack is not a merge target: the same // full inventory must report that nothing fits. p.SetInvSlot(0, &player.InventorySlot{ItemID: def.ID, Quantity: 10, Quality: 1, MaxQuality: 5}) if craftOutputFits(p, def.ID, def) { t.Error("craft output would merge into a stack with a different quality") } if placeCraftOutput(p, def.ID, def, 2) { t.Error("craft output was placed with no free slot and no matching stack") } }) } func TestCraftOutputCreatesStackWithFreshProps(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") seedCorrectQualityItems(t, h) h.g.RunOnOwner(func() { h.g.ItemStore.Reload(h.dir) def := mustLoadItem(t, h.g, "correct_quality_gem") if !placeCraftOutput(p, def.ID, def, 3) { t.Fatal("craft output was not placed in an empty inventory") } slot := p.InvSlot(0) if slot == nil || slot.ItemID != def.ID { t.Fatalf("slot 0 = %+v", slot) } if q, mq := h.g.pickedStackProps(def.ID); slot.Quality != q || slot.MaxQuality != mq { t.Fatalf("crafted stack %+v does not match what a pickup receives (%d,%d)", slot, q, mq) } }) } func TestPickupMergesIntoHarvestedStackWhenFull(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") writeSeedFile(t, filepath.Join(h.dir, "items", hardeningCropID+".yaml"), []byte("id: test_potato\nname: test potato\nstackable: true\nmax_quality: 5\n")) conn := newChanConn() sess := &net.Session{Conn: conn, Player: p} sess.SetState(net.StateGame) h.g.RunOnOwner(func() { h.g.ItemStore.Reload(h.dir) readyHarvest(p, 3, 3) h.g.advanceHarvest(sess, p) stack := p.InvSlot(0) if stack == nil || stack.ItemID != hardeningCropID { t.Fatalf("harvested stack = %+v", stack) } quality, maxQuality := h.g.pickedStackProps(hardeningCropID) if stack.Quality != quality || stack.MaxQuality != maxQuality { t.Fatalf("harvested stack %+v does not match what a pickup receives (%d,%d)", stack, quality, maxQuality) } // A full inventory must still merge a ground pickup into the stack // the harvest just created. fillInventoryExcept(p) h.g.World.AddGroundItem(p.RoomID, hardeningCropID, 2) h.g.doGet(sess, "test potato") if got := p.CountItem(hardeningCropID); got != 5 { t.Errorf("potatoes carried = %d, want 5", got) } if got := h.g.World.GroundItems(p.RoomID)[hardeningCropID]; got != 0 { t.Errorf("ground potatoes = %d, want 0 (they fit the existing stack)", got) } }) } func TestPickupMergesIntoAmmoReturnedStack(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") seedCorrectQualityItems(t, h) conn := newChanConn() sess := &net.Session{Conn: conn, Player: p} sess.SetState(net.StateGame) h.g.RunOnOwner(func() { h.g.ItemStore.Reload(h.dir) p.Equipment[item.SlotAmmo] = "correct_quality_shard" p.AmmoQty = 4 if !h.g.unequipAmmo(p) { t.Fatal("unequipAmmo refused an empty inventory") } stack := p.InvSlot(0) if stack == nil || stack.ItemID != "correct_quality_shard" { t.Fatalf("returned ammo stack = %+v", stack) } quality, maxQuality := h.g.pickedStackProps("correct_quality_shard") if stack.Quality != quality || stack.MaxQuality != maxQuality { t.Fatalf("returned ammo stack %+v does not match what a pickup receives (%d,%d)", stack, quality, maxQuality) } fillInventoryExcept(p) h.g.World.AddGroundItem(p.RoomID, "correct_quality_shard", 3) h.g.doGet(sess, "correct quality shard") if got := p.CountItem("correct_quality_shard"); got != 7 { t.Errorf("shards carried = %d, want 7", got) } if got := h.g.World.GroundItems(p.RoomID)["correct_quality_shard"]; got != 0 { t.Errorf("ground shards = %d, want 0 (they fit the returned stack)", got) } }) }