diff options
Diffstat (limited to 'internal/game/render_predictions.go')
| -rw-r--r-- | internal/game/render_predictions.go | 341 |
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))) +} |
