aboutsummaryrefslogtreecommitdiff
path: root/internal/game/production_output_regression_test.go
blob: cee028dd2e9f164f3ebff6a92386bfa226196c84 (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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
package game

// Crafting-output regressions: a full inventory must never consume
// materials without producing the output, and recipes whose entries share
// the same item must reserve aggregate quantities (two one-ore entries need
// two ore), never double-count one stack.

import (
	"os"
	"path/filepath"
	"testing"

	"thehouseoficarus/internal/item"
	"thehouseoficarus/internal/net"
	"thehouseoficarus/internal/player"
)

const headlessArrowYAML = `id: headless_arrow
name: headless arrow
stackable: true
value: 1
craft:
  - ingredients:
      - items:
          - arrow_shaft
        quantity: 15
      - items:
          - feather
        quantity: 15
    level: 1
    output_qty: 15
    type: fletching
    ticks_per_cycle: 2
    xp: 1
`

func seedArrowWorld(t *testing.T, dir string) {
	t.Helper()
	seedWorld(t, dir)
	defs := map[string]string{
		"arrow_shaft":    "id: arrow_shaft\nname: arrow shaft\nstackable: true\nvalue: 1\n",
		"feather":        "id: feather\nname: feather\nstackable: true\nvalue: 1\n",
		"headless_arrow": headlessArrowYAML,
	}
	for id, data := range defs {
		path := filepath.Join(dir, "items", id+".yaml")
		if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(path, []byte(data), 0600); err != nil {
			t.Fatal(err)
		}
	}
}

// TestFullInventoryFletchPreservesMaterials: a full inventory must refuse
// the craft ("too full") WITHOUT consuming the ingredients — before the
// fix, the 15-shaft/15-feather batch was consumed and the output silently
// dropped.
func TestFullInventoryFletchPreservesMaterials(t *testing.T) {
	dir := t.TempDir()
	seedArrowWorld(t, dir)
	h := harnessFromDir(t, dir)
	h.seedAccount("alice")
	p := h.seedCharacter("alice", "alice")
	h.g.RunOnOwner(func() {
		p.SetInvSlot(0, &player.InventorySlot{ItemID: "arrow_shaft", Quantity: 30})
		p.SetInvSlot(1, &player.InventorySlot{ItemID: "feather", Quantity: 30})
		for i := 2; i < 28; i++ {
			p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1})
		}
		def, err := h.g.ItemStore.Load("headless_arrow")
		if err != nil {
			t.Error(err)
			return
		}
		if !h.g.canDoFletchItem(p, def) {
			t.Error("recipe unexpectedly unavailable")
			return
		}
		sess := &net.Session{Conn: newChanConn(), Player: p}
		sess.SetState(net.StateGame)
		h.g.startFletchAction(sess, p, def, 1)
		// The timed fletch advances: phase 0 announces, phase 1 crafts.
		h.g.advanceFletch(sess, p)
		h.g.advanceFletch(sess, p)
		if p.CountItem("arrow_shaft") != 30 || p.CountItem("feather") != 30 {
			t.Errorf("materials consumed with no output: shafts=%d feathers=%d; want 30 each",
				p.CountItem("arrow_shaft"), p.CountItem("feather"))
		}
		if p.CountItem("headless_arrow") != 0 {
			t.Errorf("output produced into a full inventory: %d", p.CountItem("headless_arrow"))
		}
	})
}

