aboutsummaryrefslogtreecommitdiff
path: root/internal/game/render_predictions.go
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/render_predictions.go
parent8b78aa4cd930fde145959704ba7bdc0e57465b9b (diff)
downloadthehouseoficarus-c84a9944356f590d90d819d652bdf1349ebacd40.tar.gz
feat: prediction exchange with live polymarket data
Diffstat (limited to 'internal/game/render_predictions.go')
-rw-r--r--internal/game/render_predictions.go341
1 files changed, 341 insertions, 0 deletions
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)))
+}