package game import ( "context" "fmt" "sort" "strconv" "strings" "time" "thehouseoficarus/internal/cryptomarket" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) func cryptoUseTarget(s string) bool { switch s { case "crypto", "cryptocurrency", "crypto exchange": return true } return false } func (g *Game) atCryptoCounter(p *player.Player) bool { if p == nil || g.World == nil { return false } r, e := g.World.LoadRoom(p.RoomID) return e == nil && r.CryptoExchange } func (g *Game) openCryptoCounter(s *net.Session) { if !g.atCryptoCounter(s.Player) { s.WriteLine("There is no Crypto Exchange here.") return } if s.CryptoActive { s.CryptoShowHints = true g.executeCrypto(s, "refresh", nil) return } if !g.leaveCasinoGames(s.Player) { s.WriteLine("Finish your casino round first.") return } g.closePredictionCounter(s) g.closeCryptoCounter(s) s.CryptoActive = true s.CryptoShowHints = true g.executeCrypto(s, "markets", nil) } func (g *Game) closeCryptoCounter(s *net.Session) { if s.CryptoCancel != nil { s.CryptoCancel() s.CryptoCancel = nil } s.CryptoActive = false s.CryptoBusy = false s.CryptoEpoch++ s.CryptoSelected = "" s.CryptoScreen = "markets" s.CryptoShowHints = false s.CryptoUnavailable = false } // cryptoVerbs is the counter's own vocabulary. Exact matches win, and unique // prefixes are accepted just like every other casino and counter interface. // Real world commands keep their meaning unless the counter explicitly owns // them (buy, sell, option, close, rules and help are claimed on the counter). var cryptoVerbs = []string{ "markets", "view", "buy", "sell", "short", "cover", "option", "write", "quote", "contracts", "close", "active", "portfolio", "closed", "fund", "withdraw", "repay", "refresh", "back", "help", "rules", "stop", "leave", "exit", } func cryptoVerb(cmd string) (string, bool) { for _, v := range cryptoVerbs { if cmd == v { return v, true } } if _, exists := commandRegistry[cmd]; exists { return "", false } found := "" for _, v := range cryptoVerbs { if strings.HasPrefix(v, cmd) { if found != "" { return "", true } found = v } } return found, found != "" } func (g *Game) cryptoInputOwned(s *net.Session, cmd string) bool { if !s.CryptoActive || !g.atCryptoCounter(s.Player) { return false } _, ok := cryptoVerb(cmd) return ok } func (g *Game) handleCryptoInput(s *net.Session, cmd string, args []string) bool { if !s.CryptoActive { return false } if !g.atCryptoCounter(s.Player) { g.closeCryptoCounter(s) return false } if cmd == "help" && len(args) > 0 { return false } verb, owned := cryptoVerb(cmd) if !owned { if cmd == "use" && cryptoUseTarget(strings.ToLower(strings.Join(args, " "))) { return false } switch cmd { case "use", "play", "bank", "quit", "logout": g.closeCryptoCounter(s) } return false } if verb == "" { s.WriteLine("That shortcut is ambiguous. Choose a command below.") g.cryptoHints(s) return true } // Keep the global option/options settings command reachable: only a // real option trade (option call|put ...) belongs to the counter. The // plural options command is the player's settings command, not ours. if verb == "option" && !(len(args) > 0 && (args[0] == "call" || args[0] == "put")) { return false } switch verb { case "stop", "leave", "exit": g.closeCryptoCounter(s) s.WriteLine("You step away from the Crypto Exchange. Your positions stay open.") case "help", "rules": g.cryptoHelp(s) default: g.executeCrypto(s, verb, args) } return true } func (g *Game) cryptoHelp(s *net.Session) { g.predictionFrame(s, "HOW TO TRADE", []string{ "Fund your account, choose a currency, then buy coins or back a falling price with a short.", "Try: fund 1000, view BTC, buy BTC 0.001. Sell a position with close ID.", "Options let you bet on a price by a future date. Use quote to check the price first.", "Shorts and written options can leave debts. The exchange collects these from your bank and future deposits.", "Your positions are checked when you return, not while you are away. See help crypto for details.", }) g.cryptoHints(s, true) } // A transaction snapshots only the fields it owns. Money and ledger always // share one atomic save, including bank garnishment; uncertain renames remain // applied using the existing casino persistence contract. func (g *Game) cryptoTransaction(p *player.Player, mutate func() error) error { before := p.Crypto.Clone() credits, bank := p.Credits, p.BankedCredits rollback := func() { p.Crypto = before; p.Credits = credits; p.BankedCredits = bank } if e := p.Crypto.Validate(); e != nil { return e } if e := mutate(); e != nil { rollback() return e } p.Crypto.SweepBank(&p.BankedCredits) if e := p.Crypto.Validate(); e != nil { rollback() return e } if e := (casinoWallet{g: g, p: p}).save("crypto exchange"); e != nil { rollback() return fmt.Errorf("exchange could not save; balances unchanged: %w", e) } return nil } func (g *Game) reconcileCrypto(p *player.Player, snap cryptomarket.Snapshot, now time.Time) ([]string, error) { work := p.Crypto.Clone() messages, e := work.Reconcile(snap, now) if e != nil { return nil, e } bank := p.BankedCredits paid := work.SweepBank(&bank) if len(messages) == 0 && paid == 0 { return nil, nil } e = g.cryptoTransaction(p, func() error { p.Crypto = work; p.BankedCredits = bank; return nil }) if e != nil { return nil, e } if paid > 0 { messages = append(messages, fmt.Sprintf("The Crypto Exchange collected %d bank credits against debt.", paid)) } return messages, nil } func (g *Game) cryptoFunds(s *net.Session, action string, args []string, verified ...cryptomarket.Snapshot) { if len(args) != 1 { s.WriteLine(action + " CREDITS") return } n, e := strconv.ParseInt(args[0], 10, 64) if e != nil || n <= 0 || n > cryptomarket.MaxMoney { s.WriteLine("Enter a positive whole-credit amount within the economy limit.") return } p := s.Player e = g.cryptoTransaction(p, func() error { p.Crypto.SweepBank(&p.BankedCredits) switch action { case "fund", "repay": if action == "repay" && (p.Crypto.Balance >= 0 || n > -p.Crypto.Balance) { return fmt.Errorf("repay at most the outstanding exchange debt") } if int64(p.Credits) < n { return fmt.Errorf("not enough carried credits") } balance, err := cryptomarket.Sum(p.Crypto.Balance, n) if err != nil { return err } p.Credits -= int(n) p.Crypto.Balance = balance case "withdraw": if p.Crypto.Balance < n { return fmt.Errorf("not enough available exchange cash") } // Only withdraw free cash, never collateral. Derivatives must have // verified nonnegative close equity, even when a positive settlement // is waiting for balance-cap room. Missing data cannot release cash. for _, v := range p.Crypto.Positions { if v.Status != "open" || (!v.Risky() && !v.Option()) { continue } if len(verified) != 1 { return fmt.Errorf("derivatives need current quotes before withdrawing") } expiry, _ := verified[0].Expiry(v.Symbol, v.Expiry) value, err := cryptomarket.Value(v, verified[0].Quotes[v.Symbol], time.Now().UTC(), expiry) if err != nil || value < 0 { return fmt.Errorf("settle derivative liabilities before withdrawing; quote or expiry data may be unavailable") } } v, err := addCreditsChecked(p.Credits, int(n)) if err != nil { return err } p.Credits = v p.Crypto.Balance -= n } return nil }) if e != nil { s.WriteLine(e.Error()) return } g.cryptoBalance(s) } func (g *Game) cryptoBalance(s *net.Session) { s.WriteLine(g.colorize(s, "casino_credits", fmt.Sprintf("Exchange cash: %d credits | Bank: %d | Carried: %d", s.Player.Crypto.Balance, s.Player.BankedCredits, s.Player.Credits))) if s.Player.Crypto.Balance < 0 { s.WriteLine(g.colorize(s, "casino_result_loss", "DEBT: future bank deposits will be seized until paid. Use repay CREDITS.")) } } func (g *Game) executeCrypto(s *net.Session, action string, args []string) { if s.CryptoBusy { s.WriteLine("Crypto quotes are updating; wait or stop.") g.cryptoHints(s) return } hintsHandled := false defer func() { if !hintsHandled { g.cryptoHints(s) } }() if action == "back" { action = "markets" args = nil } if action == "refresh" { action = s.CryptoScreen if action == "" { action = "markets" } if action == "view" { args = []string{s.CryptoSelected} } } switch action { case "fund", "repay": g.cryptoFunds(s, action, args) return case "closed": g.cryptoPortfolio(s, cryptomarket.Snapshot{}, true) return case "contracts": g.cryptoOptions(s) return } // Parse and reject malformed commands BEFORE making any HTTP requests. symbol, kind, quantity, strike := "", "", "", "" days := 0 id := int64(0) switch action { case "withdraw": if len(args) != 1 { s.WriteLine("withdraw CREDITS") return } n, err := strconv.ParseInt(args[0], 10, 64) if err != nil || n <= 0 || n > cryptomarket.MaxMoney { s.WriteLine("Enter a positive whole-credit amount within the economy limit.") return } case "markets", "active", "portfolio": if len(args) != 0 { s.WriteLine("This command takes no arguments.") return } case "view": if len(args) != 1 { s.WriteLine("view SYMBOL") return } symbol = strings.ToUpper(args[0]) case "buy", "short": if len(args) != 2 { s.WriteLine(action + " SYMBOL QUANTITY") return } symbol = strings.ToUpper(args[0]) quantity = args[1] kind = "spot" if action == "short" { kind = "short" } case "option", "write", "quote": validKind := len(args) > 0 && (args[0] == "call" || args[0] == "put" || (action == "quote" && (args[0] == "write-call" || args[0] == "write-put"))) if len(args) != 5 || !validKind { s.WriteLine(action + " call|put SYMBOL QUANTITY STRIKE DAYS") return } kind = args[0] if action == "write" { kind = "write-" + kind } symbol = strings.ToUpper(args[1]) quantity = args[2] strike = args[3] days, _ = strconv.Atoi(args[4]) if days != 1 && days != 7 && days != 30 { s.WriteLine("DAYS must be 1, 7 or 30.") return } if _, e := cryptomarket.Decimal(strike); e != nil { s.WriteLine(e.Error()) return } case "sell", "cover", "close": if len(args) != 1 { s.WriteLine(action + " POSITION_ID") return } id, _ = strconv.ParseInt(args[0], 10, 64) found := false for _, p := range s.Player.Crypto.Positions { if p.ID == id && p.Status == "open" { symbol = p.Symbol found = true if action == "cover" && p.Kind != "short" { s.WriteLine("cover is for shorts; use close ID.") return } } } if !found { s.WriteLine("Choose an open position ID from active.") return } default: hintsHandled = true g.cryptoHelp(s) return } if symbol != "" { if _, ok := cryptomarket.Find(symbol); !ok { s.WriteLine("Supported currencies: BTC ETH SOL DOGE SHIB.") return } } if quantity != "" { if _, e := cryptomarket.Decimal(quantity); e != nil { s.WriteLine(e.Error()) return } } symbols := map[string]bool{} for _, sym := range cryptomarket.Symbols(s.Player.Crypto) { symbols[sym] = true } if symbol != "" { symbols[symbol] = true } if action == "markets" { for _, i := range cryptomarket.Instruments { symbols[i.Symbol] = true } } list := []string{} for sym := range symbols { list = append(list, sym) } sort.Strings(list) if g.CryptoClient == nil { g.CryptoClient = cryptomarket.NewClient() } client := g.CryptoClient p, room, epoch := s.Player, s.Player.RoomID, s.CryptoEpoch ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second) s.CryptoCancel = cancel s.CryptoBusy = true hintsHandled = true s.WriteLine("Updating crypto quotes...") work := func() { defer cancel() snap, fetchErr := client.Fetch(ctx, list) if ctx.Err() != nil { fetchErr = ctx.Err() } apply := func() { if !s.CryptoActive || s.CryptoEpoch != epoch { return } s.CryptoBusy = false s.CryptoCancel = nil if g.isShuttingDown() || s.Player != p || s.State() != net.StateGame || s.IsDisconnecting() || p.RoomID != room || !g.atCryptoCounter(p) { return } if g.owner != nil && g.loggedInChars[p.Name] != s { return } if g.owner != nil { defer g.reprompt(s) } defer g.cryptoHints(s) s.CryptoUnavailable = fetchErr != nil if fetchErr != nil { g.predictionFrame(s, "COUNTER CLOSED", []string{"Prices are unavailable. Try refresh in a moment.", "No trade was made. Your positions are unchanged."}) g.cryptoBalance(s) return } now := time.Now().UTC() messages, e := g.reconcileCrypto(p, snap, now) if e != nil { s.WriteLine(e.Error()) return } for _, m := range messages { s.WriteLine(m) } switch action { case "withdraw": g.cryptoFunds(s, action, args, snap) case "markets": g.cryptoMarkets(s, snap) case "view": s.CryptoSelected = symbol g.cryptoView(s, symbol, snap) case "active", "portfolio": g.cryptoPortfolio(s, snap, false) case "quote": preview := p.Crypto.Clone() preview.Balance = cryptomarket.MaxMoney receipt, err := preview.Open(symbol, kind, quantity, strike, days, snap.Quotes[symbol], now) if err != nil { s.WriteLine(err.Error()) return } lines := []string{ g.colorize(s, "casino_emphasis", fmt.Sprintf("%s %s x %s", kind, symbol, quantity)), fmt.Sprintf("Debit %s | premium %s | collateral %s credits", g.colorize(s, "casino_credits", strconv.FormatInt(receipt.Cost, 10)), g.colorize(s, "casino_credits", strconv.FormatInt(receipt.Premium, 10)), g.colorize(s, "casino_credits", strconv.FormatInt(receipt.Collateral, 10))), fmt.Sprintf("Strike %s | expires %s UTC", strike, time.Unix(receipt.Expiry, 0).UTC().Format("2006-01-02")), g.colorize(s, "casino_action", "No order placed. Actual execution reprices."), } g.predictionFrame(s, "INDICATIVE QUOTE", lines) default: var receipt cryptomarket.Position e = g.cryptoTransaction(p, func() error { if id > 0 { var err error receipt, err = p.Crypto.Close(id, snap, now, "closed") return err } // No new risk while an expired contract lacks its settlement data. for _, v := range p.Crypto.Positions { if v.Status != "open" { continue } if v.Option() && v.Expiry <= now.Unix() { return fmt.Errorf("settlement data pending; new positions are paused") } if v.Risky() || v.Option() { if _, err := cryptomarket.Value(v, snap.Quotes[v.Symbol], now, ""); err != nil { return fmt.Errorf("existing derivative cannot be valued; new positions are paused") } } } var err error receipt, err = p.Crypto.Open(symbol, kind, quantity, strike, days, snap.Quotes[symbol], now) return err }) if e != nil { s.WriteLine(e.Error()) return } if id > 0 { category, verb := "casino_result_push", "released" if receipt.Proceeds > 0 { category = "casino_result_win" } else if receipt.Proceeds < 0 { category, verb = "casino_result_loss", "charged" } s.WriteLine(g.colorize(s, category, fmt.Sprintf("Closed #%d: %+d credits %s to exchange cash.", receipt.ID, receipt.Proceeds, verb))) } else { s.WriteLine(fmt.Sprintf("Opened #%d %s %s %s; debited %d credits.", receipt.ID, receipt.Kind, g.colorize(s, "casino_emphasis", receipt.Symbol), receipt.Quantity, receipt.Cost)) if receipt.Option() { s.WriteLine(fmt.Sprintf("Strike %s; premium %d; expires %s UTC.", receipt.Strike, receipt.Premium, time.Unix(receipt.Expiry, 0).UTC().Format("2006-01-02 15:04"))) } } g.cryptoBalance(s) } } if g.owner == nil { apply() } else { g.run(apply) } } if g.owner == nil { work() } else { go work() } } func (g *Game) cryptoMarkets(s *net.Session, snap cryptomarket.Snapshot) { s.CryptoSelected = "" s.CryptoScreen = "markets" rows := [][]string{} for _, i := range cryptomarket.Instruments { q, ok := snap.Quotes[i.Symbol] if !ok || q.Validate(time.Now()) != nil { rows = append(rows, []string{ g.colorize(s, "casino_emphasis", i.Symbol), g.colorize(s, "casino_menu", "--"), g.colorize(s, "casino_menu", "--"), }) continue } rows = append(rows, []string{ g.colorize(s, "casino_emphasis", i.Symbol), g.colorize(s, "casino_result_loss", q.Ask), g.colorize(s, "casino_result_win", q.Bid), }) } headers := []string{ g.colorize(s, "casino_menu", "Currency"), g.colorize(s, "casino_menu", "Buy"), g.colorize(s, "casino_menu", "Sell"), } g.predictionTable(s, g.colorize(s, "casino_emphasis", "CRYPTO EXCHANGE"), headers, rows, 0) g.cryptoBalance(s) } func (g *Game) cryptoView(s *net.Session, symbol string, snap cryptomarket.Snapshot) { s.CryptoScreen = "view" q, ok := snap.Quotes[symbol] if ok { s.WriteLine(fmt.Sprintf("%s / USD: bid %s | ask %s | fetched %s UTC", g.colorize(s, "casino_emphasis", symbol), g.colorize(s, "casino_result_win", q.Bid), g.colorize(s, "casino_result_loss", q.Ask), q.At.UTC().Format("15:04:05"))) } else { s.WriteLine(g.colorize(s, "casino_result_loss", symbol+": current quote unavailable")) } lines := cryptomarket.Chart(snap.Candles[symbol], predictionWidth(s)-4, s.Player.OptionBool("unicode"), func(text string, up bool) string { key := "casino_result_loss" if up { key = "casino_result_win" } return g.colorize(s, key, text) }) g.predictionFrame(s, symbol+" DAILY CANDLES", lines) g.cryptoBalance(s) } func (g *Game) cryptoPortfolio(s *net.Session, snap cryptomarket.Snapshot, closed bool) { s.CryptoScreen = "active" if closed { s.CryptoScreen = "closed" } now := time.Now().UTC() rows := [][]string{} for _, p := range s.Player.Crypto.Positions { if (p.Status != "open") != closed { continue } desc := p.Kind + " " + p.Symbol if p.Option() { desc += " " + p.Strike + " " + time.Unix(p.Expiry, 0).UTC().Format("01-02") } if closed { desc += " (" + p.Status + ")" } if p.Risky() { desc += " [coll " + strconv.FormatInt(p.Collateral, 10) + "]" } value, valued := int64(0), false if closed { value, valued = p.Proceeds, true } else { expiry, _ := snap.Expiry(p.Symbol, p.Expiry) if v, e := cryptomarket.Value(p, snap.Quotes[p.Symbol], now, expiry); e == nil { value, valued = v, true } } valueText, pnlText := g.colorize(s, "casino_menu", "n/a"), g.colorize(s, "casino_menu", "n/a") if valued { valueText = strconv.FormatInt(value, 10) pl := value - p.Cost category := "casino_result_push" if pl > 0 { category = "casino_result_win" } else if pl < 0 { category = "casino_result_loss" } pnlText = g.colorize(s, category, fmt.Sprintf("%+d", pl)) } rows = append(rows, []string{ g.colorize(s, "casino_menu", strconv.FormatInt(p.ID, 10)), g.colorize(s, "casino_emphasis", desc), p.Quantity, strconv.FormatInt(p.Cost, 10), valueText, pnlText, }) } if len(rows) == 0 { message := "No open positions. Use view SYMBOL to trade." if closed { message = "No completed trades yet." } g.predictionFrame(s, "CRYPTO PORTFOLIO", []string{message}) g.cryptoBalance(s) return } headers := []string{"#", "Position", "Qty", "Cost"} title := "CRYPTO POSITIONS" last := "P/L" if closed { headers = append(headers, "Released") title, last = "CRYPTO TRADE HISTORY", "Net" } else { headers = append(headers, "Value") } headers = append(headers, last) colored := make([]string, len(headers)) for i, h := range headers { colored[i] = g.colorize(s, "casino_menu", h) } g.predictionTable(s, g.colorize(s, "casino_emphasis", fmt.Sprintf("%s (%d)", title, len(rows))), colored, rows, 1) g.cryptoBalance(s) }