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]) } number1 := func() int { if len(args) <= 1 { return 0 } n, _ := strconv.Atoi(args[1]) 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 = number1() } 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 := number1() 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 = number1() 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 = number1() - 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, 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 // Compute the payout before mutating contract status: if the credit cap // rejects the deposit we must leave the contracts resolved so the player // can collect later. Marking them collected first destroyed the winnings. total, count := 0, 0 for _, c := range p.PredictionContracts { if c.Status != "resolved" { continue } total += c.Proceeds 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 } for i, c := range p.PredictionContracts { if c.Status != "resolved" { continue } c.Status = "collected" p.PredictionContracts[i] = c } 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, 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, 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) }