package game import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" "thehouseoficarus/internal/combat" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/prediction" "thehouseoficarus/internal/world" ) func predictionFixture(t *testing.T) (*Game, *net.Session, *casinoTestConn, prediction.Market) { t.Helper() dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "rooms"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "rooms", "1.yaml"), []byte("name: Test Counter\nprediction_exchange: true\n"), 0644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(dir, "players", "characters"), 0700); err != nil { t.Fatal(err) } g := &Game{Deps: Deps{World: world.New(dir), AccountStore: player.NewAccountStore(dir)}, Combat: combat.NewTracker()} p := player.New("alice") p.RoomID = 1 p.Credits = 10000 p.Options = map[string]any{"color": "none", "wrap_width": 0} conn := &casinoTestConn{} s := &net.Session{Player: p, Conn: conn} s.SetState(net.StateGame) m := prediction.Market{ID: "1", Question: "Will the sun rise?", Description: "Resolve Yes when the sun rises.", Outcomes: `["Yes","No"]`, Tokens: `["101","102"]`, Prices: `["0.421","0.579"]`, Active: true, Accepting: true, End: time.Now().Add(time.Hour).UTC().Format(time.RFC3339)} return g, s, conn, m } func TestPredictionBuySellDurable(t *testing.T) { g, s, conn, m := predictionFixture(t) buy := predictionRequest{action: "buy", id: "1", outcome: 1, quantity: 10} g.tradePrediction(s, buy, m) if s.Player.Credits != 9570 || len(s.Player.PredictionContracts) != 1 { t.Fatalf("buy failed: %s", conn.data) } loaded, err := g.AccountStore.LoadCharacter("alice") if err != nil { t.Fatal(err) } if loaded.Credits != 9570 || len(loaded.PredictionContracts) != 1 || loaded.PredictionContracts[0].Token != "101" { t.Fatal("ledger not persisted with debit") } s.Player = loaded m.Prices = `["0.60","0.40"]` g.predictionActive(s, map[string]prediction.Market{"1": m}) if !strings.Contains(string(conn.data), "+170") || !strings.Contains(string(conn.data), "ACTIVE CONTRACTS") { t.Fatalf("missing portfolio P/L: %s", conn.data) } g.tradePrediction(s, predictionRequest{action: "sell", lot: 0}, m) if s.Player.Credits != 10170 || s.Player.PredictionContracts[0].Status != "sold" { t.Fatal("sale failed") } g.tradePrediction(s, predictionRequest{action: "sell", lot: 0}, m) if s.Player.Credits != 10170 { t.Fatal("duplicate sale paid twice") } loaded, err = g.AccountStore.LoadCharacter("alice") if err != nil || loaded.Credits != 10170 || loaded.PredictionContracts[0].Proceeds != 600 { t.Fatal("sale not durable") } } func TestPredictionResolutionCollectExactlyOnceAcrossReload(t *testing.T) { g, s, _, m := predictionFixture(t) g.tradePrediction(s, predictionRequest{action: "buy", outcome: 1, quantity: 10}, m) m.Accepting = false m.Prices = `["0.6","0.4"]` g.settlePredictions(s, map[string]prediction.Market{"1": m}) if s.Player.Credits != 9570 || s.Player.PredictionContracts[0].Status != "open" { t.Fatal("non-terminal close resolved a contract") } m.Closed = true m.Prices = `["1","0"]` if !g.settlePredictions(s, map[string]prediction.Market{"1": m}) { t.Fatal("settlement failed") } // Resolution records the 100/share payout but moves no credits. if s.Player.Credits != 9570 || s.Player.PredictionContracts[0].Status != "resolved" || s.Player.PredictionContracts[0].Proceeds != 1000 { t.Fatalf("resolution wrong: credits=%d status=%s proceeds=%d", s.Player.Credits, s.Player.PredictionContracts[0].Status, s.Player.PredictionContracts[0].Proceeds) } loaded, err := g.AccountStore.LoadCharacter("alice") if err != nil { t.Fatal(err) } s.Player = loaded g.collectPredictions(s) if s.Player.Credits != 10570 || s.Player.PredictionContracts[0].Status != "collected" { t.Fatal("collect did not pay or mark the contract") } loaded, err = g.AccountStore.LoadCharacter("alice") if err != nil { t.Fatal(err) } s.Player = loaded g.collectPredictions(s) if s.Player.Credits != 10570 { t.Fatal("collect paid twice") } } func TestPredictionBuyByOutcomeName(t *testing.T) { g, s, _, m := predictionFixture(t) if !g.tradePrediction(s, predictionRequest{action: "buy", id: "1", quantity: 10, outcomeName: "yes"}, m) { t.Fatal("buy by outcome name failed") } if s.Player.PredictionContracts[0].Outcome != "Yes" { t.Fatalf("wrong outcome: %s", s.Player.PredictionContracts[0].Outcome) } if g.tradePrediction(s, predictionRequest{action: "buy", id: "1", quantity: 10, outcomeName: "maybe"}, m) { t.Fatal("unknown outcome name accepted") } if g.tradePrediction(s, predictionRequest{action: "buy", id: "1", quantity: 10, outcome: 9}, m) { t.Fatal("out-of-range outcome number accepted") } } func TestPredictionBuyByPartialOutcomeName(t *testing.T) { g, s, _, m := predictionFixture(t) m.Outcomes = `["Natus Vincere","9z"]` m.Tokens = `["a","b"]` m.Prices = `["0.70","0.30"]` for _, name := range []string{"natus", "natus vincere", "NATUS VINCERE"} { if !g.tradePrediction(s, predictionRequest{action: "buy", id: "1", quantity: 1, outcomeName: name}, m) { t.Fatalf("partial name %q failed", name) } if got := s.Player.PredictionContracts[len(s.Player.PredictionContracts)-1].Outcome; got != "Natus Vincere" { t.Fatalf("name %q matched %q", name, got) } } } func TestPredictionRejectsClosed(t *testing.T) { g, s, _, m := predictionFixture(t) r := predictionRequest{action: "buy", outcome: 1, quantity: 10} m.Closed = true g.tradePrediction(s, r, m) m.Closed = false m.End = time.Now().Add(-time.Minute).Format(time.RFC3339) g.tradePrediction(s, r, m) if s.Player.Credits != 10000 || len(s.Player.PredictionContracts) != 0 { t.Fatal("unsafe purchase accepted") } } func TestPredictionSaveRollback(t *testing.T) { g, s, _, m := predictionFixture(t) g.tradePrediction(s, predictionRequest{action: "buy", outcome: 1, quantity: 10}, m) bad := filepath.Join(t.TempDir(), "file") if err := os.WriteFile(bad, []byte("not a directory"), 0600); err != nil { t.Fatal(err) } g.AccountStore = player.NewAccountStore(bad) g.tradePrediction(s, predictionRequest{action: "buy", outcome: 1, quantity: 10}, m) if s.Player.Credits != 9570 || len(s.Player.PredictionContracts) != 1 { t.Fatal("failed debit not rolled back") } g.tradePrediction(s, predictionRequest{action: "sell", lot: 0}, m) if s.Player.Credits != 9570 || s.Player.PredictionContracts[0].Status != "open" { t.Fatal("failed sale not rolled back") } m.Closed = true m.Resolution = "resolved" m.Prices = `["1","0"]` if g.settlePredictions(s, map[string]prediction.Market{"1": m}) { t.Fatal("failed settlement reported success") } if s.Player.Credits != 9570 || s.Player.PredictionContracts[0].Status != "open" { t.Fatal("failed settlement not rolled back") } } func TestPredictionCounterInteractionAndOutage(t *testing.T) { g, s, conn, m := predictionFixture(t) var failed atomic.Bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if failed.Load() { w.WriteHeader(503) return } 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.4}]}`)) default: w.WriteHeader(404) } })) defer server.Close() g.PredictionClient = prediction.NewClient() g.PredictionClient.Gamma = server.URL g.PredictionClient.CLOB = server.URL g.doUse(s, "predictions") if len(s.PredictionIDs) != 1 || !strings.Contains(string(conn.data), "Updating markets...") { t.Fatalf("no menu: %s", conn.data) } g.executePredictions(s, []string{"view", "1"}, "") if strings.Contains(string(conn.data), m.Description) || !strings.Contains(string(conn.data), "100%") { t.Fatalf("detail must show graph but not resolution text: %s", conn.data) } g.executePredictions(s, []string{"rules"}, "") if !strings.Contains(string(conn.data), m.Description) { t.Fatal("rules did not show resolution text") } failed.Store(true) conn.data = nil g.executePredictions(s, []string{"active"}, "") // Even an empty portfolio must check availability. if !strings.Contains(string(conn.data), "CLOSED") { t.Fatal("empty active list bypassed outage") } g.executePredictions(s, []string{"buy", "10", "1"}, "") if s.Player.Credits != 10000 || len(s.Player.PredictionContracts) != 0 { t.Fatal("traded stale cached price") } failed.Store(false) g.executePredictions(s, []string{"buy", "10", "1"}, "") if len(s.Player.PredictionContracts) != 1 { t.Fatal("counter did not recover") } } func TestPredictionFetchDoesNotBlockOwnerAndMovementCancels(t *testing.T) { g, s, _, m := predictionFixture(t) started, release := make(chan struct{}), make(chan struct{}) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(started); <-release; json.NewEncoder(w).Encode(m) })) 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.PredictionIDs = []string{"1"} s.PredictionActive = true s.PredictionSelected = "1" s.PredictionScreen = "view" g.run(func() { g.executePredictions(s, []string{"buy", "10", "1"}, "") }) select { case <-started: case <-time.After(time.Second): t.Fatal("fetch not started") } // This must run while the server is still stalled. moved := make(chan struct{}) go func() { g.run(func() { s.Player.RoomID = 2 }); close(moved) }() select { case <-moved: case <-time.After(time.Second): close(release) t.Fatal("HTTP blocked the game owner") } close(release) deadline := time.Now().Add(2 * time.Second) for { busy := true g.run(func() { busy = s.PredictionBusy }) if !busy { break } if time.Now().After(deadline) { t.Fatal("callback did not complete") } time.Sleep(time.Millisecond) } g.run(func() { if s.Player.Credits != 10000 || len(s.Player.PredictionContracts) != 0 { t.Error("trade completed after leaving counter") } }) }