package game import ( "path/filepath" "testing" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/prediction" ) // Regression: gather verbs must normalize to the gather action type, otherwise // startAction's bhType != verb gate rejects mine/chop/fish/cut outright. func TestGatherVerbsNormalizeToGatherAction(t *testing.T) { for _, verb := range []string{"mine", "chop", "fish", "cut", "shear"} { if got := normalizeVerb(verb); got != string(behavior.TypeGather) { t.Errorf("normalizeVerb(%q) = %q, want %q", verb, got, behavior.TypeGather) } } } // Regression: an unbounded step count must be rejected before it allocates. func TestParseWalkDirectionsRejectsHugeCount(t *testing.T) { if _, err := parseWalkDirections("999999999n"); err == nil { t.Fatal("expected an error for an unbounded step count") } dirs, err := parseWalkDirections("3n2e") if err != nil { t.Fatalf("parseWalkDirections: %v", err) } if len(dirs) != 5 { t.Fatalf("len(dirs) = %d, want 5", len(dirs)) } } // Regression: collectPredictions must not mark resolved contracts collected // when the credit cap blocks the payout, or the winnings are lost forever. func TestCollectPredictionsKeepsContractsWhenCreditCapBlocks(t *testing.T) { h := newHarness(t) h.seedAccount("alice") h.seedCharacter("alice", "alice") sess, _ := h.connect("alice") sess.Player.PredictionContracts = []prediction.Contract{ {MarketID: "m1", Status: "resolved", Proceeds: 1000}, } sess.Player.Credits = casino.MaxCredits h.g.RunOnOwner(func() { h.g.collectPredictions(sess) }) if got := sess.Player.PredictionContracts[0].Status; got != "resolved" { t.Fatalf("credit-cap collect changed status to %q; the payout would be lost", got) } // With room under the cap, the same contract must still be collectable. sess.Player.Credits = 0 h.g.RunOnOwner(func() { h.g.collectPredictions(sess) }) if got := sess.Player.PredictionContracts[0].Status; got != "collected" { t.Fatalf("status = %q, want collected", got) } if sess.Player.Credits != 1000 { t.Fatalf("credits = %d, want 1000", sess.Player.Credits) } } // Regression: a craft with no ingredient entries must be unsatisfiable, not // "always satisfied" (which would loop producing output for free). func TestPlanIngredientsRejectsEmptyCraft(t *testing.T) { c := &item.CraftDef{Type: "fletch"} if plan := planIngredients(c, func(string) int { return 999 }); plan != nil { t.Fatalf("empty craft produced a plan: %+v", plan) } } // Regression: a command reached through a prefix/abbreviation must not have its // arguments lowercased (global/symbol/prompt/addlocalobj read rawInput). func TestGlobalCommandPreservesArgumentCase(t *testing.T) { h := newHarness(t) h.seedAccount("alice") h.seedCharacter("alice", "alice") h.seedAccount("bob") h.seedCharacter("bob", "bob") alice, _ := h.connect("alice") _, bobConn := h.connect("bob") h.g.RunOnOwner(func() { h.g.HandleSessionOnOwner(alice, "glo Hello World") }) if !bobConn.Contains("Hello World") { t.Fatalf("global broadcast lost argument case: %s", bobConn.Bytes()) } } // Regression: `get 5 credits` used to sweep the whole purse headroom. func TestPickupCreditsHonorsRequestedQuantity(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") h.g.RunOnOwner(func() { p.Credits = 0 h.g.World.AddGroundItem(p.RoomID, "credits", 10) sess := &net.Session{Player: p, Conn: newChanConn()} h.g.pickupCredits(sess, p, "credits", 5) if p.Credits != 5 { t.Errorf("credits = %d, want 5 (output: %s)", p.Credits, sess.Conn.(*chanConn).Bytes()) } }) } // Regression: the player-file classifier must canonicalize repeated "/" and // "data/" prefixes so a raw-editor path cannot dodge the online check. // Regression: availability scans every recipe, but execution used Craft[0], so // a player satisfying a later recipe (e.g. oak arrow shafts) got a start-then- // abort. usableCraftIndex must pick the satisfied recipe. func TestUsableCraftIndexSelectsSatisfiedRecipe(t *testing.T) { g := &Game{} p := player.New("alice") def := &item.ItemDef{ ID: "arrow_shaft", Craft: item.CraftList{ {Type: "fletching", Level: 1, Ingredients: []item.IngredientEntry{{Items: []string{"logs"}, Quantity: 1}}}, {Type: "fletching", Level: 1, Ingredients: []item.IngredientEntry{{Items: []string{"oak_logs"}, Quantity: 1}}}, }, } p.SetInvSlot(0, &player.InventorySlot{ItemID: "oak_logs", Quantity: 5}) if idx := g.usableCraftIndex(p, def); idx != 1 { t.Fatalf("usableCraftIndex = %d, want 1 (the satisfied oak recipe)", idx) } } // End-to-end gather: the missing coverage the audit called out. Start a mine // action on a seeded rock with a pickaxe and tick it to a drop. func TestGatherYieldsDrop(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") writeSeedFile(t, filepath.Join(h.dir, "objects", "copper_rock.yaml"), []byte("id: copper_rock\nname: copper rock\ngather:\n skill: mining\n tools: [pickaxe]\n success: {base: 1, cap: 1}\n gather_message: You mine the rock.\n drops:\n - item_id: copper_ore\n weight: 100\n")) writeSeedFile(t, filepath.Join(h.dir, "items", "pickaxe.yaml"), []byte("id: pickaxe\nname: pickaxe\ntool:\n type: pickaxe\n speed: 1\n")) writeSeedFile(t, filepath.Join(h.dir, "items", "copper_ore.yaml"), []byte("id: copper_ore\nname: copper ore\nstackable: true\n")) sess := &net.Session{Conn: newChanConn(), Player: p} sess.SetState(net.StateGame) h.g.RunOnOwner(func() { h.g.ItemStore.Reload(h.dir) h.g.ObjectStore.Reload(h.dir) h.g.World.EnsureObjectStates(1, []string{"copper_rock"}) p.SetInvSlot(0, &player.InventorySlot{ItemID: "pickaxe", Quantity: 1}) obj, _, err := h.g.resolveObjectDef(1, "copper_rock") if err != nil { t.Fatalf("resolve object: %v", err) } st := h.g.World.GetObjState(1, "copper_rock", 0) if st == nil { t.Fatal("no object state after EnsureObjectStates") } h.g.startGather(sess, p, obj, st) if p.Action == nil { t.Fatalf("gather action did not start: %s", sess.Conn.(*chanConn).Bytes()) } h.g.advanceGather(sess, p) // step 0: announce h.g.advanceGather(sess, p) // step 1: resolve the drop if got := p.CountItem("copper_ore"); got == 0 { t.Fatalf("gather yielded no ore (output: %s)", sess.Conn.(*chanConn).Bytes()) } }) } func TestPlayerDataFileNameCanonicalizesPrefixes(t *testing.T) { for _, in := range []string{ "players/characters/alice.yaml", "//players/characters/alice.yaml", "data/data/players/characters/alice.yaml", "/data/players/characters/alice.yaml", } { name, kind, ok := playerDataFileName(in) if !ok || name != "alice" || kind != "characters" { t.Errorf("playerDataFileName(%q) = (%q, %q, %v), want (alice, characters, true)", in, name, kind, ok) } } }