aboutsummaryrefslogtreecommitdiff
path: root/internal/game/production_ingredients_test.go
blob: 520634684982625ea0c7479ee31ebe7979ba6b87 (plain)
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, 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)
	}
}