aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-05 06:03:49 -0400
committerhistoria <[not public]>2026-10-05 06:03:49 -0400
commitc84a9944356f590d90d819d652bdf1349ebacd40 (patch)
treeaf1ee15992e141fb3eb6f26cac02cf06306d4982
parent8b78aa4cd930fde145959704ba7bdc0e57465b9b (diff)
downloadthehouseoficarus-c84a9944356f590d90d819d652bdf1349ebacd40.tar.gz
feat: prediction exchange with live polymarket data
-rw-r--r--data/help/casino.yaml6
-rw-r--r--data/help/predictions.yaml67
-rw-r--r--data/help/use.yaml9
-rw-r--r--data/mobs/vesta/casino_host.yaml5
-rw-r--r--data/mobs/vesta/prediction_clerk.yaml69
-rw-r--r--data/rooms/casino/6100.yaml10
-rw-r--r--data/rooms/casino/6110.yaml17
-rw-r--r--internal/game/cmd_look.go10
-rw-r--r--internal/game/cmd_registry.go4
-rw-r--r--internal/game/cmd_use.go7
-rw-r--r--internal/game/cmd_verbs.go4
-rw-r--r--internal/game/game.go9
-rw-r--r--internal/game/prediction_interface.go155
-rw-r--r--internal/game/prediction_interface_test.go272
-rw-r--r--internal/game/prediction_ux_regression_test.go171
-rw-r--r--internal/game/predictions.go737
-rw-r--r--internal/game/predictions_test.go281
-rw-r--r--internal/game/render_predictions.go341
-rw-r--r--internal/game/vesta_world_test.go5
-rw-r--r--internal/net/server.go28
-rw-r--r--internal/player/player.go16
-rw-r--r--internal/prediction/market.go274
-rw-r--r--internal/prediction/market_test.go167
-rw-r--r--internal/world/room.go29
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 {