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..067c5703b3c --- /dev/null +++ b/examples/gno.land/r/stefann/fomo3d/errors.gno @@ -0,0 +1,25 @@ +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") + + // 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") +) 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..37069bd90f0 --- /dev/null +++ b/examples/gno.land/r/stefann/fomo3d/events.gno @@ -0,0 +1,103 @@ +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" + OwnershipTransferredEvent = "OwnershipTransferred" +) + +// 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), + ) +} + +func EmitOwnershipTransferred(previousOwner, newOwner std.Address) { + std.Emit( + OwnershipTransferredEvent, + EventPreviousOwnerKey, previousOwner.String(), + EventNewOwnerKey, newOwner.String(), + ) +} 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..99b7c6ec03f --- /dev/null +++ b/examples/gno.land/r/stefann/fomo3d/fomo3d.gno @@ -0,0 +1,447 @@ +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 { + 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 +} + +var ( + gameState GameState + players *avl.Tree // maps address -> PlayerInfo + Ownable *ownable.Ownable +) + +// init initializes the first round +func init() { + Ownable = ownable.New() // Initialize with caller as owner + hof.Register() +} + +// StartGame starts a new game round +func StartGame() { + if !gameState.Ended && gameState.StartBlock != 0 { + panic(ErrGameInProgress.Error()) + } + + 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() + + // Emit event for game start + 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[0].Amount + 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.GetOrigCaller() + 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.Coins{{"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 { + addr := std.Address(key) + playerInfo := value.(PlayerInfo) + playerInfo.Dividends += playerInfo.Keys * dividendPerKey + players.Set(addr.String(), 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) + + // Emit events for key purchase + EmitKeysPurchased( + buyer, + numKeys, + gameState.KeyPrice, + jackpotShare, + dividendShare, + ) +} + +// ClaimDividends allows players to withdraw their earned dividends +func ClaimDividends() { + caller := std.GetOrigCaller() + + 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.Coins{{"ugnot", amount}}, + ) + + // Emit event for dividend claim + 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.Coins{{"ugnot", amount}}, + ) + + EmitOwnerFeeClaimed(Ownable.Owner(), amount) +} + +// TransferOwnership allows the current owner to transfer ownership to a new address +func TransferOwnership(newOwner std.Address) { + Ownable.AssertCallerIsOwner() + Ownable.TransferOwnership(newOwner) + EmitOwnershipTransferred(Ownable.Owner(), newOwner) +} + +// 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 + gameState.CurrentRound++ + + // Send jackpot to winner + banker := std.GetBanker(std.BankerTypeRealmSend) + banker.SendCoins( + std.CurrentRealm().Addr(), + gameState.LastBuyer, + std.Coins{{"ugnot", gameState.Jackpot}}, + ) + + // Emit event for game end + EmitGameEnded( + gameState.CurrentRound, + gameState.LastBuyer, + gameState.Jackpot, + ) +} + +// 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 +} + +// 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 "" +} + +// GetPlayerInfo returns a player's keys and dividends +func GetPlayerInfo(addrOrName interface{}) (int64, int64) { + var addr std.Address + + switch v := addrOrName.(type) { + case std.Address: + addr = v + case string: + addr = stringToAddress(v) + if addr == "" { + panic(ErrInvalidAddressOrName.Error()) + } + default: + return 0, 0 + } + + if info, exists := players.Get(addr.String()); exists { + playerInfo := info.(PlayerInfo) + return playerInfo.Keys, playerInfo.Dividends + } + return 0, 0 +} + +// GetOwnerInfo returns the owner address and unclaimed fees +func GetOwnerInfo() (std.Address, int64) { + return Ownable.Owner(), gameState.OwnerFee +} + +// 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": + addr := stringToAddress(parts[1]) + if addr == "" { + return "Invalid address or unregistered username. Please use a valid Gno address or registered username.\n\n" + } + keys, dividends := GetPlayerInfo(addr) + return RenderPlayer(addr, keys, dividends) + default: + return "404: Invalid path\n\n" + } +} + +// 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 blockchain-based 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") + + // 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("⏱️ **Time Remaining:** %d blocks\n\n", gameState.EndBlock-std.GetHeight())) + } + builder.WriteString(ufmt.Sprintf("💰 **Current Jackpot:** %d ugnot\n\n", gameState.Jackpot)) + builder.WriteString(ufmt.Sprintf("🔑 **Current 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)) + builder.WriteString(ufmt.Sprintf("🔄 **Current Round:** %d\n\n", gameState.CurrentRound)) + } + + // Play Game section + builder.WriteString("## How to Play\n\n") + builder.WriteString("1. **Buy Keys** - Send GNOT to this realm with function `BuyKeys()`\n") + builder.WriteString("2. **Collect Dividends** - Call `ClaimDividends()` to collect your earnings\n") + builder.WriteString("3. **Check Your Stats** - Visit `:player/YOUR_ADDRESS` to see your keys and dividends\n") + if gameState.Ended { + builder.WriteString("4. **Start New Round** - Call `StartGame()` to begin a new round\n") + } + builder.WriteString("\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") + 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)) + + builder.WriteString("## Actions\n\n") + builder.WriteString("* To buy more keys, send GNOT to this realm with `BuyKeys()`\n") + 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() +} 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..6070c933f2a --- /dev/null +++ b/examples/gno.land/r/stefann/fomo3d/fomo3d_test.gno @@ -0,0 +1,340 @@ +package fomo3d + +import ( + "std" + "testing" + + "gno.land/p/demo/avl" + "gno.land/p/demo/ownable" + "gno.land/p/demo/testutils" +) + +// Test helpers +func shouldEqual(t *testing.T, got interface{}, expected interface{}) { + t.Helper() + if got != expected { + t.Errorf("expected %v(%T), got %v(%T)", expected, expected, got, got) + } +} + +func shouldPanic(t *testing.T, f func()) { + t.Helper() + defer func() { + if r := recover(); r == nil { + t.Errorf("expected panic") + } + }() + f() +} + +func shouldNoPanic(t *testing.T, f func()) { + t.Helper() + defer func() { + if r := recover(); r != nil { + t.Errorf("unexpected panic: %v", r) + } + }() + f() +} + +// 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: 1, + 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) + shouldNoPanic(t, func() { + 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 + shouldEqual(t, fees, expectedFees) + + // Test unauthorized fee claim (using old owner) + std.TestSetOrigCaller(owner) + shouldPanic(t, 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}}) + shouldNoPanic(t, ClaimOwnerFee) + + // Verify fees were claimed + _, feesAfter := GetOwnerInfo() + shouldEqual(t, feesAfter, int64(0)) + + finalBalance := std.GetBanker(std.BankerTypeRealmSend).GetCoins(nonOwner) + shouldEqual(t, finalBalance.AmountOf("ugnot"), initialBalance.AmountOf("ugnot")+expectedFees) +} + +// 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 + shouldEqual(t, gameState.CurrentRound, int64(1)) + shouldEqual(t, gameState.KeyPrice, MIN_KEY_PRICE) + shouldEqual(t, gameState.Ended, true) + + // Start game + shouldNoPanic(t, StartGame) + shouldEqual(t, gameState.Ended, false) + shouldEqual(t, gameState.StartBlock, std.GetHeight()) + + 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}}) + shouldPanic(t, 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() + shouldNoPanic(t, BuyKeys) + + // Verify time extension + _, endBlock, _, _, _, _, _, _, _, _ := GetGameState() + shouldEqual(t, endBlock, currentBlock+TIME_EXTENSION) + + // Verify player state + keys, dividends := GetPlayerInfo(player1) + + shouldEqual(t, keys, int64(3)) + shouldEqual(t, dividends, int64(0)) + shouldEqual(t, gameState.LastBuyer, player1) + + // Verify game state + _, endBlock, _, buyer, pot, price, keys, isEnded, nextPot, round := GetGameState() + shouldEqual(t, buyer, player1) + shouldEqual(t, keys, int64(3)) + shouldEqual(t, isEnded, false) + + shouldEqual(t, pot, payment*JACKPOT_PERCENT/100) + + // Verify owner fee + _, ownerFees := GetOwnerInfo() + shouldEqual(t, ownerFees, payment*OWNER_FEE_PERCENT/100) + }) + + 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}}) + shouldNoPanic(t, BuyKeys) + + // Check player1 received dividends + keys1, dividends1 := GetPlayerInfo(player1) + + shouldEqual(t, keys1, int64(3)) + expectedDividends := payment * DIVIDENDS_PERCENT / 100 * 3 / gameState.TotalKeys + shouldEqual(t, dividends1, expectedDividends) + + // Test claiming dividends + { + // Player1 claims dividends + std.TestSetOrigCaller(player1) + initialBalance := std.GetBanker(std.BankerTypeRealmSend).GetCoins(player1) + shouldNoPanic(t, ClaimDividends) + + // Verify dividends were claimed + _, dividendsAfter := GetPlayerInfo(player1) + shouldEqual(t, dividendsAfter, int64(0)) + + lastBuyerBalance := std.GetBanker(std.BankerTypeRealmSend).GetCoins(player1) + shouldEqual(t, lastBuyerBalance.AmountOf("ugnot"), initialBalance.AmountOf("ugnot")+expectedDividends) + } + }) + + t.Run("game ending", func(t *testing.T) { + // Try ending too early + shouldPanic(t, EndGame) + + // Skip to end of current time window + currentEndBlock := gameState.EndBlock + std.TestSkipHeights(currentEndBlock - std.GetHeight() + 1) + + // End game successfully + shouldNoPanic(t, EndGame) + shouldEqual(t, gameState.Ended, true) + + shouldEqual(t, gameState.CurrentRound, int64(2)) + + // Verify winner received jackpot + lastBuyerBalance := std.GetBanker(std.BankerTypeRealmSend).GetCoins(gameState.LastBuyer) + shouldEqual(t, lastBuyerBalance.AmountOf("ugnot"), gameState.Jackpot) + }) + + // 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 + shouldNoPanic(t, StartGame) + shouldEqual(t, gameState.Ended, false) + shouldEqual(t, gameState.CurrentRound, int64(2)) + + start, end, last, buyer, pot, price, keys, isEnded, nextPot, round := GetGameState() + shouldEqual(t, round, int64(2)) + shouldEqual(t, pot, expectedNextPot) + shouldEqual(t, nextPot, int64(0)) + }) +} + +// Test individual components +func TestStartGame(t *testing.T) { + setupTestGame(t) + + // Test starting first game + shouldNoPanic(t, StartGame) + shouldEqual(t, gameState.Ended, false) + shouldEqual(t, gameState.StartBlock, std.GetHeight()) + + // Test cannot start while game in progress + shouldPanic(t, 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}}) + shouldPanic(t, 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}}) + shouldPanic(t, 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}}) + shouldPanic(t, 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}}) + shouldNoPanic(t, BuyKeys) +} + +func TestClaimDividends(t *testing.T) { + setupTestGame(t) + StartGame() + + player := testutils.TestAddress("player") + std.TestSetOrigCaller(player) + + // Test claiming with no dividends + shouldPanic(t, 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) + shouldNoPanic(t, ClaimDividends) +} + +func TestEndGame(t *testing.T) { + setupTestGame(t) + StartGame() + + // Skip to end + 1 to ensure we're past the end block + currentEndBlock := gameState.EndBlock + std.TestSkipHeights(currentEndBlock - std.GetHeight() + 1) + + shouldPanic(t, EndGame) // Should panic because no keys purchased + + // Reset and start new game + setupTestGame(t) + StartGame() + + // Buy keys + player := testutils.TestAddress("player") + std.TestSetOrigCaller(player) + 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}}) + shouldNoPanic(t, BuyKeys) + + // Test ending too early + shouldPanic(t, EndGame) + + // Skip to end of current time window + currentEndBlock = gameState.EndBlock + std.TestSkipHeights(currentEndBlock - std.GetHeight() + 1) + + shouldNoPanic(t, EndGame) +} 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