aboutsummaryrefslogtreecommitdiff
path: root/internal/game/predictions_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/predictions_test.go')
-rw-r--r--internal/game/predictions_test.go281
1 files changed, 281 insertions, 0 deletions
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")
+ }
+ })
+}