// TestFullInventoryStackMergeStillCrafts: a full inventory may still craft
// when the output merges into an existing stack.
func TestFullInventoryStackMergeStillCrafts(t *testing.T) {
	dir := t.TempDir()
	seedArrowWorld(t, dir)
	h := harnessFromDir(t, dir)
	h.seedAccount("alice")
	p := h.seedCharacter("alice", "alice")
	h.g.RunOnOwner(func() {
		p.SetInvSlot(0, &player.InventorySlot{ItemID: "arrow_shaft", Quantity: 30})
		p.SetInvSlot(1, &player.InventorySlot{ItemID: "feather", Quantity: 30})
		p.SetInvSlot(2, &player.InventorySlot{ItemID: "headless_arrow", Quantity: 5})
		for i := 3; i < 28; i++ {
			p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1})
		}
		def, err := h.g.ItemStore.Load("headless_arrow")
		if err != nil {
			t.Error(err)
			return
		}
		sess := &net.Session{Conn: newChanConn(), Player: p}
		sess.SetState(net.StateGame)
		h.g.startFletchAction(sess, p, def, 1)
		h.g.advanceFletch(sess, p)
		h.g.advanceFletch(sess, p)
		if got := p.CountItem("headless_arrow"); got != 20 {
			t.Errorf("headless_arrow = %d, want 20 (5 existing + 15 new, merged)", got)
		}
		if p.CountItem("arrow_shaft") != 15 || p.CountItem("feather") != 15 {
			t.Errorf("ingredients not consumed: shafts=%d feathers=%d", p.CountItem("arrow_shaft"), p.CountItem("feather"))
		}
	})
}

// TestOverlappingRecipeReservesAggregateStock: two ingredient entries
// listing the same item cannot both consume one unit of it.
func TestOverlappingRecipeReservesAggregateStock(t *testing.T) {
	p := player.New("alice")
	p.SetInvSlot(0, &player.InventorySlot{ItemID: "ore", Quantity: 1})
	c := &item.CraftDef{Ingredients: []item.IngredientEntry{
		{Items: []string{"ore"}, Quantity: 1},
		{Items: []string{"ore"}, Quantity: 1},
	}}
	if craftHasAllItemsQty(c, p.CountItem) {
		t.Error("one ore accepted for two one-ore entries")
	}
	if consumeIngredients(c, p.CountItem, p.RemoveItem) {
		t.Error("consumption reports success despite insufficient aggregate stock")
	}
	if p.CountItem("ore") != 1 {
		t.Error("a refused recipe removed partial materials")
	}
}

// TestOverlappingRecipeReservesAcrossEntries: reservations apply across
// differing alternative lists — two one-water entries need TWO water-like
// items, and the plan consumes distinct ones, never one twice.
func TestOverlappingRecipeReservesAcrossEntries(t *testing.T) {
	// Only one water-carrying item: the second entry cannot be satisfied.
	p := player.New("alice")
	p.SetInvSlot(0, &player.InventorySlot{ItemID: "clay", Quantity: 1})
	p.SetInvSlot(1, &player.InventorySlot{ItemID: "bucket_of_water", Quantity: 1})
	c := &item.CraftDef{Ingredients: []item.IngredientEntry{
		{Items: []string{"clay"}, Quantity: 1},
		{Items: []string{"water", "bucket_of_water"}, Quantity: 1},
		{Items: []string{"bucket_of_water", "water"}, Quantity: 1},
	}}
	if planIngredients(c, p.CountItem) != nil {
		t.Error("one water unit accepted for two one-water entries")
	}
	if p.CountItem("clay") != 1 || p.CountItem("bucket_of_water") != 1 {
		t.Error("availability planning mutated inventory")
	}

	// Both water items present: the plan consumes each exactly once.
	p.SetInvSlot(2, &player.InventorySlot{ItemID: "water", Quantity: 1})
	plan := planIngredients(c, p.CountItem)
	if plan == nil {
		t.Fatal("clay + bucket + water must satisfy the recipe")
	}
	chosen := map[string]int{}
	for _, s := range plan {
		chosen[s.id]++
	}
	for id, want := range map[string]int{"clay": 1, "bucket_of_water": 1, "water": 1} {
		if chosen[id] != want {
			t.Errorf("plan chose %s %d times, want %d (plan=%+v)", id, chosen[id], want, plan)
		}
	}
}