diff options
| author | historia <[not public]> | 2026-10-05 06:03:49 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-05 06:03:49 -0400 |
| commit | c84a9944356f590d90d819d652bdf1349ebacd40 (patch) | |
| tree | af1ee15992e141fb3eb6f26cac02cf06306d4982 | |
| parent | 8b78aa4cd930fde145959704ba7bdc0e57465b9b (diff) | |
| download | thehouseoficarus-c84a9944356f590d90d819d652bdf1349ebacd40.tar.gz | |
feat: prediction exchange with live polymarket data
24 files changed, 2657 insertions, 36 deletions
diff --git a/data/help/casino.yaml b/data/help/casino.yaml index 478a742..6e2b7ce 100644 --- a/data/help/casino.yaml +++ b/data/help/casino.yaml @@ -1,10 +1,14 @@ name: casino aliases: [play, bet, rebet, table, watch, rules] category: Interaction -related: [big6, roulette, racetrack, blackjack, baccarat, craps, slots, video poker, pai gow poker, plinko, cash hunt] +related: [big6, roulette, racetrack, blackjack, baccarat, craps, slots, video poker, pai gow poker, plinko, cash hunt, predictions] description: | Casino quick guide + use predictions Enter the casino's Prediction Exchange, east of the vestibule. + Buy/sell house contracts and check your portfolio. + See help predictions for prices, limits and settlement. + play <game> Join a table; use play to list nearby games. bet N Wager chips first, then credits. bet N on BET Choose a roulette, Big 6, baccarat or craps bet. diff --git a/data/help/predictions.yaml b/data/help/predictions.yaml new file mode 100644 index 0000000..e00ef34 --- /dev/null +++ b/data/help/predictions.yaml @@ -0,0 +1,67 @@ +name: predictions +aliases: [prediction market, prediction counter, prediction exchange, use predictions] +category: Interaction +related: [casino, bank] +description: | + Prediction Exchange + + In the casino, enter the Prediction Exchange east of the vestibule. Use + predictions, use market, or use exchange at the counter. The commands + below work only inside the exchange. Type stop (or leave/exit) to step + away; your contracts remain invested. + + N / view N Open a numbered market + markets Browse popular eligible markets + next / prev Turn market or contract pages + find WORDS Search the popular-market selection + buy SHARES OUTCOME Buy outcome shares at the live quote. + OUTCOME is a number or name: buy 10 yes + active Your open contracts, valued and marked + closed Your sold, resolved and collected contracts + N / holding N Read contract N's market from active/closed + sell N Sell all shares of contract N to the house + collect Withdraw winnings from resolved contracts + back Return to the market selection + refresh / table Refresh the current screen + look Room, current market screen and casino hints + rules Selected market's resolution rules; + general game rules when browsing + help predictions How to play + stop Leave the counter + + Example: use market, then 1, then buy 10 yes buys ten shares of the + "Yes" outcome. buy 10 2 also works. Example: sell 3 sells all shares of + contract 3. Sales close the whole contract; buy separate contracts if + you want to sell in smaller portions. + + Markets are listed in one shared table: number, question, and a single + two-sided odds bar with a percent at each end. Unicode frames, bars and + line-history graphs follow your unicode option; ASCII mode substitutes + plain characters. Colors follow your color mode. Hints use the standard + casino command menu and casino_hints option. Look shows the room followed + by the live counter and hints, like other casino games. The normal + what/verbs commands also list available commands. Resolution rules are + shown only when requested with rules. History plots the first outcome's + available prices over the past week; new markets may be empty. + + All contracts use fictional carried credits. There is no real-money + trading, wallet, external account, or player order book. The house uses + Polymarket's indicative outcome prices. Winning shares pay 100 credits; + losing shares pay nothing. When a market closes, each contract resolves + automatically: a winning share is worth 100 credits and a losing share 0. + Resolved winnings wait at the counter until you type collect. Each share's + buy price rounds up and sell price rounds down to a whole credit BEFORE + multiplying by quantity: a 42.1-credit quote buys at 43 and sells at 42 + credits per share. No partial credits or fees. Maximum 50 active contracts; + 1-10,000 shares per purchase. + + Prices update only on interaction, with no background polling. A failed + request closes the counter for that interaction: no stale-price trading. + Leaving the interface during an update cancels the pending operation. + + Closed, paused, expired markets and sports past a supplied start time + cannot trade. Finalized outcomes settle on your next interaction, even + after reconnecting. Ambiguous or disputed resolutions wait without payout. + Open values are indicative, not guaranteed sale proceeds; closed values + are actual proceeds. Browsing selects up to 100 popular markets, while + contracts outside that selection remain on your active list. diff --git a/data/help/use.yaml b/data/help/use.yaml index 34f5a4b..22f4658 100644 --- a/data/help/use.yaml +++ b/data/help/use.yaml @@ -10,6 +10,12 @@ description: | The "use" command routes to different systems based on the target: + Prediction Exchange (east of the casino vestibule) + use predictions, use market, or use exchange opens the counter. + Choose a market number, then buy shares, check active/closed, or sell. + Resolved contracts are collected with collect. + Look shows current casino hints; stop leaves the interface. + Station object (furnace, anvil, fire, range, workbench, etc.) Shows a combined production menu of all recipes you can make. @@ -26,6 +32,7 @@ description: | production action. You are prompted "How many?" before starting. Examples: + use market Open the casino prediction exchange use range Cook using a cooking range use furnace Smelt ore or craft jewelry use anvil Smith bars into equipment @@ -35,7 +42,7 @@ description: | use flour on water Combine into dough push gate Push a gate open (shortcut for use) - See also: cook, smelt, smith, recipe + See also: cook, smelt, smith, recipe, predictions aliases: - "pull" diff --git a/data/mobs/vesta/casino_host.yaml b/data/mobs/vesta/casino_host.yaml index f41360a..ae236c2 100644 --- a/data/mobs/vesta/casino_host.yaml +++ b/data/mobs/vesta/casino_host.yaml @@ -12,7 +12,8 @@ talk: - 'Welcome to Domita Fortuna. Take the stairs up to the gaming street: blackjack, baccarat, roulette, craps, then slots, one salon each going east. Step north or south inside each salon for its house editions. - The bank booth down by the door stores your valuables, and no - membership or wager is needed to explore.' + The Prediction Exchange is through the east arch, where you can back + real-world questions. The bank booth down by the door stores your + valuables, and no membership or wager is needed to explore.' options: - text: Thank you. diff --git a/data/mobs/vesta/prediction_clerk.yaml b/data/mobs/vesta/prediction_clerk.yaml new file mode 100644 index 0000000..4207dff --- /dev/null +++ b/data/mobs/vesta/prediction_clerk.yaml @@ -0,0 +1,69 @@ +name: Quill the prediction clerk +description: Quill wears a brass eye-shade and keeps one finger on a chalk rail of shifting odds. +protected: true +unique: true +idle_descriptions: +- rubs out a price and writes a new one +- taps the odds board with a chalk stick +- glances from the board to a ledger and back +talk: + nodes: + start: + messages: + - Welcome to the Prediction Exchange. Every board here asks a real-world question, and + every question has outcomes you can back with credits. + - A winning share pays 100 credits. A losing share pays nothing. The house quotes the + other side of every trade, so you never need another player at the counter. + options: + - goto: trade + text: How do I place a prediction? + - goto: prices + text: How do the prices work? + - goto: resolve + text: What happens when a market resolves? + - goto: goodbye + text: Just looking, thank you. + trade: + messages: + - 'Use predictions at the counter, then browse the numbered markets. Pick one with + its number to see the odds and the resolution rules.' + - 'To buy, type buy SHARES OUTCOME — for example, buy 10 yes, or buy 10 natus vincere. + Outcome names and numbers both work, and a partial name is enough if it is unambiguous.' + - 'To close a position early, type sell CONTRACT from your active list. Your open + contracts and their profit or loss live under active; finished ones under closed.' + options: + - goto: prices + text: And the prices? + - goto: resolve + text: What about resolved markets? + - goto: start + text: Back to the beginning. + prices: + messages: + - Each share trades between 0 and 100 credits, roughly tracking the crowd's chance of + that outcome. A 30-credit share is an implied 30 percent chance. + - The board moves as the world moves. Each share's buy price rounds up and its sell + price rounds down to whole credits, so there are no half-credits at the counter. + - Prices refresh whenever someone uses the counter. If the feed is unavailable we close + the desk rather than trade on stale numbers. + options: + - goto: trade + text: How do I trade again? + - goto: resolve + text: What happens when it resolves? + - goto: start + text: Back to the beginning. + resolve: + messages: + - When a market closes, every contract resolves on its own. A winning share becomes + worth 100 credits and a losing share becomes worth nothing. + - The winnings wait here at the counter until you type collect. You can read a market's + fine print with rules before you buy, and closed shows everything you have settled. + options: + - goto: trade + text: How do I buy shares? + - goto: start + text: Back to the beginning. + goodbye: + messages: + - Good luck. The board is always moving, and there is no rush to trade a quiet market. diff --git a/data/rooms/casino/6100.yaml b/data/rooms/casino/6100.yaml index e0734b5..ef03517 100644 --- a/data/rooms/casino/6100.yaml +++ b/data/rooms/casino/6100.yaml @@ -1,20 +1,22 @@ name: Vestibule description: A ceiling of tiny brass suns hangs over polished stone. Icarus Leisure's emblem repeats on - every column. Grand stairs rise to the ground gaming floor. + every column. Grand stairs rise to the ground gaming floor, and the Prediction Exchange glows to the east. exits: south: room: 5111 up: room: 6101 + east: + room: 6110 mobs: - id: casino_host - id: casino_cashier objects: - name: wayboard description: 'Domita Fortuna in one line: take the stairs up to the gaming street — blackjack, - baccarat, roulette, craps, slots, video poker, then Pai Gow Poker, going east. Each salon keeps its own house - editions in the galleries north and south. The bank booth by the door stores your valuables; - no wager is needed to explore.' + baccarat, roulette, craps, slots, video poker, then Pai Gow Poker, going east. The Prediction + Exchange is through the east arch. Each salon keeps its own house editions in the galleries north + and south. The bank booth by the door stores your valuables; no wager is needed to explore.' inroom_description: A wayboard stands where travelers can read it. - id: bank_booth item_spawns: diff --git a/data/rooms/casino/6110.yaml b/data/rooms/casino/6110.yaml new file mode 100644 index 0000000..0e69c41 --- /dev/null +++ b/data/rooms/casino/6110.yaml @@ -0,0 +1,17 @@ +name: Prediction Exchange +prediction_exchange: true +description: Brass rails divide a wall of glowing odds boards from a polished counter. Each board + carries real-world questions with a yes-and-no bar; clerks chalk new prices as the world moves. + A bell rings somewhere above the trading floor. +exits: + west: + room: 6100 +mobs: +- id: prediction_clerk +objects: +- name: odds board + description: 'The board lists open markets with live prices. Winning shares pay 100 credits + each. Use predictions (or use market/use exchange) to trade, active and closed to review your + contracts, and collect to withdraw resolved winnings. The house takes the other side of every + trade; players never trade with each other.' + inroom_description: A vast odds board glows above the counter. diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go index 38cc83c..ab01c4c 100644 --- a/internal/game/cmd_look.go +++ b/internal/game/cmd_look.go @@ -65,6 +65,16 @@ func (g *Game) doLook(sess *net.Session) { // Seated at a table: look keeps its room-first order, then appends the // live game state and the current hint menu. g.showCasinoGameLook(sess, p, room) + if sess.PredictionActive && room.PredictionExchange { + sess.WriteLine("") + if sess.PredictionBusy { + sess.WriteLine("Updating markets...") + sess.PredictionShowHints = true + g.predictionHints(sess) + } else { + g.executePredictions(sess, []string{"refresh"}, "look") + } + } } func (g *Game) doExits(sess *net.Session) { diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index 4d48509..abc1539 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -398,6 +398,10 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI } } + if g.handlePredictionInput(sess, cmd, args) { + return + } + // Cash Hunt owns numeric choices only while seated; other games and // world commands keep their usual behavior. if p != nil && g.Casino != nil { diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go index 8c82f9c..54a36f5 100644 --- a/internal/game/cmd_use.go +++ b/internal/game/cmd_use.go @@ -29,7 +29,12 @@ func (g *Game) doUse(sess *net.Session, input string) { return } - lower := strings.ToLower(input) + lower := strings.ToLower(strings.TrimSpace(input)) + + if predictionUseTarget(lower) && g.atPredictionCounter(p) { + g.openPredictionCounter(sess) + return + } if lower == "bank" || lower == "bank booth" || strings.HasPrefix(lower, "bank ") { g.doBank(sess) diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go index 5cbbd5f..87e8ee0 100644 --- a/internal/game/cmd_verbs.go +++ b/internal/game/cmd_verbs.go @@ -191,6 +191,10 @@ func (g *Game) collectRoomCommands(sess *net.Session) roomCommandSections { func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) { p := sess.Player + if sess.PredictionActive && g.atPredictionCounter(p) { + g.showCasinoHints(sess, g.predictionCommands(sess)) + return + } // A seated casino player has no room verbs: the only valid commands are // the game's live hints, so what/verbs shows those instead of exits and // objects that cannot be used mid-hand. diff --git a/internal/game/game.go b/internal/game/game.go index 521c892..1ce89e5 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -21,6 +21,7 @@ import ( "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" + "thehouseoficarus/internal/prediction" "thehouseoficarus/internal/world" ) @@ -60,6 +61,7 @@ type Game struct { Hub *net.Hub GlobalFlags *GlobalFlagStore Combat *combat.Tracker + PredictionClient *prediction.Client Casino *casino.Manager flagIndex *flagTriggerIndex queue *CommandQueue @@ -629,6 +631,13 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { } cmd := parts[0] + // Counter commands exist only while using the counter, before global prefixes. + if g.predictionInputOwned(sess, cmd) { + g.executeCommand(sess, cmd, parts[1:], input) + g.writePrompt(sess) + return + } + // Seated casino players may shorten any of their game's verbs to an // unambiguous prefix (b 1000 -> bet 1000, sp -> spin at Big 6). Only the // current game's vocabulary is considered, and exact commands still win, so diff --git a/internal/game/prediction_interface.go b/internal/game/prediction_interface.go new file mode 100644 index 0000000..2e37145 --- /dev/null +++ b/internal/game/prediction_interface.go @@ -0,0 +1,155 @@ +package game + +import ( + "strings" + + "thehouseoficarus/internal/net" +) + +func predictionUseTarget(input string) bool { + switch strings.ToLower(strings.TrimSpace(input)) { + case "predictions", "prediction", "market", "counter", "exchange", "prediction market", "prediction counter", "prediction exchange", "prediction market counter": + return true + } + return false +} + +var predictionVerbs = []string{"markets", "view", "find", "next", "prev", "back", "active", "closed", "holding", "buy", "sell", "collect", "refresh", "rules", "stop"} + +func (g *Game) openPredictionCounter(s *net.Session) { + if !g.atPredictionCounter(s.Player) { + s.WriteLine("There is no Prediction Exchange here.") + return + } + if !g.leaveCasinoGames(s.Player) { + s.WriteLine("Finish your current casino round before using the counter.") + return + } + if s.PredictionActive { + g.executePredictions(s, []string{"refresh"}, "") + return + } + s.PredictionActive = true + s.PredictionEpoch++ + s.PredictionBusy = false + s.PredictionSelected = "" + s.PredictionScreen = "list" + s.PredictionPage = 1 + s.PredictionBoardPage = 1 + s.PredictionSearch = "" + s.PredictionUnavailable = false + g.executePredictions(s, nil, "open") +} + +func (g *Game) closePredictionCounter(s *net.Session) { + if s.PredictionCancel != nil { + s.PredictionCancel() + s.PredictionCancel = nil + } + s.PredictionActive = false + s.PredictionEpoch++ // Invalidate any download, including an in-flight trade. + s.PredictionBusy = false + s.PredictionSelected = "" +} + +// Exact world commands keep their meaning unless explicitly part of this UI. +// Abbreviations are scoped to the counter; no new global command is registered. +func predictionVerb(cmd string) (string, bool) { + switch cmd { + case "list": + return "markets", true + case "table", "game": + return "refresh", true + case "status", "portfolio", "board": + return "active", true + case "help": + return "help", true + case "leave", "exit": + return "stop", true + } + for _, v := range predictionVerbs { + if cmd == v { + return v, true + } + } + if _, exists := commandRegistry[cmd]; exists { + return "", false + } + found := "" + for _, v := range predictionVerbs { + if strings.HasPrefix(v, cmd) { + if found != "" { + return "", true + } + found = v + } + } + return found, found != "" +} +func (g *Game) predictionInputOwned(s *net.Session, cmd string) bool { + if !s.PredictionActive || !g.atPredictionCounter(s.Player) { + return false + } + if isAllDigits(cmd) { + return true + } + _, ok := predictionVerb(cmd) + return ok +} +func (g *Game) handlePredictionInput(s *net.Session, cmd string, args []string) bool { + if !s.PredictionActive { + return false + } + if !g.atPredictionCounter(s.Player) { + g.closePredictionCounter(s) + return false + } + if cmd == "help" && len(args) > 0 { + return false + } + if isAllDigits(cmd) { + action := "view" + if s.PredictionScreen == "active" || s.PredictionScreen == "closed" { + action = "holding" + } + g.executePredictions(s, []string{action, cmd}, "") + return true + } + verb, owned := predictionVerb(cmd) + if !owned { + if cmd == "use" && predictionUseTarget(strings.Join(args, " ")) { + return false + } + switch cmd { + case "play", "use", "bank", "quit", "logout": + g.closePredictionCounter(s) + } + return false + } + if verb == "" { + s.WriteLine("That shortcut is ambiguous. Choose a command below.") + g.predictionHints(s) + return true + } + if verb == "stop" { + g.closePredictionCounter(s) + s.WriteLine(g.colorize(s, "casino_action", "You step away from the Prediction Exchange. Your contracts stay with the house.")) + return true + } + switch verb { + case "markets": + verb = "list" + case "back": + args = nil + case "help": + g.predictionHelp(s) + return true + case "rules": + if s.PredictionScreen != "view" || s.PredictionSelected == "" { + g.predictionHelp(s) + return true + } + } + g.executePredictions(s, append([]string{verb}, args...), "") + return true +} diff --git a/internal/game/prediction_interface_test.go b/internal/game/prediction_interface_test.go new file mode 100644 index 0000000..e6a9dc9 --- /dev/null +++ b/internal/game/prediction_interface_test.go @@ -0,0 +1,272 @@ +package game + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "regexp" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + "unicode/utf8" + + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/prediction" +) + +func predictionTestServer(t *testing.T, g *Game, m prediction.Market) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/markets": + json.NewEncoder(w).Encode([]prediction.Market{m}) + case "/markets/1": + json.NewEncoder(w).Encode(m) + case "/prices-history": + w.Write([]byte(`{"history":[{"t":100,"p":0.3},{"t":200,"p":0.6}]}`)) + default: + w.WriteHeader(404) + } + })) + t.Cleanup(server.Close) + g.PredictionClient = prediction.NewClient() + g.PredictionClient.Gamma = server.URL + g.PredictionClient.CLOB = server.URL +} +func TestPredictionModalCommandsAndEntryAliases(t *testing.T) { + for _, alias := range []string{"predictions", "market", "counter", "exchange", "prediction market counter"} { + t.Run(alias, func(t *testing.T) { + g, s, conn, m := predictionFixture(t) + predictionTestServer(t, g, m) + if _, ok := commandRegistry["predictions"]; ok { + t.Fatal("standalone predictions command survives") + } + if _, ok := commandRegistry["predict"]; ok { + t.Fatal("standalone predict alias survives") + } + if g.predictionInputOwned(s, "active") { + t.Fatal("counter verbs escaped session scope") + } + g.doUse(s, alias) + if !s.PredictionActive || s.PredictionScreen != "list" { + t.Fatalf("not entered: %s", conn.data) + } + g.handleGameCommand(s, "1") + if s.PredictionSelected != "1" || s.PredictionScreen != "view" { + t.Fatalf("numeric selection failed: %s", conn.data) + } + g.handleGameCommand(s, "buy 10 9") + if s.PredictionScreen != "view" || s.PredictionSelected != "1" || len(s.Player.PredictionContracts) != 0 { + t.Fatal("rejected order lost selection or changed ledger") + } + g.handleGameCommand(s, "buy 10 1") + if len(s.Player.PredictionContracts) != 1 || s.Player.Credits != 9570 { + t.Fatalf("context buy failed: %s", conn.data) + } + if s.PredictionScreen != "view" { + t.Fatal("purchase lost the selected market") + } + g.handleGameCommand(s, "active") + g.handleGameCommand(s, "1") + if s.PredictionScreen != "view" { + t.Fatal("portfolio number did not open holding") + } + g.handleGameCommand(s, "active") + g.handleGameCommand(s, "sell 1") + if s.Player.PredictionContracts[0].Status != "sold" { + t.Fatal("context sale failed") + } + g.handleGameCommand(s, "stop") + if s.PredictionActive || g.predictionInputOwned(s, "active") { + t.Fatal("stop did not leave mode") + } + }) + } +} +func TestPredictionScreenGlyphsColorAndWidths(t *testing.T) { + for _, unicode := range []bool{true, false} { + t.Run(map[bool]string{true: "unicode", false: "ascii"}[unicode], func(t *testing.T) { + g, s, conn, m := predictionFixture(t) + s.Player.Options["unicode"] = unicode + s.Player.Options["color"] = "xterm256" + s.Player.Options["casino_hints"] = "on" + g.predictionList(s, []prediction.Market{m}, 1, "") + m.Prices = `["0.60","0.40"]` + g.predictionList(s, []prediction.Market{m}, 1, "") + g.predictionView(s, m, []prediction.Point{{T: 100, P: .3}, {T: 200, P: .6}}) + text := string(conn.data) + if !strings.Contains(text, "\x1b[") { + t.Fatal("no color") + } + text = regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(text, "") + if strings.Contains(text, "predictions buy") || strings.Contains(text, "predictions board") { + t.Fatal("old command hints") + } + for _, want := range []string{"Available", "buy", "ODDS HISTORY", "Period move:", "+30.0", "Market", "Odds", "Outcome"} { + if !strings.Contains(text, want) { + t.Fatalf("missing %q", want) + } + } + if unicode { + for _, glyph := range []string{"╔", "║", "█", "░", "↑", "─"} { + if !strings.Contains(text, glyph) { + t.Fatalf("missing glyph %s", glyph) + } + } + } else { + for _, r := range text { + if r > 127 { + t.Fatalf("ASCII mode emitted %U", r) + } + } + if !strings.Contains(text, "#") || !strings.Contains(text, "^") { + t.Fatal("ASCII graphics missing") + } + } + for _, line := range strings.Split(text, "\n") { + if width := color.VisibleLen(strings.TrimSuffix(line, "\r")); width > 80 { + t.Fatalf("line exceeds terminal (%d): %s", width, line) + } + } + if !utf8.ValidString(text) { + t.Fatal("broken UTF-8") + } + }) + } +} +func TestPredictionHintPreferencesAndWorldCommands(t *testing.T) { + g, s, conn, m := predictionFixture(t) + predictionTestServer(t, g, m) + s.Player.Options["casino_hints"] = "off" + g.doUse(s, "market") + if strings.Contains(string(conn.data), "view N") { + t.Fatal("ignored hints off") + } + conn.data = nil + g.handleGameCommand(s, "what") + if !strings.Contains(string(conn.data), "view N") { + t.Fatal("global what did not show the casino menu") + } + if g.predictionInputOwned(s, "what") || g.predictionInputOwned(s, "verbs") { + t.Fatal("global commands claimed by counter") + } + if strings.Contains(string(conn.data), "YOUR NEXT MOVE") || strings.Contains(string(conn.data), "what /") { + t.Fatal("custom hint footer survived") + } + if g.predictionInputOwned(s, "north") || g.predictionInputOwned(s, "inventory") { + t.Fatal("world command intercepted") + } + epoch := s.PredictionEpoch + g.handlePredictionInput(s, "use", []string{"market"}) + if !s.PredictionActive || s.PredictionEpoch != epoch { + t.Fatal("reusing counter invalidates pending operation") + } + g.handlePredictionInput(s, "play", []string{"roulette"}) + if s.PredictionActive { + t.Fatal("another casino game did not release mode") + } +} +func TestPredictionStopCancelsPendingTrade(t *testing.T) { + g, s, _, m := predictionFixture(t) + entered, canceled := make(chan struct{}), make(chan struct{}) + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + close(entered) + <-r.Context().Done() + close(canceled) + })) + defer server.Close() + g.PredictionClient = prediction.NewClient() + g.PredictionClient.Gamma = server.URL + g.owner = newOwner() + defer g.owner.stop() + g.loggedInChars = map[string]*net.Session{"alice": s} + s.PredictionActive = true + s.PredictionSelected = m.ID + s.PredictionScreen = "view" + g.run(func() { g.executePredictions(s, []string{"buy", "10", "1"}, "") }) + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("request not started") + } + g.run(func() { g.executeCommand(s, "stop", nil, "stop") }) + select { + case <-canceled: + case <-time.After(time.Second): + t.Fatal("stop did not cancel network request") + } + g.run(func() { + if s.PredictionActive || s.PredictionBusy || s.Player.Credits != 10000 || len(s.Player.PredictionContracts) != 0 { + t.Fatal("stopped trade changed state") + } + }) + if calls.Load() != 1 { + t.Fatal("unexpected requests") + } +} +func TestPredictionRoomEntryClearsModeAndCancelsRequest(t *testing.T) { + _, s, _, _ := predictionFixture(t) + canceled := false + s.PredictionActive = true + s.PredictionBusy = true + s.PredictionSelected = "1" + s.PredictionCancel = func() { canceled = true } + hub := net.NewHub() + hub.Add(s) + hub.EnterRoom(s, 2) + if s.PredictionActive || s.PredictionBusy || s.PredictionSelected != "" || !canceled { + t.Fatal("room change retained counter mode") + } +} + +func TestPredictionBackKeepsSearchAndPage(t *testing.T) { + g, s, _, m := predictionFixture(t) + markets := make([]prediction.Market, 21) + for i := range markets { + markets[i] = m + markets[i].ID = strconv.Itoa(i + 1) + markets[i].Question = "Sports market " + markets[i].ID + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/markets": + json.NewEncoder(w).Encode(markets) + case "/prices-history": + w.Write([]byte(`{"history":[]}`)) + default: + n, _ := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/markets/")) + if n < 1 || n > len(markets) { + w.WriteHeader(404) + return + } + json.NewEncoder(w).Encode(markets[n-1]) + } + })) + defer server.Close() + g.PredictionClient = prediction.NewClient() + g.PredictionClient.Gamma = server.URL + g.PredictionClient.CLOB = server.URL + g.doUse(s, "market") + g.handleGameCommand(s, "find sports") + g.handleGameCommand(s, "next") + if s.PredictionPage != 2 { + t.Fatal("next did not turn page") + } + g.handleGameCommand(s, "5") + if s.PredictionSelected != "5" { + t.Fatal("wrong selected market") + } + g.handleGameCommand(s, "back") + if s.PredictionScreen != "list" || s.PredictionPage != 2 || s.PredictionSearch != "sports" { + t.Fatal("back lost browsing position") + } + g.handleGameCommand(s, "markets") + if s.PredictionPage != 1 || s.PredictionSearch != "" { + t.Fatal("markets did not reset selection") + } +} diff --git a/internal/game/prediction_ux_regression_test.go b/internal/game/prediction_ux_regression_test.go new file mode 100644 index 0000000..159a492 --- /dev/null +++ b/internal/game/prediction_ux_regression_test.go @@ -0,0 +1,171 @@ +package game + +import ( + "fmt" + "strings" + "testing" + + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/prediction" + "thehouseoficarus/internal/world" +) + +func TestPredictionOneComplementaryBarPerMarket(t *testing.T) { + for _, unicode := range []bool{true, false} { + t.Run(fmt.Sprint(unicode), func(t *testing.T) { + g, s, conn, m := predictionFixture(t) + s.Player.Options["unicode"] = unicode + for _, detail := range []bool{false, true} { + conn.data = nil + if detail { + g.predictionView(s, m, nil) + } else { + g.predictionList(s, []prediction.Market{m}, 1, "") + } + output := string(conn.data) + bars := 0 + for _, line := range strings.Split(output, "\n") { + if strings.Contains(line, "█") || strings.Contains(line, "#") && strings.Contains(line, ".") { + bars++ + } + } + if bars != 1 { + t.Fatalf("detail=%v: got %d bars, want one\n%s", detail, bars, output) + } + if !strings.Contains(output, "YES") || !strings.Contains(output, "42.1%") || !strings.Contains(output, "57.9%") || !strings.Contains(output, "NO") { + t.Fatal("missing two-sided odds labels") + } + if strings.Contains(output, m.Description) || strings.Contains(output, "RESOLUTION RULES") { + t.Fatal("resolution text shown by default") + } + if detail { + for _, quote := range []string{"CHOOSE YOUR OUTCOME", "Outcome", "Buy", "Sell", "43", "42", "58", "57"} { + if !strings.Contains(output, quote) { + t.Fatalf("missing integer quote %q", quote) + } + } + } + } + }) + } +} + +func TestPredictionRulesFramePreservesTextWithoutCarriageReturns(t *testing.T) { + for _, width := range []int{40, 80} { + t.Run(fmt.Sprint(width), func(t *testing.T) { + g, s, conn, m := predictionFixture(t) + s.Player.Options["wrap_width"] = width + url := "https://example.org/" + strings.Repeat("long-resolution-source", 10) + m.Description = strings.Repeat("This market resolves according to the party controlling the chamber. ", 12) + "\r\n\r\n" + url + predictionTestServer(t, g, m) + g.doUse(s, "market") + g.handleGameCommand(s, "1") + if strings.Contains(string(conn.data), "RESOLUTION RULES") { + t.Fatal("rules appeared before request") + } + conn.data = nil + g.handleGameCommand(s, "rules") + text := strings.ReplaceAll(string(conn.data), "\r\n", "\n") + if strings.Contains(text, "\r") { + t.Fatal("bare CR inside frame can overwrite text") + } + var body strings.Builder + for _, line := range strings.Split(text, "\n") { + if strings.HasPrefix(line, "║") { + if !strings.HasSuffix(line, "║") { + t.Fatalf("missing right edge: %q", line) + } + if color.VisibleLen(line) != width { + t.Fatalf("row width %d, want %d: %q", color.VisibleLen(line), width, line) + } + body.WriteString(strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "║"), "║"))) + } + } + if !strings.Contains(text, "RESOLUTION RULES") || !strings.Contains(body.String(), url) { + t.Fatal("rules or unbroken URL was lost during wrapping") + } + if s.PredictionScreen != "view" || s.PredictionSelected != m.ID { + t.Fatal("reading rules lost selected market") + } + }) + } +} + +func TestPredictionLookUsesCasinoMenuAndRoomFirstOrder(t *testing.T) { + g, s, conn, m := predictionFixture(t) + g.Hub = net.NewHub() + g.MobStore = world.NewMobStore(t.TempDir()) + s.Player.Options["tiny_map"] = "off" + s.Player.Options["casino_hints"] = "once" + predictionTestServer(t, g, m) + g.doUse(s, "counter") + g.handleGameCommand(s, "1") + conn.data = nil + g.handleGameCommand(s, "look") + text := strings.ReplaceAll(string(conn.data), "\r\n", "\n") + room, screen := strings.Index(text, "Test Counter"), strings.Index(text, "THE MARKET") + if room < 0 || screen < room { + t.Fatalf("look not room first: %s", text) + } + expected := g.renderCasinoCommands(s, "Available 10000 credits", "", g.predictionCommands(s)) + if !strings.Contains(text, expected) { + t.Fatalf("look did not use the shared casino menu\nwant: %s\ngot: %s", expected, text) + } + for _, unwanted := range []string{"YOUR NEXT MOVE", "what -", "what /", "RESOLUTION RULES"} { + if strings.Contains(text, unwanted) { + t.Fatalf("look leaked %q", unwanted) + } + } + conn.data = nil + g.handleGameCommand(s, "what") + text = strings.ReplaceAll(string(conn.data), "\r\n", "\n") + if !strings.Contains(text, g.casinoMenuCommands(s, g.predictionCommands(s))) { + t.Fatal("normal what did not use casino hint renderer") + } + if g.predictionInputOwned(s, "look") || g.predictionInputOwned(s, "what") { + t.Fatal("counter hijacked normal world command") + } + conn.data = nil + s.Player.Options["casino_hints"] = "off" + g.handleGameCommand(s, "look") + if strings.Contains(string(conn.data), "view N") { + t.Fatal("look ignored casino_hints off") + } +} + +func TestPredictionFrameLongColoredToken(t *testing.T) { + g, s, _, _ := predictionFixture(t) + s.Player.Options["color"] = "xterm256" + token := g.colorize(s, "casino_action", strings.Repeat("x", 160)) + lines := predictionFrameLines(token, 36) + for _, line := range lines { + if strings.Contains(line, "\r") || color.VisibleLen(line) > 36 { + t.Fatalf("unsafe wrapped row %q", line) + } + } +} + +func TestPredictionOddsBarsAlign(t *testing.T) { + g, s, _, _ := predictionFixture(t) + s.Player.Options["color"] = "none" + target := 36 + pairs := [][2]string{{"Yes", "No"}, {"M80", "TYLOO"}, {"9z", "Natus Vincere"}, {"Falcons", "Saints"}} + cells := make([]string, 0, len(pairs)) + for _, pair := range pairs { + m := prediction.Market{ID: "1", Outcomes: `["` + pair[0] + `","` + pair[1] + `"]`, Tokens: `["a","b"]`, Prices: `["0.421","0.579"]`} + cells = append(cells, g.predictionOddsCell(s, m, target)) + } + start := strings.Index(cells[0], "█") + if start < 0 { + t.Fatalf("no bar: %q", cells[0]) + } + for _, cell := range cells { + if got := strings.Index(cell, "█"); got != start { + t.Fatalf("bar offset %d, want %d: %q", got, start, cell) + } + if color.VisibleLen(cell) != target { + t.Fatalf("cell width %d, want %d: %q", color.VisibleLen(cell), target, cell) + } + } +} 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) +} diff --git a/internal/game/predictions_test.go b/internal/game/predictions_test.go new file mode 100644 index 0000000..f10f849 --- /dev/null +++ b/internal/game/predictions_test.go @@ -0,0 +1,281 @@ +package game + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/prediction" + "thehouseoficarus/internal/world" +) + +func predictionFixture(t *testing.T) (*Game, *net.Session, *casinoTestConn, prediction.Market) { + t.Helper() + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "rooms"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "rooms", "1.yaml"), []byte("name: Test Counter\nprediction_exchange: true\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "players", "characters"), 0700); err != nil { + t.Fatal(err) + } + g := &Game{Deps: Deps{World: world.New(dir), AccountStore: player.NewAccountStore(dir)}, Combat: combat.NewTracker()} + p := player.New("alice") + p.RoomID = 1 + p.Credits = 10000 + p.Options = map[string]any{"color": "none", "wrap_width": 0} + conn := &casinoTestConn{} + s := &net.Session{Player: p, Conn: conn} + s.SetState(net.StateGame) + m := prediction.Market{ID: "1", Question: "Will the sun rise?", Description: "Resolve Yes when the sun rises.", Outcomes: `["Yes","No"]`, Tokens: `["101","102"]`, Prices: `["0.421","0.579"]`, Active: true, Accepting: true, End: time.Now().Add(time.Hour).UTC().Format(time.RFC3339)} + return g, s, conn, m +} +func TestPredictionBuySellDurable(t *testing.T) { + g, s, conn, m := predictionFixture(t) + buy := predictionRequest{action: "buy", id: "1", outcome: 1, quantity: 10} + g.tradePrediction(s, buy, m) + if s.Player.Credits != 9570 || len(s.Player.PredictionContracts) != 1 { + t.Fatalf("buy failed: %s", conn.data) + } + loaded, err := g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 9570 || len(loaded.PredictionContracts) != 1 || loaded.PredictionContracts[0].Token != "101" { + t.Fatal("ledger not persisted with debit") + } + s.Player = loaded + m.Prices = `["0.60","0.40"]` + g.predictionActive(s, map[string]prediction.Market{"1": m}) + if !strings.Contains(string(conn.data), "+170") || !strings.Contains(string(conn.data), "ACTIVE CONTRACTS") { + t.Fatalf("missing portfolio P/L: %s", conn.data) + } + g.tradePrediction(s, predictionRequest{action: "sell", lot: 0}, m) + if s.Player.Credits != 10170 || s.Player.PredictionContracts[0].Status != "sold" { + t.Fatal("sale failed") + } + g.tradePrediction(s, predictionRequest{action: "sell", lot: 0}, m) + if s.Player.Credits != 10170 { + t.Fatal("duplicate sale paid twice") + } + loaded, err = g.AccountStore.LoadCharacter("alice") + if err != nil || loaded.Credits != 10170 || loaded.PredictionContracts[0].Proceeds != 600 { + t.Fatal("sale not durable") + } +} +func TestPredictionResolutionCollectExactlyOnceAcrossReload(t *testing.T) { + g, s, _, m := predictionFixture(t) + g.tradePrediction(s, predictionRequest{action: "buy", outcome: 1, quantity: 10}, m) + m.Accepting = false + m.Prices = `["0.6","0.4"]` + g.settlePredictions(s, map[string]prediction.Market{"1": m}) + if s.Player.Credits != 9570 || s.Player.PredictionContracts[0].Status != "open" { + t.Fatal("non-terminal close resolved a contract") + } + m.Closed = true + m.Prices = `["1","0"]` + if !g.settlePredictions(s, map[string]prediction.Market{"1": m}) { + t.Fatal("settlement failed") + } + // Resolution records the 100/share payout but moves no credits. + if s.Player.Credits != 9570 || s.Player.PredictionContracts[0].Status != "resolved" || s.Player.PredictionContracts[0].Proceeds != 1000 { + t.Fatalf("resolution wrong: credits=%d status=%s proceeds=%d", s.Player.Credits, s.Player.PredictionContracts[0].Status, s.Player.PredictionContracts[0].Proceeds) + } + loaded, err := g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + s.Player = loaded + g.collectPredictions(s) + if s.Player.Credits != 10570 || s.Player.PredictionContracts[0].Status != "collected" { + t.Fatal("collect did not pay or mark the contract") + } + loaded, err = g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + s.Player = loaded + g.collectPredictions(s) + if s.Player.Credits != 10570 { + t.Fatal("collect paid twice") + } +} +func TestPredictionBuyByOutcomeName(t *testing.T) { + g, s, _, m := predictionFixture(t) + if !g.tradePrediction(s, predictionRequest{action: "buy", id: "1", quantity: 10, outcomeName: "yes"}, m) { + t.Fatal("buy by outcome name failed") + } + if s.Player.PredictionContracts[0].Outcome != "Yes" { + t.Fatalf("wrong outcome: %s", s.Player.PredictionContracts[0].Outcome) + } + if g.tradePrediction(s, predictionRequest{action: "buy", id: "1", quantity: 10, outcomeName: "maybe"}, m) { + t.Fatal("unknown outcome name accepted") + } + if g.tradePrediction(s, predictionRequest{action: "buy", id: "1", quantity: 10, outcome: 9}, m) { + t.Fatal("out-of-range outcome number accepted") + } +} + +func TestPredictionBuyByPartialOutcomeName(t *testing.T) { + g, s, _, m := predictionFixture(t) + m.Outcomes = `["Natus Vincere","9z"]` + m.Tokens = `["a","b"]` + m.Prices = `["0.70","0.30"]` + for _, name := range []string{"natus", "natus vincere", "NATUS VINCERE"} { + if !g.tradePrediction(s, predictionRequest{action: "buy", id: "1", quantity: 1, outcomeName: name}, m) { + t.Fatalf("partial name %q failed", name) + } + if got := s.Player.PredictionContracts[len(s.Player.PredictionContracts)-1].Outcome; got != "Natus Vincere" { + t.Fatalf("name %q matched %q", name, got) + } + } +} + +func TestPredictionRejectsClosed(t *testing.T) { + g, s, _, m := predictionFixture(t) + r := predictionRequest{action: "buy", outcome: 1, quantity: 10} + m.Closed = true + g.tradePrediction(s, r, m) + m.Closed = false + m.End = time.Now().Add(-time.Minute).Format(time.RFC3339) + g.tradePrediction(s, r, m) + if s.Player.Credits != 10000 || len(s.Player.PredictionContracts) != 0 { + t.Fatal("unsafe purchase accepted") + } +} +func TestPredictionSaveRollback(t *testing.T) { + g, s, _, m := predictionFixture(t) + g.tradePrediction(s, predictionRequest{action: "buy", outcome: 1, quantity: 10}, m) + bad := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(bad, []byte("not a directory"), 0600); err != nil { + t.Fatal(err) + } + g.AccountStore = player.NewAccountStore(bad) + g.tradePrediction(s, predictionRequest{action: "buy", outcome: 1, quantity: 10}, m) + if s.Player.Credits != 9570 || len(s.Player.PredictionContracts) != 1 { + t.Fatal("failed debit not rolled back") + } + g.tradePrediction(s, predictionRequest{action: "sell", lot: 0}, m) + if s.Player.Credits != 9570 || s.Player.PredictionContracts[0].Status != "open" { + t.Fatal("failed sale not rolled back") + } + m.Closed = true + m.Resolution = "resolved" + m.Prices = `["1","0"]` + if g.settlePredictions(s, map[string]prediction.Market{"1": m}) { + t.Fatal("failed settlement reported success") + } + if s.Player.Credits != 9570 || s.Player.PredictionContracts[0].Status != "open" { + t.Fatal("failed settlement not rolled back") + } +} +func TestPredictionCounterInteractionAndOutage(t *testing.T) { + g, s, conn, m := predictionFixture(t) + var failed atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if failed.Load() { + w.WriteHeader(503) + return + } + switch r.URL.Path { + case "/markets": + json.NewEncoder(w).Encode([]prediction.Market{m}) + case "/markets/1": + json.NewEncoder(w).Encode(m) + case "/prices-history": + w.Write([]byte(`{"history":[{"t":100,"p":0.3},{"t":200,"p":0.4}]}`)) + default: + w.WriteHeader(404) + } + })) + defer server.Close() + g.PredictionClient = prediction.NewClient() + g.PredictionClient.Gamma = server.URL + g.PredictionClient.CLOB = server.URL + g.doUse(s, "predictions") + if len(s.PredictionIDs) != 1 || !strings.Contains(string(conn.data), "Updating markets...") { + t.Fatalf("no menu: %s", conn.data) + } + g.executePredictions(s, []string{"view", "1"}, "") + if strings.Contains(string(conn.data), m.Description) || !strings.Contains(string(conn.data), "100%") { + t.Fatalf("detail must show graph but not resolution text: %s", conn.data) + } + g.executePredictions(s, []string{"rules"}, "") + if !strings.Contains(string(conn.data), m.Description) { + t.Fatal("rules did not show resolution text") + } + failed.Store(true) + conn.data = nil + g.executePredictions(s, []string{"active"}, "") // Even an empty portfolio must check availability. + if !strings.Contains(string(conn.data), "CLOSED") { + t.Fatal("empty active list bypassed outage") + } + g.executePredictions(s, []string{"buy", "10", "1"}, "") + if s.Player.Credits != 10000 || len(s.Player.PredictionContracts) != 0 { + t.Fatal("traded stale cached price") + } + failed.Store(false) + g.executePredictions(s, []string{"buy", "10", "1"}, "") + if len(s.Player.PredictionContracts) != 1 { + t.Fatal("counter did not recover") + } +} +func TestPredictionFetchDoesNotBlockOwnerAndMovementCancels(t *testing.T) { + g, s, _, m := predictionFixture(t) + started, release := make(chan struct{}), make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(started); <-release; json.NewEncoder(w).Encode(m) })) + defer server.Close() + g.PredictionClient = prediction.NewClient() + g.PredictionClient.Gamma = server.URL + g.owner = newOwner() + defer g.owner.stop() + g.loggedInChars = map[string]*net.Session{"alice": s} + s.PredictionIDs = []string{"1"} + s.PredictionActive = true + s.PredictionSelected = "1" + s.PredictionScreen = "view" + g.run(func() { g.executePredictions(s, []string{"buy", "10", "1"}, "") }) + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("fetch not started") + } + // This must run while the server is still stalled. + moved := make(chan struct{}) + go func() { g.run(func() { s.Player.RoomID = 2 }); close(moved) }() + select { + case <-moved: + case <-time.After(time.Second): + close(release) + t.Fatal("HTTP blocked the game owner") + } + close(release) + deadline := time.Now().Add(2 * time.Second) + for { + busy := true + g.run(func() { busy = s.PredictionBusy }) + if !busy { + break + } + if time.Now().After(deadline) { + t.Fatal("callback did not complete") + } + time.Sleep(time.Millisecond) + } + g.run(func() { + if s.Player.Credits != 10000 || len(s.Player.PredictionContracts) != 0 { + t.Error("trade completed after leaving counter") + } + }) +} 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))) +} diff --git a/internal/game/vesta_world_test.go b/internal/game/vesta_world_test.go index 3a2e320..8072dca 100644 --- a/internal/game/vesta_world_test.go +++ b/internal/game/vesta_world_test.go @@ -285,8 +285,9 @@ func TestVestaCasinoFloorsAndVariants(t *testing.T) { continue } // The vestibule stays at street level (below the gaming floor); - // every gaming room shares 6101's floor. - if id != 6100 && grid.Coord[id][2] != grid.Coord[6101][2] { + // every gaming room shares 6101's floor. Support rooms (the + // vestibule, the Prediction Exchange) may sit on the ground floor. + if id != 6100 && (len(r.CasinoTables)+len(r.CasinoMachines)) > 0 && grid.Coord[id][2] != grid.Coord[6101][2] { t.Errorf("casino room %d drifted off the gaming floor", id) } if strings.Contains(r.Name, "Future ") { diff --git a/internal/net/server.go b/internal/net/server.go index d983161..5cf7310 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -66,10 +66,22 @@ type Session struct { PendingUndigDir string PendingUndigRoom int // Casino spectator preferences are session-local, owner-goroutine only. - CasinoWatchRoom int - CasinoWatchTable string - promptVisible bool - writeMu sync.Mutex + PredictionActive bool + PredictionEpoch uint64 + PredictionCancel context.CancelFunc + PredictionSelected string + PredictionScreen string + PredictionBoardPage int + PredictionShowHints bool + PredictionUnavailable bool + PredictionBusy bool + PredictionIDs []string + PredictionPage int + PredictionSearch string + CasinoWatchRoom int + CasinoWatchTable string + promptVisible bool + writeMu sync.Mutex // Disconnect bookkeeping is written by the connection goroutine that // discovered the dead transport (Hub.Remove's deferred path) and read @@ -506,6 +518,14 @@ func (h *Hub) EnterRoom(s *Session, roomID int) { // room stops it, so leaving and returning does not silently resume it. s.CasinoWatchRoom = 0 s.CasinoWatchTable = "" + s.PredictionActive = false + if s.PredictionCancel != nil { + s.PredictionCancel() + s.PredictionCancel = nil + } + s.PredictionEpoch++ + s.PredictionBusy = false + s.PredictionSelected = "" h.leaveRoomLocked(s) if h.rooms[roomID] == nil { h.rooms[roomID] = make(map[*Session]bool) diff --git a/internal/player/player.go b/internal/player/player.go index 43f5d7e..5acced6 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -7,6 +7,7 @@ import ( "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/item" + "thehouseoficarus/internal/prediction" ) type SkillName string @@ -194,13 +195,14 @@ type Player struct { // here, and the same save that settles or refunds the round removes it. // If the process dies with an entry still listed, startup returns the // stake (refund-on-lost-round). See PendingWager. - PendingWagers []PendingWager `yaml:"pending_wagers,omitempty"` - Description string `yaml:"description"` - AttackStyle AttackStyle `yaml:"attack_style"` - Prompt string `yaml:"prompt"` - Options map[string]any `yaml:"-"` - Flags map[string]any `yaml:"flags"` - PendingSequence *PendingSequence `yaml:"pending_sequence,omitempty"` + PendingWagers []PendingWager `yaml:"pending_wagers,omitempty"` + PredictionContracts []prediction.Contract `yaml:"prediction_contracts,omitempty"` + Description string `yaml:"description"` + AttackStyle AttackStyle `yaml:"attack_style"` + Prompt string `yaml:"prompt"` + Options map[string]any `yaml:"-"` + Flags map[string]any `yaml:"flags"` + PendingSequence *PendingSequence `yaml:"pending_sequence,omitempty"` RegenerateTick int AmmoQty int `yaml:"ammo_qty,omitempty"` Action *behavior.Action `yaml:"-"` diff --git a/internal/prediction/market.go b/internal/prediction/market.go new file mode 100644 index 0000000..af74ff9 --- /dev/null +++ b/internal/prediction/market.go @@ -0,0 +1,274 @@ +// Package prediction implements a paper exchange: no wallets or real orders. +package prediction + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "unicode" +) + +const FaceValue = 100 +const MaxContracts = 50 + +type Contract struct { + MarketID string `yaml:"market_id"` + Title string `yaml:"title"` + Outcome string `yaml:"outcome"` + Token string `yaml:"token"` + Quantity int `yaml:"quantity"` + Cost int `yaml:"cost"` + Proceeds int `yaml:"proceeds"` + Status string `yaml:"status"` // open, sold, settled + Opened string `yaml:"opened"` + Closed string `yaml:"closed,omitempty"` +} + +type Market struct { + ID string `json:"id"` + Question string `json:"question"` + Description string `json:"description"` + Outcomes string `json:"outcomes"` + Prices string `json:"outcomePrices"` + Tokens string `json:"clobTokenIds"` + Active bool `json:"active"` + Closed bool `json:"closed"` + Archived bool `json:"archived"` + Accepting bool `json:"acceptingOrders"` + End string `json:"endDate"` + Start string `json:"gameStartTime"` + Resolution string `json:"umaResolutionStatus"` +} + +// Clean removes remote terminal controls and collapses paragraphs for local wrapping. +func Clean(s string, max int) string { + r := []rune(strings.Join(strings.FieldsFunc(s, func(r rune) bool { + return unicode.IsControl(r) || unicode.IsSpace(r) || r == '\u202e' || unicode.Is(unicode.Cf, r) + }), " ")) + if len(r) > max { + r = r[:max] + } + return string(r) +} +func (m Market) Values() (names, tokens []string, prices []float64, err error) { + var raw []string + if json.Unmarshal([]byte(m.Outcomes), &names) != nil || json.Unmarshal([]byte(m.Tokens), &tokens) != nil || json.Unmarshal([]byte(m.Prices), &raw) != nil || len(names) < 2 || len(names) != len(raw) || len(tokens) != len(names) { + return nil, nil, nil, fmt.Errorf("invalid market outcomes") + } + seen := make(map[string]bool) + for i, token := range tokens { + if token == "" || seen[token] || strings.TrimSpace(names[i]) == "" { + return nil, nil, nil, fmt.Errorf("invalid outcome identity") + } + seen[token] = true + } + for _, s := range raw { + p, e := strconv.ParseFloat(s, 64) + if e != nil || math.IsNaN(p) || math.IsInf(p, 0) || p < 0 || p > 1 { + return nil, nil, nil, fmt.Errorf("invalid price") + } + prices = append(prices, p) + } + return +} +func (m Market) Tradable(now time.Time) bool { + if !m.Active || m.Closed || m.Archived || !m.Accepting || m.Resolution != "" && m.Resolution != "unresolved" { + return false + } + // Fail closed on missing/malformed expiry; sports stop at kickoff where supplied. + end, err := time.Parse(time.RFC3339, m.End) + if err != nil || !now.Before(end) { + return false + } + if m.Start != "" { + start, err := time.Parse(time.RFC3339, m.Start) + if err != nil { + start, err = time.Parse("2006-01-02 15:04:05-07", m.Start) + } + if err != nil || !now.Before(start) { + return false + } + } + _, _, _, err = m.Values() + return err == nil +} +func (m Market) Price(token string) (float64, error) { + _, tokens, prices, err := m.Values() + if err != nil { + return 0, err + } + for i, t := range tokens { + if t == token { + return prices[i], nil + } + } + return 0, fmt.Errorf("outcome changed") +} + +// Settlement reports the terminal per-share payout for a closed market. A +// resolved flag is not required: once a market stops trading its payout vector +// is normally driven to whole values (1, 0, or 0.5 for a push). Near-1 quotes +// are rejected, so a paused or disputed market is never inferred as decided. +func (m Market) Settlement(token string) (float64, bool) { + if !m.Closed { + return 0, false + } + _, _, prices, err := m.Values() + if err != nil { + return 0, false + } + sum := 0.0 + for _, p := range prices { + if p != 0 && p != 1 && p != 0.5 { + return 0, false + } + sum += p + } + if sum != 1 { + return 0, false + } + p, err := m.Price(token) + return p, err == nil +} + +// House quotes are whole credits PER SHARE, not fractional prices rounded only +// after multiplication. A 42.1-credit quote costs 43 and sells for 42 per share. +func BuyPrice(p float64) int { return int(math.Ceil(math.Nextafter(p*FaceValue, 0))) } +func SellPrice(p float64) int { return int(math.Floor(math.Nextafter(p*FaceValue, math.Inf(1)))) } +func BuyCost(p float64, q int) int { return BuyPrice(p) * q } +func SellValue(p float64, q int) int { return SellPrice(p) * q } + +type Client struct { + HTTP *http.Client + Gamma, CLOB string +} + +func NewClient() *Client { + return &Client{HTTP: &http.Client{Timeout: 8 * time.Second}, Gamma: "https://gamma-api.polymarket.com", CLOB: "https://clob.polymarket.com"} +} +func (c *Client) get(ctx context.Context, address string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "IcarusMUD-PaperMarkets/1.0") + req.Header.Set("Cache-Control", "no-cache") + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("market API: HTTP %d", resp.StatusCode) + } + return json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(out) +} +func (c *Client) List(ctx context.Context) ([]Market, error) { + var ms []Market + err := c.get(ctx, c.Gamma+"/markets?limit=100&active=true&closed=false&order=volume24hr&ascending=false", &ms) + if err != nil { + return nil, err + } + out := []Market{} + for _, m := range ms { + if m.ID != "" && m.Question != "" && m.Tradable(time.Now()) { + out = append(out, m) + } + } + return out, nil +} +func (c *Client) Market(ctx context.Context, id string) (Market, error) { + var m Market + err := c.get(ctx, c.Gamma+"/markets/"+url.PathEscape(id), &m) + if err == nil && (m.ID != id || strings.TrimSpace(m.Question) == "") { + err = fmt.Errorf("market identity mismatch") + } + if err == nil { + _, _, _, err = m.Values() + } + return m, err +} + +type Point struct { + T int64 `json:"t"` + P float64 `json:"p"` +} + +func (c *Client) History(ctx context.Context, token string) ([]Point, error) { + var v struct { + History []Point `json:"history"` + } + err := c.get(ctx, c.CLOB+"/prices-history?market="+url.QueryEscape(token)+"&interval=1w&fidelity=60", &v) + for _, p := range v.History { + if math.IsNaN(p.P) || math.IsInf(p.P, 0) || p.P < 0 || p.P > 1 { + return nil, fmt.Errorf("invalid history") + } + } + return v.History, err +} + +// Graph preserves the plain-text rendering for non-interactive consumers. +func Graph(points []Point) string { return GraphMode(points, false, 48) } + +// GraphMode uses connected Unicode plotting unless the client requests ASCII. +func GraphMode(points []Point, unicode bool, width int) string { + if len(points) < 2 { + return "Odds history is not available for this outcome." + } + width = max(4, min(80, width)) + horizontal, vertical, downCorner, upCorner, endDown, endUp := '-', '|', '+', '+', '+', '+' + side, bottom, left, right, arrow := "|", "-", "+", "+", "->" + if unicode { + horizontal, vertical, downCorner, upCorner, endDown, endUp = '─', '│', '┐', '┘', '└', '┌' + side, bottom, left, right, arrow = "│", "─", "└", "┘", "→" + } + grid := make([][]rune, 11) + for i := range grid { + grid[i] = []rune(strings.Repeat(" ", width)) + } + previous := -1 + for x := 0; x < width; x++ { + // Interpolate samples so sparse history remains a continuous trace. + position := float64(x) * float64(len(points)-1) / float64(width-1) + i := int(position) + fraction := position - float64(i) + p := points[i].P + if i+1 < len(points) { + p += fraction * (points[i+1].P - p) + } + if p < 0 || p > 1 || math.IsNaN(p) { + previous = -1 + continue + } + row := 10 - int(math.Round(p*10)) + if previous < 0 || previous == row { + grid[row][x] = horizontal + } else { + for y := min(previous, row) + 1; y < max(previous, row); y++ { + grid[y][x] = vertical + } + if row > previous { + grid[previous][x] = downCorner + grid[row][x] = endDown + } else { + grid[previous][x] = upCorner + grid[row][x] = endUp + } + } + previous = row + } + var b strings.Builder + for y, row := range grid { + fmt.Fprintf(&b, "%3d%% %s%s%s\n", 100-y*10, side, string(row), side) + } + b.WriteString(" " + left + strings.Repeat(bottom, width) + right + "\n") + fmt.Fprintf(&b, " %s UTC %s %s UTC", time.Unix(points[0].T, 0).UTC().Format("Jan 02 15:04"), arrow, time.Unix(points[len(points)-1].T, 0).UTC().Format("Jan 02 15:04")) + return b.String() +} diff --git a/internal/prediction/market_test.go b/internal/prediction/market_test.go new file mode 100644 index 0000000..e5e0bcb --- /dev/null +++ b/internal/prediction/market_test.go @@ -0,0 +1,167 @@ +package prediction + +import ( + "context" + "fmt" + "math" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +func fixture() Market { + return Market{ID: "1", Question: "Will it rain?", Description: "Rain at the airport.", Outcomes: `["Yes","No"]`, Tokens: `["11","12"]`, Prices: `["0.421","0.579"]`, Active: true, Accepting: true, End: time.Now().Add(time.Hour).UTC().Format(time.RFC3339)} +} +func TestTradable(t *testing.T) { + good := fixture() + if !good.Tradable(time.Now()) { + t.Fatal("valid market rejected") + } + cases := map[string]func(*Market){"closed": func(m *Market) { m.Closed = true }, "inactive": func(m *Market) { m.Active = false }, "paused": func(m *Market) { m.Accepting = false }, "archived": func(m *Market) { m.Archived = true }, "expired": func(m *Market) { m.End = time.Now().Add(-time.Hour).Format(time.RFC3339) }, "missing expiry": func(m *Market) { m.End = "" }, "started sport": func(m *Market) { m.Start = time.Now().Add(-time.Minute).Format(time.RFC3339) }, "bad start": func(m *Market) { m.Start = "tomorrow" }, "disputed": func(m *Market) { m.Resolution = "disputed" }, "bad price": func(m *Market) { m.Prices = `["NaN","0.5"]` }, "duplicate tokens": func(m *Market) { m.Tokens = `["11","11"]` }} + for name, change := range cases { + t.Run(name, func(t *testing.T) { + m := good + change(&m) + if m.Tradable(time.Now()) { + t.Fatal("unsafe market tradable") + } + }) + } + good.Start = time.Now().Add(time.Minute).UTC().Format("2006-01-02 15:04:05-07") + if !good.Tradable(time.Now()) { + t.Fatal("Gamma sports date format rejected") + } +} +func TestSettlementOnlyFinalized(t *testing.T) { + m := fixture() + m.Prices = `["1","0"]` + if _, ok := m.Settlement("11"); ok { + t.Fatal("open market settled") + } + m.Closed = true + m.Prices = `["0.999","0.001"]` + if _, ok := m.Settlement("11"); ok { + t.Fatal("near-final quote settled") + } + m.Prices = `["1","0"]` + if p, ok := m.Settlement("11"); !ok || p != 1 { + t.Fatal("winner not paid") + } + if p, ok := m.Settlement("12"); !ok || p != 0 { + t.Fatal("loser not settled") + } + m.Prices = `["0.5","0.5"]` + if p, ok := m.Settlement("11"); !ok || p != .5 { + t.Fatal("split result rejected") + } + if _, ok := m.Settlement("unknown"); ok { + t.Fatal("wrong token settled") + } +} +func TestRoundingCannotMintCredits(t *testing.T) { + for q := 1; q <= 20; q++ { + for i := 1; i < 1000; i++ { + p := float64(i) / 1000 + if SellValue(p, q) > BuyCost(p, q) { + t.Fatal("round-trip profit") + } + } + } + if BuyCost(.421, 1) != 43 || SellValue(.421, 1) != 42 || BuyCost(.29, 100) != 2900 || BuyCost(.421, 10) != 430 || SellValue(.421, 10) != 420 || BuyPrice(1e-14) != 1 || SellPrice(.29) != 29 { + t.Fatal("incorrect whole-credit rounding") + } +} +func TestClientFailuresAndBoundaries(t *testing.T) { + for _, tc := range []struct { + name, body string + status int + }{ + {"rate limit", `{}`, 429}, {"server error", `{}`, 500}, {"bad JSON", `<html>`, 200}, {"wrong identity", `{"id":"2"}`, 200}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(tc.status); fmt.Fprint(w, tc.body) })) + defer server.Close() + c := NewClient() + c.Gamma = server.URL + if _, err := c.Market(context.Background(), "1"); err == nil { + t.Fatal("accepted bad response") + } + }) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := NewClient().List(ctx); err == nil { + t.Fatal("ignored cancellation") + } +} +func TestGraphUsesContinuousLines(t *testing.T) { + points := []Point{{T: 100, P: .2}, {T: 200, P: .8}, {T: 300, P: .3}} + unicode := GraphMode(points, true, 32) + for _, bad := range []string{"●", "*", "•"} { + if strings.Contains(unicode, bad) { + t.Fatalf("dot plot survived: %s", unicode) + } + } + for _, part := range []string{"─", "┘", "┌", "┐", "└"} { + if !strings.Contains(unicode, part) { + t.Fatalf("missing connected line %s", part) + } + } + flat := GraphMode([]Point{{P: .5}, {P: .5}}, true, 16) + if !strings.Contains(flat, " 50% │"+strings.Repeat("─", 16)+"│") { + t.Fatal("flat series should be an unbroken line") + } + ascii := GraphMode(points, false, 32) + for _, r := range ascii { + if r > 127 { + t.Fatal("ASCII chart contains Unicode") + } + } +} + +func TestGraphAndTerminalSanitization(t *testing.T) { + graph := Graph([]Point{{T: 100, P: .1}, {T: 200, P: .9}}) + for _, want := range []string{"100%", " 0%", "-", "UTC"} { + if !strings.Contains(graph, want) { + t.Fatalf("missing %s", want) + } + } + if !strings.Contains(Graph(nil), "not available") { + t.Fatal("empty history") + } + _ = Graph([]Point{{P: math.NaN()}, {P: 2}}) + clean := Clean("a\x1b[31m\r\n\xff\u202eb", 100) + if strings.ContainsAny(clean, "\x1b\r\n\u202e") { + t.Fatal("terminal control survived") + } +} + +// Explicit opt-in smoke test; normal tests never depend on the live service. +func TestPolymarketLive(t *testing.T) { + if os.Getenv("TEST_POLYMARKET_LIVE") != "1" { + t.Skip("set TEST_POLYMARKET_LIVE=1 for live smoke test") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + c := NewClient() + markets, err := c.List(ctx) + if err != nil { + t.Fatal(err) + } + if len(markets) == 0 { + t.Fatal("no eligible markets") + } + m, err := c.Market(ctx, markets[0].ID) + if err != nil { + t.Fatal(err) + } + _, tokens, _, _ := m.Values() + history, err := c.History(ctx, tokens[0]) + if err != nil { + t.Fatal(err) + } + t.Logf("%d eligible markets; fetched %q and %d history points", len(markets), m.Question, len(history)) +} diff --git a/internal/world/room.go b/internal/world/room.go index 3783a73..a87ec41 100644 --- a/internal/world/room.go +++ b/internal/world/room.go @@ -86,20 +86,21 @@ type ExitDef struct { } type Room struct { - ID int `yaml:"id"` - Name string `yaml:"name"` - Color string `yaml:"color"` - Description behavior.DescList `yaml:"description"` - Exits map[ExitDir]ExitDef `yaml:"exits"` - Objects []RoomObject `yaml:"objects"` - ItemSpawns []SpawnDef `yaml:"item_spawns"` - Mobs []RoomMob `yaml:"mobs"` - OnEnter []behavior.Trigger `yaml:"on_enter,omitempty"` - OnExit []behavior.Trigger `yaml:"on_exit,omitempty"` - Hazard string `yaml:"hazard"` - BlockTransport bool `yaml:"block_transport"` - CasinoTables []casino.TableConfig `yaml:"casino_tables,omitempty"` - CasinoMachines []casino.MachineConfig `yaml:"casino_machines,omitempty"` + ID int `yaml:"id"` + Name string `yaml:"name"` + Color string `yaml:"color"` + Description behavior.DescList `yaml:"description"` + Exits map[ExitDir]ExitDef `yaml:"exits"` + Objects []RoomObject `yaml:"objects"` + ItemSpawns []SpawnDef `yaml:"item_spawns"` + Mobs []RoomMob `yaml:"mobs"` + OnEnter []behavior.Trigger `yaml:"on_enter,omitempty"` + OnExit []behavior.Trigger `yaml:"on_exit,omitempty"` + Hazard string `yaml:"hazard"` + BlockTransport bool `yaml:"block_transport"` + PredictionExchange bool `yaml:"prediction_exchange,omitempty"` + CasinoTables []casino.TableConfig `yaml:"casino_tables,omitempty"` + CasinoMachines []casino.MachineConfig `yaml:"casino_machines,omitempty"` } type RoomMob struct { |
