From c84a9944356f590d90d819d652bdf1349ebacd40 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Mon, 5 Oct 2026 06:03:49 -0400 Subject: feat: prediction exchange with live polymarket data --- internal/game/predictions_test.go | 281 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 internal/game/predictions_test.go (limited to 'internal/game/predictions_test.go') diff --git a/internal/game/predictions_test.go b/internal/game/predictions_test.go new file mode 100644 index 0000000..f10f849 --- /dev/null +++ b/internal/game/predictions_test.go @@ -0,0 +1,281 @@ +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") + } + }) +} -- cgit v1.2.3