package game import ( "encoding/json" "net/http" "net/http/httptest" "regexp" "strconv" "strings" "sync/atomic" "testing" "time" "unicode/utf8" "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/prediction" ) func predictionTestServer(t *testing.T, g *Game, m prediction.Market) { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/markets": json.NewEncoder(w).Encode([]prediction.Market{m}) case "/markets/1": json.NewEncoder(w).Encode(m) case "/prices-history": w.Write([]byte(`{"history":[{"t":100,"p":0.3},{"t":200,"p":0.6}]}`)) default: w.WriteHeader(404) } })) t.Cleanup(server.Close) g.PredictionClient = prediction.NewClient() g.PredictionClient.Gamma = server.URL g.PredictionClient.CLOB = server.URL } func TestPredictionModalCommandsAndEntryAliases(t *testing.T) { for _, alias := range []string{"predictions", "market", "counter", "exchange", "prediction market counter"} { t.Run(alias, func(t *testing.T) { g, s, conn, m := predictionFixture(t) predictionTestServer(t, g, m) if _, ok := commandRegistry["predictions"]; ok { t.Fatal("standalone predictions command survives") } if _, ok := commandRegistry["predict"]; ok { t.Fatal("standalone predict alias survives") } if g.predictionInputOwned(s, "active") { t.Fatal("counter verbs escaped session scope") } g.doUse(s, alias) if !s.PredictionActive || s.PredictionScreen != "list" { t.Fatalf("not entered: %s", conn.data) } g.handleGameCommand(s, "1") if s.PredictionSelected != "1" || s.PredictionScreen != "view" { t.Fatalf("numeric selection failed: %s", conn.data) } g.handleGameCommand(s, "buy 10 9") if s.PredictionScreen != "view" || s.PredictionSelected != "1" || len(s.Player.PredictionContracts) != 0 { t.Fatal("rejected order lost selection or changed ledger") } g.handleGameCommand(s, "buy 10 1") if len(s.Player.PredictionContracts) != 1 || s.Player.Credits != 9570 { t.Fatalf("context buy failed: %s", conn.data) } if s.PredictionScreen != "view" { t.Fatal("purchase lost the selected market") } g.handleGameCommand(s, "active") g.handleGameCommand(s, "1") if s.PredictionScreen != "view" { t.Fatal("portfolio number did not open holding") } g.handleGameCommand(s, "active") g.handleGameCommand(s, "sell 1") if s.Player.PredictionContracts[0].Status != "sold" { t.Fatal("context sale failed") } g.handleGameCommand(s, "stop") if s.PredictionActive || g.predictionInputOwned(s, "active") { t.Fatal("stop did not leave mode") } }) } } func TestPredictionScreenGlyphsColorAndWidths(t *testing.T) { for _, unicode := range []bool{true, false} { t.Run(map[bool]string{true: "unicode", false: "ascii"}[unicode], func(t *testing.T) { g, s, conn, m := predictionFixture(t) s.Player.Options["unicode"] = unicode s.Player.Options["color"] = "xterm256" s.Player.Options["casino_hints"] = "on" g.predictionList(s, []prediction.Market{m}, 1, "") m.Prices = `["0.60","0.40"]` g.predictionList(s, []prediction.Market{m}, 1, "") g.predictionView(s, m, []prediction.Point{{T: 100, P: .3}, {T: 200, P: .6}}) text := string(conn.data) if !strings.Contains(text, "\x1b[") { t.Fatal("no color") } text = regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(text, "") if strings.Contains(text, "predictions buy") || strings.Contains(text, "predictions board") { t.Fatal("old command hints") } for _, want := range []string{"Available", "buy", "ODDS HISTORY", "Period move:", "+30.0", "Market", "Odds", "Outcome"} { if !strings.Contains(text, want) { t.Fatalf("missing %q", want) } } if unicode { for _, glyph := range []string{"╔", "║", "█", "░", "↑", "─"} { if !strings.Contains(text, glyph) { t.Fatalf("missing glyph %s", glyph) } } } else { for _, r := range text { if r > 127 { t.Fatalf("ASCII mode emitted %U", r) } } if !strings.Contains(text, "#") || !strings.Contains(text, "^") { t.Fatal("ASCII graphics missing") } } for _, line := range strings.Split(text, "\n") { if width := color.VisibleLen(strings.TrimSuffix(line, "\r")); width > 80 { t.Fatalf("line exceeds terminal (%d): %s", width, line) } } if !utf8.ValidString(text) { t.Fatal("broken UTF-8") } }) } } func TestPredictionHintPreferencesAndWorldCommands(t *testing.T) { g, s, conn, m := predictionFixture(t) predictionTestServer(t, g, m) s.Player.Options["casino_hints"] = "off" g.doUse(s, "market") if strings.Contains(string(conn.data), "view N") { t.Fatal("ignored hints off") } conn.data = nil g.handleGameCommand(s, "what") if !strings.Contains(string(conn.data), "view N") { t.Fatal("global what did not show the casino menu") } if g.predictionInputOwned(s, "what") || g.predictionInputOwned(s, "verbs") { t.Fatal("global commands claimed by counter") } if strings.Contains(string(conn.data), "YOUR NEXT MOVE") || strings.Contains(string(conn.data), "what /") { t.Fatal("custom hint footer survived") } if g.predictionInputOwned(s, "north") || g.predictionInputOwned(s, "inventory") { t.Fatal("world command intercepted") } epoch := s.PredictionEpoch g.handlePredictionInput(s, "use", []string{"market"}) if !s.PredictionActive || s.PredictionEpoch != epoch { t.Fatal("reusing counter invalidates pending operation") } g.handlePredictionInput(s, "play", []string{"roulette"}) if s.PredictionActive { t.Fatal("another casino game did not release mode") } } func TestPredictionStopCancelsPendingTrade(t *testing.T) { g, s, _, m := predictionFixture(t) entered, canceled := make(chan struct{}), make(chan struct{}) var calls atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) close(entered) <-r.Context().Done() close(canceled) })) defer server.Close() g.PredictionClient = prediction.NewClient() g.PredictionClient.Gamma = server.URL g.owner = newOwner() defer g.owner.stop() g.loggedInChars = map[string]*net.Session{"alice": s} s.PredictionActive = true s.PredictionSelected = m.ID s.PredictionScreen = "view" g.run(func() { g.executePredictions(s, []string{"buy", "10", "1"}, "") }) select { case <-entered: case <-time.After(time.Second): t.Fatal("request not started") } g.run(func() { g.executeCommand(s, "stop", nil, "stop") }) select { case <-canceled: case <-time.After(time.Second): t.Fatal("stop did not cancel network request") } g.run(func() { if s.PredictionActive || s.PredictionBusy || s.Player.Credits != 10000 || len(s.Player.PredictionContracts) != 0 { t.Fatal("stopped trade changed state") } }) if calls.Load() != 1 { t.Fatal("unexpected requests") } } func TestPredictionRoomEntryClearsModeAndCancelsRequest(t *testing.T) { _, s, _, _ := predictionFixture(t) canceled := false s.PredictionActive = true s.PredictionBusy = true s.PredictionSelected = "1" s.PredictionCancel = func() { canceled = true } hub := net.NewHub() hub.Add(s) hub.EnterRoom(s, 2) if s.PredictionActive || s.PredictionBusy || s.PredictionSelected != "" || !canceled { t.Fatal("room change retained counter mode") } } func TestPredictionBackKeepsSearchAndPage(t *testing.T) { g, s, _, m := predictionFixture(t) markets := make([]prediction.Market, 21) for i := range markets { markets[i] = m markets[i].ID = strconv.Itoa(i + 1) markets[i].Question = "Sports market " + markets[i].ID } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/markets": json.NewEncoder(w).Encode(markets) case "/prices-history": w.Write([]byte(`{"history":[]}`)) default: n, _ := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/markets/")) if n < 1 || n > len(markets) { w.WriteHeader(404) return } json.NewEncoder(w).Encode(markets[n-1]) } })) defer server.Close() g.PredictionClient = prediction.NewClient() g.PredictionClient.Gamma = server.URL g.PredictionClient.CLOB = server.URL g.doUse(s, "market") g.handleGameCommand(s, "find sports") g.handleGameCommand(s, "next") if s.PredictionPage != 2 { t.Fatal("next did not turn page") } g.handleGameCommand(s, "5") if s.PredictionSelected != "5" { t.Fatal("wrong selected market") } g.handleGameCommand(s, "back") if s.PredictionScreen != "list" || s.PredictionPage != 2 || s.PredictionSearch != "sports" { t.Fatal("back lost browsing position") } g.handleGameCommand(s, "markets") if s.PredictionPage != 1 || s.PredictionSearch != "" { t.Fatal("markets did not reset selection") } }