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) } }