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))) }