From 90ad8c0c33b519d5830c69d4128bdc984b5e9de8 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Mon, 5 Oct 2026 14:22:14 -0400 Subject: feat: cryptocurrency exchange added to casino --- internal/cryptomarket/chart.go | 78 ++++ internal/cryptomarket/client.go | 347 +++++++++++++++++ internal/cryptomarket/client_test.go | 216 +++++++++++ internal/cryptomarket/exchange.go | 426 +++++++++++++++++++++ internal/cryptomarket/exchange_test.go | 339 ++++++++++++++++ internal/cryptomarket/money.go | 72 ++++ internal/game/cmd_bank.go | 30 +- internal/game/cmd_look.go | 9 + internal/game/cmd_registry.go | 3 + internal/game/cmd_use.go | 4 + internal/game/cmd_verbs.go | 4 + internal/game/crypto.go | 681 +++++++++++++++++++++++++++++++++ internal/game/crypto_test.go | 384 +++++++++++++++++++ internal/game/crypto_ux_test.go | 147 +++++++ internal/game/game.go | 4 +- internal/game/prediction_interface.go | 1 + internal/game/render_crypto.go | 63 +++ internal/net/server.go | 19 + internal/player/player.go | 2 + internal/world/room.go | 29 +- 20 files changed, 2838 insertions(+), 20 deletions(-) create mode 100644 internal/cryptomarket/chart.go create mode 100644 internal/cryptomarket/client.go create mode 100644 internal/cryptomarket/client_test.go create mode 100644 internal/cryptomarket/exchange.go create mode 100644 internal/cryptomarket/exchange_test.go create mode 100644 internal/cryptomarket/money.go create mode 100644 internal/game/crypto.go create mode 100644 internal/game/crypto_test.go create mode 100644 internal/game/crypto_ux_test.go create mode 100644 internal/game/render_crypto.go (limited to 'internal') 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 { -- cgit v1.2.3