package game // Regression: failed-craft delivery used FirstFreeSlot only, while the // pre-roll fit gate (craftFailFits -> craftOutputFits) counts a matching // stack. A stackable fail replacement could therefore be destroyed on a full // inventory even though the gate promised it would fit. Delivery must merge // like craft/harvest/pickup does. import ( "path/filepath" "testing" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) const polishFailRecipeYAML = `id: polish_out name: polish out stackable: true craft: - type: smithing subtype: smelting fail: polish_ash ingredients: - items: - polish_ore quantity: 1 success: base: 0 cap: 0 per_level: 0 output_qty: 1 ticks_per_cycle: 1 ` func seedPolishFailItems(t *testing.T, dir string) { t.Helper() writeSeedFile(t, filepath.Join(dir, "items", "polish_out.yaml"), []byte(polishFailRecipeYAML)) writeSeedFile(t, filepath.Join(dir, "items", "polish_ash.yaml"), []byte("id: polish_ash\nname: polish ash\nstackable: true\nvalue: 1\n")) writeSeedFile(t, filepath.Join(dir, "items", "polish_ore.yaml"), []byte("id: polish_ore\nname: polish ore\nstackable: true\nvalue: 1\n")) } func TestPlaceCraftFailMergesIntoMatchingStack(t *testing.T) { h := newHarness(t) seedPolishFailItems(t, h.dir) p := player.New("alice") h.g.RunOnOwner(func() { h.g.ItemStore.Reload(h.dir) p.SetInvSlot(0, &player.InventorySlot{ItemID: "polish_ash", Quantity: 3}) fillInventoryExcept(p) h.g.placeCraftFail(p, &item.CraftDef{Fail: "polish_ash"}) if slot := p.InvSlot(0); slot == nil || slot.Quantity != 4 { t.Fatalf("fail replacement was not merged into the matching stack: %+v", p.InvSlot(0)) } }) } func TestCraftFailureMergesFailStackWhenFull(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") seedPolishFailItems(t, h.dir) sess := &net.Session{Conn: newChanConn(), Player: p} sess.SetState(net.StateGame) h.g.RunOnOwner(func() { h.g.ItemStore.Reload(h.dir) // Full inventory: a matching output stack, a matching fail stack and // the ingredient. Consuming the ingredient frees its slot, so the old // FirstFreeSlot delivery landed the fail item there as a second stack. p.SetInvSlot(0, &player.InventorySlot{ItemID: "polish_ash", Quantity: 1}) p.SetInvSlot(1, &player.InventorySlot{ItemID: "polish_ore", Quantity: 1}) p.SetInvSlot(2, &player.InventorySlot{ItemID: "polish_out", Quantity: 1}) for i := 3; i < 28; i++ { p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1}) } p.Action = &behavior.Action{ Type: behavior.TypeSmelt, TargetID: "polish_out", Data: &behavior.ProductionData{ItemID: "polish_out", Phase: 1, Remaining: 1}, } h.g.advanceProduction(sess, p) if got := p.CountItem("polish_ore"); got != 0 { t.Errorf("ingredient = %d, want 0 (consumed)", got) } if got := p.CountItem("polish_ash"); got != 2 { t.Errorf("fail product = %d, want 2", got) } stacks := 0 for i := 0; i < 28; i++ { if s := p.InvSlot(i); s != nil && s.ItemID == "polish_ash" { stacks++ } } if stacks != 1 { t.Errorf("fail product was delivered as %d stacks, want 1 merged stack", stacks) } if slot := p.InvSlot(0); slot == nil || slot.Quantity != 2 { t.Errorf("matching fail stack = %+v, want quantity 2", p.InvSlot(0)) } }) }