aboutsummaryrefslogtreecommitdiff
path: root/internal/game/crypto_test.go
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/game/crypto_test.go
parentc84a9944356f590d90d819d652bdf1349ebacd40 (diff)
downloadthehouseoficarus-90ad8c0c33b519d5830c69d4128bdc984b5e9de8.tar.gz
feat: cryptocurrency exchange added to casino
Diffstat (limited to 'internal/game/crypto_test.go')
-rw-r--r--internal/game/crypto_test.go384
1 files changed, 384 insertions, 0 deletions
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)
+ }
+}