aboutsummaryrefslogtreecommitdiff
path: root/internal/game/predictions.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/predictions.go')
-rw-r--r--internal/game/predictions.go737
1 files changed, 737 insertions, 0 deletions
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)
+}