From 32a3c59cebf2a16277e54d715a67adfdbe00b456 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 17 Sep 2026 22:00:33 -0400 Subject: fix: exact casino settlement/refund coverage, journal damage handling, craft output ordering, aggregate ingredient planning --- internal/game/production_output_regression_test.go | 186 +++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 internal/game/production_output_regression_test.go (limited to 'internal/game/production_output_regression_test.go') 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) + } + } +} -- cgit v1.2.3