package game // End-to-end regression: a combine craft with alternative ingredients must // consume the alternative the availability check verified. Before the fix, // combine consumption debited the first alternative with ANY stock and // ignored a failed RemoveItem: a player holding a short first alternative // and a full second one produced output while keeping the second // alternative and losing the short first one. import ( "os" "path/filepath" "testing" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) const testDoughYAML = `id: test_dough name: test dough stackable: false value: 2 craft: - type: combine ingredients: - items: - pot_of_flour quantity: 1 - items: - bucket_of_water - jug_of_water quantity: 2 byproducts: - empty_bucket - empty_jug output_qty: 1 ` // Minimal ingredient definitions so startup validation resolves the recipe. var testDoughIngredients = map[string]string{ "pot_of_flour": "pot of flour", "bucket_of_water": "bucket of water", "jug_of_water": "jug of water", "empty_bucket": "empty bucket", "empty_jug": "empty jug", } func seedTestDoughWorld(t *testing.T, dir string) { t.Helper() seedWorld(t, dir) for id, name := range testDoughIngredients { path := filepath.Join(dir, "items", id+".yaml") if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatal(err) } data := "id: " + id + "\nname: " + name + "\nstackable: false\nvalue: 1\n" if err := os.WriteFile(path, []byte(data), 0644); err != nil { t.Fatal(err) } } path := filepath.Join(dir, "items", "test_dough.yaml") if err := os.WriteFile(path, []byte(testDoughYAML), 0644); err != nil { t.Fatal(err) } } func TestCombineAlternativesConsumeVerifiedSource(t *testing.T) { dir := t.TempDir() seedTestDoughWorld(t, dir) h := harnessFromDir(t, dir) h.seedAccount("alice") h.seedCharacter("alice", "alice") // Ingredients and byproducts were seeded as real item definitions. h.g.RunOnOwner(func() { p, err := h.g.AccountStore.LoadCharacter("alice") if err != nil { t.Error(err) return } p.SetInvSlot(0, &player.InventorySlot{ItemID: "pot_of_flour", Quantity: 1}) p.SetInvSlot(1, &player.InventorySlot{ItemID: "bucket_of_water", Quantity: 1}) p.SetInvSlot(2, &player.InventorySlot{ItemID: "jug_of_water", Quantity: 2}) dough, _ := h.g.ItemStore.Load("test_dough") if dough == nil || dough.FirstCraft() == nil { t.Error("test_dough recipe missing from the craft index") return } conn := newChanConn() sess := &net.Session{Conn: conn, Player: p} sess.SetState(net.StateGame) h.g.startCombine(sess, p, dough, dough.FirstCraft(), 1, 0) if p.Action == nil { t.Error("combine action did not start") return } // Phase 0 announces, phase 1 completes steps, phase 2 consumes and // produces. h.g.advanceCombine(sess, p) // phase 0 -> 1 h.g.advanceCombine(sess, p) // phase 1 -> 2 h.g.advanceCombine(sess, p) // phase 2: consume + output if got := p.CountItem("test_dough"); got != 1 { t.Errorf("test_dough = %d, want 1", got) } // The verified alternative (the jug, 2 >= 2) is consumed in full; // the short bucket stays untouched. if got := p.CountItem("jug_of_water"); got != 0 { t.Errorf("jug_of_water = %d, want 0", got) } if got := p.CountItem("bucket_of_water"); got != 1 { t.Errorf("bucket_of_water = %d, want 1 (short alternative untouched)", got) } if got := p.CountItem("pot_of_flour"); got != 0 { t.Errorf("pot_of_flour = %d, want 0", got) } // The byproduct matches the consumed alternative. if got := p.CountItem("empty_jug"); got != 1 { t.Errorf("empty_jug = %d, want 1", got) } }) } func TestCombineAlternativesRefuseShortStock(t *testing.T) { dir := t.TempDir() seedTestDoughWorld(t, dir) h := harnessFromDir(t, dir) h.seedAccount("alice") h.seedCharacter("alice", "alice") h.g.RunOnOwner(func() { p, err := h.g.AccountStore.LoadCharacter("alice") if err != nil { t.Error(err) return } // 1 bucket + 1 jug: neither alternative covers quantity 2. p.SetInvSlot(0, &player.InventorySlot{ItemID: "pot_of_flour", Quantity: 1}) p.SetInvSlot(1, &player.InventorySlot{ItemID: "bucket_of_water", Quantity: 1}) p.SetInvSlot(2, &player.InventorySlot{ItemID: "jug_of_water", Quantity: 1}) dough, _ := h.g.ItemStore.Load("test_dough") if dough == nil { t.Error("test_dough missing") return } if craftHasAllItemsQty(dough.FirstCraft(), p.CountItem) { t.Error("1 bucket + 1 jug must not satisfy a quantity-2 water entry") } }) }