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()) } }) } // End-to-end burn: the missing coverage the audit called out. Start a fire // with a tinderbox and tick until it catches. func TestBurnCreatesFire(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") writeSeedFile(t, filepath.Join(h.dir, "items", "logs.yaml"), []byte("id: logs\nname: logs\nburn_ticks: 100\nfire_level: 1\nfire_xp: 10\n")) writeSeedFile(t, filepath.Join(h.dir, "items", "tinderbox.yaml"), []byte("id: tinderbox\nname: tinderbox\ntool:\n type: fire\n speed: 1\n")) sess := &net.Session{Conn: newChanConn(), Player: p} sess.SetState(net.StateGame) h.g.RunOnOwner(func() { h.g.ItemStore.Reload(h.dir) p.SetInvSlot(0, &player.InventorySlot{ItemID: "logs", Quantity: 5}) p.SetInvSlot(1, &player.InventorySlot{ItemID: "tinderbox", Quantity: 1}) h.g.startBurn(sess, p, "logs", false) if p.Action == nil || p.Action.Type != behavior.TypeBurn { t.Fatalf("burn action did not start: %s", sess.Conn.(*chanConn).Bytes()) } // Each attempt is two calls (phase 1 then the roll); 200 calls is ~100 // attempts at >=5% success, so failure is effectively impossible. for i := 0; i < 200 && p.Action != nil; i++ { h.g.advanceBurn(sess, p) } if p.Action != nil { t.Fatal("burn never completed") } if !h.g.hasFireObject(1) { t.Fatalf("no fire created (output: %s)", sess.Conn.(*chanConn).Bytes()) } }) } // Regression: a terminal talk node (messages, no options, no goto) must clear // the talk action, or it lingers as a stale action that suppresses the prompt // and no-ops every tick. func TestTerminalTalkNodeClearsAction(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") writeSeedFile(t, filepath.Join(h.dir, "objects", "statue.yaml"), []byte("id: statue\nname: statue\ntalk:\n nodes:\n start:\n messages: [The statue says nothing more.]\n")) sess := &net.Session{Conn: newChanConn(), Player: p} sess.SetState(net.StateGame) h.g.RunOnOwner(func() { h.g.ObjectStore.Reload(h.dir) obj, err := h.g.ObjectStore.Load("statue") if err != nil { t.Fatal(err) } h.g.startTalk(sess, p, obj) if p.Action != nil { t.Fatalf("terminal talk node left a stale action: %+v", p.Action) } if sess.State() != net.StateGame { t.Fatalf("state = %v, want StateGame", sess.State()) } }) } // End-to-end object steal: the missing coverage the audit called out. func TestObjectStealYieldsLoot(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") writeSeedFile(t, filepath.Join(h.dir, "objects", "market_stall.yaml"), []byte("id: market_stall\nname: market stall\nsteal:\n level: 1\n xp: 10\n speed: 1\n drops:\n - item_id: stolen_coins\n weight: 100\n")) writeSeedFile(t, filepath.Join(h.dir, "items", "stolen_coins.yaml"), []byte("id: stolen_coins\nname: stolen coins\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{"market_stall"}) obj, err := h.g.ObjectStore.Load("market_stall") if err != nil { t.Fatal(err) } h.g.startSteal(sess, p, "object", nil, obj) if p.Action == nil || p.Action.Type != behavior.TypeSteal { t.Fatalf("steal action did not start: %s", sess.Conn.(*chanConn).Bytes()) } // ~50% success per attempt; 200 attempts makes failure negligible. for i := 0; i < 200 && p.CountItem("stolen_coins") == 0; i++ { h.g.advanceSteal(sess, p) } if got := p.CountItem("stolen_coins"); got == 0 { t.Fatalf("steal yielded no loot (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) } } }