diff options
26 files changed, 2946 insertions, 22 deletions
diff --git a/data/help/bank.yaml b/data/help/bank.yaml index 7b505ae..bc14170 100644 --- a/data/help/bank.yaml +++ b/data/help/bank.yaml @@ -10,8 +10,12 @@ description: | list Same as browse deposit <item> Move an item from your inventory to the bank deposit all Deposit everything from your inventory + deposit credits N Deposit carried credits + withdraw credits N Withdraw banked credits withdraw <item> Move an item from the bank to your inventory leave Exit the bank interface Bank booths can be found in Town Square and the Forge. Items in storage are safe and accessible from any bank booth. + Crypto Exchange debt automatically takes bank credits and future credit + deposits until repaid. Bank items are not seized. See help crypto. diff --git a/data/help/casino.yaml b/data/help/casino.yaml index 6e2b7ce..fc7d85f 100644 --- a/data/help/casino.yaml +++ b/data/help/casino.yaml @@ -1,7 +1,7 @@ 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, predictions] +related: [big6, roulette, racetrack, blackjack, baccarat, craps, slots, video poker, pai gow poker, plinko, cash hunt, predictions, crypto] description: | Casino quick guide @@ -9,6 +9,10 @@ description: | Buy/sell house contracts and check your portfolio. See help predictions for prices, limits and settlement. + use crypto Open the Crypto Exchange east of the Prediction Exchange. + Spot trading, shorts, house options and daily candles. + See help crypto before taking on debt. + 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/crypto.yaml b/data/help/crypto.yaml new file mode 100644 index 0000000..6b7e0a1 --- /dev/null +++ b/data/help/crypto.yaml @@ -0,0 +1,77 @@ +name: crypto +aliases: [cryptocurrency, crypto exchange, use crypto] +category: Interaction +related: [predictions, bank, casino] +description: | + Crypto Exchange + + Go east from the Prediction Exchange and use crypto. Choose a currency + from the board, add some trading funds, and buy coins or open a short. + One dollar is one credit. All trading uses game credits, not real money. + + Getting started: + fund 1000 + view BTC + buy BTC 0.001 + active + close 1 + + markets / back Currency board: BTC, ETH, SOL, DOGE, SHIB + view SYMBOL Price chart with colored daily candles + fund CREDITS Move carried credits into your trading account + withdraw CREDITS Take out available trading funds + buy SYMBOL AMOUNT Buy coins; fractions are welcome + short SYMBOL AMOUNT Back a falling price + active / closed Your open positions / completed trades + close ID Close a whole position (sell and cover also work) + contracts Show the calls and puts trading commands + refresh Update the current screen + rules Quick introduction + stop Leave the counter without closing positions + + Command hints work just like casino games. Use what or verbs to see them + at any time; casino_hints controls automatic hints. + + Contracts + Type contracts at the counter to see the option commands. A call backs + a rising price; a put backs a falling price. Choose the + number of coins, a target price, and 1, 7 or 30 days. For example: + quote call BTC 0.01 85000 7 + option call BTC 0.01 85000 7 + Use write instead of option to take the other side. Preview written + options with quote write-call or quote write-put. Writing can create debt! + + These are house-priced options, not real exchange option contracts. + Bought options risk only their premium. They settle in credits at the + first UTC midnight at least your chosen number of days away; your + receipt shows the date. Settlement uses that day's closing price. + Target prices can be between half and twice the current coin price. + + Prices and funds + No commission or house spread. Coins trade at Kraken's buy/sell quotes. + There is no minimum trade value or maximum exposure. Total cash amounts + round to the nearest credit, not each coin's price. Tiny trades can round + to zero credits. You still need enough available funds or collateral; + normal game balance safeguards and the 50-open-position limit apply. + + Shorts reserve the opening coin value as collateral. Written options + reserve the greater of the coin value and target value, with their + premium held until closing. Reserved credits cannot be withdrawn. + Close values include returned collateral; net profit subtracts your cost. + + Returning to the counter + Prices and your positions update only when you use the exchange. + There are no background checks, including while you are offline. + On your next visit, expired options settle and shorts or written options + with too little collateral close at the available price. Large price + moves while you are away can leave you owing credits. Missing price or + expiry data leaves positions pending rather than inventing a price. + + Debt + Losses use your trading funds, then your bank credits. Any unpaid debt + stays on your account and takes future bank credit deposits until paid. + Bank items are safe. Use repay CREDITS to pay from your carried credits. + You cannot open new positions or withdraw trading funds while in debt. + + Charts follow your unicode and color options. Each candle is a completed + UTC day: its body shows open/close, and its wick shows the day's range. diff --git a/data/help/use.yaml b/data/help/use.yaml index 22f4658..c080161 100644 --- a/data/help/use.yaml +++ b/data/help/use.yaml @@ -16,6 +16,10 @@ description: | Resolved contracts are collected with collect. Look shows current casino hints; stop leaves the interface. + Crypto Exchange (east of the Prediction Exchange) + use crypto opens spot, short and house-option trading. + view SYMBOL shows daily candles; help crypto explains debt and risk. + Station object (furnace, anvil, fire, range, workbench, etc.) Shows a combined production menu of all recipes you can make. @@ -42,7 +46,7 @@ description: | use flour on water Combine into dough push gate Push a gate open (shortcut for use) - See also: cook, smelt, smith, recipe, predictions + See also: cook, smelt, smith, recipe, predictions, crypto aliases: - "pull" diff --git a/data/rooms/casino/6110.yaml b/data/rooms/casino/6110.yaml index 0e69c41..0b1c558 100644 --- a/data/rooms/casino/6110.yaml +++ b/data/rooms/casino/6110.yaml @@ -4,6 +4,8 @@ description: Brass rails divide a wall of glowing odds boards from a polished co 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: + east: + room: 6111 west: room: 6100 mobs: diff --git a/data/rooms/casino/6111.yaml b/data/rooms/casino/6111.yaml new file mode 100644 index 0000000..23c794d --- /dev/null +++ b/data/rooms/casino/6111.yaml @@ -0,0 +1,15 @@ +name: Crypto Exchange +crypto_exchange: true +description: Green and red candles crawl across luminous currency boards. Behind a + brass counter, the house offers coins, short positions, and option contracts. + A stern notice warns that trading debts follow you back to the bank. +exits: + west: + room: 6110 +objects: +- name: crypto exchange + description: 'Use crypto to trade with fictional credits. One US dollar is one + credit. Fractional coins are welcome. House-model options and shorts can + lose money; debts consume bank credits and future bank deposits. Read help + crypto before trading.' + inroom_description: A crypto trading counter stands beneath colored price candles. diff --git a/internal/cryptomarket/chart.go b/internal/cryptomarket/chart.go new file mode 100644 index 0000000..c1fef29 --- /dev/null +++ b/internal/cryptomarket/chart.go @@ -0,0 +1,78 @@ +package cryptomarket + +import ( + "fmt" + "math" + "strings" + "time" +) + +// Chart uses one column per completed daily candle, auto-scales for BTC and +// tiny tokens alike, and delegates color to the player's configured palette. +func Chart(candles []Candle, width int, unicode bool, paint func(string, bool) string) []string { + width = max(12, min(80, width)) + label := 12 + count := width - label - 2 + if count < 2 || len(candles) < 2 { + return []string{"Not enough completed daily candles."} + } + if len(candles) > count { + candles = candles[len(candles)-count:] + } + type bar struct{ o, h, l, c float64 } + bars := []bar{} + low, high := math.Inf(1), math.Inf(-1) + for _, c := range candles { + if c.Validate() != nil { + return []string{"Invalid candle history."} + } + o, _ := rat(c.Open).Float64() + h, _ := rat(c.High).Float64() + l, _ := rat(c.Low).Float64() + v, _ := rat(c.Close).Float64() + bars = append(bars, bar{o, h, l, v}) + low = math.Min(low, l) + high = math.Max(high, h) + } + if high == low { + padding := math.Max(high*0.01, 1e-18) + low -= padding + high += padding + } + const height = 10 + row := func(p float64) int { return max(0, min(height-1, int(math.Round((high-p)/(high-low)*(height-1))))) } + grid := make([][]string, height) + for y := range grid { + grid[y] = make([]string, len(bars)) + for x := range grid[y] { + grid[y][x] = " " + } + } + wick, body, doji, axis := "|", "#", "-", "|" + if unicode { + wick, body, doji, axis = "│", "█", "━", "│" + } + for x, b := range bars { + top, bottom := min(row(b.o), row(b.c)), max(row(b.o), row(b.c)) + for y := row(b.h); y <= row(b.l); y++ { + ch := wick + if y >= top && y <= bottom { + ch = body + if top == bottom { + ch = doji + } + } + if paint != nil { + ch = paint(ch, b.c >= b.o) + } + grid[y][x] = ch + } + } + out := []string{"USD / credits; daily candles (UTC). Up/down colored; body=open-close, wick=range."} + for y := range grid { + v := high - float64(y)*(high-low)/(height-1) + out = append(out, fmt.Sprintf("%11.5g %s%s", v, axis, strings.Join(grid[y], ""))) + } + out = append(out, fmt.Sprintf("%s to %s", time.Unix(candles[0].Time, 0).UTC().Format("2006-01-02"), time.Unix(candles[len(candles)-1].Time, 0).UTC().Format("2006-01-02"))) + return out +} diff --git a/internal/cryptomarket/client.go b/internal/cryptomarket/client.go new file mode 100644 index 0000000..4d6d0a8 --- /dev/null +++ b/internal/cryptomarket/client.go @@ -0,0 +1,347 @@ +package cryptomarket + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "sort" + "sync" + "time" +) + +type Instrument struct{ Symbol, Pair, Key string } + +var Instruments = []Instrument{ + {"BTC", "XBTUSD", "XXBTZUSD"}, {"ETH", "ETHUSD", "XETHZUSD"}, + {"SOL", "SOLUSD", "SOLUSD"}, {"DOGE", "DOGEUSD", "XDGUSD"}, {"SHIB", "SHIBUSD", "SHIBUSD"}, +} + +func Find(symbol string) (Instrument, bool) { + for _, i := range Instruments { + if i.Symbol == symbol { + return i, true + } + } + return Instrument{}, false +} + +type Quote struct { + Bid, Ask string + At time.Time +} + +func (q Quote) Validate(now time.Time) error { + b, e := Decimal(q.Bid) + if e != nil { + return e + } + a, e := Decimal(q.Ask) + if e != nil { + return e + } + if a.Cmp(b) < 0 || q.At.After(now.Add(5*time.Second)) || now.Sub(q.At) > 60*time.Second { + return fmt.Errorf("stale or crossed quote") + } + return nil +} +func (q Quote) mid() *big.Rat { return mul(add(rat(q.Bid), rat(q.Ask)), rat("0.5")) } + +type Candle struct { + Time int64 + Open, High, Low, Close string +} + +func (c Candle) Validate() error { + o, e := Decimal(c.Open) + if e != nil { + return e + } + h, e := Decimal(c.High) + if e != nil { + return e + } + l, e := Decimal(c.Low) + if e != nil { + return e + } + v, e := Decimal(c.Close) + if e != nil { + return e + } + if c.Time <= 0 || c.Time%86400 != 0 || h.Cmp(l) < 0 || o.Cmp(l) < 0 || o.Cmp(h) > 0 || v.Cmp(l) < 0 || v.Cmp(h) > 0 { + return fmt.Errorf("invalid daily candle") + } + return nil +} + +type Snapshot struct { + Quotes map[string]Quote + Candles map[string][]Candle +} + +// Expiry uses the completed UTC day's close, never today's price or the +// unfinished candle. Missing history leaves a contract pending, not mispriced. +func (s Snapshot) Expiry(symbol string, expiry int64) (string, bool) { + for _, c := range s.Candles[symbol] { + if c.Time+86400 == expiry && c.Validate() == nil { + return c.Close, true + } + } + return "", false +} + +type Client struct { + HTTP *http.Client + Base string + once sync.Once + gate chan struct{} + cache Snapshot + fetchedAt map[string]time.Time + historyDay int64 + history map[string][]Candle + retryAfter time.Time + lastError error +} + +func NewClient() *Client { + return &Client{HTTP: &http.Client{Timeout: 10 * time.Second}, Base: "https://api.kraken.com/0/public"} +} +func (c *Client) get(ctx context.Context, path string, out any) error { + req, e := http.NewRequestWithContext(ctx, "GET", c.Base+path, nil) + if e != nil { + return e + } + req.Header.Set("User-Agent", "IcarusMUD-PaperCrypto/1.0") + req.Header.Set("Cache-Control", "no-cache") + r, e := c.HTTP.Do(req) + if e != nil { + return e + } + defer r.Body.Close() + if r.StatusCode != 200 { + return fmt.Errorf("kraken HTTP %d", r.StatusCode) + } + var envelope struct { + Error []string `json:"error"` + Result json.RawMessage `json:"result"` + } + if e = json.NewDecoder(io.LimitReader(r.Body, 4<<20)).Decode(&envelope); e != nil { + return e + } + if len(envelope.Error) > 0 { + return fmt.Errorf("kraken rejected request") + } + return json.Unmarshal(envelope.Result, out) +} + +// Fetch requests only allowlisted USD markets. Metadata prevents trading a +// suspended/delisted pair. One failed instrument does not poison other pairs. +func (c *Client) Fetch(ctx context.Context, symbols []string) (Snapshot, error) { + s := Snapshot{Quotes: map[string]Quote{}, Candles: map[string][]Candle{}} + if len(symbols) == 0 { + return s, nil + } + for _, sym := range symbols { + if _, ok := Find(sym); !ok { + return s, fmt.Errorf("unsupported currency") + } + } + // Coalesce concurrent users, bound polling, and share immutable daily history. + c.once.Do(func() { c.gate = make(chan struct{}, 1) }) + select { + case c.gate <- struct{}{}: + case <-ctx.Done(): + return s, ctx.Err() + } + defer func() { <-c.gate }() + if ctx.Err() != nil { + return s, ctx.Err() + } + if time.Now().Before(c.retryAfter) { + return s, c.lastError + } + if c.fresh(symbols) { + return c.cache.selectSymbols(symbols), nil + } + result, err := c.fetch(ctx, symbols) + if err != nil { + // A caller's own cancellation or deadline must never become the shared + // backoff error: that would show every other visitor a closed counter + // for 15 seconds because one player walked away mid-fetch. + if ctx.Err() != nil { + return result, err + } + c.lastError = err + c.retryAfter = time.Now().Add(15 * time.Second) + return result, err + } + if c.fetchedAt == nil { + c.fetchedAt = map[string]time.Time{} + } + c.cache = c.cache.merge(result) + now := time.Now() + for sym := range result.Quotes { + c.fetchedAt[sym] = now + } + return result, nil +} + +// fresh reports whether every requested symbol has a recent quote. Each +// symbol ages independently, so another currency's fetch cannot make a stale +// quote look usable. No background work runs: only an explicit interaction +// refreshes anything. +func (c *Client) fresh(symbols []string) bool { + for _, sym := range symbols { + if _, ok := c.cache.Quotes[sym]; !ok { + return false + } + if at, ok := c.fetchedAt[sym]; !ok || time.Since(at) >= 15*time.Second { + return false + } + } + return true +} +func (s Snapshot) selectSymbols(symbols []string) Snapshot { + out := Snapshot{Quotes: map[string]Quote{}, Candles: map[string][]Candle{}} + for _, sym := range symbols { + if q, ok := s.Quotes[sym]; ok { + out.Quotes[sym] = q + } + if c, ok := s.Candles[sym]; ok { + out.Candles[sym] = append([]Candle(nil), c...) + } + } + return out +} +func (s Snapshot) merge(other Snapshot) Snapshot { + out := s.clone() + for k, v := range other.Quotes { + out.Quotes[k] = v + } + for k, v := range other.Candles { + out.Candles[k] = append([]Candle(nil), v...) + } + return out +} +func (s Snapshot) clone() Snapshot { + out := Snapshot{Quotes: map[string]Quote{}, Candles: map[string][]Candle{}} + for k, v := range s.Quotes { + out.Quotes[k] = v + } + for k, v := range s.Candles { + out.Candles[k] = append([]Candle(nil), v...) + } + return out +} +func (c *Client) fetch(ctx context.Context, symbols []string) (Snapshot, error) { + s := Snapshot{Quotes: map[string]Quote{}, Candles: map[string][]Candle{}} + pairs := "" + for _, sym := range symbols { + i, ok := Find(sym) + if !ok { + return s, fmt.Errorf("unsupported currency") + } + if pairs != "" { + pairs += "," + } + pairs += i.Pair + } + var status struct { + Status string `json:"status"` + } + if e := c.get(ctx, "/SystemStatus", &status); e != nil { + return s, e + } + if status.Status != "online" { + return s, fmt.Errorf("kraken is not online") + } + var meta map[string]struct { + Status string `json:"status"` + } + if e := c.get(ctx, "/AssetPairs?pair="+url.QueryEscape(pairs), &meta); e != nil { + return s, e + } + var ticks map[string]struct { + Ask []string `json:"a"` + Bid []string `json:"b"` + } + started := time.Now().UTC() + if e := c.get(ctx, "/Ticker?pair="+url.QueryEscape(pairs), &ticks); e != nil { + return s, e + } + day := started.Unix() / 86400 + if c.historyDay != day { + c.history = map[string][]Candle{} + c.historyDay = day + } + for _, sym := range symbols { + i, _ := Find(sym) + v, ok := ticks[i.Key] + if ok && meta[i.Key].Status == "online" && len(v.Ask) > 0 && len(v.Bid) > 0 { + q := Quote{Bid: v.Bid[0], Ask: v.Ask[0], At: started} + if q.Validate(time.Now()) == nil { + s.Quotes[sym] = q + } + } + if history, ok := c.history[sym]; ok { + s.Candles[sym] = append([]Candle(nil), history...) + continue + } + var raw map[string]json.RawMessage + if e := c.get(ctx, "/OHLC?pair="+url.QueryEscape(i.Pair)+"&interval=1440", &raw); e != nil { + continue + } + var rows [][]json.RawMessage + if json.Unmarshal(raw[i.Key], &rows) != nil { + continue + } + candles := []Candle{} + valid := true + var last int64 + for _, r := range rows { + if len(r) != 8 { + valid = false + break + } + var v Candle + if json.Unmarshal(r[0], &v.Time) != nil || json.Unmarshal(r[1], &v.Open) != nil || json.Unmarshal(r[2], &v.High) != nil || json.Unmarshal(r[3], &v.Low) != nil || json.Unmarshal(r[4], &v.Close) != nil || v.Validate() != nil || v.Time <= last { + valid = false + break + } + last = v.Time + if v.Time+86400 <= started.Unix() { + candles = append(candles, v) + } + } + if valid { + s.Candles[sym] = candles + // Retry history until the immediately preceding day is present; a + // midnight publication lag must not cache missing expiry data all day. + if len(candles) > 0 && candles[len(candles)-1].Time+86400 == day*86400 { + c.history[sym] = append([]Candle(nil), candles...) + } + } + } + if len(s.Quotes) == 0 && len(s.Candles) == 0 { + return s, fmt.Errorf("no valid live quotes") + } + return s, nil +} +func Symbols(a Account) []string { + m := map[string]bool{} + for _, p := range a.Positions { + if p.Status == "open" { + m[p.Symbol] = true + } + } + out := []string{} + for s := range m { + out = append(out, s) + } + sort.Strings(out) + return out +} diff --git a/internal/cryptomarket/client_test.go b/internal/cryptomarket/client_test.go new file mode 100644 index 0000000..39367fc --- /dev/null +++ b/internal/cryptomarket/client_test.go @@ -0,0 +1,216 @@ +package cryptomarket + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func mockKraken(t *testing.T, mode string) (*Client, *atomic.Int32) { + t.Helper() + calls := &atomic.Int32{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if mode == "http" { + w.WriteHeader(429) + return + } + if mode == "error" { + w.Write([]byte(`{"error":["EAPI:Rate limit exceeded"]}`)) + return + } + result := any(nil) + switch r.URL.Path { + case "/SystemStatus": + status := "online" + if mode == "maintenance" { + status = "maintenance" + } + result = map[string]string{"status": status} + case "/AssetPairs": + m := map[string]any{} + for _, i := range Instruments { + status := "online" + if mode == "halted" && i.Symbol == "BTC" { + status = "cancel_only" + } + m[i.Key] = map[string]string{"status": status} + } + result = m + case "/Ticker": + m := map[string]any{} + for _, i := range Instruments { + bid, ask := "100", "101" + if mode == "crossed" && i.Symbol == "BTC" { + bid = "102" + } + m[i.Key] = map[string]any{"a": []string{ask}, "b": []string{bid}} + } + result = m + case "/OHLC": + key := "" + for _, i := range Instruments { + if i.Pair == r.URL.Query().Get("pair") { + key = i.Key + } + } + now := time.Now().UTC().Truncate(24 * time.Hour).Unix() + high := "110" + if mode == "bad-candle" { + high = "99" + } + result = map[string]any{key: [][]any{{now - 86400, "100", high, "90", "101", "100", "1", 1}, {now, "100", "110", "90", "102", "100", "1", 1}}, "last": now} + default: + t.Errorf("unexpected URL %s", r.URL) + } + json.NewEncoder(w).Encode(map[string]any{"error": []string{}, "result": result}) + })) + t.Cleanup(srv.Close) + c := NewClient() + c.Base = srv.URL + return c, calls +} +func TestClientValidatesQuotesAndCompletedCandles(t *testing.T) { + for _, mode := range []string{"ok", "halted", "crossed", "bad-candle"} { + t.Run(mode, func(t *testing.T) { + c, _ := mockKraken(t, mode) + s, e := c.Fetch(context.Background(), []string{"BTC"}) + if e != nil { + t.Fatal(e) + } + _, quoted := s.Quotes["BTC"] + if quoted != (mode != "halted" && mode != "crossed") { + t.Fatal(s.Quotes) + } + n := len(s.Candles["BTC"]) + if mode == "bad-candle" { + if n != 0 { + t.Fatal("invalid candle") + } + } else if n != 1 { + t.Fatal("unfinished candle included", n) + } + }) + } +} +func TestClientErrorsBackoffAndCancellation(t *testing.T) { + for _, mode := range []string{"http", "error", "maintenance"} { + c, calls := mockKraken(t, mode) + for i := 0; i < 2; i++ { + if _, e := c.Fetch(context.Background(), []string{"BTC"}); e == nil { + t.Fatal(mode) + } + } + if calls.Load() != 1 { + t.Fatal("no backoff") + } + } + c, _ := mockKraken(t, "ok") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, e := c.Fetch(ctx, []string{"BTC"}); e == nil { + t.Fatal("ignored cancellation") + } + if _, e := c.Fetch(context.Background(), []string{"BTC/../../"}); e == nil { + t.Fatal("symbol injection") + } +} +func TestClientCoalescesAndDoesNotShareMutableCache(t *testing.T) { + c, calls := mockKraken(t, "ok") + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + s, e := c.Fetch(context.Background(), []string{"BTC"}) + if e != nil { + t.Error(e) + return + } + delete(s.Quotes, "BTC") + s.Candles["BTC"][0].Close = "1" + }() + } + wg.Wait() + // One explicit interaction fetches only the requested pair: status, + // metadata, ticker and daily history. The rest are cache hits. + if calls.Load() != 4 { + t.Fatalf("requests %d want 4", calls.Load()) + } + s, e := c.Fetch(context.Background(), []string{"BTC"}) + if e != nil || s.Quotes["BTC"].Bid != "100" || s.Candles["BTC"][0].Close != "101" { + t.Fatal(s, e) + } +} +func TestKrakenLive(t *testing.T) { + if os.Getenv("TEST_KRAKEN_LIVE") != "1" { + t.Skip("set TEST_KRAKEN_LIVE=1 for public-API smoke test") + } + c := NewClient() + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second) + defer cancel() + s, e := c.Fetch(ctx, []string{"BTC", "SHIB"}) + if e != nil { + t.Fatal(e) + } + for _, symbol := range []string{"BTC", "SHIB"} { + q, ok := s.Quotes[symbol] + if !ok || q.Validate(time.Now()) != nil || len(s.Candles[symbol]) < 2 { + t.Fatalf("%s missing quotes/candles", symbol) + } + t.Log(symbol, q.Bid, q.Ask, strings.Join(Chart(s.Candles[symbol], 30, false, nil), "\n")) + } +} + +func TestClientCancellationDoesNotPoisonSharedBackoff(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Block the first request so the caller can cancel it mid-fetch. + once.Do(func() { close(started); <-release }) + result := any(nil) + switch r.URL.Path { + case "/SystemStatus": + result = map[string]string{"status": "online"} + case "/AssetPairs": + m := map[string]any{} + for _, i := range Instruments { + m[i.Key] = map[string]string{"status": "online"} + } + result = m + case "/Ticker": + m := map[string]any{} + for _, i := range Instruments { + m[i.Key] = map[string]any{"a": []string{"100"}, "b": []string{"99"}} + } + result = m + case "/OHLC": + result = map[string]any{} + default: + t.Errorf("unexpected URL %s", r.URL) + } + json.NewEncoder(w).Encode(map[string]any{"error": []string{}, "result": result}) + })) + defer srv.Close() + c := NewClient() + c.Base = srv.URL + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { _, _ = c.Fetch(ctx, []string{"BTC"}); close(done) }() + <-started + cancel() + <-done + close(release) + // A later caller must not inherit the cancelled caller's backoff. + if _, e := c.Fetch(context.Background(), []string{"BTC"}); e != nil { + t.Fatalf("cancelled fetch poisoned the shared client: %v", e) + } +} diff --git a/internal/cryptomarket/exchange.go b/internal/cryptomarket/exchange.go new file mode 100644 index 0000000..2e65d5c --- /dev/null +++ b/internal/cryptomarket/exchange.go @@ -0,0 +1,426 @@ +package cryptomarket + +import ( + "fmt" + "math" + "math/big" + "time" +) + +// Account is the exchange ledger: Balance is available cash, negative for +// recourse debt. Collateral and written option premiums remain locked in each +// position, never spendable proceeds. +type Account struct { + Balance int64 `yaml:"balance"` + NextID int64 `yaml:"next_id"` + Positions []Position `yaml:"positions,omitempty"` +} +type Position struct { + ID int64 `yaml:"id"` + Symbol string `yaml:"symbol"` + Kind string `yaml:"kind"` // spot, short, call, put, write-call, write-put + Quantity string `yaml:"quantity"` + Entry string `yaml:"entry"` + Strike string `yaml:"strike,omitempty"` + Expiry int64 `yaml:"expiry,omitempty"` + Opened int64 `yaml:"opened"` + Collateral int64 `yaml:"collateral"` + Premium int64 `yaml:"premium"` + Cost int64 `yaml:"cost"` + Status string `yaml:"status"` + Closed int64 `yaml:"closed,omitempty"` + Proceeds int64 `yaml:"proceeds,omitempty"` +} + +func (a Account) Clone() Account { a.Positions = append([]Position(nil), a.Positions...); return a } +func (p Position) Risky() bool { + return p.Kind == "short" || p.Kind == "write-call" || p.Kind == "write-put" +} +func (p Position) Option() bool { + return p.Kind == "call" || p.Kind == "put" || p.Kind == "write-call" || p.Kind == "write-put" +} +func (p Position) Validate() error { + if _, ok := Find(p.Symbol); !ok { + return fmt.Errorf("unknown saved currency") + } + if _, e := Decimal(p.Quantity); e != nil { + return e + } + if _, e := Decimal(p.Entry); e != nil { + return e + } + if p.ID <= 0 || p.Opened <= 0 || p.Collateral < 0 || p.Collateral > MaxMoney || p.Premium < 0 || p.Premium > MaxMoney || p.Cost < 0 || p.Cost > MaxMoney { + return fmt.Errorf("invalid saved position") + } + if p.Option() { + if _, e := Decimal(p.Strike); e != nil { + return e + } + if p.Expiry <= p.Opened || p.Expiry%86400 != 0 { + return fmt.Errorf("invalid expiry") + } + } + if p.Kind != "spot" && p.Kind != "short" && !p.Option() { + return fmt.Errorf("invalid position kind") + } + if p.Proceeds < -MaxMoney || p.Proceeds > MaxMoney { + return fmt.Errorf("invalid saved cash amount") + } + if p.Risky() && p.Collateral != p.Cost { + return fmt.Errorf("invalid saved collateral") + } + if !p.Risky() && p.Collateral != 0 { + return fmt.Errorf("unexpected collateral") + } + if p.Option() { + if !p.Risky() && p.Premium != p.Cost { + return fmt.Errorf("invalid saved premium") + } + } else if p.Premium != 0 || p.Expiry != 0 || p.Strike != "" { + return fmt.Errorf("unexpected option fields") + } + switch p.Status { + case "open": + if p.Closed != 0 || p.Proceeds != 0 { + return fmt.Errorf("invalid open receipt") + } + case "closed", "liquidated", "expired": + if p.Closed < p.Opened { + return fmt.Errorf("invalid closed receipt") + } + default: + return fmt.Errorf("invalid position status") + } + return nil +} +func (a Account) Validate() error { + if a.Balance < -MaxMoney || a.Balance > MaxMoney || a.NextID < 0 || a.NextID > MaxMoney { + return fmt.Errorf("invalid exchange ledger") + } + seen := map[int64]bool{} + open := 0 + for _, p := range a.Positions { + if e := p.Validate(); e != nil { + return e + } + if seen[p.ID] || p.ID > a.NextID { + return fmt.Errorf("invalid position identity") + } + seen[p.ID] = true + if p.Status == "open" { + open++ + } + } + if open > MaxPositions { + return fmt.Errorf("too many positions") + } + return nil +} + +// OptionPrice values a house option: European, cash-settled, zero interest, +// fixed 100% annualized volatility. These are model quotes, NOT Kraken's +// listed option prices. +func OptionPrice(kind string, spot, strike *big.Rat, remaining time.Duration) (*big.Rat, error) { + s, _ := spot.Float64() + k, _ := strike.Float64() + t := remaining.Hours() / (365 * 24) + call := kind == "call" || kind == "write-call" + intrinsic := math.Max(0, s-k) + if !call { + intrinsic = math.Max(0, k-s) + } + if t <= 0 { + return floatRat(intrinsic) + } + root := math.Sqrt(t) + d1 := (math.Log(s/k) + 0.5*t) / root + d2 := d1 - root + normal := func(x float64) float64 { return 0.5 * (1 + math.Erf(x/math.Sqrt2)) } + v := s*normal(d1) - k*normal(d2) + if !call { + v = k*normal(-d2) - s*normal(-d1) + } + return floatRat(math.Max(intrinsic, v)) +} +func intrinsic(p Position, price string) *big.Rat { + v := sub(rat(price), rat(p.Strike)) + if p.Kind == "put" || p.Kind == "write-put" { + v.Neg(v) + } + if v.Sign() < 0 { + return integer(0) + } + return mul(v, rat(p.Quantity)) +} + +// Open mutates only on success. Full-lot closes prevent repeated fractional +// cash rounding from manufacturing credits. Quantity is underlying coins, not +// exchange contract lots (there is no hidden 100x multiplier). +func (a *Account) Open(symbol, kind, quantity, strike string, days int, q Quote, now time.Time) (Position, error) { + fail := func(e string) (Position, error) { return Position{}, fmt.Errorf("%s", e) } + if e := a.Validate(); e != nil { + return Position{}, e + } + if a.Balance < 0 { + return fail("pay the exchange debt before opening positions") + } + if e := q.Validate(now); e != nil { + return Position{}, e + } + if _, ok := Find(symbol); !ok { + return fail("unsupported currency") + } + quant, e := Decimal(quantity) + if e != nil { + return Position{}, e + } + n := 0 + for _, p := range a.Positions { + if p.Status == "open" { + n++ + } + } + if n >= MaxPositions || a.NextID >= MaxMoney { + return fail("portfolio is full") + } + p := Position{ID: a.NextID + 1, Symbol: symbol, Kind: kind, Quantity: quantity, Opened: now.Unix(), Status: "open", Entry: q.mid().FloatString(18)} + notional := mul(q.mid(), quant) + var cash *big.Rat + switch kind { + case "spot": + p.Entry = q.Ask + cash = mul(rat(q.Ask), quant) + case "short": + p.Entry = q.Bid + p.Collateral, e = Round(notional) + cash = integer(p.Collateral) + case "call", "put", "write-call", "write-put": + k, err := Decimal(strike) + if err != nil { + return Position{}, err + } + if days != 1 && days != 7 && days != 30 { + return fail("expiry must be 1, 7 or 30 UTC days") + } + if k.Cmp(mul(q.mid(), rat("0.5"))) < 0 || k.Cmp(mul(q.mid(), integer(2))) > 0 { + return fail("strike must be between 50% and 200% of spot") + } + p.Strike = strike + expiry := now.UTC().Add(time.Duration(days) * 24 * time.Hour) + midnight := expiry.Truncate(24 * time.Hour) + if expiry.After(midnight) { + midnight = midnight.Add(24 * time.Hour) + } + p.Expiry = midnight.Unix() + price, err := OptionPrice(kind, q.mid(), k, time.Unix(p.Expiry, 0).Sub(now)) + if err != nil { + return Position{}, err + } + premium := mul(price, quant) + p.Premium, e = Round(premium) + if e != nil { + return Position{}, e + } + if p.Risky() { + margin := notional + if v := mul(k, quant); v.Cmp(margin) > 0 { + margin = v + } + p.Collateral, e = Round(margin) + cash = integer(p.Collateral) + } else { + cash = integer(p.Premium) + } + default: + return fail("choose spot, short, call, put, write-call or write-put") + } + if e != nil { + return Position{}, e + } + cost, e := Round(cash) + if e != nil { + return Position{}, e + } + if a.Balance < cost { + return fail("insufficient exchange funds; use fund CREDITS") + } + p.Cost = cost + if e = p.Validate(); e != nil { + return Position{}, e + } + a.Balance -= cost + a.NextID = p.ID + a.Positions = append(a.Positions, p) + a.prune() + return p, nil +} +func (a *Account) prune() { + // Retain all open lots and the latest 100 closed receipts. IDs never reuse. + closed := 0 + for _, p := range a.Positions { + if p.Status != "open" { + closed++ + } + } + if closed <= 100 { + return + } + out := make([]Position, 0, len(a.Positions)) + for _, p := range a.Positions { + if p.Status != "open" && closed > 100 { + closed-- + continue + } + out = append(out, p) + } + a.Positions = out +} + +// Value is the total cash released at close (including locked collateral). +func Value(p Position, q Quote, now time.Time, expiryPrice string) (int64, error) { + if e := p.Validate(); e != nil { + return 0, e + } + if now.Unix() < p.Opened { + return 0, fmt.Errorf("clock precedes position opening") + } + var v *big.Rat + if p.Option() && now.Unix() >= p.Expiry { + if _, e := Decimal(expiryPrice); e != nil { + return 0, fmt.Errorf("awaiting exact expiry candle") + } + v = intrinsic(p, expiryPrice) + } else { + if e := q.Validate(now); e != nil { + return 0, e + } + switch p.Kind { + case "spot": + v = mul(rat(q.Bid), rat(p.Quantity)) + case "short": + v = mul(sub(rat(p.Entry), rat(q.Ask)), rat(p.Quantity)) + default: + model, e := OptionPrice(p.Kind, q.mid(), rat(p.Strike), time.Unix(p.Expiry, 0).Sub(now)) + if e != nil { + return 0, e + } + v = mul(model, rat(p.Quantity)) + } + } + if p.Kind == "short" { + v.Add(v, integer(p.Collateral)) + } else if p.Risky() { + // The whole option liability rounds exactly like a bought option's + // payout. Rounding the net refund instead would mint a credit on + // half-credit write/close round trips without a spread. + liability, err := Round(v) + if err != nil { + return 0, err + } + v.Sub(integer(p.Collateral+p.Premium), integer(liability)) + } + return Round(v) +} +func (a *Account) Close(id int64, s Snapshot, now time.Time, reason string) (Position, error) { + if reason != "closed" && reason != "expired" && reason != "liquidated" { + return Position{}, fmt.Errorf("invalid close reason") + } + if e := a.Validate(); e != nil { + return Position{}, e + } + for i, p := range a.Positions { + if p.ID != id { + continue + } + if p.Status != "open" { + return Position{}, fmt.Errorf("position is already closed") + } + expiry, _ := s.Expiry(p.Symbol, p.Expiry) + v, e := Value(p, s.Quotes[p.Symbol], now, expiry) + if e != nil { + return Position{}, e + } + balance, e := Sum(a.Balance, v) + if e != nil { + return Position{}, e + } + p.Status = reason + p.Closed = now.Unix() + p.Proceeds = v + a.Positions[i] = p + a.Balance = balance + a.prune() + return p, nil + } + return Position{}, fmt.Errorf("unknown position number") +} + +// Reconcile expires contracts and liquidates undercollateralized positions at +// observed quotes when the player uses the exchange. No retroactive imaginary fills +// or guaranteed stop price: a market gap can create recourse debt. +func (a *Account) Reconcile(s Snapshot, now time.Time) ([]string, error) { + if e := a.Validate(); e != nil { + return nil, e + } + work := a.Clone() + messages := []string{} + ids := append([]Position(nil), work.Positions...) + for _, p := range ids { + if p.Status != "open" { + continue + } + expiry, _ := s.Expiry(p.Symbol, p.Expiry) + value, e := Value(p, s.Quotes[p.Symbol], now, expiry) + if e != nil { + continue + } + reason := "" + if p.Option() && now.Unix() >= p.Expiry { + reason = "expired" + } else if p.Risky() && value <= p.Collateral/4 { + reason = "liquidated" + } + if reason == "" { + continue + } + // Keep an overflowing positive payout pending until cash is withdrawn. + // Never discard the claim or permanently prevent making room for it. + if value > 0 && work.Balance > MaxMoney-value { + continue + } + closed, e := work.Close(p.ID, s, now, reason) + if e != nil { + return nil, e + } + messages = append(messages, fmt.Sprintf("Crypto #%d %s: %+d credits released.", p.ID, reason, closed.Proceeds)) + } + if work.Balance < 0 { // A bankrupt account may reduce, but never enlarge, risk. + for _, p := range append([]Position(nil), work.Positions...) { + // Stop as soon as the available cash covers the debt; the remaining + // positions are solvent and keep their normal liquidation rules. + if work.Balance >= 0 { + break + } + if p.Status == "open" && p.Risky() { + v, e := work.Close(p.ID, s, now, "liquidated") + if e == nil { + messages = append(messages, fmt.Sprintf("Crypto #%d liquidated for account debt: %+d credits.", p.ID, v.Proceeds)) + } + } + } + } + *a = work + return messages, nil +} + +// SweepBank services debt without touching carried credits. Call in the same +// atomic save as the event that creates debt or deposits money into the bank. +func (a *Account) SweepBank(bank *int) int64 { + if a.Balance >= 0 || *bank <= 0 { + return 0 + } + paid := min(-a.Balance, int64(*bank)) + *bank -= int(paid) + a.Balance += paid + return paid +} diff --git a/internal/cryptomarket/exchange_test.go b/internal/cryptomarket/exchange_test.go new file mode 100644 index 0000000..89c6a82 --- /dev/null +++ b/internal/cryptomarket/exchange_test.go @@ -0,0 +1,339 @@ +package cryptomarket + +import ( + "fmt" + "reflect" + "strings" + "testing" + "time" +) + +var testNow = time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + +func quote(price string) Quote { return Quote{Bid: price, Ask: price, At: testNow} } +func snapshot(symbol, price string, now time.Time) Snapshot { + q := quote(price) + q.At = now + return Snapshot{Quotes: map[string]Quote{symbol: q}, Candles: map[string][]Candle{}} +} +func mustOpen(t *testing.T, a *Account, symbol, kind, qty, strike string, days int, price string) Position { + t.Helper() + p, e := a.Open(symbol, kind, qty, strike, days, quote(price), testNow) + if e != nil { + t.Fatal(e) + } + return p +} +func TestDecimalAndNearestRounding(t *testing.T) { + for _, s := range []string{"NaN", "Inf", "-1", "0", "0.0", "1e6", "1/2", " 1", "+1", "1.", ".1", "1;quit", "1000000000000000", "0.0000000000000000001"} { + if _, e := Decimal(s); e == nil { + t.Errorf("accepted %q", s) + } + } + for s, want := range map[string]int64{"0.49": 0, "0.5": 1, "42.5": 43, "-42.5": -43, "-0.49": 0, "999999999999.49": 999999999999} { + got, e := Round(rat(s)) + if e != nil || got != want { + t.Fatalf("round %s=%d/%v want %d", s, got, e, want) + } + } + for _, s := range []string{"1000000000000.5", "-1000000000000.5"} { + if _, e := Round(rat(s)); e == nil { + t.Fatal("accepted overflow") + } + } +} +func TestFractionalBTCAndHugeSHIB(t *testing.T) { + for _, tc := range []struct { + sym, qty, price string + cost, sale int64 + }{{"BTC", "0.001", "85000", 85, 85}, {"SHIB", "10000000", "0.000006", 60, 60}} { + a := Account{Balance: 1000} + p := mustOpen(t, &a, tc.sym, "spot", tc.qty, "", 0, tc.price) + if p.Cost != tc.cost { + t.Fatalf("cost %d", p.Cost) + } + closed, e := a.Close(p.ID, snapshot(tc.sym, tc.price, testNow), testNow, "closed") + if e != nil || closed.Proceeds != tc.sale { + t.Fatalf("close %+v %v", closed, e) + } + if a.Balance != 1000 { + t.Fatal(a.Balance) + } + before := a.Clone() + if _, e = a.Close(p.ID, snapshot(tc.sym, tc.price, testNow), testNow, "closed"); e == nil || !reflect.DeepEqual(before, a) { + t.Fatal("double payment") + } + } +} +func TestShortGapDebtAndBankSweep(t *testing.T) { + a := Account{Balance: 100} + p := mustOpen(t, &a, "BTC", "short", "1", "", 0, "100") + if a.Balance != 0 || p.Collateral != 100 { + t.Fatal(a) + } + messages, e := a.Reconcile(snapshot("BTC", "300", testNow), testNow) + if e != nil || len(messages) != 1 { + t.Fatalf("%v %v", messages, e) + } + if a.Balance != -100 || a.Positions[0].Status != "liquidated" { + t.Fatal(a) + } + bank := 50 + if paid := a.SweepBank(&bank); paid != 50 || bank != 0 || a.Balance != -50 { + t.Fatal(a, bank) + } + bank = 100 + a.SweepBank(&bank) + if bank != 50 || a.Balance != 0 { + t.Fatal(a, bank) + } + if m, e := a.Reconcile(snapshot("BTC", "300", testNow), testNow); e != nil || len(m) != 0 { + t.Fatal("duplicate liquidation", m, e) + } +} +func TestOptionExpiryExactCandleAndLimitedLongRisk(t *testing.T) { + for _, kind := range []string{"call", "put", "write-call", "write-put"} { + t.Run(kind, func(t *testing.T) { + a := Account{Balance: 20000} + p := mustOpen(t, &a, "BTC", kind, "1", "10000", 1, "10000") + if p.Expiry != time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC).Unix() { + t.Fatal("expiry", p) + } + now := time.Unix(p.Expiry, 0) + s := snapshot("BTC", "99999", now) + before := a.Clone() + messages, e := a.Reconcile(s, now) + if e != nil || len(messages) != 0 || !reflect.DeepEqual(a, before) { + t.Fatal("settled at wrong price") + } + s.Candles["BTC"] = []Candle{{Time: p.Expiry - 86400, Open: "10000", High: "12000", Low: "10000", Close: "12000"}} + m, e := a.Reconcile(s, now) + if e != nil || len(m) != 1 { + t.Fatal(m, e) + } + want := int64(0) + if kind == "call" { + want = 2000 + } + if kind == "write-call" { + want = p.Collateral + p.Premium - 2000 + } + if kind == "write-put" { + want = p.Collateral + p.Premium + } + if a.Positions[0].Proceeds != want { + t.Fatalf("got %d want %d", a.Positions[0].Proceeds, want) + } + }) + } +} +func TestWrittenCallCanCreateDebt(t *testing.T) { + a := Account{Balance: 10000} + p := mustOpen(t, &a, "BTC", "write-call", "1", "10000", 7, "10000") + if a.Balance != 0 { + t.Fatal("premium was withdrawable") + } + now := time.Unix(p.Expiry, 0) + s := snapshot("BTC", "1", now) + s.Candles["BTC"] = []Candle{{Time: p.Expiry - 86400, Open: "40000", High: "40000", Low: "40000", Close: "40000"}} + _, e := a.Reconcile(s, now) + if e != nil || a.Balance >= 0 { + t.Fatal(a, e) + } + want := p.Collateral + p.Premium - 30000 + if a.Balance != want { + t.Fatal(a.Balance, want) + } +} +func TestOpenValidationDoesNotMutate(t *testing.T) { + cases := []struct { + kind, qty, strike string + days int + q Quote + }{ + {"spot", "10001", "", 0, quote("100")}, // insufficient funds, not an exposure cap + {"spot", "NaN", "", 0, quote("100")}, {"call", "1", "100", 2, quote("100")}, + {"call", "1", "1000", 7, quote("100")}, + {"fake", "1", "", 0, quote("100")}, {"spot", "1", "", 0, Quote{Bid: "101", Ask: "100", At: testNow}}, + {"spot", "1", "", 0, Quote{Bid: "100", Ask: "100", At: testNow.Add(-61 * time.Second)}}, + } + for _, tc := range cases { + a := Account{Balance: 1000000} + before := a.Clone() + _, e := a.Open("BTC", tc.kind, tc.qty, tc.strike, tc.days, tc.q, testNow) + if e == nil || !reflect.DeepEqual(a, before) { + t.Fatal(tc, e, a) + } + } + a := Account{Balance: -1} + if _, e := a.Open("BTC", "spot", "1", "", 0, quote("100"), testNow); e == nil { + t.Fatal("opened in debt") + } +} +func TestNoStationaryRoundTripProfit(t *testing.T) { + for _, kind := range []string{"spot", "short", "call", "put", "write-call", "write-put"} { + for n := 10; n < 100; n++ { + a := Account{Balance: 100000} + qty := fmt.Sprintf("%d.123456", n) + p, e := a.Open("BTC", kind, qty, "100", 7, quote("100"), testNow) + if e != nil { + t.Fatal(e) + } + _, e = a.Close(p.ID, snapshot("BTC", "100", testNow), testNow, "closed") + if e != nil { + t.Fatal(e) + } + if a.Balance != 100000 { + t.Fatalf("fee or rounding arbitrage %s %s -> %d", kind, qty, a.Balance) + } + } + } +} +func TestNoMinimumOrExposureLimit(t *testing.T) { + for _, kind := range []string{"spot", "short", "call", "put", "write-call", "write-put"} { + a := Account{Balance: 10} + p := mustOpen(t, &a, "BTC", kind, "0.00001", "100", 1, "100") + if p.Cost != 0 { + t.Fatalf("tiny %s costs %d", kind, p.Cost) + } + if _, e := a.Close(p.ID, snapshot("BTC", "100", testNow), testNow, "closed"); e != nil || a.Balance != 10 { + t.Fatal(a, e) + } + } + a := Account{Balance: 10000000} + for i := 0; i < 2; i++ { + mustOpen(t, &a, "BTC", "spot", "30000", "", 0, "100") + } + if a.Balance != 4000000 { + t.Fatal(a) + } +} +func TestWrittenOptionHalfCreditRoundTrip(t *testing.T) { + for _, tc := range []struct{ kind, strike, qty string }{{"write-call", "50", "0.01"}, {"write-put", "200", "0.005"}} { + a := Account{Balance: 100} + p := mustOpen(t, &a, "BTC", tc.kind, tc.qty, tc.strike, 1, "100") + if p.Premium != 1 { + t.Fatal("expected half-credit premium rounded to one", p) + } + if _, e := a.Close(p.ID, snapshot("BTC", "100", testNow), testNow, "closed"); e != nil || a.Balance != 100 { + t.Fatal("half-credit arbitrage", a, e) + } + } +} +func TestSpotUsesVenueBidAskWithoutMarkup(t *testing.T) { + a := Account{Balance: 10000} + q := Quote{Bid: "99", Ask: "101", At: testNow} + p, e := a.Open("BTC", "spot", "10", "", 0, q, testNow) + if e != nil || p.Cost != 1010 { + t.Fatal(p, e) + } + s := Snapshot{Quotes: map[string]Quote{"BTC": q}} + p, e = a.Close(p.ID, s, testNow, "closed") + if e != nil || p.Proceeds != 990 { + t.Fatal(p, e) + } +} +func TestOutageAndOverflowDoNotLosePositions(t *testing.T) { + a := Account{Balance: 100} + p := mustOpen(t, &a, "BTC", "spot", "1", "", 0, "99") + before := a.Clone() + if _, e := a.Close(p.ID, Snapshot{}, testNow, "closed"); e == nil || !reflect.DeepEqual(a, before) { + t.Fatal("closed without data") + } + a.Balance = MaxMoney + before = a.Clone() + if _, e := a.Close(p.ID, snapshot("BTC", "100", testNow), testNow, "closed"); e == nil || !reflect.DeepEqual(a, before) { + t.Fatal("overflow mutated ledger") + } +} +func TestPositionLimitHistoryAndCorruptSave(t *testing.T) { + a := Account{Balance: 10000} + for i := 0; i < MaxPositions; i++ { + mustOpen(t, &a, "BTC", "spot", "1", "", 0, "10") + } + if _, e := a.Open("BTC", "spot", "1", "", 0, quote("10"), testNow); e == nil { + t.Fatal("position cap") + } + a.Positions[0].Quantity = "NaN" + if _, e := a.Reconcile(snapshot("BTC", "10", testNow), testNow); e == nil { + t.Fatal("corrupt position") + } + a = Account{Balance: 10000} + for i := 0; i < 110; i++ { + p := mustOpen(t, &a, "BTC", "spot", "1", "", 0, "10") + if _, e := a.Close(p.ID, snapshot("BTC", "10", testNow), testNow, "closed"); e != nil { + t.Fatal(e) + } + } + if len(a.Positions) != 100 || a.NextID != 110 { + t.Fatal(a.NextID, len(a.Positions)) + } +} +func TestExpiryAtMidnightAndBalanceCapPending(t *testing.T) { + now := testNow.Truncate(24 * time.Hour) + a := Account{Balance: 20000} + q := quote("10000") + q.At = now + p, e := a.Open("BTC", "call", "1", "10000", 1, q, now) + if e != nil { + t.Fatal(e) + } + if p.Expiry != now.Add(24*time.Hour).Unix() { + t.Fatal("midnight gained extra day", p.Expiry) + } + a.Balance = MaxMoney + expired := time.Unix(p.Expiry, 0) + s := snapshot("BTC", "30000", expired) + s.Candles["BTC"] = []Candle{{Time: p.Expiry - 86400, Open: "12000", High: "12000", Low: "12000", Close: "12000"}} + if _, e = a.Reconcile(s, expired); e != nil || a.Positions[0].Status != "open" { + t.Fatal("overflow lost claim", a, e) + } + a.Balance -= 2000 + if _, e = a.Reconcile(s, expired); e != nil || a.Balance != MaxMoney || a.Positions[0].Status != "expired" { + t.Fatal("payout did not resume", a, e) + } +} +func TestBankruptAccountStopsLiquidatingOnceSolvent(t *testing.T) { + a := Account{Balance: 300} + // Two healthy shorts; each returns its collateral when closed at entry. + mustOpen(t, &a, "BTC", "short", "1", "", 0, "100") + mustOpen(t, &a, "BTC", "short", "1", "", 0, "100") + // A small debt that one close more than covers. + a.Balance = -10 + messages, e := a.Reconcile(snapshot("BTC", "100", testNow), testNow) + if e != nil { + t.Fatal(e) + } + open := 0 + for _, p := range a.Positions { + if p.Status == "open" { + open++ + } + } + if a.Balance < 0 || open != 1 { + t.Fatalf("over-liquidated: balance %d, open %d (%v)", a.Balance, open, messages) + } +} +func TestCandlestickModesFlatAndTiny(t *testing.T) { + for _, price := range []string{"85000", "0.000006"} { + candles := []Candle{{Time: 86400, Open: price, High: price, Low: price, Close: price}, {Time: 172800, Open: price, High: price, Low: price, Close: price}} + for _, unicode := range []bool{false, true} { + calls := 0 + lines := Chart(candles, 40, unicode, func(s string, up bool) string { calls++; return s }) + joined := strings.Join(lines, "\n") + if calls == 0 || strings.Contains(joined, "NaN") { + t.Fatal(lines) + } + if unicode && !strings.Contains(joined, "━") { + t.Fatal("no unicode body") + } + if !unicode { + for _, r := range joined { + if r > 127 { + t.Fatal("non-ascii") + } + } + } + } + } +} diff --git a/internal/cryptomarket/money.go b/internal/cryptomarket/money.go new file mode 100644 index 0000000..8154623 --- /dev/null +++ b/internal/cryptomarket/money.go @@ -0,0 +1,72 @@ +// Package cryptomarket implements a fictional-credit exchange, not real orders. +package cryptomarket + +import ( + "fmt" + "math" + "math/big" + "regexp" + "strconv" +) + +const MaxMoney int64 = 1_000_000_000_000 +const MaxPositions = 50 + +// Decimal strings survive YAML/JSON without a float conversion. In particular, +// neither SHIB prices nor fractional BTC quantities are rounded to credits. +var decimalPattern = regexp.MustCompile(`^(0|[1-9][0-9]{0,14})(\.[0-9]{1,18})?$`) + +func Decimal(s string) (*big.Rat, error) { + if !decimalPattern.MatchString(s) { + return nil, fmt.Errorf("use a positive decimal (no exponent), at most 15 integer and 18 fractional digits") + } + r, ok := new(big.Rat).SetString(s) + if !ok || r.Sign() <= 0 { + return nil, fmt.Errorf("amount must be positive") + } + return r, nil +} + +func rat(s string) *big.Rat { r, _ := new(big.Rat).SetString(s); return r } +func mul(a, b *big.Rat) *big.Rat { return new(big.Rat).Mul(a, b) } +func add(a, b *big.Rat) *big.Rat { return new(big.Rat).Add(a, b) } +func sub(a, b *big.Rat) *big.Rat { return new(big.Rat).Sub(a, b) } +func integer(n int64) *big.Rat { return new(big.Rat).SetInt64(n) } + +// Round rounds the complete cash amount once, nearest credit, ties away from +// zero. Never round per coin, and never silently clamp an overflow. +func Round(r *big.Rat) (int64, error) { + if r == nil { + return 0, fmt.Errorf("invalid amount") + } + a := new(big.Rat).Abs(r) + n, rem := new(big.Int), new(big.Int) + n.QuoRem(a.Num(), a.Denom(), rem) + if rem.Lsh(rem, 1).Cmp(a.Denom()) >= 0 { + n.Add(n, big.NewInt(1)) + } + if !n.IsInt64() || n.Int64() > MaxMoney { + return 0, fmt.Errorf("amount exceeds exchange accounting limit") + } + v := n.Int64() + if r.Sign() < 0 { + v = -v + } + return v, nil +} +func Sum(a, b int64) (int64, error) { + if a < -MaxMoney || a > MaxMoney || b < -MaxMoney || b > MaxMoney || a+b < -MaxMoney || a+b > MaxMoney { + return 0, fmt.Errorf("exchange balance limit exceeded") + } + return a + b, nil +} +func floatRat(f float64) (*big.Rat, error) { + if math.IsNaN(f) || math.IsInf(f, 0) || f < 0 { + return nil, fmt.Errorf("invalid model value") + } + r, ok := new(big.Rat).SetString(strconv.FormatFloat(f, 'f', 18, 64)) + if !ok { + return nil, fmt.Errorf("invalid model value") + } + return r, nil +} diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go index deddf0b..15ce85f 100644 --- a/internal/game/cmd_bank.go +++ b/internal/game/cmd_bank.go @@ -102,6 +102,9 @@ func (g *Game) showBankBrowse(sess *net.Session) { entries = append(entries, bankEntry{slot: i, pos: len(entries) + 1, name: itemName, id: slot.ItemID, qty: slot.Quantity}) } + if p.Crypto.Balance < 0 { + sess.WriteLine(g.colorize(sess, "casino_result_loss", fmt.Sprintf("Crypto Exchange debt: %d credits. Future credit deposits repay this first.", -p.Crypto.Balance))) + } if len(entries) == 0 && p.BankedCredits <= 0 { sess.WriteLine("Your bank is empty.") return @@ -372,13 +375,26 @@ func (g *Game) doBankDepositCredits(sess *net.Session, parts []string) { sess.WriteLine("You don't have that many credits.") return } - creditsBefore, bankedBefore := p.Credits, p.BankedCredits - p.Credits -= amount - p.BankedCredits += amount - if !g.persistBankCredits(p, creditsBefore, bankedBefore) { + var seized int64 + if err := g.cryptoTransaction(p, func() error { + // Seize from the incoming deposit before the bank cap check. This also + // permits repayment when the bank itself is already at the balance cap. + seized = min(int64(amount), max(int64(0), -p.Crypto.Balance)) + bank, err := addCreditsChecked(p.BankedCredits, amount-int(seized)) + if err != nil { + return err + } + p.Credits -= amount + p.BankedCredits = bank + p.Crypto.Balance += seized + return nil + }); err != nil { sess.WriteLine("The bank terminal could not record the transfer; nothing was moved.") return } + if seized > 0 { + sess.WriteLine(fmt.Sprintf("The Crypto Exchange seized %d deposited credits to repay debt.", seized)) + } sess.WriteLine(g.colorize(sess, "currency_pickup", fmt.Sprintf("You deposit %s credits. (Banked: %s)", formatInt(amount), formatInt(p.BankedCredits)))) g.showBankBrowse(sess) } @@ -394,6 +410,10 @@ func (g *Game) doBankWithdrawCredits(sess *net.Session, parts []string) { sess.WriteLine("Invalid amount.") return } + if p.Crypto.Balance < 0 { + sess.WriteLine("Your bank credits are subject to Crypto Exchange debt. Repay the debt before withdrawing.") + return + } if p.BankedCredits < amount { sess.WriteLine("You don't have that many banked credits.") return @@ -421,7 +441,7 @@ func (g *Game) doBankWithdrawCredits(sess *net.Session, parts []string) { // a wager taken from that phantom balance would be charged against money the // player does not durably have. Reports whether the move may be announced. func (g *Game) persistBankCredits(p *player.Player, creditsBefore, bankedBefore int) bool { - if err := g.AccountStore.SaveCharacter(p); err != nil { + if err := (casinoWallet{g: g, p: p}).save("bank credit transfer"); err != nil { log.Printf("bank credit transfer %s: %v", p.Name, err) p.Credits = creditsBefore p.BankedCredits = bankedBefore diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go index ab01c4c..92aeea6 100644 --- a/internal/game/cmd_look.go +++ b/internal/game/cmd_look.go @@ -65,6 +65,15 @@ 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.CryptoActive && room.CryptoExchange { + sess.CryptoShowHints = true + if sess.CryptoBusy { + sess.WriteLine("Updating crypto quotes...") + g.cryptoHints(sess) + } else { + g.executeCrypto(sess, "refresh", nil) + } + } if sess.PredictionActive && room.PredictionExchange { sess.WriteLine("") if sess.PredictionBusy { diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index abc1539..d10ba0e 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -398,6 +398,9 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI } } + if g.handleCryptoInput(sess, cmd, args) { + return + } if g.handlePredictionInput(sess, cmd, args) { return } diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go index 54a36f5..af60204 100644 --- a/internal/game/cmd_use.go +++ b/internal/game/cmd_use.go @@ -31,6 +31,10 @@ func (g *Game) doUse(sess *net.Session, input string) { lower := strings.ToLower(strings.TrimSpace(input)) + if cryptoUseTarget(lower) && g.atCryptoCounter(p) { + g.openCryptoCounter(sess) + return + } if predictionUseTarget(lower) && g.atPredictionCounter(p) { g.openPredictionCounter(sess) return diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go index 87e8ee0..b5b0c3d 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.CryptoActive && g.atCryptoCounter(p) { + g.showCasinoHints(sess, g.cryptoCommands(sess)) + return + } if sess.PredictionActive && g.atPredictionCounter(p) { g.showCasinoHints(sess, g.predictionCommands(sess)) return 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) +} diff --git a/internal/game/crypto_test.go b/internal/game/crypto_test.go new file mode 100644 index 0000000..0ad5786 --- /dev/null +++ b/internal/game/crypto_test.go @@ -0,0 +1,384 @@ +package game + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "thehouseoficarus/internal/cryptomarket" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func cryptoFixture(t *testing.T) (*Game, *net.Session, *casinoTestConn) { + t.Helper() + g, s, c, _ := predictionFixture(t) + dir := t.TempDir() + if e := os.MkdirAll(filepath.Join(dir, "rooms"), 0755); e != nil { + t.Fatal(e) + } + if e := os.WriteFile(filepath.Join(dir, "rooms", "1.yaml"), []byte("name: Crypto\ncrypto_exchange: true\n"), 0644); e != nil { + t.Fatal(e) + } + g.World = world.New(dir) + s.CryptoActive = true + s.Player.Crypto.Balance = 1000 + return g, s, c +} +func gameCryptoSnapshot(price string) cryptomarket.Snapshot { + return cryptomarket.Snapshot{Quotes: map[string]cryptomarket.Quote{"BTC": {Bid: price, Ask: price, At: time.Now().UTC()}}, Candles: map[string][]cryptomarket.Candle{}} +} +func TestCryptoFundsSaveAndDebtDepositRecovery(t *testing.T) { + g, s, c := cryptoFixture(t) + p := s.Player + g.cryptoFunds(s, "fund", []string{"100"}) + if p.Credits != 9900 || p.Crypto.Balance != 1100 { + t.Fatal(p.Credits, p.Crypto) + } + g.cryptoFunds(s, "withdraw", []string{"50"}) + if p.Credits != 9950 || p.Crypto.Balance != 1050 { + t.Fatal(p.Credits, p.Crypto) + } + p.Crypto.Balance = -150 + p.BankedCredits = 0 + g.doBankDepositCredits(s, []string{"deposit", "credits", "100"}) + if p.Credits != 9850 || p.Crypto.Balance != -50 || p.BankedCredits != 0 { + t.Fatal(p.Credits, p.Crypto, p.BankedCredits) + } + if !strings.Contains(string(c.data), "seized 100") { + t.Fatal(c.data) + } + g.doBankDepositCredits(s, []string{"deposit", "credits", "100"}) + if p.Credits != 9750 || p.Crypto.Balance != 0 || p.BankedCredits != 50 { + t.Fatal(p.Credits, p.Crypto, p.BankedCredits) + } + loaded, e := g.AccountStore.LoadCharacter(p.Name) + if e != nil || loaded.Crypto.Balance != 0 || loaded.BankedCredits != 50 || loaded.Credits != 9750 { + t.Fatal(loaded, e) + } +} +func TestCryptoLiquidationBankAndPersistentNegativeBalance(t *testing.T) { + g, s, _ := cryptoFixture(t) + p := s.Player + p.Crypto.Balance = 100 + p.BankedCredits = 50 + q := gameCryptoSnapshot("100").Quotes["BTC"] + if _, e := p.Crypto.Open("BTC", "short", "1", "", 0, q, time.Now().UTC()); e != nil { + t.Fatal(e) + } + messages, e := g.reconcileCrypto(p, gameCryptoSnapshot("300"), time.Now().UTC()) + if e != nil || len(messages) != 2 { + t.Fatal(messages, e) + } + if p.Crypto.Balance != -50 || p.BankedCredits != 0 || p.Credits != 10000 { + t.Fatal(p.Crypto, p.BankedCredits, p.Credits) + } + loaded, e := g.AccountStore.LoadCharacter(p.Name) + if e != nil || loaded.Crypto.Balance != -50 || loaded.Crypto.Positions[0].Status != "liquidated" { + t.Fatal(loaded, e) + } + s.Player = loaded + g.cryptoFunds(s, "repay", []string{"50"}) + if loaded.Crypto.Balance != 0 || loaded.Credits != 9950 { + t.Fatal(loaded.Crypto, loaded.Credits) + } +} +func TestCryptoAtomicRollbackIncludingBankGarnishment(t *testing.T) { + g, s, _ := cryptoFixture(t) + p := s.Player + p.Crypto.Balance = 100 + p.BankedCredits = 50 + q := gameCryptoSnapshot("100").Quotes["BTC"] + if _, e := p.Crypto.Open("BTC", "short", "1", "", 0, q, time.Now().UTC()); e != nil { + t.Fatal(e) + } + before := p.Crypto.Clone() + bank, credits := p.BankedCredits, p.Credits + // Force failure before rename by putting a directory at the target path. + if e := os.Mkdir(g.AccountStore.CharPath(p.Name), 0700); e != nil { + t.Fatal(e) + } + if _, e := g.reconcileCrypto(p, gameCryptoSnapshot("300"), time.Now().UTC()); e == nil { + t.Fatal("save succeeded") + } + if !reflect.DeepEqual(before, p.Crypto) || bank != p.BankedCredits || credits != p.Credits { + t.Fatal("partial rollback") + } + p.Crypto = cryptomarket.Account{Balance: -100} + before = p.Crypto.Clone() + g.doBankDepositCredits(s, []string{"deposit", "credits", "100"}) + if !reflect.DeepEqual(before, p.Crypto) || p.Credits != credits || p.BankedCredits != bank { + t.Fatal("deposit rollback lost debt") + } +} +func TestCryptoWithdrawLocksAndBankCap(t *testing.T) { + g, s, c := cryptoFixture(t) + p := s.Player + q := gameCryptoSnapshot("100").Quotes["BTC"] + if _, e := p.Crypto.Open("BTC", "short", "1", "", 0, q, time.Now().UTC()); e != nil { + t.Fatal(e) + } + balance := p.Crypto.Balance + g.cryptoFunds(s, "withdraw", []string{"1"}) + if p.Crypto.Balance != balance || !strings.Contains(string(c.data), "derivatives") { + t.Fatal(c.data) + } + p.Crypto = cryptomarket.Account{Balance: -10} + p.BankedCredits = int(cryptomarket.MaxMoney) + before := p.Credits + g.doBankDepositCredits(s, []string{"deposit", "credits", "10"}) + if p.Crypto.Balance != 0 || p.BankedCredits != int(cryptomarket.MaxMoney) || p.Credits != before-10 { + t.Fatal("repayment at cap") + } + before = p.Credits + g.doBankDepositCredits(s, []string{"deposit", "credits", "1"}) + if p.Credits != before || p.BankedCredits != int(cryptomarket.MaxMoney) { + t.Fatal("bank overflow") + } +} +func TestCryptoInterfaceIsolationAndMovement(t *testing.T) { + g, s, c := cryptoFixture(t) + if !g.cryptoInputOwned(s, "short") || g.cryptoInputOwned(s, "north") || g.cryptoInputOwned(s, "look") { + t.Fatal("ownership") + } + g.handleCryptoInput(s, "buy", []string{"BTC", "NaN"}) + if !strings.Contains(string(c.data), "decimal") { + t.Fatal(c.data) + } + g.handleCryptoInput(s, "stop", nil) + if s.CryptoActive || g.cryptoInputOwned(s, "short") { + t.Fatal("stop") + } + s.CryptoActive = true + cancelled := false + s.CryptoCancel = func() { cancelled = true } + epoch := s.CryptoEpoch + h := net.NewHub() + h.EnterRoom(s, 2) + if s.CryptoActive || !cancelled || s.CryptoEpoch <= epoch { + t.Fatal("room change did not cancel") + } +} +func cryptoMockClient(t *testing.T, price string) *cryptomarket.Client { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + result := any(nil) + switch r.URL.Path { + case "/SystemStatus": + result = map[string]string{"status": "online"} + case "/AssetPairs": + m := map[string]any{} + for _, i := range cryptomarket.Instruments { + m[i.Key] = map[string]string{"status": "online"} + } + result = m + case "/Ticker": + m := map[string]any{} + for _, i := range cryptomarket.Instruments { + m[i.Key] = map[string]any{"a": []string{price}, "b": []string{price}} + } + result = m + case "/OHLC": + key := "" + for _, i := range cryptomarket.Instruments { + if i.Pair == r.URL.Query().Get("pair") { + key = i.Key + } + } + now := time.Now().UTC().Truncate(24 * time.Hour).Unix() + result = map[string]any{key: [][]any{{now - 172800, price, price, price, price, price, "1", 1}, {now - 86400, price, price, price, price, price, "1", 1}}} + default: + t.Errorf("unknown path %s", r.URL) + } + json.NewEncoder(w).Encode(map[string]any{"error": []string{}, "result": result}) + })) + t.Cleanup(srv.Close) + c := cryptomarket.NewClient() + c.Base = srv.URL + return c +} +func TestCryptoRevisitSettlesOnlyVisitorAndNoRepeatedDebt(t *testing.T) { + g, s, _ := cryptoFixture(t) + p := s.Player + p.Crypto.Balance = 100 + p.BankedCredits = 50 + q := gameCryptoSnapshot("100").Quotes["BTC"] + if _, e := p.Crypto.Open("BTC", "short", "1", "", 0, q, time.Now().UTC()); e != nil { + t.Fatal(e) + } + if e := g.AccountStore.SaveCharacter(p); e != nil { + t.Fatal(e) + } + // Another character's position is untouched when this player visits. + offline := player.New("bob") + offline.Crypto = p.Crypto.Clone() + if e := g.AccountStore.SaveCharacter(offline); e != nil { + t.Fatal(e) + } + g.CryptoClient = cryptoMockClient(t, "300") + g.doUse(s, "crypto") + loaded, e := g.AccountStore.LoadCharacter(p.Name) + if e != nil || loaded.Crypto.Balance != -50 || loaded.BankedCredits != 0 { + t.Fatal(loaded, e) + } + saved, e := g.AccountStore.LoadCharacter("bob") + if e != nil || !reflect.DeepEqual(saved.Crypto, offline.Crypto) { + t.Fatal("offline position changed", saved, e) + } + for _, system := range TickSystemOrder() { + if strings.Contains(strings.ToLower(system.Name), "crypto") { + t.Fatal("crypto background tick registered") + } + } + before := loaded.Crypto.Clone() + g.executeCrypto(s, "active", nil) + loaded, e = g.AccountStore.LoadCharacter(p.Name) + if e != nil || !reflect.DeepEqual(before, loaded.Crypto) { + t.Fatal("repeat debt", loaded, e) + } +} +func TestCryptoCommandsTradeAndRender(t *testing.T) { + g, s, c := cryptoFixture(t) + g.CryptoClient = cryptoMockClient(t, "100") + g.doUse(s, "crypto") + if !s.CryptoActive { + t.Fatal("use crypto did not open counter") + } + before := s.Player.Crypto.Clone() + g.executeCrypto(s, "quote", []string{"write-call", "BTC", "10", "100", "7"}) + if !reflect.DeepEqual(before, s.Player.Crypto) || !strings.Contains(string(c.data), "No order placed") { + t.Fatal("quote mutated ledger", string(c.data)) + } + g.executeCrypto(s, "buy", []string{"BTC", "1.005"}) + if len(s.Player.Crypto.Positions) != 1 || s.Player.Crypto.Balance != 899 { + t.Fatal(s.Player.Crypto, c.data) + } + g.executeCrypto(s, "view", []string{"BTC"}) + if !strings.Contains(string(c.data), "DAILY CANDLES") { + t.Fatal(c.data) + } + g.executeCrypto(s, "sell", []string{"1"}) + if s.Player.Crypto.Positions[0].Status != "closed" { + t.Fatal(c.data) + } + if s.Player.Crypto.Balance != 1000 { + t.Fatal(s.Player.Crypto) + } +} +func TestCryptoInteractionUsesLivePlayerNotOldFile(t *testing.T) { + g, s, _ := cryptoFixture(t) + p := s.Player + p.Crypto.Balance = 100 + q := gameCryptoSnapshot("100").Quotes["BTC"] + if _, e := p.Crypto.Open("BTC", "short", "1", "", 0, q, time.Now().UTC()); e != nil { + t.Fatal(e) + } + if e := g.AccountStore.SaveCharacter(p); e != nil { + t.Fatal(e) + } + p.Credits = 1234 + g.loggedInChars = map[string]*net.Session{p.Name: s} + g.CryptoClient = cryptoMockClient(t, "300") + g.executeCrypto(s, "active", nil) + loaded, e := g.AccountStore.LoadCharacter(p.Name) + if e != nil || loaded.Credits != 1234 || loaded.Crypto.Balance != -100 { + t.Fatal(loaded, e) + } +} +func TestCryptoAsyncMovementCancelsWithoutBlockingOwner(t *testing.T) { + g, s, _ := cryptoFixture(t) + started, release := make(chan struct{}), make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/SystemStatus" { + close(started) + <-release + json.NewEncoder(w).Encode(map[string]any{"error": []string{}, "result": map[string]string{"status": "online"}}) + return + } + w.WriteHeader(503) + })) + defer srv.Close() + g.CryptoClient = cryptomarket.NewClient() + g.CryptoClient.Base = srv.URL + g.owner = newOwner() + defer g.owner.stop() + g.loggedInChars = map[string]*net.Session{s.Player.Name: s} + g.run(func() { g.executeCrypto(s, "buy", []string{"BTC", "1"}) }) + select { + case <-started: + case <-time.After(time.Second): + close(release) + t.Fatal("fetch not started") + } + 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 owner") + } + close(release) + deadline := time.Now().Add(2 * time.Second) + for { + busy := true + g.run(func() { busy = s.CryptoBusy }) + if !busy { + break + } + if time.Now().After(deadline) { + t.Fatal("callback stalled") + } + time.Sleep(time.Millisecond) + } + g.run(func() { + if s.Player.Crypto.Balance != 1000 || len(s.Player.Crypto.Positions) != 0 { + t.Error("trade after movement") + } + }) +} +func TestCryptoVerifiedWithdrawDoesNotReleaseCollateral(t *testing.T) { + g, s, _ := cryptoFixture(t) + p := s.Player + snap := gameCryptoSnapshot("100") + if _, e := p.Crypto.Open("BTC", "short", "1", "", 0, snap.Quotes["BTC"], time.Now().UTC()); e != nil { + t.Fatal(e) + } + g.cryptoFunds(s, "withdraw", []string{"900"}, snap) + if p.Crypto.Balance != 0 || p.Crypto.Positions[0].Collateral != 100 || p.Credits != 10900 { + t.Fatal(p.Crypto, p.Credits) + } + g.cryptoFunds(s, "withdraw", []string{"1"}, snap) + if p.Crypto.Balance != 0 { + t.Fatal("withdrew collateral") + } +} +func TestCryptoSaveRoundTrip(t *testing.T) { + g, s, _ := cryptoFixture(t) + p := s.Player + p.Crypto.Balance = 100 + now := time.Now().UTC() + q := cryptomarket.Quote{Bid: "0.000006", Ask: "0.000006", At: now} + if _, e := p.Crypto.Open("SHIB", "spot", "10000000.123456789012345678", "", 0, q, now); e != nil { + t.Fatal(e) + } + if e := g.AccountStore.SaveCharacter(p); e != nil { + t.Fatal(e) + } + loaded, e := g.AccountStore.LoadCharacter(p.Name) + if e != nil || !reflect.DeepEqual(p.Crypto, loaded.Crypto) { + t.Fatal(loaded, e) + } + // Old characters without any crypto fields still have valid empty accounts. + old := player.New("old") + if e := old.Crypto.Validate(); e != nil { + t.Fatal(e) + } +} diff --git a/internal/game/crypto_ux_test.go b/internal/game/crypto_ux_test.go new file mode 100644 index 0000000..6ac4a57 --- /dev/null +++ b/internal/game/crypto_ux_test.go @@ -0,0 +1,147 @@ +package game + +import ( + "strings" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" +) + +func TestCryptoEntryIsBoardAndSharedHintsNotRules(t *testing.T) { + g, s, conn := cryptoFixture(t) + g.CryptoClient = cryptoMockClient(t, "100") + g.doUse(s, "crypto") + out := string(conn.data) + for _, wanted := range []string{"CRYPTO EXCHANGE", "Currency", "Buy", "Sell", "BTC", "view SYMBOL", "fund CREDITS", "stop"} { + if !strings.Contains(out, wanted) { + t.Fatalf("missing %q: %s", wanted, out) + } + } + for _, unwanted := range []string{"HOW TO TRADE", "Paper trading only", "HOUSE-MODEL", "volatility", "collateral", "Black-Scholes", "0.5%", "10-credit", "1,000,000"} { + if strings.Contains(out, unwanted) { + t.Fatalf("entry dumped rules %q: %s", unwanted, out) + } + } + for _, line := range strings.Split(out, "\n") { + if color.VisibleLen(strings.TrimSuffix(line, "\r")) > 80 { + t.Fatalf("wide entry line: %q", line) + } + } +} +func TestCryptoHintPreferencesAndExplicitWhat(t *testing.T) { + for _, mode := range []string{"off", "once", "on"} { + t.Run(mode, func(t *testing.T) { + g, s, conn := cryptoFixture(t) + g.CryptoClient = cryptoMockClient(t, "100") + s.Player.Options["casino_hints"] = mode + g.doUse(s, "crypto") + if got := strings.Contains(string(conn.data), "view SYMBOL"); got != (mode != "off") { + t.Fatalf("entry hints %s: %s", mode, conn.data) + } + conn.data = nil + g.executeCrypto(s, "refresh", nil) + if got := strings.Contains(string(conn.data), "view SYMBOL"); got != (mode == "on") { + t.Fatalf("repeat hints %s: %s", mode, conn.data) + } + conn.data = nil + // Look marks the same ShowHints flag the prediction counter uses. + s.CryptoShowHints = true + g.executeCrypto(s, "refresh", nil) + if got := strings.Contains(string(conn.data), "view SYMBOL"); got != (mode != "off") { + t.Fatalf("look hints %s: %s", mode, conn.data) + } + conn.data = nil + g.handleGameCommand(s, "what") + if !strings.Contains(string(conn.data), "view SYMBOL") || strings.Contains(string(conn.data), "HOW TO TRADE") { + t.Fatalf("what did not show command menu: %s", conn.data) + } + if g.cryptoInputOwned(s, "what") || g.cryptoInputOwned(s, "verbs") || g.cryptoInputOwned(s, "north") { + t.Fatal("counter stole a world command") + } + }) + } +} +func TestCryptoContextMenusAndRefresh(t *testing.T) { + g, s, conn := cryptoFixture(t) + g.CryptoClient = cryptoMockClient(t, "100") + s.Player.Options["casino_hints"] = "on" + g.executeCrypto(s, "view", []string{"BTC"}) + if !strings.Contains(string(conn.data), "buy BTC AMOUNT") || !strings.Contains(string(conn.data), "contracts") { + t.Fatalf("missing currency hints: %s", conn.data) + } + conn.data = nil + g.executeCrypto(s, "contracts", nil) + if !strings.Contains(string(conn.data), "option TYPE BTC") || !strings.Contains(string(conn.data), "quote TYPE BTC") { + t.Fatalf("missing option hints: %s", conn.data) + } + conn.data = nil + g.executeCrypto(s, "active", nil) + g.executeCrypto(s, "refresh", nil) + if s.CryptoScreen != "active" || strings.Contains(string(conn.data), "DAILY CANDLES") || !strings.Contains(string(conn.data), "close ID") { + t.Fatalf("refresh left portfolio: %s", conn.data) + } + conn.data = nil + g.executeCrypto(s, "closed", nil) + g.executeCrypto(s, "refresh", nil) + if s.CryptoScreen != "closed" || strings.Contains(string(conn.data), "close ID") { + t.Fatalf("closed hints or refresh wrong: %s", conn.data) + } + conn.data = nil + g.executeCrypto(s, "back", nil) + if s.CryptoScreen != "markets" || !strings.Contains(string(conn.data), "view SYMBOL") { + t.Fatalf("back failed: %s", conn.data) + } + s.CryptoBusy = true + commands := g.cryptoCommands(s) + if len(commands) != 1 || commands[0] != (casino.MenuCommand{Command: "stop", Desc: "Leave and cancel the update"}) { + t.Fatal(commands) + } +} +func TestCryptoGlobalOptionCommandsStayReachable(t *testing.T) { + g, s, _ := cryptoFixture(t) + g.CryptoClient = cryptoMockClient(t, "100") + // The settings command is not claimed by the counter... + if g.cryptoInputOwned(s, "options") { + t.Fatal("counter stole the options settings command") + } + if g.handleCryptoInput(s, "option", nil) { + t.Fatal("bare option should fall through to settings") + } + // ...but a real option trade is claimed. + if !g.cryptoInputOwned(s, "option") { + t.Fatal("option trade should be owned") + } + if !g.handleCryptoInput(s, "option", []string{"call", "BTC", "0.01", "100", "7"}) { + t.Fatal("option trade was not handled") + } +} + +func TestCryptoCommandAbbreviations(t *testing.T) { + g, s, conn := cryptoFixture(t) + g.CryptoClient = cryptoMockClient(t, "100") + // Unique prefixes resolve like every other counter command. + if got, ok := cryptoVerb("mar"); !ok || got != "markets" { + t.Fatalf("mar -> %q, %v", got, ok) + } + if got, ok := cryptoVerb("opt"); !ok || got != "option" { + t.Fatalf("opt -> %q, %v", got, ok) + } + if !g.cryptoInputOwned(s, "mar") { + t.Fatal("mar should be owned") + } + // Ambiguous shortcuts are claimed and explained instead of running. + if got, ok := cryptoVerb("b"); !ok || got != "" { + t.Fatalf("b -> %q, %v", got, ok) + } + conn.data = nil + if !g.handleCryptoInput(s, "b", nil) || !strings.Contains(string(conn.data), "ambiguous") { + t.Fatalf("ambiguous shortcut: %s", conn.data) + } + // Real world commands still win over counter prefixes. + for _, cmd := range []string{"look", "north", "score", "inventory"} { + if got, ok := cryptoVerb(cmd); ok { + t.Fatalf("%s was claimed as %q", cmd, got) + } + } +} diff --git a/internal/game/game.go b/internal/game/game.go index 1ce89e5..0c550ae 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -15,6 +15,7 @@ import ( "thehouseoficarus/internal/casino" "thehouseoficarus/internal/combat" "thehouseoficarus/internal/config" + "thehouseoficarus/internal/cryptomarket" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/game/hacking" "thehouseoficarus/internal/item" @@ -62,6 +63,7 @@ type Game struct { GlobalFlags *GlobalFlagStore Combat *combat.Tracker PredictionClient *prediction.Client + CryptoClient *cryptomarket.Client Casino *casino.Manager flagIndex *flagTriggerIndex queue *CommandQueue @@ -632,7 +634,7 @@ 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) { + if g.cryptoInputOwned(sess, cmd) || g.predictionInputOwned(sess, cmd) { g.executeCommand(sess, cmd, parts[1:], input) g.writePrompt(sess) return diff --git a/internal/game/prediction_interface.go b/internal/game/prediction_interface.go index 2e37145..3281f2d 100644 --- a/internal/game/prediction_interface.go +++ b/internal/game/prediction_interface.go @@ -29,6 +29,7 @@ func (g *Game) openPredictionCounter(s *net.Session) { g.executePredictions(s, []string{"refresh"}, "") return } + g.closeCryptoCounter(s) s.PredictionActive = true s.PredictionEpoch++ s.PredictionBusy = false diff --git a/internal/game/render_crypto.go b/internal/game/render_crypto.go new file mode 100644 index 0000000..899f83b --- /dev/null +++ b/internal/game/render_crypto.go @@ -0,0 +1,63 @@ +package game + +import ( + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/net" +) + +// Use the same contextual command columns and casino_hints policy as the +// prediction counter and casino tables. Rules are opt-in, never an entry screen. +func (g *Game) cryptoCommands(s *net.Session) []casino.MenuCommand { + if s.CryptoBusy { + return []casino.MenuCommand{{Command: "stop", Desc: "Leave and cancel the update"}} + } + commands := []casino.MenuCommand{} + if !s.CryptoUnavailable { + symbol := s.CryptoSelected + if symbol == "" { + symbol = "SYMBOL" + } + switch s.CryptoScreen { + case "view": + commands = append(commands, + casino.MenuCommand{Command: "buy " + symbol + " AMOUNT", Desc: "Buy coins"}, + casino.MenuCommand{Command: "short " + symbol + " AMOUNT", Desc: "Back a falling price"}, + casino.MenuCommand{Command: "contracts", Desc: "Trade calls and puts"}, + casino.MenuCommand{Command: "back", Desc: "Return to the currencies"}) + case "contracts": + commands = append(commands, + casino.MenuCommand{Command: "quote TYPE " + symbol + " AMOUNT PRICE DAYS", Desc: "Preview a call, put, write-call or write-put"}, + casino.MenuCommand{Command: "option TYPE " + symbol + " AMOUNT PRICE DAYS", Desc: "Buy a call or put"}, + casino.MenuCommand{Command: "write TYPE " + symbol + " AMOUNT PRICE DAYS", Desc: "Write a call or put; risk of debt"}, + casino.MenuCommand{Command: "back", Desc: "Return to the currencies"}) + case "active": + commands = append(commands, casino.MenuCommand{Command: "close ID", Desc: "Close a whole position"}, casino.MenuCommand{Command: "view SYMBOL", Desc: "View a currency's chart"}) + default: + commands = append(commands, casino.MenuCommand{Command: "view SYMBOL", Desc: "Choose a currency"}) + } + commands = append(commands, casino.MenuCommand{Command: "active", Desc: "Your open positions"}, casino.MenuCommand{Command: "closed", Desc: "Your completed trades"}) + } + if s.Player.Crypto.Balance < 0 { + commands = append(commands, casino.MenuCommand{Command: "repay CREDITS", Desc: "Pay your exchange debt"}) + } else { + commands = append(commands, casino.MenuCommand{Command: "fund CREDITS", Desc: "Add trading funds"}, casino.MenuCommand{Command: "withdraw CREDITS", Desc: "Take out available credits"}) + } + return append(commands, casino.MenuCommand{Command: "rules", Desc: "How to trade"}, casino.MenuCommand{Command: "refresh", Desc: "Update this screen"}, casino.MenuCommand{Command: "stop", Desc: "Leave the counter"}) +} +func (g *Game) cryptoHints(s *net.Session, force ...bool) { + forced := len(force) > 0 && force[0] + show := g.casinoHintsVisible(s, &casino.MenuView{ShowHints: s.CryptoShowHints}) + s.CryptoShowHints = false + if !forced && !show { + return + } + s.WriteLine("") + s.WriteLine(g.renderCasinoCommands(s, "", "", g.cryptoCommands(s))) +} +func (g *Game) cryptoOptions(s *net.Session) { + s.CryptoScreen = "contracts" + g.predictionFrame(s, "OPTIONS", []string{ + "Choose a call for a rising price, or a put for a falling price. Preview with quote before buying or writing.", + "AMOUNT is coins, PRICE is your target, and DAYS is 1, 7 or 30. Writing options can leave you owing credits.", + }) +} diff --git a/internal/net/server.go b/internal/net/server.go index 5cf7310..6367419 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -66,6 +66,14 @@ type Session struct { PendingUndigDir string PendingUndigRoom int // Casino spectator preferences are session-local, owner-goroutine only. + CryptoActive bool + CryptoBusy bool + CryptoEpoch uint64 + CryptoCancel context.CancelFunc + CryptoSelected string + CryptoScreen string + CryptoShowHints bool + CryptoUnavailable bool PredictionActive bool PredictionEpoch uint64 PredictionCancel context.CancelFunc @@ -518,6 +526,17 @@ 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.CryptoActive = false + s.CryptoEpoch++ + s.CryptoBusy = false + s.CryptoSelected = "" + s.CryptoScreen = "" + s.CryptoShowHints = false + s.CryptoUnavailable = false + if s.CryptoCancel != nil { + s.CryptoCancel() + s.CryptoCancel = nil + } s.PredictionActive = false if s.PredictionCancel != nil { s.PredictionCancel() diff --git a/internal/player/player.go b/internal/player/player.go index 5acced6..aad0b8e 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -6,6 +6,7 @@ import ( "sort" "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/cryptomarket" "thehouseoficarus/internal/item" "thehouseoficarus/internal/prediction" ) @@ -197,6 +198,7 @@ type Player struct { // stake (refund-on-lost-round). See PendingWager. PendingWagers []PendingWager `yaml:"pending_wagers,omitempty"` PredictionContracts []prediction.Contract `yaml:"prediction_contracts,omitempty"` + Crypto cryptomarket.Account `yaml:"crypto,omitempty"` Description string `yaml:"description"` AttackStyle AttackStyle `yaml:"attack_style"` Prompt string `yaml:"prompt"` diff --git a/internal/world/room.go b/internal/world/room.go index a87ec41..e200f32 100644 --- a/internal/world/room.go +++ b/internal/world/room.go @@ -86,21 +86,22 @@ 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"` + 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"` + CryptoExchange bool `yaml:"crypto_exchange,omitempty"` + CasinoTables []casino.TableConfig `yaml:"casino_tables,omitempty"` + CasinoMachines []casino.MachineConfig `yaml:"casino_machines,omitempty"` } type RoomMob struct { |
