aboutsummaryrefslogtreecommitdiff
path: root/internal/game/production_ingredients_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-16 21:35:55 -0400
committerhistoria <[not public]>2026-09-16 21:35:55 -0400
commitfea2cc6839ec3ee17940a2dea69622dc6f4deabf (patch)
tree9d893525ca20d2cc920e9e866f84f71b605d8ae9 /internal/game/production_ingredients_test.go
parent35ddd0ad2215760bb1d3dc7724480f179ea33fa6 (diff)
downloadthehouseoficarus-fea2cc6839ec3ee17940a2dea69622dc6f4deabf.tar.gz
fix: unify crafting ingredient resolution; fix superheat alternatives, quantity gates, and dropped combine byproducts
Diffstat (limited to 'internal/game/production_ingredients_test.go')
-rw-r--r--internal/game/production_ingredients_test.go124
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)
+ }
+}