aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-05 14:22:14 -0400
committerhistoria <[not public]>2026-10-05 14:22:14 -0400
commit90ad8c0c33b519d5830c69d4128bdc984b5e9de8 (patch)
treec10bc0bebfbed6fde01a7f08cf6e2d98809b91a7 /internal
parentc84a9944356f590d90d819d652bdf1349ebacd40 (diff)
downloadthehouseoficarus-90ad8c0c33b519d5830c69d4128bdc984b5e9de8.tar.gz
feat: cryptocurrency exchange added to casino
Diffstat (limited to 'internal')
-rw-r--r--internal/cryptomarket/chart.go78
-rw-r--r--internal/cryptomarket/client.go347
-rw-r--r--internal/cryptomarket/client_test.go216
-rw-r--r--internal/cryptomarket/exchange.go426
-rw-r--r--internal/cryptomarket/exchange_test.go339
-rw-r--r--internal/cryptomarket/money.go72
-rw-r--r--internal/game/cmd_bank.go30
-rw-r--r--internal/game/cmd_look.go9
-rw-r--r--internal/game/cmd_registry.go3
-rw-r--r--internal/game/cmd_use.go4
-rw-r--r--internal/game/cmd_verbs.go4
-rw-r--r--internal/game/crypto.go681
-rw-r--r--internal/game/crypto_test.go384
-rw-r--r--internal/game/crypto_ux_test.go147
-rw-r--r--internal/game/game.go4
-rw-r--r--internal/game/prediction_interface.go1
-rw-r--r--internal/game/render_crypto.go63
-rw-r--r--internal/net/server.go19
-rw-r--r--internal/player/player.go2
-rw-r--r--internal/world/room.go29
20 files changed, 2838 insertions, 20 deletions
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 {