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) } } }