diff options
Diffstat (limited to 'internal/game/crypto.go')
| -rw-r--r-- | internal/game/crypto.go | 681 |
1 files changed, 681 insertions, 0 deletions
diff --git a/internal/game/crypto.go b/internal/game/crypto.go new file mode 100644 index 0000000..08a6b64 --- /dev/null +++ b/internal/game/crypto.go @@ -0,0 +1,681 @@ +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) +} |
