diff options
Diffstat (limited to 'internal/game/production_ingredients_test.go')
| -rw-r--r-- | internal/game/production_ingredients_test.go | 124 |
1 files changed, 124 insertions, 0 deletions
diff --git a/internal/game/production_ingredients_test.go b/internal/game/production_ingredients_test.go new file mode 100644 index 0000000..d799df8 --- /dev/null +++ b/internal/game/production_ingredients_test.go @@ -0,0 +1,124 @@ +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, &item.ItemDef{Craft: item.CraftList{*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, &item.ItemDef{Craft: item.CraftList{*craft}}); len(got) != 1 || got[0] != "empty_bucket" { + t.Errorf("byproducts = %v, want [empty_bucket]", got) + } +} |
