aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-05 06:03:49 -0400
committerhistoria <[not public]>2026-10-05 06:03:49 -0400
commitc84a9944356f590d90d819d652bdf1349ebacd40 (patch)
treeaf1ee15992e141fb3eb6f26cac02cf06306d4982 /internal/game
parent8b78aa4cd930fde145959704ba7bdc0e57465b9b (diff)
downloadthehouseoficarus-c84a9944356f590d90d819d652bdf1349ebacd40.tar.gz
feat: prediction exchange with live polymarket data
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/cmd_look.go10
-rw-r--r--internal/game/cmd_registry.go4
-rw-r--r--internal/game/cmd_use.go7
-rw-r--r--internal/game/cmd_verbs.go4
-rw-r--r--internal/game/game.go9
-rw-r--r--internal/game/prediction_interface.go155
-rw-r--r--internal/game/prediction_interface_test.go272
-rw-r--r--internal/game/prediction_ux_regression_test.go171
-rw-r--r--internal/game/predictions.go737
-rw-r--r--internal/game/predictions_test.go281
-rw-r--r--internal/game/render_predictions.go341
-rw-r--r--internal/game/vesta_world_test.go5
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 ") {