aboutsummaryrefslogtreecommitdiff
path: root/internal/game/production_output_regression_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/production_output_regression_test.go')
-rw-r--r--internal/game/production_output_regression_test.go186
1 files changed, 186 insertions, 0 deletions
diff --git a/internal/game/production_output_regression_test.go b/internal/game/production_output_regression_test.go
new file mode 100644
index 0000000..cee028d
--- /dev/null
+++ b/internal/game/production_output_regression_test.go
@@ -0,0 +1,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)
+ }
+ }
+}