From 90ad8c0c33b519d5830c69d4128bdc984b5e9de8 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Mon, 5 Oct 2026 14:22:14 -0400 Subject: feat: cryptocurrency exchange added to casino --- internal/game/crypto_test.go | 384 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 internal/game/crypto_test.go (limited to 'internal/game/crypto_test.go') 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) + } +} -- cgit v1.2.3