1package fomo3d
2
3import (
4 "std"
5 "strings"
6
7 "gno.land/p/demo/avl"
8 "gno.land/p/demo/ownable"
9 "gno.land/p/demo/ufmt"
10
11 "gno.land/r/demo/users"
12 "gno.land/r/leon/hof"
13)
14
15// FOMO3D (Fear Of Missing Out 3D) is a blockchain-based game that combines elements
16// of a lottery and investment mechanics. Players purchase keys using GNOT tokens,
17// where each key purchase:
18// - Extends the game timer
19// - Increases the key price by 1%
20// - Makes the buyer the potential winner of the jackpot
21// - Distributes dividends to all key holders
22//
23// Game Mechanics:
24// - The last person to buy a key before the timer expires wins the jackpot (47% of all purchases)
25// - Key holders earn dividends from each purchase (28% of all purchases)
26// - 20% of purchases go to the next round's starting pot
27// - 5% goes to development fee
28// - Game ends when the timer expires
29//
30// Inspired by the original Ethereum FOMO3D game but implemented in Gno.
31
32const (
33 MIN_KEY_PRICE int64 = 100000 // minimum key price in ugnot
34 TIME_EXTENSION int64 = 86400 // time extension in blocks when new key is bought (~24 hours @ 1s blocks)
35
36 // Distribution percentages (total 100%)
37 JACKPOT_PERCENT int64 = 47 // 47% goes to jackpot
38 DIVIDENDS_PERCENT int64 = 28 // 28% distributed to key holders
39 NEXT_ROUND_POT int64 = 20 // 20% goes to next round's starting pot
40 OWNER_FEE_PERCENT int64 = 5 // 5% goes to contract owner
41)
42
43type PlayerInfo struct {
44 Keys int64 // number of keys owned
45 Dividends int64 // unclaimed dividends in ugnot
46}
47
48// GameState represents the current state of the FOMO3D game
49type GameState struct { // TODO: Separate GameState and RoundState and save round history tree in GameState
50 StartBlock int64 // Block when the game started
51 EndBlock int64 // Block when the game will end
52 LastKeyBlock int64 // Block of last key purchase
53 LastBuyer std.Address // Address of last key buyer
54 Jackpot int64 // Current jackpot in ugnot
55 KeyPrice int64 // Current price of keys in ugnot
56 TotalKeys int64 // Total number of keys in circulation
57 Ended bool // Whether the game has ended
58 CurrentRound int64 // Current round number
59 NextPot int64 // Next round's starting pot
60 OwnerFee int64 // Accumulated owner fees
61 BuyKeysLink string // Link to BuyKeys function
62 ClaimDividendsLink string // Link to ClaimDividends function
63 StartGameLink string // Link to StartGame function
64}
65
66var (
67 gameState GameState
68 players *avl.Tree // maps address -> PlayerInfo
69 Ownable *ownable.Ownable
70)
71
72func init() {
73 Ownable = ownable.New()
74 players = avl.NewTree()
75 gameState.Ended = true
76 hof.Register()
77}
78
79// StartGame starts a new game round
80func StartGame() {
81 if !gameState.Ended && gameState.StartBlock != 0 {
82 panic(ErrGameInProgress.Error())
83 }
84
85 gameState.CurrentRound++
86 gameState.StartBlock = std.ChainHeight()
87 gameState.EndBlock = gameState.StartBlock + TIME_EXTENSION // Initial 24h window
88 gameState.LastKeyBlock = gameState.StartBlock
89 gameState.Jackpot = gameState.NextPot
90 gameState.NextPot = 0
91 gameState.Ended = false
92 gameState.KeyPrice = MIN_KEY_PRICE
93 gameState.TotalKeys = 0
94
95 // Clear previous round's player data
96 players = avl.NewTree()
97
98 emitGameStarted(
99 gameState.CurrentRound,
100 gameState.StartBlock,
101 gameState.EndBlock,
102 gameState.Jackpot,
103 )
104}
105
106// BuyKeys allows players to purchase keys
107func BuyKeys() {
108 if gameState.Ended {
109 panic(ErrGameEnded.Error())
110 }
111
112 currentBlock := std.ChainHeight()
113 if currentBlock > gameState.EndBlock {
114 panic(ErrGameTimeExpired.Error())
115 }
116
117 // Get sent coins
118 sent := std.OriginSend()
119 if len(sent) != 1 || sent[0].Denom != "ugnot" {
120 panic(ErrInvalidPayment.Error())
121 }
122
123 payment := sent.AmountOf("ugnot")
124 if payment < gameState.KeyPrice {
125 panic(ErrInsufficientPayment.Error())
126 }
127
128 // Calculate number of keys that can be bought and actual cost
129 numKeys := payment / gameState.KeyPrice
130 actualCost := numKeys * gameState.KeyPrice
131 excess := payment - actualCost
132
133 // Update buyer's info
134 buyer := std.PreviousRealm().Address()
135 var buyerInfo PlayerInfo
136 if info, exists := players.Get(buyer.String()); exists {
137 buyerInfo = info.(PlayerInfo)
138 }
139
140 buyerInfo.Keys += numKeys
141 gameState.TotalKeys += numKeys
142
143 // Distribute actual cost
144 jackpotShare := actualCost * JACKPOT_PERCENT / 100
145 dividendShare := actualCost * DIVIDENDS_PERCENT / 100
146 nextPotShare := actualCost * NEXT_ROUND_POT / 100
147 ownerShare := actualCost * OWNER_FEE_PERCENT / 100
148
149 // Update pools
150 gameState.Jackpot += jackpotShare
151 gameState.NextPot += nextPotShare
152 gameState.OwnerFee += ownerShare
153
154 // Return excess payment to buyer if any
155 if excess > 0 {
156 banker := std.NewBanker(std.BankerTypeOriginSend)
157 banker.SendCoins(
158 std.CurrentRealm().Address(),
159 buyer,
160 std.NewCoins(std.NewCoin("ugnot", excess)),
161 )
162 }
163
164 // Distribute dividends to all key holders
165 if players.Size() > 0 && gameState.TotalKeys > 0 {
166 dividendPerKey := dividendShare / gameState.TotalKeys
167 players.Iterate("", "", func(key string, value any) bool {
168 playerInfo := value.(PlayerInfo)
169 playerInfo.Dividends += playerInfo.Keys * dividendPerKey
170 players.Set(key, playerInfo)
171 return false
172 })
173 }
174
175 // Update game state
176 gameState.LastBuyer = buyer
177 gameState.LastKeyBlock = currentBlock
178 gameState.EndBlock = currentBlock + TIME_EXTENSION // Always extend 24h from current block
179 gameState.KeyPrice += (gameState.KeyPrice * numKeys) / 100
180
181 // Save buyer's updated info
182 players.Set(buyer.String(), buyerInfo)
183
184 emitKeysPurchased(
185 buyer,
186 numKeys,
187 gameState.KeyPrice,
188 jackpotShare,
189 dividendShare,
190 )
191}
192
193// ClaimDividends allows players to withdraw their earned dividends
194func ClaimDividends() {
195 caller := std.PreviousRealm().Address()
196
197 info, exists := players.Get(caller.String())
198 if !exists {
199 panic(ErrNoDividendsToClaim.Error())
200 }
201
202 playerInfo := info.(PlayerInfo)
203 if playerInfo.Dividends == 0 {
204 panic(ErrNoDividendsToClaim.Error())
205 }
206
207 // Reset dividends and send coins
208 amount := playerInfo.Dividends
209 playerInfo.Dividends = 0
210 players.Set(caller.String(), playerInfo)
211
212 banker := std.NewBanker(std.BankerTypeRealmSend)
213 banker.SendCoins(
214 std.CurrentRealm().Address(),
215 caller,
216 std.NewCoins(std.NewCoin("ugnot", amount)),
217 )
218
219 emitDividendsClaimed(caller, amount)
220}
221
222// ClaimOwnerFee allows the owner to withdraw accumulated fees
223func ClaimOwnerFee() {
224 Ownable.AssertCallerIsOwner()
225
226 if gameState.OwnerFee == 0 {
227 panic(ErrNoFeesToClaim.Error())
228 }
229
230 amount := gameState.OwnerFee
231 gameState.OwnerFee = 0
232
233 banker := std.NewBanker(std.BankerTypeRealmSend)
234 banker.SendCoins(
235 std.CurrentRealm().Address(),
236 Ownable.Owner(),
237 std.NewCoins(std.NewCoin("ugnot", amount)),
238 )
239
240 emitOwnerFeeClaimed(Ownable.Owner(), amount)
241}
242
243// EndGame ends the current round and distributes the jackpot
244func EndGame() {
245 if gameState.Ended {
246 panic(ErrGameEnded.Error())
247 }
248
249 currentBlock := std.ChainHeight()
250 if currentBlock <= gameState.EndBlock {
251 panic(ErrGameNotInProgress.Error())
252 }
253
254 if gameState.LastBuyer == "" {
255 panic(ErrNoKeysPurchased.Error())
256 }
257
258 gameState.Ended = true
259
260 // Send jackpot to winner
261 banker := std.NewBanker(std.BankerTypeRealmSend)
262 banker.SendCoins(
263 std.CurrentRealm().Address(),
264 gameState.LastBuyer,
265 std.NewCoins(std.NewCoin("ugnot", gameState.Jackpot)),
266 )
267
268 emitGameEnded(
269 gameState.CurrentRound,
270 gameState.LastBuyer,
271 gameState.Jackpot,
272 )
273
274 // Mint NFT for the winner
275 if err := mintRoundWinnerNFT(gameState.LastBuyer, gameState.CurrentRound); err != nil {
276 panic(err.Error())
277 }
278}
279
280// GetGameState returns current game state
281func GetGameState() (int64, int64, int64, std.Address, int64, int64, int64, bool, int64, int64) {
282 return gameState.StartBlock,
283 gameState.EndBlock,
284 gameState.LastKeyBlock,
285 gameState.LastBuyer,
286 gameState.Jackpot,
287 gameState.KeyPrice,
288 gameState.TotalKeys,
289 gameState.Ended,
290 gameState.NextPot,
291 gameState.CurrentRound
292}
293
294// GetOwnerInfo returns the owner address and unclaimed fees
295func GetOwnerInfo() (std.Address, int64) {
296 return Ownable.Owner(), gameState.OwnerFee
297}
298
299// Helper to convert string (address or username) to address
300func stringToAddress(input string) std.Address {
301 // Check if input is valid address
302 addr := std.Address(input)
303 if addr.IsValid() {
304 return addr
305 }
306
307 // Not an address, try to find namespace
308 if user := users.GetUserByName(input); user != nil {
309 return user.Address
310 }
311
312 return ""
313}
314
315func isPlayerInGame(addr std.Address) bool {
316 _, exists := players.Get(addr.String())
317 return exists
318}
319
320// GetPlayerInfo returns a player's keys and dividends
321func GetPlayerInfo(addrOrName string) (int64, int64) {
322 addr := stringToAddress(addrOrName)
323
324 if addr == "" {
325 panic(ErrInvalidAddressOrName.Error())
326 }
327
328 if !isPlayerInGame(addr) {
329 panic(ErrPlayerNotInGame.Error())
330 }
331
332 info, _ := players.Get(addr.String())
333 playerInfo := info.(PlayerInfo)
334 return playerInfo.Keys, playerInfo.Dividends
335}
336
337// Render handles the rendering of game state
338func Render(path string) string {
339 parts := strings.Split(path, "/")
340 c := len(parts)
341
342 switch {
343 case path == "":
344 return RenderHome()
345 case c == 2 && parts[0] == "player":
346 if gameState.Ended {
347 return ufmt.Sprintf("🔴 Game has not started yet.\n\n Call [`StartGame()`](%s) to start a new round.\n\n", gameState.StartGameLink)
348 }
349 addr := stringToAddress(parts[1])
350 if addr == "" || !isPlayerInGame(addr) {
351 return "Address not found in game. You need to buy keys first to view your stats.\n\n"
352 }
353 keys, dividends := GetPlayerInfo(parts[1])
354 return RenderPlayer(addr, keys, dividends)
355 default:
356 return "404: Invalid path\n\n"
357 }
358}
fomo3d.gno
9.51 Kb · 358 lines