package game // Ingredient-alternative resolution regressions: availability checks and // consumption must resolve each ingredient entry identically (the FIRST // alternative covering the entry's full quantity), consumption must never // partially consume an alternative, and byproducts must match the // alternative that was actually consumed. Before the fix, the availability // check required full quantity of ONE alternative while consumption // debited the first alternative with ANY stock and ignored a failed // RemoveItem — a player holding 1 gold bar and 2 silver bars for a // "2 gold OR 2 silver" recipe kept both silver and lost the gold. import ( "testing" "thehouseoficarus/internal/item" "thehouseoficarus/internal/player" ) // mixedCraft is one batch of "2 gold OR 2 silver + 1 flux" with byproducts // keyed to the alternative actually used. func mixedCraft() item.CraftDef { return item.CraftDef{ Type: "smithing", Ingredients: []item.IngredientEntry{ {Items: []string{"gold_bar", "silver_bar"}, Quantity: 2, Byproducts: []string{"gold_dust", "silver_dust"}}, {Items: []string{"flux"}, Quantity: 1}, }, } } func invOf(t *testing.T, items map[string]int) *player.Player { t.Helper() p := player.New("tester") for id, qty := range items { if qty <= 0 { continue } slot := p.FirstFreeSlot() if slot < 0 { t.Fatalf("inventory overflow seeding %s", id) } p.SetInvSlot(slot, &player.InventorySlot{ItemID: id, Quantity: qty}) } return p } func TestCraftHasAllItemsQtyRequiresFullQuantityOfOneAlternative(t *testing.T) { c := mixedCraft() // One silver covers the full quantity; the lone gold is irrelevant. if !craftHasAllItemsQty(&c, invOf(t, map[string]int{"gold_bar": 1, "silver_bar": 2, "flux": 1}).CountItem) { t.Error("2 silver + 1 flux should satisfy a 2-gold-OR-2-silver recipe") } // No alternative covers the gold/silver entry, even when both are // partially stocked. if craftHasAllItemsQty(&c, invOf(t, map[string]int{"gold_bar": 1, "silver_bar": 1, "flux": 1}).CountItem) { t.Error("1 gold + 1 silver must NOT satisfy the entry") } } func TestCraftHasAllItemsQtyRejectsShortStock(t *testing.T) { c := &item.CraftDef{Ingredients: []item.IngredientEntry{{Items: []string{"feather"}, Quantity: 15}}} if craftHasAllItemsQty(c, invOf(t, map[string]int{"feather": 14}).CountItem) { t.Error("14 of 15 required feathers must not satisfy the recipe") } if !craftHasAllItemsQty(c, invOf(t, map[string]int{"feather": 15}).CountItem) { t.Error("15 feathers should satisfy the recipe") } } func TestConsumeIngredientsUsesTheVerifiedAlternative(t *testing.T) { c := mixedCraft() p := invOf(t, map[string]int{"gold_bar": 1, "silver_bar": 2, "flux": 1}) if !consumeIngredients(&c, p.CountItem, p.RemoveItem) { t.Fatal("consumeIngredients refused a recipe the availability check passes") } if got := p.CountItem("silver_bar"); got != 0 { t.Errorf("silver_bar = %d, want 0 (the alternative covering the full quantity)", got) } if got := p.CountItem("gold_bar"); got != 1 { t.Errorf("gold_bar = %d, want 1 (the short alternative must be untouched)", got) } if got := p.CountItem("flux"); got != 0 { t.Errorf("flux = %d, want 0", got) } } func TestConsumeIngredientsNeverPartiallyConsumes(t *testing.T) { c := &item.CraftDef{Ingredients: []item.IngredientEntry{ {Items: []string{"gold_bar"}, Quantity: 1}, {Items: []string{"silver_bar"}, Quantity: 2}, }} p := invOf(t, map[string]int{"gold_bar": 1, "silver_bar": 1}) // The second entry cannot resolve: NOTHING may be removed. if consumeIngredients(c, p.CountItem, p.RemoveItem) { t.Fatal("consumeIngredients succeeded with a missing ingredient") } if got := p.CountItem("gold_bar"); got != 1 { t.Errorf("gold_bar = %d, want 1 (nothing may be consumed on a failed resolve)", got) } } func TestCollectByproductsMatchesConsumedAlternative(t *testing.T) { g := &Game{} craft := &item.CraftDef{Ingredients: []item.IngredientEntry{ {Items: []string{"bucket_of_water", "jug_of_water"}, Quantity: 1, Byproducts: []string{"empty_bucket", "empty_jug"}}, }} // Only the jug is present: the empty jug returns. p := invOf(t, map[string]int{"jug_of_water": 1}) if got := g.collectByproducts(p, craft); len(got) != 1 || got[0] != "empty_jug" { t.Errorf("byproducts = %v, want [empty_jug]", got) } // Both present: consumption picks the FIRST alternative (the bucket), // so the byproduct must be the empty bucket. p = invOf(t, map[string]int{"bucket_of_water": 1, "jug_of_water": 1}) if got := g.collectByproducts(p, craft); len(got) != 1 || got[0] != "empty_bucket" { t.Errorf("byproducts = %v, want [empty_bucket]", got) } }