1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
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)
}
}
|