diff options
| author | historia <[not public]> | 2026-10-05 06:03:49 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-05 06:03:49 -0400 |
| commit | c84a9944356f590d90d819d652bdf1349ebacd40 (patch) | |
| tree | af1ee15992e141fb3eb6f26cac02cf06306d4982 /internal/game | |
| parent | 8b78aa4cd930fde145959704ba7bdc0e57465b9b (diff) | |
| download | thehouseoficarus-c84a9944356f590d90d819d652bdf1349ebacd40.tar.gz | |
feat: prediction exchange with live polymarket data
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/cmd_look.go | 10 | ||||
| -rw-r--r-- | internal/game/cmd_registry.go | 4 | ||||
| -rw-r--r-- | internal/game/cmd_use.go | 7 | ||||
| -rw-r--r-- | internal/game/cmd_verbs.go | 4 | ||||
| -rw-r--r-- | internal/game/game.go | 9 | ||||
| -rw-r--r-- | internal/game/prediction_interface.go | 155 | ||||
| -rw-r--r-- | internal/game/prediction_interface_test.go | 272 | ||||
| -rw-r--r-- | internal/game/prediction_ux_regression_test.go | 171 | ||||
| -rw-r--r-- | internal/game/predictions.go | 737 | ||||
| -rw-r--r-- | internal/game/predictions_test.go | 281 | ||||
| -rw-r--r-- | internal/game/render_predictions.go | 341 | ||||
| -rw-r--r-- | internal/game/vesta_world_test.go | 5 |
12 files changed, 1993 insertions, 3 deletions
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go index 38cc83c..ab01c4c 100644 --- a/internal/game/cmd_look.go +++ b/internal/game/cmd_look.go @@ -65,6 +65,16 @@ func (g *Game) doLook(sess *net.Session) { // Seated at a table: look keeps its room-first order, then appends the // live game state and the current hint menu. g.showCasinoGameLook(sess, p, room) + if sess.PredictionActive && room.PredictionExchange { + sess.WriteLine("") + if sess.PredictionBusy { + sess.WriteLine("Updating markets...") + sess.PredictionShowHints = true + g.predictionHints(sess) + } else { + g.executePredictions(sess, []string{"refresh"}, "look") + } + } } func (g *Game) doExits(sess *net.Session) { diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index 4d48509..abc1539 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -398,6 +398,10 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI } } + if g.handlePredictionInput(sess, cmd, args) { + return + } + // Cash Hunt owns numeric choices only while seated; other games and // world commands keep their usual behavior. if p != nil && g.Casino != nil { diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go index 8c82f9c..54a36f5 100644 --- a/internal/game/cmd_use.go +++ b/internal/game/cmd_use.go @@ -29,7 +29,12 @@ func (g *Game) doUse(sess *net.Session, input string) { return } - lower := strings.ToLower(input) + lower := strings.ToLower(strings.TrimSpace(input)) + + if predictionUseTarget(lower) && g.atPredictionCounter(p) { + g.openPredictionCounter(sess) + return + } if lower == "bank" || lower == "bank booth" || strings.HasPrefix(lower, "bank ") { g.doBank(sess) diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go index 5cbbd5f..87e8ee0 100644 --- a/internal/game/cmd_verbs.go +++ b/internal/game/cmd_verbs.go @@ -191,6 +191,10 @@ func (g *Game) collectRoomCommands(sess *net.Session) roomCommandSections { func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) { p := sess.Player + if sess.PredictionActive && g.atPredictionCounter(p) { + g.showCasinoHints(sess, g.predictionCommands(sess)) + return + } // A seated casino player has no room verbs: the only valid commands are // the game's live hints, so what/verbs shows those instead of exits and // objects that cannot be used mid-hand. diff --git a/internal/game/game.go b/internal/game/game.go index 521c892..1ce89e5 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -21,6 +21,7 @@ import ( "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" + "thehouseoficarus/internal/prediction" "thehouseoficarus/internal/world" ) @@ -60,6 +61,7 @@ type Game struct { Hub *net.Hub GlobalFlags *GlobalFlagStore Combat *combat.Tracker + PredictionClient *prediction.Client Casino *casino.Manager flagIndex *flagTriggerIndex queue *CommandQueue @@ -629,6 +631,13 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { } cmd := parts[0] + // Counter commands exist only while using the counter, before global prefixes. + if g.predictionInputOwned(sess, cmd) { + g.executeCommand(sess, cmd, parts[1:], input) + g.writePrompt(sess) + return + } + // Seated casino players may shorten any of their game's verbs to an // unambiguous prefix (b 1000 -> bet 1000, sp -> spin at Big 6). Only the // current game's vocabulary is considered, and exact commands still win, so diff --git a/internal/game/prediction_interface.go b/internal/game/prediction_interface.go new file mode 100644 index 0000000..2e37145 --- /dev/null +++ b/internal/game/prediction_interface.go @@ -0,0 +1,155 @@ +package game + +import ( + "strings" + + "thehouseoficarus/internal/net" +) + +func predictionUseTarget(input string) bool { + switch strings.ToLower(strings.TrimSpace(input)) { + case "predictions", "prediction", "market", "counter", "exchange", "prediction market", "prediction counter", "prediction exchange", "prediction market counter": + return true + } + return false +} + +var predictionVerbs = []string{"markets", "view", "find", "next", "prev", "back", "active", "closed", "holding", "buy", "sell", "collect", "refresh", "rules", "stop"} + +func (g *Game) openPredictionCounter(s *net.Session) { + if !g.atPredictionCounter(s.Player) { + s.WriteLine("There is no Prediction Exchange here.") + return + } + if !g.leaveCasinoGames(s.Player) { + s.WriteLine("Finish your current casino round before using the counter.") + return + } + if s.PredictionActive { + g.executePredictions(s, []string{"refresh"}, "") + return + } + s.PredictionActive = true + s.PredictionEpoch++ + s.PredictionBusy = false + s.PredictionSelected = "" + s.PredictionScreen = "list" + s.PredictionPage = 1 + s.PredictionBoardPage = 1 + s.PredictionSearch = "" + s.PredictionUnavailable = false + g.executePredictions(s, nil, "open") +} + +func (g *Game) closePredictionCounter(s *net.Session) { + if s.PredictionCancel != nil { + s.PredictionCancel() + s.PredictionCancel = nil + } + s.PredictionActive = false + s.PredictionEpoch++ // Invalidate any download, including an in-flight trade. + s.PredictionBusy = false + s.PredictionSelected = "" +} + +// Exact world commands keep their meaning unless explicitly part of this UI. +// Abbreviations are scoped to the counter; no new global command is registered. +func predictionVerb(cmd string) (string, bool) { + switch cmd { + case "list": + return "markets", true + case "table", "game": + return "refresh", true + case "status", "portfolio", "board": + return "active", true + case "help": + return "help", true + case "leave", "exit": + return "stop", true + } + for _, v := range predictionVerbs { + if cmd == v { + return v, true + } + } + if _, exists := commandRegistry[cmd]; exists { + return "", false + } + found := "" + for _, v := range predictionVerbs { + if strings.HasPrefix(v, cmd) { + if found != "" { + return "", true + } + found = v + } + } + return found, found != "" +} +func (g *Game) predictionInputOwned(s *net.Session, cmd string) bool { + if !s.PredictionActive || !g.atPredictionCounter(s.Player) { + return false + } + if isAllDigits(cmd) { + return true + } + _, ok := predictionVerb(cmd) + return ok +} +func (g *Game) handlePredictionInput(s *net.Session, cmd string, args []string) bool { + if !s.PredictionActive { + return false + } + if !g.atPredictionCounter(s.Player) { + g.closePredictionCounter(s) + return false + } + if cmd == "help" && len(args) > 0 { + return false + } + if isAllDigits(cmd) { + action := "view" + if s.PredictionScreen == "active" || s.PredictionScreen == "closed" { + action = "holding" + } + g.executePredictions(s, []string{action, cmd}, "") + return true + } + verb, owned := predictionVerb(cmd) + if !owned { + if cmd == "use" && predictionUseTarget(strings.Join(args, " ")) { + return false + } + switch cmd { + case "play", "use", "bank", "quit", "logout": + g.closePredictionCounter(s) + } + return false + } + if verb == "" { + s.WriteLine("That shortcut is ambiguous. Choose a command below.") + g.predictionHints(s) + return true + } + if verb == "stop" { + g.closePredictionCounter(s) + s.WriteLine(g.colorize(s, "casino_action", "You step away from the Prediction Exchange. Your contracts stay with the house.")) + return true + } + switch verb { + case "markets": + verb = "list" + case "back": + args = nil + case "help": + g.predictionHelp(s) + return true + case "rules": + if s.PredictionScreen != "view" || s.PredictionSelected == "" { + g.predictionHelp(s) + return true + } + } + g.executePredictions(s, append([]string{verb}, args...), "") + return true +} diff --git a/internal/game/prediction_interface_test.go b/internal/game/prediction_interface_test.go new file mode 100644 index 0000000..e6a9dc9 --- /dev/null +++ b/internal/game/prediction_interface_test.go @@ -0,0 +1,272 @@ +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") + } +} diff --git a/internal/game/prediction_ux_regression_test.go b/internal/game/prediction_ux_regression_test.go new file mode 100644 index 0000000..159a492 --- /dev/null +++ b/internal/game/prediction_ux_regression_test.go @@ -0,0 +1,171 @@ +package game + +import ( + "fmt" + "strings" + "testing" + + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/prediction" + "thehouseoficarus/internal/world" +) + +func TestPredictionOneComplementaryBarPerMarket(t *testing.T) { + for _, unicode := range []bool{true, false} { + t.Run(fmt.Sprint(unicode), func(t *testing.T) { + g, s, conn, m := predictionFixture(t) + s.Player.Options["unicode"] = unicode + for _, detail := range []bool{false, true} { + conn.data = nil + if detail { + g.predictionView(s, m, nil) + } else { + g.predictionList(s, []prediction.Market{m}, 1, "") + } + output := string(conn.data) + bars := 0 + for _, line := range strings.Split(output, "\n") { + if strings.Contains(line, "█") || strings.Contains(line, "#") && strings.Contains(line, ".") { + bars++ + } + } + if bars != 1 { + t.Fatalf("detail=%v: got %d bars, want one\n%s", detail, bars, output) + } + if !strings.Contains(output, "YES") || !strings.Contains(output, "42.1%") || !strings.Contains(output, "57.9%") || !strings.Contains(output, "NO") { + t.Fatal("missing two-sided odds labels") + } + if strings.Contains(output, m.Description) || strings.Contains(output, "RESOLUTION RULES") { + t.Fatal("resolution text shown by default") + } + if detail { + for _, quote := range []string{"CHOOSE YOUR OUTCOME", "Outcome", "Buy", "Sell", "43", "42", "58", "57"} { + if !strings.Contains(output, quote) { + t.Fatalf("missing integer quote %q", quote) + } + } + } + } + }) + } +} + +func TestPredictionRulesFramePreservesTextWithoutCarriageReturns(t *testing.T) { + for _, width := range []int{40, 80} { + t.Run(fmt.Sprint(width), func(t *testing.T) { + g, s, conn, m := predictionFixture(t) + s.Player.Options["wrap_width"] = width + url := "https://example.org/" + strings.Repeat("long-resolution-source", 10) + m.Description = strings.Repeat("This market resolves according to the party controlling the chamber. ", 12) + "\r\n\r\n" + url + predictionTestServer(t, g, m) + g.doUse(s, "market") + g.handleGameCommand(s, "1") + if strings.Contains(string(conn.data), "RESOLUTION RULES") { + t.Fatal("rules appeared before request") + } + conn.data = nil + g.handleGameCommand(s, "rules") + text := strings.ReplaceAll(string(conn.data), "\r\n", "\n") + if strings.Contains(text, "\r") { + t.Fatal("bare CR inside frame can overwrite text") + } + var body strings.Builder + for _, line := range strings.Split(text, "\n") { + if strings.HasPrefix(line, "║") { + if !strings.HasSuffix(line, "║") { + t.Fatalf("missing right edge: %q", line) + } + if color.VisibleLen(line) != width { + t.Fatalf("row width %d, want %d: %q", color.VisibleLen(line), width, line) + } + body.WriteString(strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "║"), "║"))) + } + } + if !strings.Contains(text, "RESOLUTION RULES") || !strings.Contains(body.String(), url) { + t.Fatal("rules or unbroken URL was lost during wrapping") + } + if s.PredictionScreen != "view" || s.PredictionSelected != m.ID { + t.Fatal("reading rules lost selected market") + } + }) + } +} + +func TestPredictionLookUsesCasinoMenuAndRoomFirstOrder(t *testing.T) { + g, s, conn, m := predictionFixture(t) + g.Hub = net.NewHub() + g.MobStore = world.NewMobStore(t.TempDir()) + s.Player.Options["tiny_map"] = "off" + s.Player.Options["casino_hints"] = "once" + predictionTestServer(t, g, m) + g.doUse(s, "counter") + g.handleGameCommand(s, "1") + conn.data = nil + g.handleGameCommand(s, "look") + text := strings.ReplaceAll(string(conn.data), "\r\n", "\n") + room, screen := strings.Index(text, "Test Counter"), strings.Index(text, "THE MARKET") + if room < 0 || screen < room { + t.Fatalf("look not room first: %s", text) + } + expected := g.renderCasinoCommands(s, "Available 10000 credits", "", g.predictionCommands(s)) + if !strings.Contains(text, expected) { + t.Fatalf("look did not use the shared casino menu\nwant: %s\ngot: %s", expected, text) + } + for _, unwanted := range []string{"YOUR NEXT MOVE", "what -", "what /", "RESOLUTION RULES"} { + if strings.Contains(text, unwanted) { + t.Fatalf("look leaked %q", unwanted) + } + } + conn.data = nil + g.handleGameCommand(s, "what") + text = strings.ReplaceAll(string(conn.data), "\r\n", "\n") + if !strings.Contains(text, g.casinoMenuCommands(s, g.predictionCommands(s))) { + t.Fatal("normal what did not use casino hint renderer") + } + if g.predictionInputOwned(s, "look") || g.predictionInputOwned(s, "what") { + t.Fatal("counter hijacked normal world command") + } + conn.data = nil + s.Player.Options["casino_hints"] = "off" + g.handleGameCommand(s, "look") + if strings.Contains(string(conn.data), "view N") { + t.Fatal("look ignored casino_hints off") + } +} + +func TestPredictionFrameLongColoredToken(t *testing.T) { + g, s, _, _ := predictionFixture(t) + s.Player.Options["color"] = "xterm256" + token := g.colorize(s, "casino_action", strings.Repeat("x", 160)) + lines := predictionFrameLines(token, 36) + for _, line := range lines { + if strings.Contains(line, "\r") || color.VisibleLen(line) > 36 { + t.Fatalf("unsafe wrapped row %q", line) + } + } +} + +func TestPredictionOddsBarsAlign(t *testing.T) { + g, s, _, _ := predictionFixture(t) + s.Player.Options["color"] = "none" + target := 36 + pairs := [][2]string{{"Yes", "No"}, {"M80", "TYLOO"}, {"9z", "Natus Vincere"}, {"Falcons", "Saints"}} + cells := make([]string, 0, len(pairs)) + for _, pair := range pairs { + m := prediction.Market{ID: "1", Outcomes: `["` + pair[0] + `","` + pair[1] + `"]`, Tokens: `["a","b"]`, Prices: `["0.421","0.579"]`} + cells = append(cells, g.predictionOddsCell(s, m, target)) + } + start := strings.Index(cells[0], "█") + if start < 0 { + t.Fatalf("no bar: %q", cells[0]) + } + for _, cell := range cells { + if got := strings.Index(cell, "█"); got != start { + t.Fatalf("bar offset %d, want %d: %q", got, start, cell) + } + if color.VisibleLen(cell) != target { + t.Fatalf("cell width %d, want %d: %q", color.VisibleLen(cell), target, cell) + } + } +} diff --git a/internal/game/predictions.go b/internal/game/predictions.go new file mode 100644 index 0000000..c61e64e --- /dev/null +++ b/internal/game/predictions.go @@ -0,0 +1,737 @@ +package game + +import ( + "context" + "fmt" + "log" + "sort" + "strconv" + "strings" + "sync" + "time" + + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/prediction" +) + +const ( + predictionPageSize = 10 + predictionClosedPageSize = 15 +) + +type predictionRequest struct { + action, id, search, outcomeName string + page, outcome, quantity, lot int + showHints bool +} +type predictionResult struct { + list []prediction.Market + markets map[string]prediction.Market + history []prediction.Point + err error +} + +func (g *Game) atPredictionCounter(p *player.Player) bool { + if p == nil { + return false + } + r, err := g.World.LoadRoom(p.RoomID) + return err == nil && r.PredictionExchange +} + +// normalizeOutcome lower-cases and collapses an outcome name so players can +// match it with any spacing or capitalization. +func normalizeOutcome(s string) string { + return strings.ToLower(strings.Join(strings.Fields(s), " ")) +} + +func (g *Game) predictionHelp(s *net.Session) { + g.predictionFrame(s, "HOW TO PLAY", []string{ + "Pick a market number, choose an outcome, and back your prediction!", + "Winning shares pay 100 credits each. Losing shares pay 0. No chips or real money.", + "The house uses Polymarket indicative prices, not exchange fills. No fees.", + "Each share's buy price rounds UP and sell price rounds DOWN to whole credits, before multiplying by shares.", + "buy SHARES OUTCOME = buy at the house's live quote; OUTCOME is a number or name, e.g. buy 10 yes.", + "sell CONTRACT = sell all shares of a contract on your active list.", + "Resolved contracts pay 100 credits per winning share. Type collect to withdraw them.", + "Up to 50 active contracts; 1-10,000 shares per purchase.", + "Closed, paused and expired markets cannot trade. Finalized results settle on your next visit. Disputed results wait.", + "active lists your open contracts; closed lists sold, resolved and collected ones. holding N opens a contract's market.", + "rules shows the selected market's full resolution text.", + "stop leaves the counter without selling anything. No automatic background polling.", + }) + g.predictionHints(s, true) +} + +func (g *Game) executePredictions(s *net.Session, args []string, origin string) { + if !s.PredictionActive { + s.WriteLine("Use predictions to approach the counter.") + return + } + if !g.atPredictionCounter(s.Player) { + s.WriteLine("There is no Prediction Exchange here. Look for the east arch in the casino vestibule.") + return + } + if s.PredictionBusy { + s.WriteLine("Updating markets... please wait for your current request.") + return + } + r := predictionRequest{action: "list", page: 1, lot: -1, showHints: origin == "open" || origin == "look"} + if len(args) > 0 { + r.action = strings.ToLower(args[0]) + } + number := func(i int) int { + if i >= len(args) { + return 0 + } + n, _ := strconv.Atoi(args[i]) + return n + } + switch r.action { + case "help": + g.predictionHelp(s) + return + case "collect": + g.collectPredictions(s) + return + case "rules": + r.id = s.PredictionSelected + if r.id == "" || s.PredictionScreen != "view" { + g.predictionHelp(s) + return + } + case "back": + r.action = "list" + r.page = max(1, s.PredictionPage) + r.search = s.PredictionSearch + case "list": + if len(args) > 1 { + r.page = number(1) + } + case "refresh", "next", "prev": + r.action = s.PredictionScreen + if r.action == "" { + r.action = "list" + } + switch r.action { + case "view": + r.id = s.PredictionSelected + case "closed": + r.page = s.PredictionBoardPage + if args[0] == "next" { + r.page++ + } else if args[0] == "prev" { + r.page = max(1, r.page-1) + } + case "active": + // Active contracts always fit on a single page. + default: + r.action = "list" + r.page = s.PredictionPage + r.search = s.PredictionSearch + if args[0] == "next" { + r.page++ + } else if args[0] == "prev" { + r.page = max(1, r.page-1) + } + } + case "find": + r.action = "list" + r.search = strings.ToLower(strings.Join(args[1:], " ")) + case "active", "closed": + // Page state lives on the session; the renderer clamps it. + case "board": + r.action = "active" + case "view": + if len(args) == 1 && s.PredictionSelected != "" { + r.id = s.PredictionSelected + break + } + n := number(1) + if n < 1 || n > len(s.PredictionIDs) { + s.WriteLine("Choose a market number from markets.") + g.predictionHints(s) + return + } + r.id = s.PredictionIDs[n-1] + case "buy": + r.id = s.PredictionSelected + if r.id == "" || s.PredictionScreen != "view" { + s.WriteLine("Choose a market first: view N (or just its number).") + g.predictionHints(s) + return + } + r.quantity = number(1) + if len(args) < 3 || r.quantity < 1 || r.quantity > 10000 { + s.WriteLine("buy SHARES OUTCOME - for example: buy 10 yes or buy 10 2") + g.predictionHints(s) + return + } + outcomeTok := normalizeOutcome(strings.Join(args[2:], " ")) + if n, err := strconv.Atoi(outcomeTok); err == nil { + r.outcome = n + } else { + r.outcomeName = outcomeTok + } + case "holding", "sell": + r.lot = number(1) - 1 + if r.lot < 0 || r.lot >= len(s.Player.PredictionContracts) { + s.WriteLine("Use a contract number from active.") + return + } + r.id = s.Player.PredictionContracts[r.lot].MarketID + if r.action == "holding" { + r.action = "view" + } else if len(args) != 2 { + s.WriteLine("sell CONTRACT - for example: sell 3") + g.predictionHints(s) + return + } + default: + g.predictionHelp(s) + return + } + if r.page < 1 { + s.WriteLine("Page numbers start at 1.") + return + } + if g.PredictionClient == nil { + g.PredictionClient = prediction.NewClient() + } + client := g.PredictionClient + p, roomID := s.Player, s.Player.RoomID + epoch := s.PredictionEpoch + ids := map[string]bool{} + for _, c := range p.PredictionContracts { + if c.Status == "open" { + ids[c.MarketID] = true + } + } + if r.id != "" { + ids[r.id] = true + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + s.PredictionCancel = cancel + s.PredictionBusy = true + s.WriteLine(g.colorize(s, "casino_menu", g.predictionGlyph(s, "↻", ">")+" Updating markets...")) + // Only immutable IDs and an HTTP client cross the owner boundary. All player, + // credit, session, and persistence work happens back on the game owner. + work := func() { + defer cancel() + result := fetchPredictions(ctx, client, r, ids) + g.run(func() { + if !s.PredictionActive || s.PredictionEpoch != epoch { + return + } + s.PredictionBusy = false + s.PredictionCancel = nil + if g.isShuttingDown() || s.Player != p || s.State() != net.StateGame || s.IsDisconnecting() || p.RoomID != roomID || !g.atPredictionCounter(p) { + return + } + if g.owner != nil && g.loggedInChars[p.Name] != s { + return + } + if g.owner != nil { + defer g.reprompt(s) + } + s.PredictionShowHints = r.showHints + s.PredictionUnavailable = result.err != nil + if result.err != nil { + log.Printf("prediction market refresh failed: %v", result.err) + g.predictionFrame(s, "COUNTER CLOSED", []string{g.colorize(s, "casino_result_loss", "Live market data is unavailable."), "No trades or settlements were made.", "Your existing contracts are safe. Try refresh or stop."}) + g.predictionHints(s) + return + } + if !g.settlePredictions(s, result.markets) { + g.predictionHints(s) + return + } + switch r.action { + case "list": + g.predictionList(s, result.list, r.page, r.search) + case "active": + g.predictionActive(s, result.markets) + case "closed": + g.predictionClosed(s, result.markets, r.page) + case "view": + if _, ok := result.markets[r.id]; ok { + g.predictionView(s, result.markets[r.id], result.history) + } else { + g.predictionFrame(s, "MARKET UNAVAILABLE", []string{"That market is no longer available."}) + g.predictionHints(s) + } + case "rules": + g.predictionRules(s, result.markets[r.id]) + case "buy", "sell": + traded := g.tradePrediction(s, r, result.markets[r.id]) + if traded && r.action == "sell" { + g.predictionActive(s, result.markets) + } else { + g.predictionHints(s) + } + } + }) + } + if g.owner == nil { + work() + } else { + go work() + } +} + +func fetchPredictions(ctx context.Context, client *prediction.Client, r predictionRequest, ids map[string]bool) predictionResult { + result := predictionResult{markets: make(map[string]prediction.Market)} + if r.action == "list" || len(ids) == 0 { + result.list, result.err = client.List(ctx) + if result.err != nil { + return result + } + } + var mu sync.Mutex + var wg sync.WaitGroup + slots := make(chan struct{}, 4) + for id := range ids { + wg.Add(1) + go func(id string) { + defer wg.Done() + select { + case slots <- struct{}{}: + case <-ctx.Done(): + mu.Lock() + result.err = ctx.Err() + mu.Unlock() + return + } + defer func() { <-slots }() + m, err := client.Market(ctx, id) + mu.Lock() + defer mu.Unlock() + if err != nil { + result.err = err + } else { + result.markets[id] = m + } + }(id) + } + wg.Wait() + if result.err == nil && r.action == "view" { + _, tokens, _, err := result.markets[r.id].Values() + if err != nil { + result.err = err + } else { + result.history, result.err = client.History(ctx, tokens[0]) + } + } + return result +} + +// Persist ledger and credits together. Roll back only when replacement did not +// happen; casinoWallet.save already handles durability-uncertain replacements. +func (g *Game) savePredictions(s *net.Session, before []prediction.Contract, credits int) bool { + if err := (casinoWallet{g: g, p: s.Player}).save("prediction market"); err != nil { + log.Printf("prediction market save failed for %s: %v", s.Player.Name, err) + s.Player.PredictionContracts = before + s.Player.Credits = credits + s.WriteLine("The counter cannot record this transaction. Your credits and contracts are unchanged.") + return false + } + return true +} + +// settlePredictions marks finalized contracts as resolved and records their +// payout, but does not move any credits. Winnings stay at the counter until the +// player withdraws them with collect, so a resolved contract is never silently +// spent and a losing contract leaves a visible closed record. +func (g *Game) settlePredictions(s *net.Session, markets map[string]prediction.Market) bool { + p := s.Player + before := append([]prediction.Contract(nil), p.PredictionContracts...) + credits := p.Credits + count, won := 0, 0 + for i, c := range p.PredictionContracts { + if c.Status != "open" { + continue + } + price, ok := markets[c.MarketID].Settlement(c.Token) + if !ok { + continue + } + payout := prediction.SellValue(price, c.Quantity) + c.Status = "resolved" + c.Proceeds = payout + c.Closed = time.Now().UTC().Format(time.RFC3339) + p.PredictionContracts[i] = c + count++ + won += payout + } + if count == 0 { + return true + } + if !g.savePredictions(s, before, credits) { + return false + } + if won > 0 { + s.WriteLine(g.colorize(s, "casino_result_win", fmt.Sprintf("%d contract(s) resolved; %d credits await collection. Type collect to withdraw.", count, won))) + } else { + s.WriteLine(g.colorize(s, "casino_result_push", fmt.Sprintf("%d contract(s) resolved without a payout. Type collect to clear them.", count))) + } + return true +} + +// collectPredictions withdraws every resolved contract's payout and moves the +// contracts to collected. It needs no market data, so winnings remain reachable +// even when the market API is down. +func (g *Game) collectPredictions(s *net.Session) { + p := s.Player + before := append([]prediction.Contract(nil), p.PredictionContracts...) + credits := p.Credits + total, count := 0, 0 + for i, c := range p.PredictionContracts { + if c.Status != "resolved" { + continue + } + total += c.Proceeds + c.Status = "collected" + p.PredictionContracts[i] = c + count++ + } + if count == 0 { + s.WriteLine(g.colorize(s, "casino_action", "You have no resolved winnings to collect.")) + g.predictionHints(s) + return + } + balance, err := addCreditsChecked(p.Credits, total) + if err != nil { + s.WriteLine("Collecting would exceed your credit limit. Bank or spend some credits and try again.") + return + } + p.Credits = balance + if !g.savePredictions(s, before, credits) { + return + } + if total > 0 { + s.WriteLine(g.colorize(s, "casino_result_win", fmt.Sprintf("Collected %d credits from %d resolved contract(s).", total, count))) + } else { + s.WriteLine(g.colorize(s, "casino_result_push", fmt.Sprintf("Cleared %d resolved contract(s); no payout this time.", count))) + } + g.predictionClosed(s, nil, 1) +} +func (g *Game) tradePrediction(s *net.Session, r predictionRequest, m prediction.Market) (traded bool) { + p := s.Player + if !m.Tradable(time.Now()) { + s.WriteLine("This market is closed, paused, expired, or awaiting resolution. The house cannot trade it.") + return + } + before := append([]prediction.Contract(nil), p.PredictionContracts...) + credits := p.Credits + if r.action == "buy" { + names, tokens, prices, err := m.Values() + if err != nil { + s.WriteLine("Invalid outcome. Read the market again.") + return + } + n := -1 + if r.outcome >= 1 && r.outcome <= len(names) { + n = r.outcome - 1 + } + if n < 0 && r.outcomeName != "" { + // Accept exact, prefix or partial outcome names (buy 10 natus). + wanted := normalizeOutcome(r.outcomeName) + for _, pass := range []int{0, 1, 2} { + for i, name := range names { + candidate := normalizeOutcome(prediction.Clean(name, 80)) + match := candidate == wanted + if pass == 1 { + match = strings.HasPrefix(candidate, wanted) + } else if pass == 2 { + match = strings.Contains(candidate, wanted) + } + if match { + n = i + break + } + } + if n >= 0 { + break + } + } + } + if n < 0 { + s.WriteLine("Unknown outcome. Choose a number or name from the outcome list.") + g.predictionHints(s) + return + } + if prices[n] <= 0 || prices[n] >= 1 { + s.WriteLine("The house is not accepting trades at a terminal quote.") + return + } + active := 0 + for _, c := range before { + if c.Status == "open" { + active++ + } + } + if active >= prediction.MaxContracts { + s.WriteLine("Your portfolio is full. Sell or settle a contract first.") + return + } + cost := prediction.BuyCost(prices[n], r.quantity) + if cost > p.Credits { + s.WriteLine(fmt.Sprintf("You need %d carried credits. Chips and banked credits are not used.", cost)) + return + } + p.Credits -= cost + p.PredictionContracts = append(p.PredictionContracts, prediction.Contract{MarketID: m.ID, Title: prediction.Clean(m.Question, 200), Outcome: prediction.Clean(names[n], 80), Token: tokens[n], Quantity: r.quantity, Cost: cost, Status: "open", Opened: time.Now().UTC().Format(time.RFC3339)}) + if g.savePredictions(s, before, credits) { + traded = true + s.WriteLine(g.colorize(s, "casino_result_win", fmt.Sprintf("Bought %d shares of %s for %d credits; pays %d credits if it wins.", r.quantity, prediction.Clean(names[n], 80), cost, r.quantity*prediction.FaceValue))) + } + } else { + c := p.PredictionContracts[r.lot] + if c.Status != "open" { + s.WriteLine("That contract is no longer active.") + return + } + price, err := m.Price(c.Token) + if err != nil || price <= 0 || price >= 1 { + s.WriteLine("No tradable quote for this outcome.") + return + } + proceeds := prediction.SellValue(price, c.Quantity) + balance, err := addCreditsChecked(p.Credits, proceeds) + if err != nil { + s.WriteLine("This sale would exceed your credit limit.") + return + } + p.Credits = balance + c.Status = "sold" + c.Proceeds = proceeds + c.Closed = time.Now().UTC().Format(time.RFC3339) + p.PredictionContracts[r.lot] = c + if g.savePredictions(s, before, credits) { + traded = true + pl := g.colorize(s, "casino_result_win", fmt.Sprintf("Selling contract #%d paid %d credits.", r.lot+1, proceeds)) + if proceeds < c.Cost { + pl = g.colorize(s, "casino_result_loss", fmt.Sprintf("Selling contract #%d paid %d credits.", r.lot+1, proceeds)) + } + s.WriteLine(pl) + } + } + return +} + +func (g *Game) predictionList(s *net.Session, markets []prediction.Market, page int, search string) { + filtered := []prediction.Market{} + for _, m := range markets { + if search == "" || strings.Contains(strings.ToLower(m.Question+" "+m.Description), search) { + filtered = append(filtered, m) + } + } + pages := max(1, (len(filtered)+predictionPageSize-1)/predictionPageSize) + page = max(1, min(page, pages)) + s.PredictionPage = page + s.PredictionScreen = "list" + s.PredictionSelected = "" + s.PredictionSearch = search + s.PredictionIDs = nil + if len(filtered) == 0 { + g.predictionFrame(s, "NO OPEN MARKETS", []string{"No eligible markets matched this selection.", "Try markets to clear the search, or refresh to check again."}) + g.predictionHints(s) + return + } + width := predictionWidth(s) + oddsTarget := min(36, max(20, width-32)) + marketTarget := max(10, width-13-oddsTarget) + sep := g.predictionSep(s) + title := fmt.Sprintf("PREDICTION EXCHANGE%sMARKETS %d/%d", sep, page, pages) + if search != "" { + title = fmt.Sprintf("PREDICTION EXCHANGE%s%q %d/%d", sep, prediction.Clean(search, 24), page, pages) + } + rows := [][]string{} + for _, m := range filtered { + s.PredictionIDs = append(s.PredictionIDs, m.ID) + } + for i := (page - 1) * predictionPageSize; i < min(page*predictionPageSize, len(filtered)); i++ { + m := filtered[i] + rows = append(rows, []string{ + g.colorize(s, "casino_menu", strconv.Itoa(i+1)), + g.colorize(s, "casino_emphasis", prediction.Clean(m.Question, marketTarget)), + g.predictionOddsCell(s, m, oddsTarget), + }) + } + headers := []string{g.colorize(s, "casino_menu", "#"), g.colorize(s, "casino_menu", "Market"), g.colorize(s, "casino_menu", "Odds")} + g.predictionTable(s, g.colorize(s, "casino_emphasis", title), headers, rows, 1) + g.predictionHints(s) +} + +func (g *Game) predictionView(s *net.Session, m prediction.Market, history []prediction.Point) { + names, _, prices, err := m.Values() + if err != nil { + g.predictionFrame(s, "MARKET UNAVAILABLE", []string{"The market returned invalid data."}) + g.predictionHints(s) + return + } + state := "CLOSED / awaiting finalized result" + if m.Tradable(time.Now()) { + state = "OPEN" + } + if m.Resolution == "resolved" { + state = "RESOLVED" + } + s.PredictionScreen = "view" + s.PredictionSelected = m.ID + marketLines := []string{ + g.colorize(s, "casino_emphasis", prediction.Clean(m.Question, 200)), + g.colorize(s, "casino_action", "Scheduled end: "+prediction.Clean(m.End, 40)) + g.colorize(s, "casino_credits", " | "+state), + } + if bar := g.predictionOddsCell(s, m, predictionWidth(s)-6); bar != "" { + marketLines = append(marketLines, "", bar) + } + g.predictionFrame(s, "THE MARKET", marketLines) + outcomeRows := [][]string{} + for i, n := range names { + outcomeRows = append(outcomeRows, []string{ + g.colorize(s, "casino_menu", strconv.Itoa(i+1)), + g.colorize(s, "casino_emphasis", prediction.Clean(n, 40)), + g.colorize(s, "casino_result_loss", strconv.Itoa(prediction.BuyPrice(prices[i]))), + g.colorize(s, "casino_result_win", strconv.Itoa(prediction.SellPrice(prices[i]))), + }) + } + headers := []string{g.colorize(s, "casino_menu", "#"), g.colorize(s, "casino_menu", "Outcome"), g.colorize(s, "casino_menu", "Buy"), g.colorize(s, "casino_menu", "Sell")} + g.predictionTable(s, g.colorize(s, "casino_emphasis", "CHOOSE YOUR OUTCOME"), headers, outcomeRows, 1) + graphLines := g.predictionGraph(s, history, max(4, min(48, predictionWidth(s)-12))) + if len(history) > 1 { + graphLines = append(graphLines, "Period move: "+g.predictionDelta(s, (history[len(history)-1].P-history[0].P)*100, "pp")) + } + g.predictionFrame(s, "ODDS HISTORY / "+prediction.Clean(names[0], 22)+" / 1 WEEK", graphLines) + g.predictionHints(s) +} + +func (g *Game) predictionRules(s *net.Session, m prediction.Market) { + lines := []string{g.colorize(s, "casino_emphasis", prediction.Clean(m.Question, 200)), ""} + description := strings.ReplaceAll(m.Description, "\r\n", "\n") + if strings.TrimSpace(description) == "" { + lines = append(lines, "No resolution rules supplied. Consider avoiding this market.") + } else { + for _, paragraph := range strings.Split(description, "\n") { + lines = append(lines, prediction.Clean(paragraph, len([]rune(paragraph)))) + } + } + g.predictionFrame(s, "RESOLUTION RULES", lines) + g.predictionHints(s) +} + +func (g *Game) predictionActive(s *net.Session, markets map[string]prediction.Market) { + p := s.Player + s.PredictionScreen = "active" + s.PredictionSelected = "" + active := []int{} + for i, c := range p.PredictionContracts { + if c.Status == "open" { + active = append(active, i) + } + } + if len(active) == 0 { + g.predictionFrame(s, "ACTIVE CONTRACTS", []string{"You hold no active contracts.", "Browse the markets to place a prediction."}) + g.predictionHints(s) + return + } + width := predictionWidth(s) + budget := width - (3*6 + 1) + pickTarget := max(10, budget-30) + unicode := s.Player.OptionBool("unicode") + rows := [][]string{} + for _, i := range active { + c := p.PredictionContracts[i] + m := markets[c.MarketID] + price, err := m.Price(c.Token) + value := 0 + status := "" + if err != nil { + value = c.Cost + status = g.predictionSep(s) + "quote unavailable" + } else { + value = prediction.SellValue(price, c.Quantity) + if !m.Tradable(time.Now()) { + status = g.predictionSep(s) + "locked" + } + } + pl := value - c.Cost + category := "casino_result_push" + if pl > 0 { + category = "casino_result_win" + } else if pl < 0 { + category = "casino_result_loss" + } + pick := color.TruncateVisible(prediction.Clean(c.Outcome, 12)+g.predictionSep(s)+prediction.Clean(c.Title, 200)+status, pickTarget, unicode) + rows = append(rows, []string{ + g.colorize(s, "casino_menu", strconv.Itoa(i+1)), + g.colorize(s, "casino_emphasis", pick), + strconv.Itoa(c.Quantity), + strconv.Itoa(c.Cost), + strconv.Itoa(value), + g.colorize(s, category, fmt.Sprintf("%+d", pl)), + }) + } + headers := []string{g.colorize(s, "casino_menu", "#"), g.colorize(s, "casino_menu", "Pick"), g.colorize(s, "casino_menu", "Shares"), g.colorize(s, "casino_menu", "Cost"), g.colorize(s, "casino_menu", "Value"), g.colorize(s, "casino_menu", "P/L")} + g.predictionTable(s, g.colorize(s, "casino_emphasis", fmt.Sprintf("ACTIVE CONTRACTS (%d)", len(active))), headers, rows, 1) + g.predictionHints(s) +} + +func (g *Game) predictionClosed(s *net.Session, markets map[string]prediction.Market, page int) { + p := s.Player + s.PredictionScreen = "closed" + s.PredictionSelected = "" + closed := []int{} + for i, c := range p.PredictionContracts { + if c.Status != "open" { + closed = append(closed, i) + } + } + if len(closed) == 0 { + g.predictionFrame(s, "CLOSED CONTRACTS", []string{"You have no sold, resolved or collected contracts."}) + g.predictionHints(s) + return + } + // Newest first; contract numbers never change. + sort.SliceStable(closed, func(i, j int) bool { + a, b := p.PredictionContracts[closed[i]], p.PredictionContracts[closed[j]] + if a.Closed == b.Closed { + return closed[i] > closed[j] + } + return a.Closed > b.Closed + }) + pages := max(1, (len(closed)+predictionClosedPageSize-1)/predictionClosedPageSize) + page = max(1, min(page, pages)) + s.PredictionBoardPage = page + width := predictionWidth(s) + budget := width - (3*6 + 1) + pickTarget := max(10, budget-30) + unicode := s.Player.OptionBool("unicode") + rows := [][]string{} + start := (page - 1) * predictionClosedPageSize + for j := start; j < min(start+predictionClosedPageSize, len(closed)); j++ { + i := closed[j] + c := p.PredictionContracts[i] + pl := c.Proceeds - c.Cost + category := "casino_result_push" + if pl > 0 { + category = "casino_result_win" + } else if pl < 0 { + category = "casino_result_loss" + } + pick := color.TruncateVisible(prediction.Clean(c.Outcome, 12)+g.predictionSep(s)+prediction.Clean(c.Title, 200)+g.predictionSep(s)+c.Status, pickTarget, unicode) + rows = append(rows, []string{ + g.colorize(s, "casino_menu", strconv.Itoa(i+1)), + g.colorize(s, "casino_emphasis", pick), + strconv.Itoa(c.Quantity), + strconv.Itoa(c.Cost), + strconv.Itoa(c.Proceeds), + g.colorize(s, category, fmt.Sprintf("%+d", pl)), + }) + } + headers := []string{g.colorize(s, "casino_menu", "#"), g.colorize(s, "casino_menu", "Pick"), g.colorize(s, "casino_menu", "Shares"), g.colorize(s, "casino_menu", "Cost"), g.colorize(s, "casino_menu", "Value"), g.colorize(s, "casino_menu", "P/L")} + g.predictionTable(s, g.colorize(s, "casino_emphasis", fmt.Sprintf("CLOSED CONTRACTS%s%d/%d", g.predictionSep(s), page, pages)), headers, rows, 1) + g.predictionHints(s) +} 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") + } + }) +} diff --git a/internal/game/render_predictions.go b/internal/game/render_predictions.go new file mode 100644 index 0000000..851aa67 --- /dev/null +++ b/internal/game/render_predictions.go @@ -0,0 +1,341 @@ +package game + +import ( + "fmt" + "math" + "strconv" + "strings" + "unicode/utf8" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/prediction" + "thehouseoficarus/internal/ui" +) + +// Fixed fields keep the outcome labels, percentages and bars on the same +// columns from one market row to the next. +const ( + predictionNameWidth = 6 + predictionPctWidth = 5 +) + +func predictionWidth(s *net.Session) int { + width := 80 + if w := s.Player.OptionInt("wrap_width"); w > 0 { + width = min(width, w) + } + return max(12, width) +} +func (g *Game) predictionGlyph(s *net.Session, unicode, ascii string) string { + if s.Player.OptionBool("unicode") { + return unicode + } + return ascii +} +func (g *Game) predictionSep(s *net.Session) string { + if s.Player.OptionBool("unicode") { + return " · " + } + return " | " +} +func padRightRunes(s string, n int) string { + if d := n - utf8.RuneCountInString(s); d > 0 { + return s + strings.Repeat(" ", d) + } + return s +} +func padLeftRunes(s string, n int) string { + if d := n - utf8.RuneCountInString(s); d > 0 { + return strings.Repeat(" ", d) + s + } + return s +} + +func (g *Game) predictionFrame(s *net.Session, title string, lines []string) { + width := predictionWidth(s) + inner := width - 4 + tl, tr, bl, br, h, v := "+", "+", "+", "+", "-", "|" + if s.Player.OptionBool("unicode") { + tl, tr, bl, br, h, v = "╔", "╗", "╚", "╝", "═", "║" + } + border := func(text string) string { return g.colorize(s, "casino_menu", text) } + title = color.TruncateVisible(" "+title+" ", width-4, s.Player.OptionBool("unicode")) + top := tl + h + title + strings.Repeat(h, max(0, width-3-color.VisibleLen(title))) + tr + out := []string{border(top)} + for _, text := range lines { + for _, line := range predictionFrameLines(text, inner) { + out = append(out, border(v)+" "+line+strings.Repeat(" ", max(0, inner-color.VisibleLen(line)))+" "+border(v)) + } + } + out = append(out, border(bl+strings.Repeat(h, width-2)+br)) + s.WriteLine(strings.Join(out, "\n")) +} + +// WrapANSI emits CRLF. Strip those separators before padding framed rows; +// an embedded CR would return the Telnet cursor to column zero mid-row. +func predictionFrameLines(text string, width int) []string { + var out []string + for _, line := range strings.Split(color.WrapANSI(text, width), "\n") { + line = strings.TrimSuffix(line, "\r") + if color.VisibleLen(line) <= width { + out = append(out, line) + continue + } + // WrapANSI word-wraps but does not split long URLs/identifiers. Split those + // without cutting an ANSI sequence or losing any of the resolution text. + var b strings.Builder + runes := []rune(line) + visible := 0 + for i := 0; i < len(runes); i++ { + if runes[i] == '\x1b' && i+1 < len(runes) && runes[i+1] == '[' { + b.WriteRune(runes[i]) + i++ + b.WriteRune(runes[i]) + for i+1 < len(runes) { + i++ + b.WriteRune(runes[i]) + if runes[i] >= 0x40 && runes[i] <= 0x7e { + break + } + } + continue + } + if visible == width { + out = append(out, b.String()) + b.Reset() + visible = 0 + } + b.WriteRune(runes[i]) + visible++ + } + if b.Len() > 0 { + out = append(out, b.String()) + } + } + return out +} + +func (g *Game) predictionDirection(s *net.Session, delta float64) string { + if delta > 0 { + return g.predictionGlyph(s, "↑", "^") + } + if delta < 0 { + return g.predictionGlyph(s, "↓", "v") + } + return g.predictionGlyph(s, "→", "=") +} +func (g *Game) predictionDelta(s *net.Session, delta float64, unit string) string { + category := "casino_result_push" + if delta > 0 { + category = "casino_result_win" + } else if delta < 0 { + category = "casino_result_loss" + } + format := "%s %+.1f %s" + if unit == "cr" { + format = "%s %+.0f %s" + } + return g.colorize(s, category, fmt.Sprintf(format, g.predictionDirection(s, delta), delta, unit)) +} + +func predictionPct(p float64) string { + v := p * 100 + if math.Abs(v-math.Round(v)) < 0.05 { + return fmt.Sprintf("%.0f%%", math.Round(v)) + } + return fmt.Sprintf("%.1f%%", v) +} + +// predictionTable renders a shared, single-frame table with the same column +// layout across every counter screen. The flex column absorbs overflow so live +// numbers (shares, cost, P/L) are never truncated to make room for prose. +func (g *Game) predictionTable(s *net.Session, title string, headers []string, rows [][]string, flex int) { + unicode := s.Player.OptionBool("unicode") + width := predictionWidth(s) + budget := width - (3*len(headers) + 1) + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = color.VisibleLen(h) + } + for _, row := range rows { + for i, cell := range row { + if i < len(widths) && color.VisibleLen(cell) > widths[i] { + widths[i] = color.VisibleLen(cell) + } + } + } + total := 0 + for _, w := range widths { + total += w + } + if total > budget { + excess := total - budget + if flex >= 0 && flex < len(widths) && widths[flex] > 4 { + shrink := min(excess, widths[flex]-4) + widths[flex] -= shrink + excess -= shrink + } + for excess > 0 { + progress := false + for i := range widths { + if i == flex { + continue + } + if widths[i] > 1 { + widths[i]-- + excess-- + progress = true + if excess == 0 { + break + } + } + } + if !progress { + break + } + } + for r := range rows { + for i := range rows[r] { + if i < len(widths) { + rows[r][i] = color.TruncateVisible(rows[r][i], widths[i], unicode) + } + } + } + } + table := ui.Table{Title: title, Columns: headers, Rows: rows} + s.WriteLine(strings.Join(table.Render(unicode, width), "\n")) +} + +// predictionOddsCell draws one complementary bar per market. Every row shares +// the same left/right field widths, so outcome labels, percentages and bars +// line up down the column. Multi-outcome markets list their actual labels. +func (g *Game) predictionOddsCell(s *net.Session, m prediction.Market, target int) string { + names, _, prices, err := m.Values() + if err != nil || target < 8 { + return "" + } + unicode := s.Player.OptionBool("unicode") + if len(names) != 2 { + parts := make([]string, 0, len(names)) + for i, name := range names { + parts = append(parts, prediction.Clean(name, 12)+" "+predictionPct(prices[i])) + } + return color.TruncateVisible(strings.Join(parts, g.predictionSep(s)), target, unicode) + } + leftPct, rightPct := predictionPct(prices[0]), predictionPct(prices[1]) + left := padRightRunes(strings.ToUpper(prediction.Clean(names[0], predictionNameWidth)), predictionNameWidth) + + " " + padLeftRunes(leftPct, predictionPctWidth) + right := padRightRunes(rightPct, predictionPctWidth) + rightName := padRightRunes(strings.ToUpper(prediction.Clean(names[1], predictionNameWidth)), predictionNameWidth) + bar := target - color.VisibleLen(left) - color.VisibleLen(right) - color.VisibleLen(rightName) - 3 + if bar < 3 { + // Narrow screens: keep the aligned percentages, drop the names. + left = padLeftRunes(leftPct, predictionPctWidth) + right = padRightRunes(rightPct, predictionPctWidth) + rightName = "" + bar = target - 2*predictionPctWidth - 3 + } + if bar < 3 { + bar = 3 + } + split := int(math.Round(prices[0] * float64(bar))) + split = max(0, min(bar, split)) + fill, empty := g.predictionGlyph(s, "█", "#"), g.predictionGlyph(s, "░", ".") + barText := g.colorize(s, "casino_result_win", strings.Repeat(fill, split)) + + g.colorize(s, "casino_result_loss", strings.Repeat(empty, bar-split)) + cell := g.colorize(s, "casino_result_win", left) + " " + barText + " " + g.colorize(s, "casino_result_loss", right) + if rightName != "" { + cell += " " + g.colorize(s, "casino_action", rightName) + } + return color.TruncateVisible(cell, target, unicode) +} + +// predictionHeat maps a probability to an RGB color: red at 0%, green at 100%. +func predictionHeat(p float64) (int, int, int) { + if p < 0.5 { + t := p / 0.5 + return 230, int(80 + 120*t), 60 + } + t := (p - 0.5) / 0.5 + return int(230 - 170*t), 200, 80 +} + +// predictionGraph colors the odds history: the trace is shaded by price while +// the axes and labels stay dim, so the line reads clearly at a glance. +func (g *Game) predictionGraph(s *net.Session, points []prediction.Point, width int) []string { + raw := prediction.GraphMode(points, s.Player.OptionBool("unicode"), width) + lines := strings.Split(raw, "\n") + vert := "|" + if s.Player.OptionBool("unicode") { + vert = "│" + } + mode := g.colorMode(s) + out := make([]string, 0, len(lines)) + for _, line := range lines { + i := strings.Index(line, vert) + j := strings.LastIndex(line, vert) + if i < 0 || j <= i { + out = append(out, g.colorize(s, "casino_action", line)) + continue + } + pct := 0.0 + if len(line) >= 4 && line[3] == '%' { + if n, err := strconv.Atoi(strings.TrimSpace(line[:3])); err == nil { + pct = float64(n) / 100 + } + } + r, gc, b := predictionHeat(pct) + spec := color.ColorSpec{Fg: color.NearestXterm256(r, gc, b)} + out = append(out, g.colorize(s, "casino_action", line[:i+len(vert)])+ + color.Render(mode, spec, line[i+len(vert):j])+ + g.colorize(s, "casino_action", line[j:])) + } + return out +} + +// predictionCommands returns the context-sensitive verb hints. The counter +// uses the same menu hooks as every casino game, so look/what and the +// casino_hints option behave identically. +func (g *Game) predictionCommands(s *net.Session) []casino.MenuCommand { + if s.PredictionBusy { + return []casino.MenuCommand{{Command: "stop", Desc: "Leave the counter and cancel the pending request"}} + } + commands := []casino.MenuCommand{} + if !s.PredictionUnavailable { + switch s.PredictionScreen { + case "view": + commands = append(commands, casino.MenuCommand{Command: "buy SHARES OUTCOME", Desc: "Buy outcome shares, e.g. buy 10 yes"}, casino.MenuCommand{Command: "back", Desc: "Return to your place in the market list"}) + case "active": + commands = append(commands, casino.MenuCommand{Command: "holding N", Desc: "Open contract N's market (or type its number)"}, casino.MenuCommand{Command: "sell N", Desc: "Sell all shares of contract N to the house"}) + case "closed": + commands = append(commands, casino.MenuCommand{Command: "holding N", Desc: "Open contract N's market (or type its number)"}) + default: + commands = append(commands, casino.MenuCommand{Command: "view N", Desc: "Open market N (or type its number)"}, casino.MenuCommand{Command: "find WORDS", Desc: "Search the popular-market selection"}) + } + if s.PredictionScreen == "list" || s.PredictionScreen == "closed" { + commands = append(commands, casino.MenuCommand{Command: "next", Desc: "Next page"}, casino.MenuCommand{Command: "prev", Desc: "Previous page"}) + } + rules := "How to play prediction markets" + if s.PredictionScreen == "view" { + rules = "Resolution rules for the selected market" + } + commands = append(commands, casino.MenuCommand{Command: "markets", Desc: "Browse the market selection"}, casino.MenuCommand{Command: "active", Desc: "Your open contracts"}, casino.MenuCommand{Command: "closed", Desc: "Your sold and resolved contracts"}, casino.MenuCommand{Command: "rules", Desc: rules}) + } + return append(commands, casino.MenuCommand{Command: "collect", Desc: "Withdraw winnings from resolved contracts"}, casino.MenuCommand{Command: "refresh", Desc: "Update this screen with live data"}, casino.MenuCommand{Command: "stop", Desc: "Leave the counter"}) +} + +// predictionHints uses the exact same visibility policy, balance line and +// command-column renderer as the other casino games. +func (g *Game) predictionHints(s *net.Session, force ...bool) { + forced := len(force) > 0 && force[0] + show := g.casinoHintsVisible(s, &casino.MenuView{ShowHints: s.PredictionShowHints}) + s.PredictionShowHints = false + if !forced && !show { + return + } + balance := fmt.Sprintf("Available %s credits", g.colorize(s, "casino_credits", fmt.Sprint(s.Player.Credits))) + s.WriteLine("") + s.WriteLine(g.renderCasinoCommands(s, balance, "", g.predictionCommands(s))) +} diff --git a/internal/game/vesta_world_test.go b/internal/game/vesta_world_test.go index 3a2e320..8072dca 100644 --- a/internal/game/vesta_world_test.go +++ b/internal/game/vesta_world_test.go @@ -285,8 +285,9 @@ func TestVestaCasinoFloorsAndVariants(t *testing.T) { continue } // The vestibule stays at street level (below the gaming floor); - // every gaming room shares 6101's floor. - if id != 6100 && grid.Coord[id][2] != grid.Coord[6101][2] { + // every gaming room shares 6101's floor. Support rooms (the + // vestibule, the Prediction Exchange) may sit on the ground floor. + if id != 6100 && (len(r.CasinoTables)+len(r.CasinoMachines)) > 0 && grid.Coord[id][2] != grid.Coord[6101][2] { t.Errorf("casino room %d drifted off the gaming floor", id) } if strings.Contains(r.Name, "Future ") { |
