diff --git a/examples/gno.land/r/stefann/fomo3d/errors.gno b/examples/gno.land/r/stefann/fomo3d/errors.gno new file mode 100644 index 00000000000..df70ab08c55 --- /dev/null +++ b/examples/gno.land/r/stefann/fomo3d/errors.gno @@ -0,0 +1,30 @@ +package fomo3d + +import "errors" + +var ( + // Game state errors + ErrGameInProgress = errors.New("fomo3d: game already in progress") + ErrGameNotInProgress = errors.New("fomo3d: game not in progress") + ErrGameEnded = errors.New("fomo3d: game has ended") + ErrGameTimeExpired = errors.New("fomo3d: game time expired") + ErrNoKeysPurchased = errors.New("fomo3d: no keys purchased") + ErrPlayerNotInGame = errors.New("fomo3d: player is not in the game") + + // Payment errors + ErrInvalidPayment = errors.New("fomo3d: must send ugnot only") + ErrInsufficientPayment = errors.New("fomo3d: insufficient payment for key") + + // Dividend errors + ErrNoDividendsToClaim = errors.New("fomo3d: no dividends to claim") + + // Fee errors + ErrNoFeesToClaim = errors.New("fomo3d: no owner fees to claim") + + // Resolution errors + ErrInvalidAddressOrName = errors.New("fomo3d: invalid address or unregistered username") + + // NFT errors + ErrUnauthorizedMint = errors.New("fomo3d: only the Fomo3D game realm can mint winner NFTs") + ErrZeroAddress = errors.New("fomo3d: zero address") +) diff --git a/examples/gno.land/r/stefann/fomo3d/events.gno b/examples/gno.land/r/stefann/fomo3d/events.gno new file mode 100644 index 00000000000..ea404466955 --- /dev/null +++ b/examples/gno.land/r/stefann/fomo3d/events.gno @@ -0,0 +1,94 @@ +package fomo3d + +import ( + "std" + + "gno.land/p/demo/ufmt" +) + +// Event names +const ( + // Game events + GameStartedEvent = "GameStarted" + GameEndedEvent = "GameEnded" + KeysPurchasedEvent = "KeysPurchased" + + // Player events + DividendsClaimedEvent = "DividendsClaimed" + + // Admin events + OwnerFeeClaimedEvent = "OwnerFeeClaimed" +) + +// Event keys +const ( + // Common keys + EventRoundKey = "round" + EventAmountKey = "amount" + + // Game keys + EventStartBlockKey = "startBlock" + EventEndBlockKey = "endBlock" + EventStartingPotKey = "startingPot" + EventWinnerKey = "winner" + EventJackpotKey = "jackpot" + + // Player keys + EventBuyerKey = "buyer" + EventNumKeysKey = "numKeys" + EventPriceKey = "price" + EventJackpotShareKey = "jackpotShare" + EventDividendShareKey = "dividendShare" + EventClaimerKey = "claimer" + + // Admin keys + EventOwnerKey = "owner" + EventPreviousOwnerKey = "previousOwner" + EventNewOwnerKey = "newOwner" +) + +func emitGameStarted(round, startBlock, endBlock, startingPot int64) { + std.Emit( + GameStartedEvent, + EventRoundKey, ufmt.Sprintf("%d", round), + EventStartBlockKey, ufmt.Sprintf("%d", startBlock), + EventEndBlockKey, ufmt.Sprintf("%d", endBlock), + EventStartingPotKey, ufmt.Sprintf("%d", startingPot), + ) +} + +func emitGameEnded(round int64, winner std.Address, jackpot int64) { + std.Emit( + GameEndedEvent, + EventRoundKey, ufmt.Sprintf("%d", round), + EventWinnerKey, winner.String(), + EventJackpotKey, ufmt.Sprintf("%d", jackpot), + ) +} + +func emitKeysPurchased(buyer std.Address, numKeys, price, jackpotShare, dividendShare int64) { + std.Emit( + KeysPurchasedEvent, + EventBuyerKey, buyer.String(), + EventNumKeysKey, ufmt.Sprintf("%d", numKeys), + EventPriceKey, ufmt.Sprintf("%d", price), + EventJackpotShareKey, ufmt.Sprintf("%d", jackpotShare), + EventDividendShareKey, ufmt.Sprintf("%d", dividendShare), + ) +} + +func emitDividendsClaimed(claimer std.Address, amount int64) { + std.Emit( + DividendsClaimedEvent, + EventClaimerKey, claimer.String(), + EventAmountKey, ufmt.Sprintf("%d", amount), + ) +} + +func emitOwnerFeeClaimed(owner std.Address, amount int64) { + std.Emit( + OwnerFeeClaimedEvent, + EventOwnerKey, owner.String(), + EventAmountKey, ufmt.Sprintf("%d", amount), + ) +} diff --git a/examples/gno.land/r/stefann/fomo3d/fomo3d.gno b/examples/gno.land/r/stefann/fomo3d/fomo3d.gno new file mode 100644 index 00000000000..b2384ba07f4 --- /dev/null +++ b/examples/gno.land/r/stefann/fomo3d/fomo3d.gno @@ -0,0 +1,358 @@ +package fomo3d + +import ( + "std" + "strings" + + "gno.land/p/demo/avl" + "gno.land/p/demo/ownable" + "gno.land/p/demo/ufmt" + + "gno.land/r/demo/users" + "gno.land/r/leon/hof" +) + +// FOMO3D (Fear Of Missing Out 3D) is a blockchain-based game that combines elements +// of a lottery and investment mechanics. Players purchase keys using GNOT tokens, +// where each key purchase: +// - Extends the game timer +// - Increases the key price by 1% +// - Makes the buyer the potential winner of the jackpot +// - Distributes dividends to all key holders +// +// Game Mechanics: +// - The last person to buy a key before the timer expires wins the jackpot (47% of all purchases) +// - Key holders earn dividends from each purchase (28% of all purchases) +// - 20% of purchases go to the next round's starting pot +// - 5% goes to development fee +// - Game ends when the timer expires +// +// Inspired by the original Ethereum FOMO3D game but implemented in Gno. + +const ( + MIN_KEY_PRICE int64 = 100000 // minimum key price in ugnot + TIME_EXTENSION int64 = 86400 // time extension in blocks when new key is bought (~24 hours @ 1s blocks) + + // Distribution percentages (total 100%) + JACKPOT_PERCENT int64 = 47 // 47% goes to jackpot + DIVIDENDS_PERCENT int64 = 28 // 28% distributed to key holders + NEXT_ROUND_POT int64 = 20 // 20% goes to next round's starting pot + OWNER_FEE_PERCENT int64 = 5 // 5% goes to contract owner +) + +type PlayerInfo struct { + Keys int64 // number of keys owned + Dividends int64 // unclaimed dividends in ugnot +} + +// GameState represents the current state of the FOMO3D game +type GameState struct { // TODO: Separate GameState and RoundState and save round history tree in GameState + StartBlock int64 // Block when the game started + EndBlock int64 // Block when the game will end + LastKeyBlock int64 // Block of last key purchase + LastBuyer std.Address // Address of last key buyer + Jackpot int64 // Current jackpot in ugnot + KeyPrice int64 // Current price of keys in ugnot + TotalKeys int64 // Total number of keys in circulation + Ended bool // Whether the game has ended + CurrentRound int64 // Current round number + NextPot int64 // Next round's starting pot + OwnerFee int64 // Accumulated owner fees + BuyKeysLink string // Link to BuyKeys function + ClaimDividendsLink string // Link to ClaimDividends function + StartGameLink string // Link to StartGame function +} + +var ( + gameState GameState + players *avl.Tree // maps address -> PlayerInfo + Ownable *ownable.Ownable +) + +func init() { + Ownable = ownable.New() + players = avl.NewTree() + gameState.Ended = true + hof.Register() +} + +// StartGame starts a new game round +func StartGame() { + if !gameState.Ended && gameState.StartBlock != 0 { + panic(ErrGameInProgress.Error()) + } + + gameState.CurrentRound++ + gameState.StartBlock = std.GetHeight() + gameState.EndBlock = gameState.StartBlock + TIME_EXTENSION // Initial 24h window + gameState.LastKeyBlock = gameState.StartBlock + gameState.Jackpot = gameState.NextPot + gameState.NextPot = 0 + gameState.Ended = false + gameState.KeyPrice = MIN_KEY_PRICE + gameState.TotalKeys = 0 + + // Clear previous round's player data + players = avl.NewTree() + + emitGameStarted( + gameState.CurrentRound, + gameState.StartBlock, + gameState.EndBlock, + gameState.Jackpot, + ) +} + +// BuyKeys allows players to purchase keys +func BuyKeys() { + if gameState.Ended { + panic(ErrGameEnded.Error()) + } + + currentBlock := std.GetHeight() + if currentBlock > gameState.EndBlock { + panic(ErrGameTimeExpired.Error()) + } + + // Get sent coins + sent := std.GetOrigSend() + if len(sent) != 1 || sent[0].Denom != "ugnot" { + panic(ErrInvalidPayment.Error()) + } + + payment := sent.AmountOf("ugnot") + if payment < gameState.KeyPrice { + panic(ErrInsufficientPayment.Error()) + } + + // Calculate number of keys that can be bought and actual cost + numKeys := payment / gameState.KeyPrice + actualCost := numKeys * gameState.KeyPrice + excess := payment - actualCost + + // Update buyer's info + buyer := std.PrevRealm().Addr() + var buyerInfo PlayerInfo + if info, exists := players.Get(buyer.String()); exists { + buyerInfo = info.(PlayerInfo) + } + + buyerInfo.Keys += numKeys + gameState.TotalKeys += numKeys + + // Distribute actual cost + jackpotShare := actualCost * JACKPOT_PERCENT / 100 + dividendShare := actualCost * DIVIDENDS_PERCENT / 100 + nextPotShare := actualCost * NEXT_ROUND_POT / 100 + ownerShare := actualCost * OWNER_FEE_PERCENT / 100 + + // Update pools + gameState.Jackpot += jackpotShare + gameState.NextPot += nextPotShare + gameState.OwnerFee += ownerShare + + // Return excess payment to buyer if any + if excess > 0 { + banker := std.GetBanker(std.BankerTypeOrigSend) + banker.SendCoins( + std.CurrentRealm().Addr(), + buyer, + std.NewCoins(std.NewCoin("ugnot", excess)), + ) + } + + // Distribute dividends to all key holders + if players.Size() > 0 && gameState.TotalKeys > 0 { + dividendPerKey := dividendShare / gameState.TotalKeys + players.Iterate("", "", func(key string, value interface{}) bool { + playerInfo := value.(PlayerInfo) + playerInfo.Dividends += playerInfo.Keys * dividendPerKey + players.Set(key, playerInfo) + return false + }) + } + + // Update game state + gameState.LastBuyer = buyer + gameState.LastKeyBlock = currentBlock + gameState.EndBlock = currentBlock + TIME_EXTENSION // Always extend 24h from current block + gameState.KeyPrice += (gameState.KeyPrice * numKeys) / 100 + + // Save buyer's updated info + players.Set(buyer.String(), buyerInfo) + + emitKeysPurchased( + buyer, + numKeys, + gameState.KeyPrice, + jackpotShare, + dividendShare, + ) +} + +// ClaimDividends allows players to withdraw their earned dividends +func ClaimDividends() { + caller := std.PrevRealm().Addr() + + info, exists := players.Get(caller.String()) + if !exists { + panic(ErrNoDividendsToClaim.Error()) + } + + playerInfo := info.(PlayerInfo) + if playerInfo.Dividends == 0 { + panic(ErrNoDividendsToClaim.Error()) + } + + // Reset dividends and send coins + amount := playerInfo.Dividends + playerInfo.Dividends = 0 + players.Set(caller.String(), playerInfo) + + banker := std.GetBanker(std.BankerTypeRealmSend) + banker.SendCoins( + std.CurrentRealm().Addr(), + caller, + std.NewCoins(std.NewCoin("ugnot", amount)), + ) + + emitDividendsClaimed(caller, amount) +} + +// ClaimOwnerFee allows the owner to withdraw accumulated fees +func ClaimOwnerFee() { + Ownable.AssertCallerIsOwner() + + if gameState.OwnerFee == 0 { + panic(ErrNoFeesToClaim.Error()) + } + + amount := gameState.OwnerFee + gameState.OwnerFee = 0 + + banker := std.GetBanker(std.BankerTypeRealmSend) + banker.SendCoins( + std.CurrentRealm().Addr(), + Ownable.Owner(), + std.NewCoins(std.NewCoin("ugnot", amount)), + ) + + emitOwnerFeeClaimed(Ownable.Owner(), amount) +} + +// EndGame ends the current round and distributes the jackpot +func EndGame() { + if gameState.Ended { + panic(ErrGameEnded.Error()) + } + + currentBlock := std.GetHeight() + if currentBlock <= gameState.EndBlock { + panic(ErrGameNotInProgress.Error()) + } + + if gameState.LastBuyer == "" { + panic(ErrNoKeysPurchased.Error()) + } + + gameState.Ended = true + + // Send jackpot to winner + banker := std.GetBanker(std.BankerTypeRealmSend) + banker.SendCoins( + std.CurrentRealm().Addr(), + gameState.LastBuyer, + std.NewCoins(std.NewCoin("ugnot", gameState.Jackpot)), + ) + + emitGameEnded( + gameState.CurrentRound, + gameState.LastBuyer, + gameState.Jackpot, + ) + + // Mint NFT for the winner + if err := mintRoundWinnerNFT(gameState.LastBuyer, gameState.CurrentRound); err != nil { + panic(err.Error()) + } +} + +// GetGameState returns current game state +func GetGameState() (int64, int64, int64, std.Address, int64, int64, int64, bool, int64, int64) { + return gameState.StartBlock, + gameState.EndBlock, + gameState.LastKeyBlock, + gameState.LastBuyer, + gameState.Jackpot, + gameState.KeyPrice, + gameState.TotalKeys, + gameState.Ended, + gameState.NextPot, + gameState.CurrentRound +} + +// GetOwnerInfo returns the owner address and unclaimed fees +func GetOwnerInfo() (std.Address, int64) { + return Ownable.Owner(), gameState.OwnerFee +} + +// Helper to convert string (address or username) to address +func stringToAddress(input string) std.Address { + // Check if input is valid address + addr := std.Address(input) + if addr.IsValid() { + return addr + } + + // Not an address, try to find namespace + if user := users.GetUserByName(input); user != nil { + return user.Address + } + + return "" +} + +func isPlayerInGame(addr std.Address) bool { + _, exists := players.Get(addr.String()) + return exists +} + +// GetPlayerInfo returns a player's keys and dividends +func GetPlayerInfo(addrOrName string) (int64, int64) { + addr := stringToAddress(addrOrName) + + if addr == "" { + panic(ErrInvalidAddressOrName.Error()) + } + + if !isPlayerInGame(addr) { + panic(ErrPlayerNotInGame.Error()) + } + + info, _ := players.Get(addr.String()) + playerInfo := info.(PlayerInfo) + return playerInfo.Keys, playerInfo.Dividends +} + +// Render handles the rendering of game state +func Render(path string) string { + parts := strings.Split(path, "/") + c := len(parts) + + switch { + case path == "": + return RenderHome() + case c == 2 && parts[0] == "player": + if gameState.Ended { + return ufmt.Sprintf("🔴 Game has not started yet.\n\n Call [`StartGame()`](%s) to start a new round.\n\n", gameState.StartGameLink) + } + addr := stringToAddress(parts[1]) + if addr == "" || !isPlayerInGame(addr) { + return "Address not found in game. You need to buy keys first to view your stats.\n\n" + } + keys, dividends := GetPlayerInfo(parts[1]) + return RenderPlayer(addr, keys, dividends) + default: + return "404: Invalid path\n\n" + } +} diff --git a/examples/gno.land/r/stefann/fomo3d/fomo3d_test.gno b/examples/gno.land/r/stefann/fomo3d/fomo3d_test.gno new file mode 100644 index 00000000000..29f2a9b07a9 --- /dev/null +++ b/examples/gno.land/r/stefann/fomo3d/fomo3d_test.gno @@ -0,0 +1,294 @@ +package fomo3d + +import ( + "std" + "testing" + + "gno.land/p/demo/avl" + "gno.land/p/demo/grc/grc721" + "gno.land/p/demo/ownable" + "gno.land/p/demo/testutils" + "gno.land/p/demo/urequire" +) + +// Reset game state +func setupTestGame(t *testing.T) { + gameState = GameState{ + StartBlock: 0, + EndBlock: 0, + LastKeyBlock: 0, + LastBuyer: "", + Jackpot: 0, + KeyPrice: MIN_KEY_PRICE, + TotalKeys: 0, + Ended: true, + CurrentRound: 0, + NextPot: 0, + OwnerFee: 0, + } + players = avl.NewTree() + Ownable = ownable.New() +} + +// Test ownership functionality +func TestOwnership(t *testing.T) { + owner := testutils.TestAddress("owner") + nonOwner := testutils.TestAddress("nonOwner") + + // Set up initial owner + std.TestSetOrigCaller(owner) + std.TestSetOrigPkgAddr(owner) + setupTestGame(t) + + // Transfer ownership to nonOwner first to test ownership functions + std.TestSetOrigCaller(owner) + urequire.NotPanics(t, func() { + Ownable.TransferOwnership(nonOwner) + }) + + // Test fee accumulation + StartGame() + payment := MIN_KEY_PRICE * 10 + std.TestSetOrigCaller(owner) + std.TestSetOrigSend(std.Coins{{"ugnot", payment}}, nil) + std.TestIssueCoins(owner, std.Coins{{"ugnot", payment}}) + std.TestIssueCoins(std.GetOrigPkgAddr(), std.Coins{{"ugnot", payment}}) + BuyKeys() + + // Verify fee accumulation + _, fees := GetOwnerInfo() + expectedFees := payment * OWNER_FEE_PERCENT / 100 + urequire.Equal(t, expectedFees, fees) + + // Test unauthorized fee claim (using old owner) + std.TestSetOrigCaller(owner) + urequire.PanicsWithMessage(t, "ownable: caller is not owner", ClaimOwnerFee) + + // Test authorized fee claim (using new owner) + std.TestSetOrigCaller(nonOwner) + initialBalance := std.GetBanker(std.BankerTypeRealmSend).GetCoins(nonOwner) + std.TestIssueCoins(std.CurrentRealm().Addr(), std.Coins{{"ugnot", expectedFees}}) + urequire.NotPanics(t, ClaimOwnerFee) + + // Verify fees were claimed + _, feesAfter := GetOwnerInfo() + urequire.Equal(t, int64(0), feesAfter) + + finalBalance := std.GetBanker(std.BankerTypeRealmSend).GetCoins(nonOwner) + urequire.Equal(t, initialBalance.AmountOf("ugnot")+expectedFees, finalBalance.AmountOf("ugnot")) +} + +// Test full game flow +func TestFullGameFlow(t *testing.T) { + setupTestGame(t) + + player1 := testutils.TestAddress("player1") + player2 := testutils.TestAddress("player2") + player3 := testutils.TestAddress("player3") + + // Test initial state + urequire.Equal(t, int64(0), gameState.CurrentRound) + urequire.Equal(t, MIN_KEY_PRICE, gameState.KeyPrice) + urequire.Equal(t, true, gameState.Ended) + + // Start game + urequire.NotPanics(t, StartGame) + urequire.Equal(t, false, gameState.Ended) + urequire.Equal(t, std.GetHeight(), gameState.StartBlock) + urequire.Equal(t, int64(1), gameState.CurrentRound) + + t.Run("buying keys", func(t *testing.T) { + // Test insufficient payment + std.TestSetOrigCaller(player1) + std.TestIssueCoins(player1, std.Coins{{"ugnot", MIN_KEY_PRICE - 1}}) + std.TestSetOrigSend(std.Coins{{"ugnot", MIN_KEY_PRICE - 1}}, nil) + std.TestIssueCoins(std.GetOrigPkgAddr(), std.Coins{{"ugnot", MIN_KEY_PRICE - 1}}) + urequire.PanicsWithMessage(t, ErrInsufficientPayment.Error(), BuyKeys) + + // Test successful key purchase + payment := MIN_KEY_PRICE * 3 + std.TestSetOrigSend(std.Coins{{"ugnot", payment}}, nil) + std.TestIssueCoins(std.GetOrigPkgAddr(), std.Coins{{"ugnot", payment}}) + + currentBlock := std.GetHeight() + urequire.NotPanics(t, BuyKeys) + + // Verify time extension + _, endBlock, _, _, _, _, _, _, _, _ := GetGameState() + urequire.Equal(t, currentBlock+TIME_EXTENSION, endBlock) + + // Verify player state + keys, dividends := GetPlayerInfo(player1.String()) + + urequire.Equal(t, int64(3), keys) + urequire.Equal(t, int64(0), dividends) + urequire.Equal(t, player1, gameState.LastBuyer) + + // Verify game state + _, endBlock, _, buyer, pot, price, keys, isEnded, nextPot, round := GetGameState() + urequire.Equal(t, player1, buyer) + urequire.Equal(t, int64(3), keys) + urequire.Equal(t, false, isEnded) + + urequire.Equal(t, payment*JACKPOT_PERCENT/100, pot) + + // Verify owner fee + _, ownerFees := GetOwnerInfo() + urequire.Equal(t, payment*OWNER_FEE_PERCENT/100, ownerFees) + }) + + t.Run("dividend distribution and claiming", func(t *testing.T) { + // Player 2 buys keys + std.TestSetOrigCaller(player2) + payment := gameState.KeyPrice * 2 // Buy 2 keys using current keyPrice + std.TestSetOrigSend(std.Coins{{"ugnot", payment}}, nil) + std.TestIssueCoins(std.GetOrigPkgAddr(), std.Coins{{"ugnot", payment}}) + urequire.NotPanics(t, BuyKeys) + + // Check player1 received dividends + keys1, dividends1 := GetPlayerInfo(player1.String()) + + urequire.Equal(t, int64(3), keys1) + expectedDividends := payment * DIVIDENDS_PERCENT / 100 * 3 / gameState.TotalKeys + urequire.Equal(t, expectedDividends, dividends1) + + // Test claiming dividends + { + // Player1 claims dividends + std.TestSetOrigCaller(player1) + initialBalance := std.GetBanker(std.BankerTypeRealmSend).GetCoins(player1) + urequire.NotPanics(t, ClaimDividends) + + // Verify dividends were claimed + _, dividendsAfter := GetPlayerInfo(player1.String()) + urequire.Equal(t, int64(0), dividendsAfter) + + lastBuyerBalance := std.GetBanker(std.BankerTypeRealmSend).GetCoins(player1) + urequire.Equal(t, initialBalance.AmountOf("ugnot")+expectedDividends, lastBuyerBalance.AmountOf("ugnot")) + } + }) + + t.Run("game ending", func(t *testing.T) { + // Try ending too early + urequire.PanicsWithMessage(t, ErrGameNotInProgress.Error(), EndGame) + + // Skip to end of current time window + currentEndBlock := gameState.EndBlock + std.TestSkipHeights(currentEndBlock - std.GetHeight() + 1) + + // End game successfully + urequire.NotPanics(t, EndGame) + urequire.Equal(t, true, gameState.Ended) + urequire.Equal(t, int64(1), gameState.CurrentRound) + + // Verify winner received jackpot + lastBuyerBalance := std.GetBanker(std.BankerTypeRealmSend).GetCoins(gameState.LastBuyer) + urequire.Equal(t, gameState.Jackpot, lastBuyerBalance.AmountOf("ugnot")) + + // Verify NFT was minted to winner + balance, err := BalanceOf(gameState.LastBuyer) + urequire.NoError(t, err) + urequire.Equal(t, uint64(1), balance) + + // Check NFT metadata + tokenID := grc721.TokenID("1") + metadata, err := TokenMetadata(tokenID) + + urequire.NoError(t, err) + urequire.Equal(t, "Fomo3D Winner - Round #1", metadata.Name) + }) + + // Test new round + t.Run("new round", func(t *testing.T) { + // Calculate expected next pot from previous round + payment1 := MIN_KEY_PRICE * 3 + // After buying 3 keys, price increased by 3% (1% per key) + secondKeyPrice := MIN_KEY_PRICE + (MIN_KEY_PRICE * 3 / 100) + payment2 := secondKeyPrice * 2 + expectedNextPot := (payment1 * NEXT_ROUND_POT / 100) + (payment2 * NEXT_ROUND_POT / 100) + + // Start new round + urequire.NotPanics(t, StartGame) + urequire.Equal(t, false, gameState.Ended) + urequire.Equal(t, int64(2), gameState.CurrentRound) + + start, end, last, buyer, pot, price, keys, isEnded, nextPot, round := GetGameState() + urequire.Equal(t, int64(2), round) + urequire.Equal(t, expectedNextPot, pot) + urequire.Equal(t, int64(0), nextPot) + }) +} + +// Test individual components +func TestStartGame(t *testing.T) { + setupTestGame(t) + + // Test starting first game + urequire.NotPanics(t, StartGame) + urequire.Equal(t, false, gameState.Ended) + urequire.Equal(t, std.GetHeight(), gameState.StartBlock) + + // Test cannot start while game in progress + urequire.PanicsWithMessage(t, ErrGameInProgress.Error(), StartGame) +} + +func TestBuyKeys(t *testing.T) { + setupTestGame(t) + StartGame() + + player := testutils.TestAddress("player") + std.TestSetOrigCaller(player) + + // Test invalid coin denomination + std.TestIssueCoins(player, std.Coins{{"invalid", MIN_KEY_PRICE}}) + std.TestSetOrigSend(std.Coins{{"invalid", MIN_KEY_PRICE}}, nil) + std.TestIssueCoins(std.GetOrigPkgAddr(), std.Coins{{"invalid", MIN_KEY_PRICE}}) + urequire.PanicsWithMessage(t, ErrInvalidPayment.Error(), BuyKeys) + + // Test multiple coin types + std.TestIssueCoins(player, std.Coins{{"ugnot", MIN_KEY_PRICE}, {"other", 100}}) + std.TestSetOrigSend(std.Coins{{"ugnot", MIN_KEY_PRICE}, {"other", 100}}, nil) + std.TestIssueCoins(std.GetOrigPkgAddr(), std.Coins{{"ugnot", MIN_KEY_PRICE}, {"other", 100}}) + urequire.PanicsWithMessage(t, ErrInvalidPayment.Error(), BuyKeys) + + // Test insufficient payment + std.TestIssueCoins(player, std.Coins{{"ugnot", MIN_KEY_PRICE - 1}}) + std.TestSetOrigSend(std.Coins{{"ugnot", MIN_KEY_PRICE - 1}}, nil) + std.TestIssueCoins(std.GetOrigPkgAddr(), std.Coins{{"ugnot", MIN_KEY_PRICE - 1}}) + urequire.PanicsWithMessage(t, ErrInsufficientPayment.Error(), BuyKeys) + + // Test successful purchase + std.TestIssueCoins(player, std.Coins{{"ugnot", MIN_KEY_PRICE * 2}}) + std.TestSetOrigSend(std.Coins{{"ugnot", MIN_KEY_PRICE * 2}}, nil) + std.TestIssueCoins(std.GetOrigPkgAddr(), std.Coins{{"ugnot", MIN_KEY_PRICE * 2}}) + urequire.NotPanics(t, BuyKeys) +} + +func TestClaimDividends(t *testing.T) { + setupTestGame(t) + StartGame() + + player := testutils.TestAddress("player") + std.TestSetOrigCaller(player) + + // Test claiming with no dividends + urequire.PanicsWithMessage(t, ErrNoDividendsToClaim.Error(), ClaimDividends) + + // Setup player with dividends + std.TestIssueCoins(player, std.Coins{{"ugnot", MIN_KEY_PRICE}}) + std.TestSetOrigSend(std.Coins{{"ugnot", MIN_KEY_PRICE}}, nil) + std.TestIssueCoins(std.GetOrigPkgAddr(), std.Coins{{"ugnot", MIN_KEY_PRICE}}) + BuyKeys() + + // Have another player buy to generate dividends + player2 := testutils.TestAddress("player2") + std.TestSetOrigCaller(player2) + std.TestIssueCoins(player2, std.Coins{{"ugnot", MIN_KEY_PRICE * 2}}) + std.TestSetOrigSend(std.Coins{{"ugnot", MIN_KEY_PRICE * 2}}, nil) + std.TestIssueCoins(std.GetOrigPkgAddr(), std.Coins{{"ugnot", MIN_KEY_PRICE * 2}}) + BuyKeys() + + // Test successful claim + std.TestSetOrigCaller(player) + urequire.NotPanics(t, ClaimDividends) +} diff --git a/examples/gno.land/r/stefann/fomo3d/gno.mod b/examples/gno.land/r/stefann/fomo3d/gno.mod new file mode 100644 index 00000000000..1b4e630a285 --- /dev/null +++ b/examples/gno.land/r/stefann/fomo3d/gno.mod @@ -0,0 +1 @@ +module gno.land/r/stefann/fomo3d diff --git a/examples/gno.land/r/stefann/fomo3d/nft.gno b/examples/gno.land/r/stefann/fomo3d/nft.gno new file mode 100644 index 00000000000..adea2fee795 --- /dev/null +++ b/examples/gno.land/r/stefann/fomo3d/nft.gno @@ -0,0 +1,88 @@ +package fomo3d + +import ( + "std" + "strconv" + + "gno.land/p/demo/grc/grc721" +) + +var ( + fomo3dNFT = grc721.NewNFTWithMetadata("Fomo3D Winner", "FOMO") +) + +// Public getters + +func Name() string { + return fomo3dNFT.Name() +} + +func Symbol() string { + return fomo3dNFT.Symbol() +} + +func BalanceOf(owner std.Address) (uint64, error) { + return fomo3dNFT.BalanceOf(owner) +} + +func OwnerOf(tokenID grc721.TokenID) (std.Address, error) { + return fomo3dNFT.OwnerOf(tokenID) +} + +func TokenMetadata(tokenID grc721.TokenID) (grc721.Metadata, error) { + return fomo3dNFT.TokenMetadata(tokenID) +} + +// Transfer and approval methods + +func TransferFrom(from, to std.Address, tokenID grc721.TokenID) error { + return fomo3dNFT.TransferFrom(from, to, tokenID) +} + +func SafeTransferFrom(from, to std.Address, tokenID grc721.TokenID) error { + return fomo3dNFT.SafeTransferFrom(from, to, tokenID) +} + +func Approve(approved std.Address, tokenID grc721.TokenID) error { + return fomo3dNFT.Approve(approved, tokenID) +} + +func GetApproved(tokenID grc721.TokenID) (std.Address, error) { + return fomo3dNFT.GetApproved(tokenID) +} + +func SetApprovalForAll(operator std.Address, approved bool) error { + return fomo3dNFT.SetApprovalForAll(operator, approved) +} + +func IsApprovedForAll(owner, operator std.Address) bool { + return fomo3dNFT.IsApprovedForAll(owner, operator) +} + +// Mints a new NFT for the round winner +func mintRoundWinnerNFT(winner std.Address, roundNumber int64) error { + if winner == "" { + return ErrZeroAddress + } + + roundStr := strconv.FormatInt(roundNumber, 10) + tokenID := grc721.TokenID(roundStr) + + // Create metadata + metadata := grc721.Metadata{ + Name: "Fomo3D Winner - Round #" + roundStr, + Description: "Winner of Fomo3D round #" + roundStr, + Image: "https://ipfs.io/ipfs/bafybeidayyli6bpewkhgtwqpgubmo77kmgjn4r5zq2i7usoyadcmvynhhq", + ExternalURL: "https://gno.land/r/stefann/fomo3d:round/" + roundStr, // TODO: Add this render in main realm that shows details of specific round + Attributes: []grc721.Trait{}, + BackgroundColor: "2D2D2D", // Dark theme background + } + + if err := fomo3dNFT.Mint(winner, tokenID); err != nil { + return err + } + + fomo3dNFT.SetTokenMetadata(tokenID, metadata) + + return nil +} diff --git a/examples/gno.land/r/stefann/fomo3d/render.gno b/examples/gno.land/r/stefann/fomo3d/render.gno new file mode 100644 index 00000000000..ba0c7b8f147 --- /dev/null +++ b/examples/gno.land/r/stefann/fomo3d/render.gno @@ -0,0 +1,138 @@ +package fomo3d + +import ( + "std" + "strconv" + "strings" + + "gno.land/p/demo/grc/grc721" + "gno.land/p/demo/ufmt" + + "gno.land/r/demo/users" +) + +// RenderHome renders the main game state +func RenderHome() string { + var builder strings.Builder + builder.WriteString("# FOMO3D - The Ultimate Game of Greed\n\n") + + // About section + builder.WriteString("## About the Game\n\n") + builder.WriteString("FOMO3D is a game that combines elements of lottery and investment mechanics. ") + builder.WriteString("Players purchase keys using GNOT tokens, where each key purchase:\n\n") + builder.WriteString("* Extends the game timer\n") + builder.WriteString("* Increases the key price by 1%\n") + builder.WriteString("* Makes you the potential winner of the jackpot\n") + builder.WriteString("* Distributes dividends to all key holders\n\n") + builder.WriteString("## How to Win\n\n") + builder.WriteString("* Be the last person to buy a key before the timer expires!\n\n") + builder.WriteString("**Rewards Distribution:**\n") + builder.WriteString("* 47% goes to the jackpot (for the winner)\n") + builder.WriteString("* 28% distributed as dividends to all key holders\n") + builder.WriteString("* 20% goes to next round's starting pot\n") + builder.WriteString("* 5% development fee for continuous improvement\n\n") + + // Play Game section + builder.WriteString("## How to Play\n\n") + builder.WriteString(ufmt.Sprintf("1. **Buy Keys** - Send GNOT to this realm with function [`BuyKeys()`](%s)\n", gameState.BuyKeysLink)) + builder.WriteString(ufmt.Sprintf("2. **Collect Dividends** - Call [`ClaimDividends()`](%s) to collect your earnings\n", gameState.ClaimDividendsLink)) + builder.WriteString("3. **Check Your Stats** - Append `:player/` followed by your address or namespace to the current URL to view your keys and dividends\n") + if gameState.Ended { + builder.WriteString(ufmt.Sprintf("4. **Start New Round** - Call [`StartGame()`](%s) to begin a new round\n", gameState.StartGameLink)) + } + builder.WriteString("\n") + + // Game Status section + builder.WriteString("## Game Status\n\n") + if gameState.StartBlock == 0 { + builder.WriteString("🔴 Game has not started yet.\n\n") + } else { + if gameState.Ended { + builder.WriteString("🔴 **Game Status:** Ended\n") + builder.WriteString(ufmt.Sprintf("🏆 **Winner:** %s\n\n", gameState.LastBuyer)) + } else { + builder.WriteString("🟢 **Game Status:** Active\n\n") + builder.WriteString(ufmt.Sprintf("🔄 **Round:** %d\n\n", gameState.CurrentRound)) + builder.WriteString(ufmt.Sprintf("⏱️ **Time Remaining:** %d blocks\n\n", gameState.EndBlock-std.GetHeight())) + } + builder.WriteString(ufmt.Sprintf("💰 **Jackpot:** %d ugnot\n\n", gameState.Jackpot)) + builder.WriteString(ufmt.Sprintf("🔑 **Key Price:** %d ugnot\n\n", gameState.KeyPrice)) + builder.WriteString(ufmt.Sprintf("📊 **Total Keys:** %d\n\n", gameState.TotalKeys)) + builder.WriteString(ufmt.Sprintf("👤 **Last Buyer:** %s\n\n", getDisplayName(gameState.LastBuyer))) + builder.WriteString(ufmt.Sprintf("🎮 **Next Round Pot:** %d ugnot\n\n", gameState.NextPot)) + } + + // Separator before less important sections + builder.WriteString("---\n\n") + + // Vote For Me section + builder.WriteString("### Vote For Us! 🗳️\n\n") + builder.WriteString("If you enjoy playing FOMO3D, please consider upvoting this game in the [Hall of Realms](https://gno.land/r/leon/hof)!\n\n") + builder.WriteString("Your support helps more players discover the game and grow our community! 🚀\n\n") + + // Report Bug section + builder.WriteString("### Report a Bug 🪲\n\n") + builder.WriteString("Something unusual happened? Help us improve the game by reporting bugs!\n") + builder.WriteString("[Visit our GitHub repository](https://github.com/gnolang/gno/issues)\n\n") + builder.WriteString("Please include:\n") + builder.WriteString("* Detailed description of what happened\n") + builder.WriteString("* Transaction hash (if applicable)\n") + builder.WriteString("* Your address\n") + builder.WriteString("* Current round number\n") + + return builder.String() +} + +// RenderPlayer renders specific player information +func RenderPlayer(addr std.Address, keys int64, dividends int64) string { + var builder strings.Builder + displayName := getDisplayName(addr) + builder.WriteString(ufmt.Sprintf("# Player Stats: %s\n\n", displayName)) + builder.WriteString("## Your Holdings\n\n") + builder.WriteString(ufmt.Sprintf("🔑 **Keys Owned:** %d\n\n", keys)) + builder.WriteString(ufmt.Sprintf("💰 **Unclaimed Dividends:** %d ugnot\n\n", dividends)) + + // Check if player has any NFTs + nftBalance, err := BalanceOf(addr) + if err == nil && nftBalance > 0 { + builder.WriteString("## Your Victory NFTs 🏆\n\n") + + // Iterate through all rounds up to current round to find player's NFTs + for i := int64(1); i <= gameState.CurrentRound; i++ { + tokenID := grc721.TokenID(strconv.FormatInt(i, 10)) + owner, err := OwnerOf(tokenID) + if err == nil && owner == addr { + metadata, err := TokenMetadata(tokenID) + if err == nil { + builder.WriteString(ufmt.Sprintf("### Round #%d Winner\n", i)) + builder.WriteString(ufmt.Sprintf("![NFT](%s)\n\n", metadata.Image)) + builder.WriteString("---\n\n") + } + } + } + } + + builder.WriteString("## Actions\n\n") + builder.WriteString(ufmt.Sprintf("* To buy more keys, send GNOT to this realm with [`BuyKeys()`](%s)\n", gameState.BuyKeysLink)) + if dividends > 0 { + builder.WriteString("* You have unclaimed dividends! Call `ClaimDividends()` to collect them\n") + } + + return builder.String() +} + +// Helper to get display name - just returns namespace if exists, otherwise address +func getDisplayName(addr std.Address) string { + if user := users.GetUserByAddress(addr); user != nil { + return user.Name + } + return addr.String() +} + +// UpdateFunctionLinks updates the links for game functions +func UpdateFunctionLinks(buyKeysLink string, claimDividendsLink string, startGameLink string) { + Ownable.AssertCallerIsOwner() + gameState.BuyKeysLink = buyKeysLink + gameState.ClaimDividendsLink = claimDividendsLink + gameState.StartGameLink = startGameLink +}